From f7817a9c5d2a2c1e36ed69d24daa6f4643084179 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 26 Mar 2025 23:48:02 +1100 Subject: [PATCH 1/5] feat: wire up file sync window --- App/App.xaml.cs | 49 ++- App/Models/SyncSessionModel.cs | 36 +-- App/Services/MutagenController.cs | 304 ++++++++++++++++-- App/ViewModels/FileSyncListViewModel.cs | 186 +++++++++-- App/ViewModels/SyncSessionViewModel.cs | 33 ++ App/Views/FileSyncListWindow.xaml.cs | 2 +- App/Views/Pages/FileSyncListMainPage.xaml | 35 +- App/Views/Pages/TrayWindowMainPage.xaml | 10 +- MutagenSdk/MutagenClient.cs | 17 + .../Proto/service/prompting/prompting.proto | 81 +++++ MutagenSdk/Update-Proto.ps1 | 5 +- Tests.App/Services/MutagenControllerTest.cs | 139 ++++++-- 12 files changed, 752 insertions(+), 145 deletions(-) create mode 100644 App/ViewModels/SyncSessionViewModel.cs create mode 100644 MutagenSdk/Proto/service/prompting/prompting.proto diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 0b159a9..557f0a0 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Coder.Desktop.App.Models; @@ -73,6 +74,8 @@ public async Task ExitApplication() { _handleWindowClosed = false; Exit(); + var syncController = _services.GetRequiredService(); + await syncController.DisposeAsync(); var rpcController = _services.GetRequiredService(); // TODO: send a StopRequest if we're connected??? await rpcController.DisposeAsync(); @@ -86,20 +89,52 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected) // Passing in a CT with no cancellation is desired here, because // the named pipe open will block until the pipe comes up. - _ = rpcController.Reconnect(CancellationToken.None); + // TODO: log + _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t => + { +#if DEBUG + if (t.Exception != null) + { + Debug.WriteLine(t.Exception); + Debugger.Break(); + } +#endif + }); - // Load the credentials in the background. Even though we pass a CT - // with no cancellation, the method itself will impose a timeout on the - // HTTP portion. + // Load the credentials in the background. + var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var credentialManager = _services.GetRequiredService(); - _ = credentialManager.LoadCredentials(CancellationToken.None); + _ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t => + { + // TODO: log +#if DEBUG + if (t.Exception != null) + { + Debug.WriteLine(t.Exception); + Debugger.Break(); + } +#endif + credentialManagerCts.Dispose(); + }, CancellationToken.None); + + // Initialize file sync. + var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var syncSessionController = _services.GetRequiredService(); + _ = syncSessionController.Initialize(syncSessionCts.Token).ContinueWith(t => + { + // TODO: log +#if DEBUG + if (t.IsCanceled || t.Exception != null) Debugger.Break(); +#endif + syncSessionCts.Dispose(); + }, CancellationToken.None); // Prevent the TrayWindow from closing, just hide it. var trayWindow = _services.GetRequiredService(); - trayWindow.Closed += (sender, args) => + trayWindow.Closed += (_, closedArgs) => { if (!_handleWindowClosed) return; - args.Handled = true; + closedArgs.Handled = true; trayWindow.AppWindow.Hide(); }; } diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs index d8d261d..0f47054 100644 --- a/App/Models/SyncSessionModel.cs +++ b/App/Models/SyncSessionModel.cs @@ -1,4 +1,3 @@ -using System; using Coder.Desktop.App.Converters; using Coder.Desktop.MutagenSdk.Proto.Synchronization; using Coder.Desktop.MutagenSdk.Proto.Url; @@ -64,6 +63,10 @@ public class SyncSessionModel public readonly string[] Errors = []; + // If Paused is true, the session can be resumed. If false, the session can + // be paused. + public bool Paused => StatusCategory is SyncSessionStatusCategory.Paused or SyncSessionStatusCategory.Halted; + public string StatusDetails { get @@ -84,37 +87,6 @@ public string SizeDetails } } - // TODO: remove once we process sessions from the mutagen RPC - public SyncSessionModel(string alphaPath, string betaName, string betaPath, - SyncSessionStatusCategory statusCategory, - string statusString, string statusDescription, string[] errors) - { - Identifier = "TODO"; - Name = "TODO"; - - AlphaName = "Local"; - AlphaPath = alphaPath; - BetaName = betaName; - BetaPath = betaPath; - StatusCategory = statusCategory; - StatusString = statusString; - StatusDescription = statusDescription; - AlphaSize = new SyncSessionModelEndpointSize - { - SizeBytes = (ulong)new Random().Next(0, 1000000000), - FileCount = (ulong)new Random().Next(0, 10000), - DirCount = (ulong)new Random().Next(0, 10000), - }; - BetaSize = new SyncSessionModelEndpointSize - { - SizeBytes = (ulong)new Random().Next(0, 1000000000), - FileCount = (ulong)new Random().Next(0, 10000), - DirCount = (ulong)new Random().Next(0, 10000), - }; - - Errors = errors; - } - public SyncSessionModel(State state) { Identifier = state.Session.Identifier; diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 4bd5688..c88ec91 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -3,36 +3,67 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.MutagenSdk; using Coder.Desktop.MutagenSdk.Proto.Selection; using Coder.Desktop.MutagenSdk.Proto.Service.Daemon; +using Coder.Desktop.MutagenSdk.Proto.Service.Prompting; using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Url; using Coder.Desktop.Vpn.Utilities; +using Grpc.Core; using Microsoft.Extensions.Options; -using TerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Daemon.TerminateRequest; +using DaemonTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Daemon.TerminateRequest; +using SynchronizationTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Synchronization.TerminateRequest; namespace Coder.Desktop.App.Services; public class CreateSyncSessionRequest { - // TODO: this + public Uri Alpha { get; init; } + public Uri Beta { get; init; } + + public URL AlphaMutagenUrl => MutagenUrl(Alpha); + public URL BetaMutagenUrl => MutagenUrl(Beta); + + private static URL MutagenUrl(Uri uri) + { + var protocol = uri.Scheme switch + { + "file" => Protocol.Local, + "ssh" => Protocol.Ssh, + _ => throw new ArgumentException("Only 'file' and 'ssh' URLs are supported", nameof(uri)), + }; + + return new URL + { + Kind = Kind.Synchronization, + Protocol = protocol, + User = uri.UserInfo, + Host = uri.Host, + Port = uri.Port < 0 ? 0 : (uint)uri.Port, + Path = protocol is Protocol.Local ? uri.LocalPath : uri.AbsolutePath, + }; + } } -public interface ISyncSessionController +public interface ISyncSessionController : IAsyncDisposable { - Task> ListSyncSessions(CancellationToken ct); - Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct); - - Task TerminateSyncSession(string identifier, CancellationToken ct); + Task> ListSyncSessions(CancellationToken ct = default); + Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default); + Task PauseSyncSession(string identifier, CancellationToken ct = default); + Task ResumeSyncSession(string identifier, CancellationToken ct = default); + Task TerminateSyncSession(string identifier, CancellationToken ct = default); // // Initializes the controller; running the daemon if there are any saved sessions. Must be called and // complete before other methods are allowed. // - Task Initialize(CancellationToken ct); + Task Initialize(CancellationToken ct = default); } // These values are the config option names used in the registry. Any option @@ -78,7 +109,6 @@ public sealed class MutagenController : ISyncSessionController, IAsyncDisposable private readonly string _mutagenExecutablePath; - private readonly string _mutagenDataDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CoderDesktop", @@ -97,7 +127,7 @@ public MutagenController(string executablePath, string dataDirectory) public async ValueTask DisposeAsync() { - Task? transition = null; + Task? transition; using (_ = await _lock.LockAsync(CancellationToken.None)) { _disposing = true; @@ -110,24 +140,112 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } - - public async Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct) + public async Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default) { // reads of _sessionCount are atomic, so don't bother locking for this quick check. if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first"); var client = await EnsureDaemon(ct); - // TODO: implement + + await using var prompter = await CreatePrompter(client, true, ct); + var createRes = await client.Synchronization.CreateAsync(new CreateRequest + { + Prompter = prompter.Identifier, + Specification = new CreationSpecification + { + Alpha = req.AlphaMutagenUrl, + Beta = req.BetaMutagenUrl, + // TODO: probably should set these at some point + Configuration = new Configuration(), + ConfigurationAlpha = new Configuration(), + ConfigurationBeta = new Configuration(), + }, + }, cancellationToken: ct); + if (createRes == null) throw new InvalidOperationException("CreateAsync returned null"); + + // Increment session count early, to avoid list failures interfering + // with the count. using (_ = await _lock.LockAsync(ct)) { _sessionCount += 1; } - // TODO: implement this - return new SyncSessionModel(@"C:\path", "remote", "~/path", SyncSessionStatusCategory.Ok, "Watching", - "Description", []); + var listRes = await client.Synchronization.ListAsync(new ListRequest + { + Selection = new Selection + { + Specifications = { createRes.Session }, + }, + }, cancellationToken: ct); + if (listRes == null) throw new InvalidOperationException("ListAsync returned null"); + if (listRes.SessionStates.Count != 1) + throw new InvalidOperationException("ListAsync returned wrong number of sessions"); + + return new SyncSessionModel(listRes.SessionStates[0]); + } + + public async Task PauseSyncSession(string identifier, CancellationToken ct = default) + { + // reads of _sessionCount are atomic, so don't bother locking for this quick check. + if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first"); + var client = await EnsureDaemon(ct); + + // Pausing sessions doesn't require prompting as seen in the mutagen CLI. + await using var prompter = await CreatePrompter(client, false, ct); + _ = await client.Synchronization.PauseAsync(new PauseRequest + { + Prompter = prompter.Identifier, + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + + var listRes = await client.Synchronization.ListAsync(new ListRequest + { + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + if (listRes == null) throw new InvalidOperationException("ListAsync returned null"); + if (listRes.SessionStates.Count != 1) + throw new InvalidOperationException("ListAsync returned wrong number of sessions"); + + return new SyncSessionModel(listRes.SessionStates[0]); + } + + public async Task ResumeSyncSession(string identifier, CancellationToken ct = default) + { + // reads of _sessionCount are atomic, so don't bother locking for this quick check. + if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first"); + var client = await EnsureDaemon(ct); + + // Resuming sessions doesn't require prompting as seen in the mutagen CLI. + await using var prompter = await CreatePrompter(client, false, ct); + _ = await client.Synchronization.ResumeAsync(new ResumeRequest + { + Prompter = prompter.Identifier, + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + + var listRes = await client.Synchronization.ListAsync(new ListRequest + { + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); + if (listRes == null) throw new InvalidOperationException("ListAsync returned null"); + if (listRes.SessionStates.Count != 1) + throw new InvalidOperationException("ListAsync returned wrong number of sessions"); + + return new SyncSessionModel(listRes.SessionStates[0]); } - public async Task> ListSyncSessions(CancellationToken ct) + public async Task> ListSyncSessions(CancellationToken ct = default) { // reads of _sessionCount are atomic, so don't bother locking for this quick check. switch (_sessionCount) @@ -140,11 +258,19 @@ public async Task> ListSyncSessions(CancellationTo return []; } - // TODO: implement this - return []; + var client = await EnsureDaemon(ct); + var res = await client.Synchronization.ListAsync(new ListRequest + { + Selection = new Selection { All = true }, + }, cancellationToken: ct); + + if (res == null) return []; + return res.SessionStates.Select(s => new SyncSessionModel(s)); + + // TODO: the daemon should be stopped if there are no sessions. } - public async Task Initialize(CancellationToken ct) + public async Task Initialize(CancellationToken ct = default) { using (_ = await _lock.LockAsync(ct)) { @@ -155,10 +281,7 @@ public async Task Initialize(CancellationToken ct) var client = await EnsureDaemon(ct); var sessions = await client.Synchronization.ListAsync(new ListRequest { - Selection = new Selection - { - All = true, - }, + Selection = new Selection { All = true }, }, cancellationToken: ct); using (_ = await _lock.LockAsync(ct)) @@ -180,11 +303,22 @@ public async Task Initialize(CancellationToken ct) } } - public async Task TerminateSyncSession(string identifier, CancellationToken ct) + public async Task TerminateSyncSession(string identifier, CancellationToken ct = default) { if (_sessionCount < 0) throw new InvalidOperationException("Controller must be Initialized first"); var client = await EnsureDaemon(ct); - // TODO: implement + + // Terminating sessions doesn't require prompting as seen in the mutagen CLI. + await using var prompter = await CreatePrompter(client, true, ct); + + _ = await client.Synchronization.TerminateAsync(new SynchronizationTerminateRequest + { + Prompter = prompter.Identifier, + Selection = new Selection + { + Specifications = { identifier }, + }, + }, cancellationToken: ct); // here we don't use the Cancellation Token, since we want to decrement and possibly // stop the daemon even if we were cancelled, since we already successfully terminated @@ -207,7 +341,6 @@ public async Task TerminateSyncSession(string identifier, CancellationToken ct) } } - private async Task EnsureDaemon(CancellationToken ct) { while (true) @@ -253,18 +386,21 @@ private void RemoveTransition(Task transition) try { var client = new MutagenClient(_mutagenDataDirectory); - await client.Daemon.TerminateAsync(new TerminateRequest(), cancellationToken: ct); + await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); } catch (FileNotFoundException) { // Mainline; no daemon running. } + catch (InvalidOperationException) + { + // Mainline; no daemon running. + } // If we get some failure while creating the log file or starting the process, we'll retry // it up to 5 times x 100ms. Those issues should resolve themselves quickly if they are // going to at all. const int maxAttempts = 5; - ListResponse? sessions = null; for (var attempts = 1; attempts <= maxAttempts; attempts++) { ct.ThrowIfCancellationRequested(); @@ -331,6 +467,7 @@ private void StartDaemonProcessLocked() throw new InvalidOperationException("startDaemonLock called when daemonProcess already present"); // create the log file first, so ensure we have permissions + Directory.CreateDirectory(_mutagenDataDirectory); var logPath = Path.Combine(_mutagenDataDirectory, "daemon.log"); var logStream = new StreamWriter(logPath, true); @@ -338,10 +475,14 @@ private void StartDaemonProcessLocked() _daemonProcess.StartInfo.FileName = _mutagenExecutablePath; _daemonProcess.StartInfo.Arguments = "daemon run"; _daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory); + // hide the console window + _daemonProcess.StartInfo.CreateNoWindow = true; // shell needs to be disabled since we set the environment // https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.environment?view=net-8.0 _daemonProcess.StartInfo.UseShellExecute = false; _daemonProcess.StartInfo.RedirectStandardError = true; + // TODO: log exited process + // _daemonProcess.Exited += ... _daemonProcess.Start(); var writer = new LogWriter(_daemonProcess.StandardError, logStream); @@ -375,7 +516,7 @@ private void StartDaemonProcessLocked() { try { - await client.Daemon.TerminateAsync(new TerminateRequest(), cancellationToken: ct); + await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); } catch { @@ -398,6 +539,109 @@ private void StartDaemonProcessLocked() return null; } + + private static async Task CreatePrompter(MutagenClient client, bool allowPrompts = false, + CancellationToken ct = default) + { + var dup = client.Prompting.Host(cancellationToken: ct); + if (dup == null) throw new InvalidOperationException("Prompting.Host returned null"); + + try + { + // Write first request. + await dup.RequestStream.WriteAsync(new HostRequest + { + AllowPrompts = allowPrompts, + }, ct); + + // Read initial response. + if (!await dup.ResponseStream.MoveNext(ct)) + throw new InvalidOperationException("Prompting.Host response stream ended early"); + var response = dup.ResponseStream.Current; + if (response == null) + throw new InvalidOperationException("Prompting.Host response stream returned null"); + if (string.IsNullOrEmpty(response.Identifier)) + throw new InvalidOperationException("Prompting.Host response stream returned empty identifier"); + + return new Prompter(response.Identifier, dup, ct); + } + catch + { + await dup.RequestStream.CompleteAsync(); + dup.Dispose(); + throw; + } + } + + private class Prompter : IAsyncDisposable + { + private readonly AsyncDuplexStreamingCall _dup; + private readonly CancellationTokenSource _cts; + private readonly Task _handleRequestsTask; + public string Identifier { get; } + + public Prompter(string identifier, AsyncDuplexStreamingCall dup, + CancellationToken ct) + { + Identifier = identifier; + _dup = dup; + + _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _handleRequestsTask = HandleRequests(_cts.Token); + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + try + { + await _handleRequestsTask; + } + catch + { + // ignored + } + + _cts.Dispose(); + GC.SuppressFinalize(this); + } + + private async Task HandleRequests(CancellationToken ct) + { + try + { + while (true) + { + ct.ThrowIfCancellationRequested(); + + // Read next request and validate it. + if (!await _dup.ResponseStream.MoveNext(ct)) + throw new InvalidOperationException("Prompting.Host response stream ended early"); + var response = _dup.ResponseStream.Current; + if (response == null) + throw new InvalidOperationException("Prompting.Host response stream returned null"); + if (response.Message == null) + throw new InvalidOperationException("Prompting.Host response stream returned a null message"); + + // Currently we only reply to SSH fingerprint messages with + // "yes" and send an empty reply for everything else. + var reply = ""; + if (response.IsPrompt && response.Message.Contains("yes/no/[fingerprint]")) reply = "yes"; + + await _dup.RequestStream.WriteAsync(new HostRequest + { + Response = reply, + }, ct); + } + } + catch + { + await _dup.RequestStream.CompleteAsync(); + _dup.Dispose(); + // TODO: log? + } + } + } } public class LogWriter(StreamReader reader, StreamWriter writer) : IDisposable diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 45ca318..795e422 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -10,12 +10,14 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using WinRT.Interop; namespace Coder.Desktop.App.ViewModels; public partial class FileSyncListViewModel : ObservableObject { + private Window? _window; private DispatcherQueue? _dispatcherQueue; private readonly ISyncSessionController _syncSessionController; @@ -40,7 +42,9 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowSessions))] public partial string? Error { get; set; } = null; - [ObservableProperty] public partial List Sessions { get; set; } = []; + [ObservableProperty] public partial bool OperationInProgress { get; set; } = false; + + [ObservableProperty] public partial List Sessions { get; set; } = []; [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; @@ -54,7 +58,7 @@ public partial class FileSyncListViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] - public partial string NewSessionRemoteName { get; set; } = ""; + public partial string NewSessionRemoteHost { get; set; } = ""; [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] @@ -67,7 +71,7 @@ public bool NewSessionCreateEnabled { if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false; if (NewSessionLocalPathDialogOpen) return false; - if (string.IsNullOrWhiteSpace(NewSessionRemoteName)) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false; if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; return true; } @@ -85,29 +89,11 @@ public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcC _syncSessionController = syncSessionController; _rpcController = rpcController; _credentialManager = credentialManager; - - Sessions = - [ - new SyncSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows", - SyncSessionStatusCategory.Ok, "Watching", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Paused, - "Paused", - "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts, - "Conflicts", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Halted, - "Halted on root emptied", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error, - "Some error", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Unknown, - "Unknown", "Some description", []), - new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Working, - "Reconciling", "Some description", []), - ]; } - public void Initialize(DispatcherQueue dispatcherQueue) + public void Initialize(Window window, DispatcherQueue dispatcherQueue) { + _window = window; _dispatcherQueue = dispatcherQueue; if (!_dispatcherQueue.HasThreadAccess) throw new InvalidOperationException("Initialize must be called from the UI thread"); @@ -118,9 +104,7 @@ public void Initialize(DispatcherQueue dispatcherQueue) var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); MaybeSetUnavailableMessage(rpcModel, credentialModel); - - // TODO: Simulate loading until we have real data. - Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false)); + if (UnavailableMessage == null) ReloadSessions(); } private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) @@ -153,22 +137,32 @@ private void CredentialManagerCredentialsChanged(object? sender, CredentialModel private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel) { + var oldMessage = UnavailableMessage; if (rpcModel.RpcLifecycle != RpcLifecycle.Connected) + { UnavailableMessage = "Disconnected from the Windows service. Please see the tray window for more information."; + } else if (credentialModel.State != CredentialState.Valid) + { UnavailableMessage = "Please sign in to access file sync."; + } else if (rpcModel.VpnLifecycle != VpnLifecycle.Started) + { UnavailableMessage = "Please start Coder Connect from the tray window to access file sync."; + } else + { UnavailableMessage = null; + if (oldMessage != null) ReloadSessions(); + } } private void ClearNewForm() { CreatingNewSession = false; NewSessionLocalPath = ""; - NewSessionRemoteName = ""; + NewSessionRemoteHost = ""; NewSessionRemotePath = ""; } @@ -178,7 +172,7 @@ private void ReloadSessions() Loading = true; Error = null; var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - _ = _syncSessionController.ListSyncSessions(cts.Token).ContinueWith(HandleList, cts.Token); + _syncSessionController.ListSyncSessions(cts.Token).ContinueWith(HandleList, CancellationToken.None); } private void HandleList(Task> t) @@ -193,7 +187,7 @@ private void HandleList(Task> t) if (t.IsCompletedSuccessfully) { - Sessions = t.Result.ToList(); + Sessions = t.Result.Select(s => new SyncSessionViewModel(this, s)).ToList(); Loading = false; return; } @@ -246,9 +240,137 @@ private void CancelNewSession() } [RelayCommand] - private void ConfirmNewSession() + private async Task ConfirmNewSession() { - // TODO: implement - ClearNewForm(); + if (OperationInProgress || !NewSessionCreateEnabled) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + try + { + var alphaUri = new UriBuilder + { + Scheme = "file", + Host = "", + Path = NewSessionLocalPath, + }.Uri; + var betaUri = new UriBuilder + { + Scheme = "ssh", + Host = NewSessionRemoteHost, + Path = NewSessionRemotePath, + }.Uri; + + await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = alphaUri, + Beta = betaUri, + }, cts.Token); + + ClearNewForm(); + ReloadSessions(); + } + catch (Exception e) + { + var dialog = new ContentDialog + { + Title = "Failed to create sync session", + Content = $"{e}", + CloseButtonText = "Ok", + XamlRoot = _window?.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + OperationInProgress = false; + } + } + + public async Task PauseOrResumeSession(string identifier) + { + if (OperationInProgress) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var actionString = "resume/pause"; + try + { + if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session) + throw new InvalidOperationException("Session not found"); + + if (session.Model.Paused) + { + actionString = "resume"; + await _syncSessionController.ResumeSyncSession(session.Model.Identifier, cts.Token); + } + else + { + actionString = "pause"; + await _syncSessionController.PauseSyncSession(session.Model.Identifier, cts.Token); + } + + ReloadSessions(); + } + catch (Exception e) + { + var dialog = new ContentDialog + { + Title = $"Failed to {actionString} sync session", + Content = $"Identifier: {identifier}\n{e}", + CloseButtonText = "Ok", + XamlRoot = _window?.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + OperationInProgress = false; + } + } + + public async Task TerminateSession(string identifier) + { + if (OperationInProgress) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + try + { + if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session) + throw new InvalidOperationException("Session not found"); + + var confirmDialog = new ContentDialog + { + Title = "Terminate sync session", + Content = "Are you sure you want to terminate this sync session?", + PrimaryButtonText = "Terminate", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = _window?.Content.XamlRoot, + }; + var res = await confirmDialog.ShowAsync(); + if (res is not ContentDialogResult.Primary) + return; + + await _syncSessionController.TerminateSyncSession(session.Model.Identifier, cts.Token); + + ReloadSessions(); + } + catch (Exception e) + { + var dialog = new ContentDialog + { + Title = "Failed to terminate sync session", + Content = $"Identifier: {identifier}\n{e}", + CloseButtonText = "Ok", + XamlRoot = _window?.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + OperationInProgress = false; + } } } diff --git a/App/ViewModels/SyncSessionViewModel.cs b/App/ViewModels/SyncSessionViewModel.cs new file mode 100644 index 0000000..68870ac --- /dev/null +++ b/App/ViewModels/SyncSessionViewModel.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Coder.Desktop.App.ViewModels; + +public partial class SyncSessionViewModel : ObservableObject +{ + public SyncSessionModel Model { get; } + + private FileSyncListViewModel Parent { get; } + + public string Icon => Model.Paused ? "\uE768" : "\uE769"; + + public SyncSessionViewModel(FileSyncListViewModel parent, SyncSessionModel model) + { + Parent = parent; + Model = model; + } + + [RelayCommand] + public async Task PauseOrResumeSession() + { + await Parent.PauseOrResumeSession(Model.Identifier); + } + + [RelayCommand] + public async Task TerminateSession() + { + await Parent.TerminateSession(Model.Identifier); + } +} diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index 27d386d..8a409d7 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -15,7 +15,7 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) InitializeComponent(); SystemBackdrop = new DesktopAcrylicBackdrop(); - ViewModel.Initialize(DispatcherQueue); + ViewModel.Initialize(this, DispatcherQueue); RootFrame.Content = new FileSyncListMainPage(ViewModel, this); this.CenterOnScreen(); diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index 768e396..17009da 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -6,7 +6,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:models="using:Coder.Desktop.App.Models" + xmlns:viewmodels="using:Coder.Desktop.App.ViewModels" xmlns:converters="using:Coder.Desktop.App.Converters" mc:Ignorable="d" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> @@ -126,7 +126,7 @@ - + @@ -145,11 +145,18 @@ - - + + - + + @@ -157,19 +164,19 @@ @@ -177,7 +184,7 @@ + SelectedKey="{x:Bind Path=Model.StatusCategory}"> @@ -202,15 +209,15 @@ + ToolTipService.ToolTip="{x:Bind Model.StatusDetails}" /> + Text="{x:Bind Model.AlphaSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}" + ToolTipService.ToolTip="{x:Bind Model.SizeDetails}" /> @@ -315,7 +322,7 @@ + Text="{x:Bind ViewModel.NewSessionRemoteHost, Mode=TwoWay}" /> - + + - - + diff --git a/MutagenSdk/MutagenClient.cs b/MutagenSdk/MutagenClient.cs index e740af6..950d9b1 100644 --- a/MutagenSdk/MutagenClient.cs +++ b/MutagenSdk/MutagenClient.cs @@ -1,4 +1,5 @@ using Coder.Desktop.MutagenSdk.Proto.Service.Daemon; +using Coder.Desktop.MutagenSdk.Proto.Service.Prompting; using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization; using Grpc.Core; using Grpc.Net.Client; @@ -10,6 +11,7 @@ public class MutagenClient : IDisposable private readonly GrpcChannel _channel; public readonly Daemon.DaemonClient Daemon; + public readonly Prompting.PromptingClient Prompting; public readonly Synchronization.SynchronizationClient Synchronization; public MutagenClient(string dataDir) @@ -20,6 +22,20 @@ public MutagenClient(string dataDir) throw new FileNotFoundException( "Mutagen daemon lock file not found, did the mutagen daemon start successfully?", daemonLockFile); + // We should not be able to open the lock file. + try + { + using var _ = File.Open(daemonLockFile, FileMode.Open, FileAccess.Write, FileShare.None); + // We throw a FileNotFoundException if we could open the file because + // it means the same thing and allows us to return the path nicely. + throw new InvalidOperationException( + $"Mutagen daemon lock file '{daemonLockFile}' is unlocked, did the mutagen daemon start successfully?"); + } + catch (IOException) + { + // this is what we expect + } + // Read the IPC named pipe address from the sock file. var daemonSockFile = Path.Combine(dataDir, "daemon", "daemon.sock"); if (!File.Exists(daemonSockFile)) @@ -50,6 +66,7 @@ public MutagenClient(string dataDir) }); Daemon = new Daemon.DaemonClient(_channel); + Prompting = new Prompting.PromptingClient(_channel); Synchronization = new Synchronization.SynchronizationClient(_channel); } diff --git a/MutagenSdk/Proto/service/prompting/prompting.proto b/MutagenSdk/Proto/service/prompting/prompting.proto new file mode 100644 index 0000000..19ea8bb --- /dev/null +++ b/MutagenSdk/Proto/service/prompting/prompting.proto @@ -0,0 +1,81 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/prompting/prompting.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package prompting; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Service.Prompting"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/service/prompting"; + +// HostRequest encodes either an initial request to perform prompt hosting or a +// follow-up response to a message or prompt. +message HostRequest { + // AllowPrompts indicates whether or not the hoster will allow prompts. If + // not, it will only receive message requests. This field may only be set on + // the initial request. + bool allowPrompts = 1; + // Response is the prompt response, if any. On the initial request, this + // must be an empty string. When responding to a prompt, it may be any + // value. When responding to a message, it must be an empty string. + string response = 2; +} + +// HostResponse encodes either an initial response to perform prompt hosting or +// a follow-up request for messaging or prompting. +message HostResponse { + // Identifier is the prompter identifier. It is only set in the initial + // response sent after the initial request. + string identifier = 1; + // IsPrompt indicates if the response is requesting a prompt (as opposed to + // simple message display). + bool isPrompt = 2; + // Message is the message associated with the prompt or message. + string message = 3; +} + +// PromptRequest encodes a request for prompting by a specific prompter. +message PromptRequest { + // Prompter is the prompter identifier. + string prompter = 1; + // Prompt is the prompt to present. + string prompt = 2; +} + +// PromptResponse encodes the response from a prompter. +message PromptResponse { + // Response is the response returned by the prompter. + string response = 1; +} + +// Prompting allows clients to host and request prompting. +service Prompting { + // Host allows clients to perform prompt hosting. + rpc Host(stream HostRequest) returns (stream HostResponse) {} + // Prompt performs prompting using a specific prompter. + rpc Prompt(PromptRequest) returns (PromptResponse) {} +} diff --git a/MutagenSdk/Update-Proto.ps1 b/MutagenSdk/Update-Proto.ps1 index 4bf2f94..33e69e6 100644 --- a/MutagenSdk/Update-Proto.ps1 +++ b/MutagenSdk/Update-Proto.ps1 @@ -9,8 +9,9 @@ $ErrorActionPreference = "Stop" $repo = "mutagen-io/mutagen" $protoPrefix = "pkg" $entryFiles = @( - "service/synchronization/synchronization.proto", - "service/daemon/daemon.proto" + "service/daemon/daemon.proto", + "service/prompting/prompting.proto", + "service/synchronization/synchronization.proto" ) $outputNamespace = "Coder.Desktop.MutagenSdk.Proto" diff --git a/Tests.App/Services/MutagenControllerTest.cs b/Tests.App/Services/MutagenControllerTest.cs index be054a7..32cc090 100644 --- a/Tests.App/Services/MutagenControllerTest.cs +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -37,6 +37,15 @@ public void CreateTempDir() TestContext.Out.WriteLine($"temp directory: {_tempDirectory}"); } + [TearDown] + public void DeleteTempDir() + { + _tempDirectory.Delete(true); + } + + private string _mutagenBinaryPath; + private DirectoryInfo _tempDirectory; + private readonly string _arch = RuntimeInformation.ProcessArchitecture switch { Architecture.X64 => "x64", @@ -46,22 +55,8 @@ public void CreateTempDir() $"Unsupported architecture '{RuntimeInformation.ProcessArchitecture}'. Coder only supports x64 and arm64."), }; - private string _mutagenBinaryPath; - private DirectoryInfo _tempDirectory; - - [Test(Description = "Shut down daemon when no sessions")] - [CancelAfter(30_000)] - public async Task ShutdownNoSessions(CancellationToken ct) + private static async Task AcquireDaemonLock(string dataDirectory, CancellationToken ct) { - // NUnit runs each test in a temporary directory - var dataDirectory = _tempDirectory.FullName; - await using var controller = new MutagenController(_mutagenBinaryPath, dataDirectory); - await controller.Initialize(ct); - - // log file tells us the daemon was started. - var logPath = Path.Combine(dataDirectory, "daemon.log"); - Assert.That(File.Exists(logPath)); - var lockPath = Path.Combine(dataDirectory, "daemon", "daemon.lock"); // If we can lock the daemon.lock file, it means the daemon has stopped. while (true) @@ -73,7 +68,7 @@ public async Task ShutdownNoSessions(CancellationToken ct) } catch (IOException e) { - TestContext.Out.WriteLine($"Didn't get lock (will retry): {e.Message}"); + TestContext.Out.WriteLine($"Could not acquire daemon.lock (will retry): {e.Message}"); await Task.Delay(100, ct); } @@ -81,18 +76,111 @@ public async Task ShutdownNoSessions(CancellationToken ct) } } + [Test(Description = "Full sync test")] + [CancelAfter(30_000)] + public async Task Ok(CancellationToken ct) + { + // NUnit runs each test in a temporary directory + var dataDirectory = _tempDirectory.CreateSubdirectory("mutagen").FullName; + var alphaDirectory = _tempDirectory.CreateSubdirectory("alpha"); + var betaDirectory = _tempDirectory.CreateSubdirectory("beta"); + + await using var controller = new MutagenController(_mutagenBinaryPath, dataDirectory); + await controller.Initialize(ct); + + var sessions = (await controller.ListSyncSessions(ct)).ToList(); + Assert.That(sessions, Is.Empty); + + var session1 = await controller.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new Uri("file:///" + alphaDirectory.FullName), + Beta = new Uri("file:///" + betaDirectory.FullName), + }, ct); + + sessions = (await controller.ListSyncSessions(ct)).ToList(); + Assert.That(sessions, Has.Count.EqualTo(1)); + Assert.That(sessions[0].Identifier, Is.EqualTo(session1.Identifier)); + + var session2 = await controller.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new Uri("file:///" + alphaDirectory.FullName), + Beta = new Uri("file:///" + betaDirectory.FullName), + }, ct); + + sessions = (await controller.ListSyncSessions(ct)).ToList(); + Assert.That(sessions, Has.Count.EqualTo(2)); + Assert.That(sessions.Any(s => s.Identifier == session1.Identifier)); + Assert.That(sessions.Any(s => s.Identifier == session2.Identifier)); + + // Write a file to alpha. + var alphaFile = Path.Combine(alphaDirectory.FullName, "file.txt"); + var betaFile = Path.Combine(betaDirectory.FullName, "file.txt"); + const string alphaContent = "hello"; + await File.WriteAllTextAsync(alphaFile, alphaContent, ct); + + // Wait for the file to appear in beta. + while (true) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(100, ct); + if (!File.Exists(betaFile)) + { + TestContext.Out.WriteLine("Waiting for file to appear in beta"); + continue; + } + + var betaContent = await File.ReadAllTextAsync(betaFile, ct); + if (betaContent == alphaContent) break; + TestContext.Out.WriteLine($"Waiting for file contents to match, current: {betaContent}"); + } + + await controller.TerminateSyncSession(session1.Identifier, ct); + await controller.TerminateSyncSession(session2.Identifier, ct); + + // Ensure the daemon is stopped. + await AcquireDaemonLock(dataDirectory, ct); + + sessions = (await controller.ListSyncSessions(ct)).ToList(); + Assert.That(sessions, Is.Empty); + } + + [Test(Description = "Shut down daemon when no sessions")] + [CancelAfter(30_000)] + public async Task ShutdownNoSessions(CancellationToken ct) + { + // NUnit runs each test in a temporary directory + var dataDirectory = _tempDirectory.FullName; + await using var controller = new MutagenController(_mutagenBinaryPath, dataDirectory); + await controller.Initialize(ct); + + // log file tells us the daemon was started. + var logPath = Path.Combine(dataDirectory, "daemon.log"); + Assert.That(File.Exists(logPath)); + + // Ensure the daemon is stopped. + await AcquireDaemonLock(dataDirectory, ct); + } + [Test(Description = "Daemon is restarted when we create a session")] [CancelAfter(30_000)] public async Task CreateRestartsDaemon(CancellationToken ct) { // NUnit runs each test in a temporary directory - var dataDirectory = _tempDirectory.FullName; + var dataDirectory = _tempDirectory.CreateSubdirectory("mutagen").FullName; + var alphaDirectory = _tempDirectory.CreateSubdirectory("alpha"); + var betaDirectory = _tempDirectory.CreateSubdirectory("beta"); + await using (var controller = new MutagenController(_mutagenBinaryPath, dataDirectory)) { await controller.Initialize(ct); - await controller.CreateSyncSession(new CreateSyncSessionRequest(), ct); + await controller.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new Uri("file:///" + alphaDirectory.FullName), + Beta = new Uri("file:///" + betaDirectory.FullName), + }, ct); } + await AcquireDaemonLock(dataDirectory, ct); var logPath = Path.Combine(dataDirectory, "daemon.log"); Assert.That(File.Exists(logPath)); var logLines = await File.ReadAllLinesAsync(logPath, ct); @@ -107,14 +195,21 @@ public async Task CreateRestartsDaemon(CancellationToken ct) public async Task Orphaned(CancellationToken ct) { // NUnit runs each test in a temporary directory - var dataDirectory = _tempDirectory.FullName; + var dataDirectory = _tempDirectory.CreateSubdirectory("mutagen").FullName; + var alphaDirectory = _tempDirectory.CreateSubdirectory("alpha"); + var betaDirectory = _tempDirectory.CreateSubdirectory("beta"); + MutagenController? controller1 = null; MutagenController? controller2 = null; try { controller1 = new MutagenController(_mutagenBinaryPath, dataDirectory); await controller1.Initialize(ct); - await controller1.CreateSyncSession(new CreateSyncSessionRequest(), ct); + await controller1.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new Uri("file:///" + alphaDirectory.FullName), + Beta = new Uri("file:///" + betaDirectory.FullName), + }, ct); controller2 = new MutagenController(_mutagenBinaryPath, dataDirectory); await controller2.Initialize(ct); @@ -125,6 +220,8 @@ public async Task Orphaned(CancellationToken ct) if (controller2 != null) await controller2.DisposeAsync(); } + await AcquireDaemonLock(dataDirectory, ct); + var logPath = Path.Combine(dataDirectory, "daemon.log"); Assert.That(File.Exists(logPath)); var logLines = await File.ReadAllLinesAsync(logPath, ct); @@ -133,6 +230,4 @@ public async Task Orphaned(CancellationToken ct) // slightly brittle, but unlikely this log line will change. Assert.That(logLines.Count(s => s.Contains("[sync] Session manager initialized")), Is.EqualTo(3)); } - - // TODO: Add more tests once we actually implement creating sessions on the daemon } From 81f8c02311c6c9fdcefbb4ae41ff19f6b9670aee Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 27 Mar 2025 16:02:50 +1100 Subject: [PATCH 2/5] Conflict messages --- App/Models/SyncSessionModel.cs | 190 ++++++++++++++++++++++++++++++++- 1 file changed, 185 insertions(+), 5 deletions(-) diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs index 0f47054..8784fa8 100644 --- a/App/Models/SyncSessionModel.cs +++ b/App/Models/SyncSessionModel.cs @@ -1,5 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using Coder.Desktop.App.Converters; using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core; using Coder.Desktop.MutagenSdk.Proto.Url; namespace Coder.Desktop.App.Models; @@ -44,6 +49,159 @@ public string Description(string linePrefix = "") } } +public enum SyncSessionModelEntryKind +{ + Unknown, + Directory, + File, + SymbolicLink, + Untracked, + Problematic, + PhantomDirectory, +} + +public sealed class SyncSessionModelEntry +{ + public readonly SyncSessionModelEntryKind Kind; + + // For Kind == Directory only. + public readonly ReadOnlyDictionary Contents; + + // For Kind == File only. + public readonly string Digest = ""; + public readonly bool Executable; + + // For Kind = SymbolicLink only. + public readonly string Target = ""; + + // For Kind = Problematic only. + public readonly string Problem = ""; + + public SyncSessionModelEntry(Entry protoEntry) + { + Kind = protoEntry.Kind switch + { + EntryKind.Directory => SyncSessionModelEntryKind.Directory, + EntryKind.File => SyncSessionModelEntryKind.File, + EntryKind.SymbolicLink => SyncSessionModelEntryKind.SymbolicLink, + EntryKind.Untracked => SyncSessionModelEntryKind.Untracked, + EntryKind.Problematic => SyncSessionModelEntryKind.Problematic, + EntryKind.PhantomDirectory => SyncSessionModelEntryKind.PhantomDirectory, + _ => SyncSessionModelEntryKind.Unknown, + }; + + switch (Kind) + { + case SyncSessionModelEntryKind.Directory: + { + var contents = new Dictionary(); + foreach (var (key, value) in protoEntry.Contents) + contents[key] = new SyncSessionModelEntry(value); + Contents = new ReadOnlyDictionary(contents); + break; + } + case SyncSessionModelEntryKind.File: + Digest = BitConverter.ToString(protoEntry.Digest.ToByteArray()).Replace("-", "").ToLower(); + Executable = protoEntry.Executable; + break; + case SyncSessionModelEntryKind.SymbolicLink: + Target = protoEntry.Target; + break; + case SyncSessionModelEntryKind.Problematic: + Problem = protoEntry.Problem; + break; + } + } + + public new string ToString() + { + var str = Kind.ToString(); + switch (Kind) + { + case SyncSessionModelEntryKind.Directory: + str += $" ({Contents.Count} entries)"; + break; + case SyncSessionModelEntryKind.File: + str += $" ({Digest}, executable: {Executable})"; + break; + case SyncSessionModelEntryKind.SymbolicLink: + str += $" (target: {Target})"; + break; + case SyncSessionModelEntryKind.Problematic: + str += $" ({Problem})"; + break; + } + + return str; + } +} + +public sealed class SyncSessionModelConflictChange +{ + public readonly string Path; // relative to sync root + + // null means non-existent: + public readonly SyncSessionModelEntry? Old; + public readonly SyncSessionModelEntry? New; + + public SyncSessionModelConflictChange(Change protoChange) + { + Path = protoChange.Path; + Old = protoChange.Old != null ? new SyncSessionModelEntry(protoChange.Old) : null; + New = protoChange.New != null ? new SyncSessionModelEntry(protoChange.New) : null; + } + + public new string ToString() + { + const string nonExistent = ""; + var oldStr = Old != null ? Old.ToString() : nonExistent; + var newStr = New != null ? New.ToString() : nonExistent; + return $"{Path} ({oldStr} -> {newStr})"; + } +} + +public sealed class SyncSessionModelConflict +{ + public readonly string Root; // relative to sync root + public readonly List AlphaChanges; + public readonly List BetaChanges; + + public SyncSessionModelConflict(Conflict protoConflict) + { + Root = protoConflict.Root; + AlphaChanges = protoConflict.AlphaChanges.Select(change => new SyncSessionModelConflictChange(change)).ToList(); + BetaChanges = protoConflict.BetaChanges.Select(change => new SyncSessionModelConflictChange(change)).ToList(); + } + + private string? FriendlyProblem() + { + // If the change is -> !. + if (AlphaChanges.Count == 1 && BetaChanges.Count == 1 && + AlphaChanges[0].Old == null && + BetaChanges[0].Old == null && + AlphaChanges[0].New != null && + BetaChanges[0].New != null) + return + "An entry was created on both endpoints and they do not match. You can resolve this conflict by deleting one of the entries on either side."; + + return null; + } + + public string Description() + { + // This formatting is very similar to Mutagen. + var str = $"Conflict at path '{Root}':"; + foreach (var change in AlphaChanges) + str += $"\n (alpha) {change.ToString()}"; + foreach (var change in AlphaChanges) + str += $"\n (beta) {change.ToString()}"; + if (FriendlyProblem() is { } friendlyProblem) + str += $"\n\n {friendlyProblem}"; + + return str; + } +} + public class SyncSessionModel { public readonly string Identifier; @@ -61,7 +219,9 @@ public class SyncSessionModel public readonly SyncSessionModelEndpointSize AlphaSize; public readonly SyncSessionModelEndpointSize BetaSize; - public readonly string[] Errors = []; + public readonly IReadOnlyList Conflicts; + public ulong OmittedConflicts; + public readonly IReadOnlyList Errors; // If Paused is true, the session can be resumed. If false, the session can // be paused. @@ -72,7 +232,9 @@ public string StatusDetails get { var str = $"{StatusString} ({StatusCategory})\n\n{StatusDescription}"; - foreach (var err in Errors) str += $"\n\n{err}"; + foreach (var err in Errors) str += $"\n\nError: {err}"; + foreach (var conflict in Conflicts) str += $"\n\n{conflict.Description()}"; + if (OmittedConflicts > 0) str += $"\n\n{OmittedConflicts:N0} conflicts omitted"; return str; } } @@ -192,6 +354,9 @@ public SyncSessionModel(State state) StatusDescription = "The session has conflicts that need to be resolved."; } + Conflicts = state.Conflicts.Select(c => new SyncSessionModelConflict(c)).ToList(); + OmittedConflicts = state.ExcludedConflicts; + AlphaSize = new SyncSessionModelEndpointSize { SizeBytes = state.AlphaState.TotalFileSize, @@ -207,9 +372,24 @@ public SyncSessionModel(State state) SymlinkCount = state.BetaState.SymbolicLinks, }; - // TODO: accumulate errors, there seems to be multiple fields they can - // come from - if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError]; + List errors = []; + if (!string.IsNullOrWhiteSpace(state.LastError)) errors.Add($"Last error:\n {state.LastError}"); + // TODO: scan problems + transition problems + omissions should probably be fields + foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Alpha scan problem: {scanProblem}"); + if (state.AlphaState.ExcludedScanProblems > 0) + errors.Add($"Alpha scan problems omitted: {state.AlphaState.ExcludedScanProblems}"); + foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Beta scan problem: {scanProblem}"); + if (state.BetaState.ExcludedScanProblems > 0) + errors.Add($"Beta scan problems omitted: {state.BetaState.ExcludedScanProblems}"); + foreach (var transitionProblem in state.AlphaState.TransitionProblems) + errors.Add($"Alpha transition problem: {transitionProblem}"); + if (state.AlphaState.ExcludedTransitionProblems > 0) + errors.Add($"Alpha transition problems omitted: {state.AlphaState.ExcludedTransitionProblems}"); + foreach (var transitionProblem in state.AlphaState.TransitionProblems) + errors.Add($"Beta transition problem: {transitionProblem}"); + if (state.BetaState.ExcludedTransitionProblems > 0) + errors.Add($"Beta transition problems omitted: {state.BetaState.ExcludedTransitionProblems}"); + Errors = errors; } private static (string, string) NameAndPathFromUrl(URL url) From 01e7e6d7a7629fef307652f554e235686048b136 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 2 Apr 2025 15:04:00 +1100 Subject: [PATCH 3/5] PR comments --- App/Models/SyncSessionModel.cs | 223 ++++++-------------- App/Services/MutagenController.cs | 65 +++--- App/ViewModels/FileSyncListViewModel.cs | 26 +-- Tests.App/Services/MutagenControllerTest.cs | 68 ++++-- 4 files changed, 165 insertions(+), 217 deletions(-) diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs index 8784fa8..b798890 100644 --- a/App/Models/SyncSessionModel.cs +++ b/App/Models/SyncSessionModel.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using Coder.Desktop.App.Converters; using Coder.Desktop.MutagenSdk.Proto.Synchronization; @@ -49,163 +48,9 @@ public string Description(string linePrefix = "") } } -public enum SyncSessionModelEntryKind -{ - Unknown, - Directory, - File, - SymbolicLink, - Untracked, - Problematic, - PhantomDirectory, -} - -public sealed class SyncSessionModelEntry -{ - public readonly SyncSessionModelEntryKind Kind; - - // For Kind == Directory only. - public readonly ReadOnlyDictionary Contents; - - // For Kind == File only. - public readonly string Digest = ""; - public readonly bool Executable; - - // For Kind = SymbolicLink only. - public readonly string Target = ""; - - // For Kind = Problematic only. - public readonly string Problem = ""; - - public SyncSessionModelEntry(Entry protoEntry) - { - Kind = protoEntry.Kind switch - { - EntryKind.Directory => SyncSessionModelEntryKind.Directory, - EntryKind.File => SyncSessionModelEntryKind.File, - EntryKind.SymbolicLink => SyncSessionModelEntryKind.SymbolicLink, - EntryKind.Untracked => SyncSessionModelEntryKind.Untracked, - EntryKind.Problematic => SyncSessionModelEntryKind.Problematic, - EntryKind.PhantomDirectory => SyncSessionModelEntryKind.PhantomDirectory, - _ => SyncSessionModelEntryKind.Unknown, - }; - - switch (Kind) - { - case SyncSessionModelEntryKind.Directory: - { - var contents = new Dictionary(); - foreach (var (key, value) in protoEntry.Contents) - contents[key] = new SyncSessionModelEntry(value); - Contents = new ReadOnlyDictionary(contents); - break; - } - case SyncSessionModelEntryKind.File: - Digest = BitConverter.ToString(protoEntry.Digest.ToByteArray()).Replace("-", "").ToLower(); - Executable = protoEntry.Executable; - break; - case SyncSessionModelEntryKind.SymbolicLink: - Target = protoEntry.Target; - break; - case SyncSessionModelEntryKind.Problematic: - Problem = protoEntry.Problem; - break; - } - } - - public new string ToString() - { - var str = Kind.ToString(); - switch (Kind) - { - case SyncSessionModelEntryKind.Directory: - str += $" ({Contents.Count} entries)"; - break; - case SyncSessionModelEntryKind.File: - str += $" ({Digest}, executable: {Executable})"; - break; - case SyncSessionModelEntryKind.SymbolicLink: - str += $" (target: {Target})"; - break; - case SyncSessionModelEntryKind.Problematic: - str += $" ({Problem})"; - break; - } - - return str; - } -} - -public sealed class SyncSessionModelConflictChange -{ - public readonly string Path; // relative to sync root - - // null means non-existent: - public readonly SyncSessionModelEntry? Old; - public readonly SyncSessionModelEntry? New; - - public SyncSessionModelConflictChange(Change protoChange) - { - Path = protoChange.Path; - Old = protoChange.Old != null ? new SyncSessionModelEntry(protoChange.Old) : null; - New = protoChange.New != null ? new SyncSessionModelEntry(protoChange.New) : null; - } - - public new string ToString() - { - const string nonExistent = ""; - var oldStr = Old != null ? Old.ToString() : nonExistent; - var newStr = New != null ? New.ToString() : nonExistent; - return $"{Path} ({oldStr} -> {newStr})"; - } -} - -public sealed class SyncSessionModelConflict -{ - public readonly string Root; // relative to sync root - public readonly List AlphaChanges; - public readonly List BetaChanges; - - public SyncSessionModelConflict(Conflict protoConflict) - { - Root = protoConflict.Root; - AlphaChanges = protoConflict.AlphaChanges.Select(change => new SyncSessionModelConflictChange(change)).ToList(); - BetaChanges = protoConflict.BetaChanges.Select(change => new SyncSessionModelConflictChange(change)).ToList(); - } - - private string? FriendlyProblem() - { - // If the change is -> !. - if (AlphaChanges.Count == 1 && BetaChanges.Count == 1 && - AlphaChanges[0].Old == null && - BetaChanges[0].Old == null && - AlphaChanges[0].New != null && - BetaChanges[0].New != null) - return - "An entry was created on both endpoints and they do not match. You can resolve this conflict by deleting one of the entries on either side."; - - return null; - } - - public string Description() - { - // This formatting is very similar to Mutagen. - var str = $"Conflict at path '{Root}':"; - foreach (var change in AlphaChanges) - str += $"\n (alpha) {change.ToString()}"; - foreach (var change in AlphaChanges) - str += $"\n (beta) {change.ToString()}"; - if (FriendlyProblem() is { } friendlyProblem) - str += $"\n\n {friendlyProblem}"; - - return str; - } -} - public class SyncSessionModel { public readonly string Identifier; - public readonly string Name; public readonly string AlphaName; public readonly string AlphaPath; @@ -219,8 +64,8 @@ public class SyncSessionModel public readonly SyncSessionModelEndpointSize AlphaSize; public readonly SyncSessionModelEndpointSize BetaSize; - public readonly IReadOnlyList Conflicts; - public ulong OmittedConflicts; + public readonly IReadOnlyList Conflicts; // Conflict descriptions + public readonly ulong OmittedConflicts; public readonly IReadOnlyList Errors; // If Paused is true, the session can be resumed. If false, the session can @@ -231,10 +76,12 @@ public string StatusDetails { get { - var str = $"{StatusString} ({StatusCategory})\n\n{StatusDescription}"; - foreach (var err in Errors) str += $"\n\nError: {err}"; - foreach (var conflict in Conflicts) str += $"\n\n{conflict.Description()}"; - if (OmittedConflicts > 0) str += $"\n\n{OmittedConflicts:N0} conflicts omitted"; + var str = StatusString; + if (StatusCategory.ToString() != StatusString) str += $" ({StatusCategory})"; + str += $"\n\n{StatusDescription}"; + foreach (var err in Errors) str += $"\n\n-----\n\n{err}"; + foreach (var conflict in Conflicts) str += $"\n\n-----\n\n{conflict}"; + if (OmittedConflicts > 0) str += $"\n\n-----\n\n{OmittedConflicts:N0} conflicts omitted"; return str; } } @@ -252,7 +99,6 @@ public string SizeDetails public SyncSessionModel(State state) { Identifier = state.Session.Identifier; - Name = state.Session.Name; (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha); (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta); @@ -354,7 +200,7 @@ public SyncSessionModel(State state) StatusDescription = "The session has conflicts that need to be resolved."; } - Conflicts = state.Conflicts.Select(c => new SyncSessionModelConflict(c)).ToList(); + Conflicts = state.Conflicts.Select(ConflictToString).ToList(); OmittedConflicts = state.ExcludedConflicts; AlphaSize = new SyncSessionModelEndpointSize @@ -403,4 +249,55 @@ private static (string, string) NameAndPathFromUrl(URL url) return (name, path); } + + private static string ConflictToString(Conflict conflict) + { + string? friendlyProblem = null; + if (conflict.AlphaChanges.Count == 1 && conflict.BetaChanges.Count == 1 && + conflict.AlphaChanges[0].Old == null && + conflict.BetaChanges[0].Old == null && + conflict.AlphaChanges[0].New != null && + conflict.BetaChanges[0].New != null) + friendlyProblem = + "An entry was created on both endpoints and they do not match. You can resolve this conflict by deleting one of the entries on either side."; + + var str = $"Conflict at path '{conflict.Root}':"; + foreach (var change in conflict.AlphaChanges) + str += $"\n (alpha) {ChangeToString(change)}"; + foreach (var change in conflict.BetaChanges) + str += $"\n (beta) {ChangeToString(change)}"; + if (friendlyProblem != null) + str += $"\n\n{friendlyProblem}"; + + return str; + } + + private static string ChangeToString(Change change) + { + return $"{change.Path} ({EntryToString(change.Old)} -> {EntryToString(change.New)})"; + } + + private static string EntryToString(Entry? entry) + { + if (entry == null) return ""; + var str = entry.Kind.ToString(); + switch (entry.Kind) + { + case EntryKind.Directory: + str += $" ({entry.Contents.Count} entries)"; + break; + case EntryKind.File: + var digest = BitConverter.ToString(entry.Digest.ToByteArray()).Replace("-", "").ToLower(); + str += $" ({digest}, executable: {entry.Executable})"; + break; + case EntryKind.SymbolicLink: + str += $" (target: {entry.Target})"; + break; + case EntryKind.Problematic: + str += $" ({entry.Problem})"; + break; + } + + return str; + } } diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index c88ec91..752e219 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -18,39 +18,55 @@ using Grpc.Core; using Microsoft.Extensions.Options; using DaemonTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Daemon.TerminateRequest; +using MutagenProtocol = Coder.Desktop.MutagenSdk.Proto.Url.Protocol; using SynchronizationTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Synchronization.TerminateRequest; namespace Coder.Desktop.App.Services; -public class CreateSyncSessionRequest +public enum CreateSyncSessionRequestEndpointProtocol { - public Uri Alpha { get; init; } - public Uri Beta { get; init; } + Local, + Ssh, +} - public URL AlphaMutagenUrl => MutagenUrl(Alpha); - public URL BetaMutagenUrl => MutagenUrl(Beta); +public class CreateSyncSessionRequestEndpoint +{ + public required CreateSyncSessionRequestEndpointProtocol Protocol { get; init; } + public string User { get; init; } = ""; + public string Host { get; init; } = ""; + public uint Port { get; init; } = 0; + public string Path { get; init; } = ""; - private static URL MutagenUrl(Uri uri) + public URL MutagenUrl { - var protocol = uri.Scheme switch - { - "file" => Protocol.Local, - "ssh" => Protocol.Ssh, - _ => throw new ArgumentException("Only 'file' and 'ssh' URLs are supported", nameof(uri)), - }; - - return new URL - { - Kind = Kind.Synchronization, - Protocol = protocol, - User = uri.UserInfo, - Host = uri.Host, - Port = uri.Port < 0 ? 0 : (uint)uri.Port, - Path = protocol is Protocol.Local ? uri.LocalPath : uri.AbsolutePath, - }; + get + { + var protocol = Protocol switch + { + CreateSyncSessionRequestEndpointProtocol.Local => MutagenProtocol.Local, + CreateSyncSessionRequestEndpointProtocol.Ssh => MutagenProtocol.Ssh, + _ => throw new ArgumentException($"Invalid protocol '{Protocol}'", nameof(Protocol)), + }; + + return new URL + { + Kind = Kind.Synchronization, + Protocol = protocol, + User = User, + Host = Host, + Port = Port, + Path = Path, + }; + } } } +public class CreateSyncSessionRequest +{ + public required CreateSyncSessionRequestEndpoint Alpha { get; init; } + public required CreateSyncSessionRequestEndpoint Beta { get; init; } +} + public interface ISyncSessionController : IAsyncDisposable { Task> ListSyncSessions(CancellationToken ct = default); @@ -152,8 +168,8 @@ public async Task CreateSyncSession(CreateSyncSessionRequest r Prompter = prompter.Identifier, Specification = new CreationSpecification { - Alpha = req.AlphaMutagenUrl, - Beta = req.BetaMutagenUrl, + Alpha = req.Alpha.MutagenUrl, + Beta = req.Beta.MutagenUrl, // TODO: probably should set these at some point Configuration = new Configuration(), ConfigurationAlpha = new Configuration(), @@ -637,7 +653,6 @@ await _dup.RequestStream.WriteAsync(new HostRequest catch { await _dup.RequestStream.CompleteAsync(); - _dup.Dispose(); // TODO: log? } } diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 795e422..d2414d4 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -248,23 +248,19 @@ private async Task ConfirmNewSession() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); try { - var alphaUri = new UriBuilder - { - Scheme = "file", - Host = "", - Path = NewSessionLocalPath, - }.Uri; - var betaUri = new UriBuilder - { - Scheme = "ssh", - Host = NewSessionRemoteHost, - Path = NewSessionRemotePath, - }.Uri; - await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = alphaUri, - Beta = betaUri, + Alpha = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Path = NewSessionLocalPath, + }, + Beta = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Ssh, + Host = NewSessionRemoteHost, + Path = NewSessionRemotePath, + }, }, cts.Token); ClearNewForm(); diff --git a/Tests.App/Services/MutagenControllerTest.cs b/Tests.App/Services/MutagenControllerTest.cs index 32cc090..edbc3e7 100644 --- a/Tests.App/Services/MutagenControllerTest.cs +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; using Coder.Desktop.App.Services; +using NUnit.Framework.Interfaces; namespace Coder.Desktop.Tests.App.Services; @@ -40,7 +41,11 @@ public void CreateTempDir() [TearDown] public void DeleteTempDir() { - _tempDirectory.Delete(true); + // Only delete the temp directory if the test passed. + if (TestContext.CurrentContext.Result.Outcome == ResultState.Success) + _tempDirectory.Delete(true); + else + TestContext.Out.WriteLine($"persisting temp directory: {_tempDirectory}"); } private string _mutagenBinaryPath; @@ -55,7 +60,10 @@ public void DeleteTempDir() $"Unsupported architecture '{RuntimeInformation.ProcessArchitecture}'. Coder only supports x64 and arm64."), }; - private static async Task AcquireDaemonLock(string dataDirectory, CancellationToken ct) + /// + /// Ensures the daemon is stopped by waiting for the daemon.lock file to be released. + /// + private static async Task AssertDaemonStopped(string dataDirectory, CancellationToken ct) { var lockPath = Path.Combine(dataDirectory, "daemon", "daemon.lock"); // If we can lock the daemon.lock file, it means the daemon has stopped. @@ -93,8 +101,16 @@ public async Task Ok(CancellationToken ct) var session1 = await controller.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = new Uri("file:///" + alphaDirectory.FullName), - Beta = new Uri("file:///" + betaDirectory.FullName), + Alpha = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Path = alphaDirectory.FullName, + }, + Beta = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Path = betaDirectory.FullName, + }, }, ct); sessions = (await controller.ListSyncSessions(ct)).ToList(); @@ -103,8 +119,16 @@ public async Task Ok(CancellationToken ct) var session2 = await controller.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = new Uri("file:///" + alphaDirectory.FullName), - Beta = new Uri("file:///" + betaDirectory.FullName), + Alpha = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Path = alphaDirectory.FullName, + }, + Beta = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Path = betaDirectory.FullName, + }, }, ct); sessions = (await controller.ListSyncSessions(ct)).ToList(); @@ -138,7 +162,7 @@ public async Task Ok(CancellationToken ct) await controller.TerminateSyncSession(session2.Identifier, ct); // Ensure the daemon is stopped. - await AcquireDaemonLock(dataDirectory, ct); + await AssertDaemonStopped(dataDirectory, ct); sessions = (await controller.ListSyncSessions(ct)).ToList(); Assert.That(sessions, Is.Empty); @@ -158,7 +182,7 @@ public async Task ShutdownNoSessions(CancellationToken ct) Assert.That(File.Exists(logPath)); // Ensure the daemon is stopped. - await AcquireDaemonLock(dataDirectory, ct); + await AssertDaemonStopped(dataDirectory, ct); } [Test(Description = "Daemon is restarted when we create a session")] @@ -175,12 +199,20 @@ public async Task CreateRestartsDaemon(CancellationToken ct) await controller.Initialize(ct); await controller.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = new Uri("file:///" + alphaDirectory.FullName), - Beta = new Uri("file:///" + betaDirectory.FullName), + Alpha = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Path = alphaDirectory.FullName, + }, + Beta = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Path = betaDirectory.FullName, + }, }, ct); } - await AcquireDaemonLock(dataDirectory, ct); + await AssertDaemonStopped(dataDirectory, ct); var logPath = Path.Combine(dataDirectory, "daemon.log"); Assert.That(File.Exists(logPath)); var logLines = await File.ReadAllLinesAsync(logPath, ct); @@ -207,8 +239,16 @@ public async Task Orphaned(CancellationToken ct) await controller1.Initialize(ct); await controller1.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = new Uri("file:///" + alphaDirectory.FullName), - Beta = new Uri("file:///" + betaDirectory.FullName), + Alpha = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Path = alphaDirectory.FullName, + }, + Beta = new CreateSyncSessionRequestEndpoint + { + Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Path = betaDirectory.FullName, + }, }, ct); controller2 = new MutagenController(_mutagenBinaryPath, dataDirectory); @@ -220,7 +260,7 @@ await controller1.CreateSyncSession(new CreateSyncSessionRequest if (controller2 != null) await controller2.DisposeAsync(); } - await AcquireDaemonLock(dataDirectory, ct); + await AssertDaemonStopped(dataDirectory, ct); var logPath = Path.Combine(dataDirectory, "daemon.log"); Assert.That(File.Exists(logPath)); From 9b1aaa56a685b21dd5afd68d17b73277d4983528 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 2 Apr 2025 17:36:35 +1100 Subject: [PATCH 4/5] Move types around --- App/Services/MutagenController.cs | 68 ++++++++++----------- App/ViewModels/FileSyncListViewModel.cs | 8 +-- Tests.App/Services/MutagenControllerTest.cs | 32 +++++----- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 752e219..8cab77b 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -23,50 +23,50 @@ namespace Coder.Desktop.App.Services; -public enum CreateSyncSessionRequestEndpointProtocol -{ - Local, - Ssh, -} - -public class CreateSyncSessionRequestEndpoint +public class CreateSyncSessionRequest { - public required CreateSyncSessionRequestEndpointProtocol Protocol { get; init; } - public string User { get; init; } = ""; - public string Host { get; init; } = ""; - public uint Port { get; init; } = 0; - public string Path { get; init; } = ""; + public required Endpoint Alpha { get; init; } + public required Endpoint Beta { get; init; } - public URL MutagenUrl + public class Endpoint { - get + public enum ProtocolKind { - var protocol = Protocol switch - { - CreateSyncSessionRequestEndpointProtocol.Local => MutagenProtocol.Local, - CreateSyncSessionRequestEndpointProtocol.Ssh => MutagenProtocol.Ssh, - _ => throw new ArgumentException($"Invalid protocol '{Protocol}'", nameof(Protocol)), - }; + Local, + Ssh, + } - return new URL + public required ProtocolKind Protocol { get; init; } + public string User { get; init; } = ""; + public string Host { get; init; } = ""; + public uint Port { get; init; } = 0; + public string Path { get; init; } = ""; + + public URL MutagenUrl + { + get { - Kind = Kind.Synchronization, - Protocol = protocol, - User = User, - Host = Host, - Port = Port, - Path = Path, - }; + var protocol = Protocol switch + { + ProtocolKind.Local => MutagenProtocol.Local, + ProtocolKind.Ssh => MutagenProtocol.Ssh, + _ => throw new ArgumentException($"Invalid protocol '{Protocol}'", nameof(Protocol)), + }; + + return new URL + { + Kind = Kind.Synchronization, + Protocol = protocol, + User = User, + Host = Host, + Port = Port, + Path = Path, + }; + } } } } -public class CreateSyncSessionRequest -{ - public required CreateSyncSessionRequestEndpoint Alpha { get; init; } - public required CreateSyncSessionRequestEndpoint Beta { get; init; } -} - public interface ISyncSessionController : IAsyncDisposable { Task> ListSyncSessions(CancellationToken ct = default); diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index d2414d4..6a30532 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -250,14 +250,14 @@ private async Task ConfirmNewSession() { await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = new CreateSyncSessionRequestEndpoint + Alpha = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = NewSessionLocalPath, }, - Beta = new CreateSyncSessionRequestEndpoint + Beta = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Ssh, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh, Host = NewSessionRemoteHost, Path = NewSessionRemotePath, }, diff --git a/Tests.App/Services/MutagenControllerTest.cs b/Tests.App/Services/MutagenControllerTest.cs index edbc3e7..2930dbb 100644 --- a/Tests.App/Services/MutagenControllerTest.cs +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -101,14 +101,14 @@ public async Task Ok(CancellationToken ct) var session1 = await controller.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = new CreateSyncSessionRequestEndpoint + Alpha = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = alphaDirectory.FullName, }, - Beta = new CreateSyncSessionRequestEndpoint + Beta = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = betaDirectory.FullName, }, }, ct); @@ -119,14 +119,14 @@ public async Task Ok(CancellationToken ct) var session2 = await controller.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = new CreateSyncSessionRequestEndpoint + Alpha = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = alphaDirectory.FullName, }, - Beta = new CreateSyncSessionRequestEndpoint + Beta = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = betaDirectory.FullName, }, }, ct); @@ -199,14 +199,14 @@ public async Task CreateRestartsDaemon(CancellationToken ct) await controller.Initialize(ct); await controller.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = new CreateSyncSessionRequestEndpoint + Alpha = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = alphaDirectory.FullName, }, - Beta = new CreateSyncSessionRequestEndpoint + Beta = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = betaDirectory.FullName, }, }, ct); @@ -239,14 +239,14 @@ public async Task Orphaned(CancellationToken ct) await controller1.Initialize(ct); await controller1.CreateSyncSession(new CreateSyncSessionRequest { - Alpha = new CreateSyncSessionRequestEndpoint + Alpha = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = alphaDirectory.FullName, }, - Beta = new CreateSyncSessionRequestEndpoint + Beta = new CreateSyncSessionRequest.Endpoint { - Protocol = CreateSyncSessionRequestEndpointProtocol.Local, + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = betaDirectory.FullName, }, }, ct); From 1193e4bc2e4501932da817a135e7e6de7723ae01 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 2 Apr 2025 17:03:24 +1000 Subject: [PATCH 5/5] chore: rework mutagen controller, add polling (#65) - Use locks during operations for daemon process consistency - Use a state model for UI rendering - Use events to signal state changes - Fix some tooltip problems that arise from polling --- App/App.xaml.cs | 2 +- App/Models/RpcModel.cs | 9 +- App/Models/SyncSessionControllerStateModel.cs | 43 ++ App/Models/SyncSessionModel.cs | 2 + App/Services/MutagenController.cs | 608 ++++++++++-------- App/Services/RpcController.cs | 18 +- App/ViewModels/FileSyncListViewModel.cs | 44 +- App/ViewModels/SyncSessionViewModel.cs | 36 ++ App/Views/Pages/FileSyncListMainPage.xaml | 22 +- App/Views/TrayWindow.xaml.cs | 26 +- MutagenSdk/MutagenClient.cs | 23 +- MutagenSdk/NamedPipesConnectionFactory.cs | 4 +- Tests.App/Services/MutagenControllerTest.cs | 50 +- 13 files changed, 544 insertions(+), 343 deletions(-) create mode 100644 App/Models/SyncSessionControllerStateModel.cs diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 557f0a0..4a35a0f 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -120,7 +120,7 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) // Initialize file sync. var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var syncSessionController = _services.GetRequiredService(); - _ = syncSessionController.Initialize(syncSessionCts.Token).ContinueWith(t => + _ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t => { // TODO: log #if DEBUG diff --git a/App/Models/RpcModel.cs b/App/Models/RpcModel.cs index dacef38..034f405 100644 --- a/App/Models/RpcModel.cs +++ b/App/Models/RpcModel.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using Coder.Desktop.Vpn.Proto; namespace Coder.Desktop.App.Models; @@ -26,9 +25,9 @@ public class RpcModel public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown; - public List Workspaces { get; set; } = []; + public IReadOnlyList Workspaces { get; set; } = []; - public List Agents { get; set; } = []; + public IReadOnlyList Agents { get; set; } = []; public RpcModel Clone() { @@ -36,8 +35,8 @@ public RpcModel Clone() { RpcLifecycle = RpcLifecycle, VpnLifecycle = VpnLifecycle, - Workspaces = Workspaces.ToList(), - Agents = Agents.ToList(), + Workspaces = Workspaces, + Agents = Agents, }; } } diff --git a/App/Models/SyncSessionControllerStateModel.cs b/App/Models/SyncSessionControllerStateModel.cs new file mode 100644 index 0000000..524a858 --- /dev/null +++ b/App/Models/SyncSessionControllerStateModel.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace Coder.Desktop.App.Models; + +public enum SyncSessionControllerLifecycle +{ + // Uninitialized means that the daemon has not been started yet. This can + // be resolved by calling RefreshState (or any other RPC method + // successfully). + Uninitialized, + + // Stopped means that the daemon is not running. This could be because: + // - It was never started (pre-Initialize) + // - It was stopped due to no sync sessions (post-Initialize, post-operation) + // - The last start attempt failed (DaemonError will be set) + // - The last daemon process crashed (DaemonError will be set) + Stopped, + + // Running is the normal state where the daemon is running and managing + // sync sessions. This is only set after a successful start (including + // being able to connect to the daemon). + Running, +} + +public class SyncSessionControllerStateModel +{ + public SyncSessionControllerLifecycle Lifecycle { get; init; } = SyncSessionControllerLifecycle.Stopped; + + /// + /// May be set when Lifecycle is Stopped to signify that the daemon failed + /// to start or unexpectedly crashed. + /// + public string? DaemonError { get; init; } + + public required string DaemonLogFilePath { get; init; } + + /// + /// This contains the last known state of all sync sessions. Sync sessions + /// are periodically refreshed if the daemon is running. This list is + /// sorted by creation time. + /// + public IReadOnlyList SyncSessions { get; init; } = []; +} diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs index b798890..46137f5 100644 --- a/App/Models/SyncSessionModel.cs +++ b/App/Models/SyncSessionModel.cs @@ -51,6 +51,7 @@ public string Description(string linePrefix = "") public class SyncSessionModel { public readonly string Identifier; + public readonly DateTime CreatedAt; public readonly string AlphaName; public readonly string AlphaPath; @@ -99,6 +100,7 @@ public string SizeDetails public SyncSessionModel(State state) { Identifier = state.Session.Identifier; + CreatedAt = state.Session.CreationTime.ToDateTime(); (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha); (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta); diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 8cab77b..dd489df 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.IO; @@ -69,17 +68,27 @@ public URL MutagenUrl public interface ISyncSessionController : IAsyncDisposable { - Task> ListSyncSessions(CancellationToken ct = default); + public event EventHandler StateChanged; + + /// + /// Gets the current state of the controller. + /// + SyncSessionControllerStateModel GetState(); + + // All the following methods will raise a StateChanged event *BEFORE* they return. + + /// + /// Starts the daemon (if it's not running) and fully refreshes the state of the controller. This should be + /// called at startup and after any unexpected daemon crashes to attempt to retry. + /// Additionally, the first call to RefreshState will start a background task to keep the state up-to-date while + /// the daemon is running. + /// + Task RefreshState(CancellationToken ct = default); + Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default); Task PauseSyncSession(string identifier, CancellationToken ct = default); Task ResumeSyncSession(string identifier, CancellationToken ct = default); Task TerminateSyncSession(string identifier, CancellationToken ct = default); - - // - // Initializes the controller; running the daemon if there are any saved sessions. Must be called and - // complete before other methods are allowed. - // - Task Initialize(CancellationToken ct = default); } // These values are the config option names used in the registry. Any option @@ -89,36 +98,34 @@ public interface ISyncSessionController : IAsyncDisposable // If changed here, they should also be changed in the installer. public class MutagenControllerConfig { + // This is set to "[INSTALLFOLDER]\vpn\mutagen.exe" by the installer. [Required] public string MutagenExecutablePath { get; set; } = @"c:\mutagen.exe"; } -// -// A file synchronization controller based on the Mutagen Daemon. -// -public sealed class MutagenController : ISyncSessionController, IAsyncDisposable +/// +/// A file synchronization controller based on the Mutagen Daemon. +/// +public sealed class MutagenController : ISyncSessionController { - // Lock to protect all non-readonly class members. + // Protects all private non-readonly class members. private readonly RaiiSemaphoreSlim _lock = new(1, 1); - // daemonProcess is non-null while the daemon is running, starting, or + private readonly CancellationTokenSource _stateUpdateCts = new(); + private Task? _stateUpdateTask; + + // _state is the current state of the controller. It is updated + // continuously while the daemon is running and after most operations. + private SyncSessionControllerStateModel? _state; + + // _daemonProcess is non-null while the daemon is running, starting, or // in the process of stopping. private Process? _daemonProcess; private LogWriter? _logWriter; - // holds an in-progress task starting or stopping the daemon. If task is null, - // then we are not starting or stopping, and the _daemonProcess will be null if - // the daemon is currently stopped. If the task is not null, the daemon is - // starting or stopping. If stopping, the result is null. - private Task? _inProgressTransition; - // holds a client connected to the running mutagen daemon, if the daemon is running. private MutagenClient? _mutagenClient; - // holds a local count of SyncSessions, primarily so we can tell when to shut down - // the daemon because it is unneeded. - private int _sessionCount = -1; - // set to true if we are disposing the controller. Prevents the daemon from being // restarted. private bool _disposing; @@ -130,6 +137,8 @@ public sealed class MutagenController : ISyncSessionController, IAsyncDisposable "CoderDesktop", "mutagen"); + private string MutagenDaemonLog => Path.Combine(_mutagenDataDirectory, "daemon.log"); + public MutagenController(IOptions config) { _mutagenExecutablePath = config.Value.MutagenExecutablePath; @@ -141,28 +150,62 @@ public MutagenController(string executablePath, string dataDirectory) _mutagenDataDirectory = dataDirectory; } + public event EventHandler? StateChanged; + public async ValueTask DisposeAsync() { - Task? transition; - using (_ = await _lock.LockAsync(CancellationToken.None)) - { - _disposing = true; - if (_inProgressTransition == null && _daemonProcess == null && _mutagenClient == null) return; - transition = _inProgressTransition; - } + using var _ = await _lock.LockAsync(CancellationToken.None); + _disposing = true; + + await _stateUpdateCts.CancelAsync(); + if (_stateUpdateTask != null) + try + { + await _stateUpdateTask; + } + catch + { + // ignored + } + + _stateUpdateCts.Dispose(); + + using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await StopDaemon(stopCts.Token); - if (transition != null) await transition; - await StopDaemon(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); GC.SuppressFinalize(this); } + public SyncSessionControllerStateModel GetState() + { + // No lock required to read the reference. + var state = _state; + // No clone needed as the model is immutable. + if (state != null) return state; + return new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Uninitialized, + DaemonError = null, + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }; + } + + public async Task RefreshState(CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + var state = await UpdateState(client, ct); + _stateUpdateTask ??= UpdateLoop(_stateUpdateCts.Token); + return state; + } + public async Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default) { - // reads of _sessionCount are atomic, so don't bother locking for this quick check. - if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first"); + using var _ = await _lock.LockAsync(ct); var client = await EnsureDaemon(ct); - await using var prompter = await CreatePrompter(client, true, ct); + await using var prompter = await Prompter.Create(client, true, ct); var createRes = await client.Synchronization.CreateAsync(new CreateRequest { Prompter = prompter.Identifier, @@ -178,36 +221,19 @@ public async Task CreateSyncSession(CreateSyncSessionRequest r }, cancellationToken: ct); if (createRes == null) throw new InvalidOperationException("CreateAsync returned null"); - // Increment session count early, to avoid list failures interfering - // with the count. - using (_ = await _lock.LockAsync(ct)) - { - _sessionCount += 1; - } - - var listRes = await client.Synchronization.ListAsync(new ListRequest - { - Selection = new Selection - { - Specifications = { createRes.Session }, - }, - }, cancellationToken: ct); - if (listRes == null) throw new InvalidOperationException("ListAsync returned null"); - if (listRes.SessionStates.Count != 1) - throw new InvalidOperationException("ListAsync returned wrong number of sessions"); - - return new SyncSessionModel(listRes.SessionStates[0]); + var session = await GetSyncSession(client, createRes.Session, ct); + await UpdateState(client, ct); + return session; } public async Task PauseSyncSession(string identifier, CancellationToken ct = default) { - // reads of _sessionCount are atomic, so don't bother locking for this quick check. - if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first"); + using var _ = await _lock.LockAsync(ct); var client = await EnsureDaemon(ct); // Pausing sessions doesn't require prompting as seen in the mutagen CLI. - await using var prompter = await CreatePrompter(client, false, ct); - _ = await client.Synchronization.PauseAsync(new PauseRequest + await using var prompter = await Prompter.Create(client, false, ct); + await client.Synchronization.PauseAsync(new PauseRequest { Prompter = prompter.Identifier, Selection = new Selection @@ -216,29 +242,40 @@ public async Task PauseSyncSession(string identifier, Cancella }, }, cancellationToken: ct); - var listRes = await client.Synchronization.ListAsync(new ListRequest + var session = await GetSyncSession(client, identifier, ct); + await UpdateState(client, ct); + return session; + } + + public async Task ResumeSyncSession(string identifier, CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + + await using var prompter = await Prompter.Create(client, true, ct); + await client.Synchronization.ResumeAsync(new ResumeRequest { + Prompter = prompter.Identifier, Selection = new Selection { Specifications = { identifier }, }, }, cancellationToken: ct); - if (listRes == null) throw new InvalidOperationException("ListAsync returned null"); - if (listRes.SessionStates.Count != 1) - throw new InvalidOperationException("ListAsync returned wrong number of sessions"); - return new SyncSessionModel(listRes.SessionStates[0]); + var session = await GetSyncSession(client, identifier, ct); + await UpdateState(client, ct); + return session; } - public async Task ResumeSyncSession(string identifier, CancellationToken ct = default) + public async Task TerminateSyncSession(string identifier, CancellationToken ct = default) { - // reads of _sessionCount are atomic, so don't bother locking for this quick check. - if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first"); + using var _ = await _lock.LockAsync(ct); var client = await EnsureDaemon(ct); - // Resuming sessions doesn't require prompting as seen in the mutagen CLI. - await using var prompter = await CreatePrompter(client, false, ct); - _ = await client.Synchronization.ResumeAsync(new ResumeRequest + // Terminating sessions doesn't require prompting as seen in the mutagen CLI. + await using var prompter = await Prompter.Create(client, true, ct); + + await client.Synchronization.TerminateAsync(new SynchronizationTerminateRequest { Prompter = prompter.Identifier, Selection = new Selection @@ -247,6 +284,37 @@ public async Task ResumeSyncSession(string identifier, Cancell }, }, cancellationToken: ct); + await UpdateState(client, ct); + } + + private async Task UpdateLoop(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(2), ct); // 2s matches macOS app + try + { + // We use a zero timeout here to avoid waiting. If another + // operation is holding the lock, it will update the state once + // it completes anyway. + var locker = await _lock.LockAsync(TimeSpan.Zero, ct); + if (locker == null) continue; + using (locker) + { + if (_mutagenClient == null) continue; + await UpdateState(_mutagenClient, ct); + } + } + catch + { + // ignore + } + } + } + + private static async Task GetSyncSession(MutagenClient client, string identifier, + CancellationToken ct) + { var listRes = await client.Synchronization.ListAsync(new ListRequest { Selection = new Selection @@ -261,144 +329,141 @@ public async Task ResumeSyncSession(string identifier, Cancell return new SyncSessionModel(listRes.SessionStates[0]); } - public async Task> ListSyncSessions(CancellationToken ct = default) + private void ReplaceState(SyncSessionControllerStateModel state) { - // reads of _sessionCount are atomic, so don't bother locking for this quick check. - switch (_sessionCount) - { - case < 0: - throw new InvalidOperationException("Controller must be Initialized first"); - case 0: - // If we already know there are no sessions, don't start up the daemon - // again. - return []; - } - - var client = await EnsureDaemon(ct); - var res = await client.Synchronization.ListAsync(new ListRequest - { - Selection = new Selection { All = true }, - }, cancellationToken: ct); - - if (res == null) return []; - return res.SessionStates.Select(s => new SyncSessionModel(s)); - - // TODO: the daemon should be stopped if there are no sessions. + _state = state; + // Since the event handlers could block (or call back the + // SyncSessionController and deadlock), we run these in a new task. + var stateChanged = StateChanged; + if (stateChanged == null) return; + Task.Run(() => stateChanged.Invoke(this, state)); } - public async Task Initialize(CancellationToken ct = default) + /// + /// Refreshes state and potentially stops the daemon if there are no sessions. The client must not be used after + /// this method is called. + /// Must be called AND awaited with the lock held. + /// + private async Task UpdateState(MutagenClient client, + CancellationToken ct = default) { - using (_ = await _lock.LockAsync(ct)) + ListResponse listResponse; + try { - if (_sessionCount != -1) throw new InvalidOperationException("Initialized more than once"); - _sessionCount = -2; // in progress + listResponse = await client.Synchronization.ListAsync(new ListRequest + { + Selection = new Selection { All = true }, + }, cancellationToken: ct); + if (listResponse == null) + throw new InvalidOperationException("ListAsync returned null"); } - - var client = await EnsureDaemon(ct); - var sessions = await client.Synchronization.ListAsync(new ListRequest + catch (Exception e) { - Selection = new Selection { All = true }, - }, cancellationToken: ct); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var error = $"Failed to UpdateState: ListAsync: {e}"; + try + { + await StopDaemon(cts.Token); + } + catch (Exception e2) + { + error = $"Failed to UpdateState: StopDaemon failed after failed ListAsync call: {e2}"; + } - using (_ = await _lock.LockAsync(ct)) - { - _sessionCount = sessions == null ? 0 : sessions.SessionStates.Count; - // check first that no other transition is happening - if (_sessionCount != 0 || _inProgressTransition != null) - return; - - // don't pass the CancellationToken; we're not going to wait for - // this Task anyway. - var transition = StopDaemon(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token); - _inProgressTransition = transition; - _ = transition.ContinueWith(RemoveTransition, CancellationToken.None); - // here we don't need to wait for the transition to complete - // before returning from Initialize(), since other operations - // will wait for the _inProgressTransition to complete before - // doing anything. + ReplaceState(new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = error, + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }); + throw; } - } - public async Task TerminateSyncSession(string identifier, CancellationToken ct = default) - { - if (_sessionCount < 0) throw new InvalidOperationException("Controller must be Initialized first"); - var client = await EnsureDaemon(ct); - - // Terminating sessions doesn't require prompting as seen in the mutagen CLI. - await using var prompter = await CreatePrompter(client, true, ct); - - _ = await client.Synchronization.TerminateAsync(new SynchronizationTerminateRequest + var lifecycle = SyncSessionControllerLifecycle.Running; + if (listResponse.SessionStates.Count == 0) { - Prompter = prompter.Identifier, - Selection = new Selection + lifecycle = SyncSessionControllerLifecycle.Stopped; + try { - Specifications = { identifier }, - }, - }, cancellationToken: ct); - - // here we don't use the Cancellation Token, since we want to decrement and possibly - // stop the daemon even if we were cancelled, since we already successfully terminated - // the session. - using (_ = await _lock.LockAsync(CancellationToken.None)) - { - _sessionCount -= 1; - if (_sessionCount == 0) - // check first that no other transition is happening - if (_inProgressTransition == null) + await StopDaemon(ct); + } + catch (Exception e) + { + ReplaceState(new SyncSessionControllerStateModel { - var transition = StopDaemon(CancellationToken.None); - _inProgressTransition = transition; - _ = transition.ContinueWith(RemoveTransition, CancellationToken.None); - // here we don't need to wait for the transition to complete - // before returning, since other operations - // will wait for the _inProgressTransition to complete before - // doing anything. - } + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = $"Failed to stop daemon after no sessions: {e}", + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }); + throw new InvalidOperationException("Failed to stop daemon after no sessions", e); + } } + + var sessions = listResponse.SessionStates + .Select(s => new SyncSessionModel(s)) + .ToList(); + sessions.Sort((a, b) => a.CreatedAt < b.CreatedAt ? -1 : 1); + var state = new SyncSessionControllerStateModel + { + Lifecycle = lifecycle, + DaemonError = null, + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = sessions, + }; + ReplaceState(state); + return state; } + /// + /// Starts the daemon if it's not running and returns a client to it. + /// Must be called AND awaited with the lock held. + /// private async Task EnsureDaemon(CancellationToken ct) { - while (true) + ObjectDisposedException.ThrowIf(_disposing, typeof(MutagenController)); + if (_mutagenClient != null && _daemonProcess != null) + return _mutagenClient; + + try { - ct.ThrowIfCancellationRequested(); - Task transition; - using (_ = await _lock.LockAsync(ct)) + return await StartDaemon(ct); + } + catch (Exception e) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try { - if (_disposing) throw new ObjectDisposedException(ToString(), "async disposal underway"); - if (_mutagenClient != null && _inProgressTransition == null) return _mutagenClient; - if (_inProgressTransition != null) - { - transition = _inProgressTransition; - } - else - { - // no transition in progress, this implies the _mutagenClient - // must be null, and we are stopped. - _inProgressTransition = StartDaemon(ct); - transition = _inProgressTransition; - _ = transition.ContinueWith(RemoveTransition, ct); - } + await StopDaemon(cts.Token); + } + catch + { + // ignored } - // wait for the transition without holding the lock. - var result = await transition; - if (result != null) return result; + ReplaceState(new SyncSessionControllerStateModel + { + Lifecycle = SyncSessionControllerLifecycle.Stopped, + DaemonError = $"Failed to start daemon: {e}", + DaemonLogFilePath = MutagenDaemonLog, + SyncSessions = [], + }); + + throw; } } - // - // Remove the completed transition from _inProgressTransition - // - private void RemoveTransition(Task transition) + /// + /// Starts the daemon and returns a client to it. + /// Must be called AND awaited with the lock held. + /// + private async Task StartDaemon(CancellationToken ct) { - using var _ = _lock.Lock(); - if (_inProgressTransition == transition) _inProgressTransition = null; - } + // Stop the running daemon + if (_daemonProcess != null) await StopDaemon(ct); - private async Task StartDaemon(CancellationToken ct) - { - // stop any orphaned daemon + // Attempt to stop any orphaned daemon try { var client = new MutagenClient(_mutagenDataDirectory); @@ -422,10 +487,7 @@ private void RemoveTransition(Task transition) ct.ThrowIfCancellationRequested(); try { - using (_ = await _lock.LockAsync(ct)) - { - StartDaemonProcessLocked(); - } + StartDaemonProcess(); } catch (Exception e) when (e is not OperationCanceledException) { @@ -439,34 +501,16 @@ private void RemoveTransition(Task transition) break; } - return await WaitForDaemon(ct); - } - - private async Task WaitForDaemon(CancellationToken ct) - { + // Wait for the RPC to be available. while (true) { ct.ThrowIfCancellationRequested(); try { - MutagenClient? client; - using (_ = await _lock.LockAsync(ct)) - { - client = _mutagenClient ?? new MutagenClient(_mutagenDataDirectory); - } - + var client = new MutagenClient(_mutagenDataDirectory); _ = await client.Daemon.VersionAsync(new VersionRequest(), cancellationToken: ct); - - using (_ = await _lock.LockAsync(ct)) - { - if (_mutagenClient != null) - // Some concurrent process already wrote a client; unexpected - // since we should be ensuring only one transition is happening - // at a time. Start over with the new client. - continue; - _mutagenClient = client; - return _mutagenClient; - } + _mutagenClient = client; + return client; } catch (Exception e) when (e is not OperationCanceledException) // TODO: Are there other permanent errors we can detect? @@ -477,10 +521,14 @@ private void RemoveTransition(Task transition) } } - private void StartDaemonProcessLocked() + /// + /// Starts the daemon process. + /// Must be called AND awaited with the lock held. + /// + private void StartDaemonProcess() { if (_daemonProcess != null) - throw new InvalidOperationException("startDaemonLock called when daemonProcess already present"); + throw new InvalidOperationException("StartDaemonProcess called when _daemonProcess already present"); // create the log file first, so ensure we have permissions Directory.CreateDirectory(_mutagenDataDirectory); @@ -499,33 +547,32 @@ private void StartDaemonProcessLocked() _daemonProcess.StartInfo.RedirectStandardError = true; // TODO: log exited process // _daemonProcess.Exited += ... - _daemonProcess.Start(); + if (!_daemonProcess.Start()) + throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); var writer = new LogWriter(_daemonProcess.StandardError, logStream); Task.Run(() => { _ = writer.Run(); }); _logWriter = writer; } - private async Task StopDaemon(CancellationToken ct) + /// + /// Stops the daemon process. + /// Must be called AND awaited with the lock held. + /// + private async Task StopDaemon(CancellationToken ct) { - Process? process; - MutagenClient? client; - LogWriter? writer; - using (_ = await _lock.LockAsync(ct)) - { - process = _daemonProcess; - client = _mutagenClient; - writer = _logWriter; - _daemonProcess = null; - _mutagenClient = null; - _logWriter = null; - } + var process = _daemonProcess; + var client = _mutagenClient; + var writer = _logWriter; + _daemonProcess = null; + _mutagenClient = null; + _logWriter = null; try { if (client == null) { - if (process == null) return null; + if (process == null) return; process.Kill(true); } else @@ -536,12 +583,12 @@ private void StartDaemonProcessLocked() } catch { - if (process == null) return null; + if (process == null) return; process.Kill(true); } } - if (process == null) return null; + if (process == null) return; var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(5)); await process.WaitForExitAsync(cts.Token); @@ -552,41 +599,6 @@ private void StartDaemonProcessLocked() process?.Dispose(); writer?.Dispose(); } - - return null; - } - - private static async Task CreatePrompter(MutagenClient client, bool allowPrompts = false, - CancellationToken ct = default) - { - var dup = client.Prompting.Host(cancellationToken: ct); - if (dup == null) throw new InvalidOperationException("Prompting.Host returned null"); - - try - { - // Write first request. - await dup.RequestStream.WriteAsync(new HostRequest - { - AllowPrompts = allowPrompts, - }, ct); - - // Read initial response. - if (!await dup.ResponseStream.MoveNext(ct)) - throw new InvalidOperationException("Prompting.Host response stream ended early"); - var response = dup.ResponseStream.Current; - if (response == null) - throw new InvalidOperationException("Prompting.Host response stream returned null"); - if (string.IsNullOrEmpty(response.Identifier)) - throw new InvalidOperationException("Prompting.Host response stream returned empty identifier"); - - return new Prompter(response.Identifier, dup, ct); - } - catch - { - await dup.RequestStream.CompleteAsync(); - dup.Dispose(); - throw; - } } private class Prompter : IAsyncDisposable @@ -596,7 +608,7 @@ private class Prompter : IAsyncDisposable private readonly Task _handleRequestsTask; public string Identifier { get; } - public Prompter(string identifier, AsyncDuplexStreamingCall dup, + private Prompter(string identifier, AsyncDuplexStreamingCall dup, CancellationToken ct) { Identifier = identifier; @@ -622,6 +634,39 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } + public static async Task Create(MutagenClient client, bool allowPrompts = false, + CancellationToken ct = default) + { + var dup = client.Prompting.Host(cancellationToken: ct); + if (dup == null) throw new InvalidOperationException("Prompting.Host returned null"); + + try + { + // Write first request. + await dup.RequestStream.WriteAsync(new HostRequest + { + AllowPrompts = allowPrompts, + }, ct); + + // Read initial response. + if (!await dup.ResponseStream.MoveNext(ct)) + throw new InvalidOperationException("Prompting.Host response stream ended early"); + var response = dup.ResponseStream.Current; + if (response == null) + throw new InvalidOperationException("Prompting.Host response stream returned null"); + if (string.IsNullOrEmpty(response.Identifier)) + throw new InvalidOperationException("Prompting.Host response stream returned empty identifier"); + + return new Prompter(response.Identifier, dup, ct); + } + catch + { + await dup.RequestStream.CompleteAsync(); + dup.Dispose(); + throw; + } + } + private async Task HandleRequests(CancellationToken ct) { try @@ -657,31 +702,30 @@ await _dup.RequestStream.WriteAsync(new HostRequest } } } -} - -public class LogWriter(StreamReader reader, StreamWriter writer) : IDisposable -{ - public void Dispose() - { - reader.Dispose(); - writer.Dispose(); - GC.SuppressFinalize(this); - } - public async Task Run() + private class LogWriter(StreamReader reader, StreamWriter writer) : IDisposable { - try - { - string? line; - while ((line = await reader.ReadLineAsync()) != null) await writer.WriteLineAsync(line); - } - catch + public void Dispose() { - // TODO: Log? + reader.Dispose(); + writer.Dispose(); + GC.SuppressFinalize(this); } - finally + + public async Task Run() { - Dispose(); + try + { + while (await reader.ReadLineAsync() is { } line) await writer.WriteLineAsync(line); + } + catch + { + // TODO: Log? + } + finally + { + Dispose(); + } } } } diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 248a011..17d3ccb 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -96,8 +96,8 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Connecting; state.VpnLifecycle = VpnLifecycle.Stopped; - state.Workspaces.Clear(); - state.Agents.Clear(); + state.Workspaces = []; + state.Agents = []; }); if (_speaker != null) @@ -127,8 +127,8 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Disconnected; state.VpnLifecycle = VpnLifecycle.Unknown; - state.Workspaces.Clear(); - state.Agents.Clear(); + state.Workspaces = []; + state.Agents = []; }); throw new RpcOperationException("Failed to reconnect to the RPC server", e); } @@ -137,8 +137,8 @@ public async Task Reconnect(CancellationToken ct = default) { state.RpcLifecycle = RpcLifecycle.Connected; state.VpnLifecycle = VpnLifecycle.Unknown; - state.Workspaces.Clear(); - state.Agents.Clear(); + state.Workspaces = []; + state.Agents = []; }); var statusReply = await _speaker.SendRequestAwaitReply(new ClientMessage @@ -276,10 +276,8 @@ private void ApplyStatusUpdate(Status status) Status.Types.Lifecycle.Stopped => VpnLifecycle.Stopped, _ => VpnLifecycle.Stopped, }; - state.Workspaces.Clear(); - state.Workspaces.AddRange(status.PeerUpdate.UpsertedWorkspaces); - state.Agents.Clear(); - state.Agents.AddRange(status.PeerUpdate.UpsertedAgents); + state.Workspaces = status.PeerUpdate.UpsertedWorkspaces; + state.Agents = status.PeerUpdate.UpsertedAgents; }); } diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 6a30532..7fdd881 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -31,10 +31,12 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowSessions))] public partial string? UnavailableMessage { get; set; } = null; + // Initially we use the current cached state, the loading screen is only + // shown when the user clicks "Reload" on the error screen. [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowLoading))] [NotifyPropertyChangedFor(nameof(ShowSessions))] - public partial bool Loading { get; set; } = true; + public partial bool Loading { get; set; } = false; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowLoading))] @@ -100,11 +102,13 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue) _rpcController.StateChanged += RpcControllerStateChanged; _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; + _syncSessionController.StateChanged += SyncSessionStateChanged; var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); MaybeSetUnavailableMessage(rpcModel, credentialModel); - if (UnavailableMessage == null) ReloadSessions(); + var syncSessionState = _syncSessionController.GetState(); + UpdateSyncSessionState(syncSessionState); } private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) @@ -135,6 +139,19 @@ private void CredentialManagerCredentialsChanged(object? sender, CredentialModel MaybeSetUnavailableMessage(rpcModel, credentialModel); } + private void SyncSessionStateChanged(object? sender, SyncSessionControllerStateModel syncSessionState) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => SyncSessionStateChanged(sender, syncSessionState)); + return; + } + + UpdateSyncSessionState(syncSessionState); + } + private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel) { var oldMessage = UnavailableMessage; @@ -158,6 +175,12 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede } } + private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState) + { + Error = syncSessionState.DaemonError; + Sessions = syncSessionState.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList(); + } + private void ClearNewForm() { CreatingNewSession = false; @@ -172,23 +195,24 @@ private void ReloadSessions() Loading = true; Error = null; var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - _syncSessionController.ListSyncSessions(cts.Token).ContinueWith(HandleList, CancellationToken.None); + _syncSessionController.RefreshState(cts.Token).ContinueWith(HandleRefresh, CancellationToken.None); } - private void HandleList(Task> t) + private void HandleRefresh(Task t) { // Ensure we're on the UI thread. if (_dispatcherQueue == null) return; if (!_dispatcherQueue.HasThreadAccess) { - _dispatcherQueue.TryEnqueue(() => HandleList(t)); + _dispatcherQueue.TryEnqueue(() => HandleRefresh(t)); return; } if (t.IsCompletedSuccessfully) { - Sessions = t.Result.Select(s => new SyncSessionViewModel(this, s)).ToList(); + Sessions = t.Result.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList(); Loading = false; + Error = t.Result.DaemonError; return; } @@ -248,6 +272,7 @@ private async Task ConfirmNewSession() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); try { + // The controller will send us a state changed event. await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest { Alpha = new CreateSyncSessionRequest.Endpoint @@ -264,7 +289,6 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest }, cts.Token); ClearNewForm(); - ReloadSessions(); } catch (Exception e) { @@ -295,6 +319,7 @@ public async Task PauseOrResumeSession(string identifier) if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session) throw new InvalidOperationException("Session not found"); + // The controller will send us a state changed event. if (session.Model.Paused) { actionString = "resume"; @@ -305,8 +330,6 @@ public async Task PauseOrResumeSession(string identifier) actionString = "pause"; await _syncSessionController.PauseSyncSession(session.Model.Identifier, cts.Token); } - - ReloadSessions(); } catch (Exception e) { @@ -349,9 +372,8 @@ public async Task TerminateSession(string identifier) if (res is not ContentDialogResult.Primary) return; + // The controller will send us a state changed event. await _syncSessionController.TerminateSyncSession(session.Model.Identifier, cts.Token); - - ReloadSessions(); } catch (Exception e) { diff --git a/App/ViewModels/SyncSessionViewModel.cs b/App/ViewModels/SyncSessionViewModel.cs index 68870ac..7de6500 100644 --- a/App/ViewModels/SyncSessionViewModel.cs +++ b/App/ViewModels/SyncSessionViewModel.cs @@ -2,6 +2,8 @@ using Coder.Desktop.App.Models; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; namespace Coder.Desktop.App.ViewModels; @@ -30,4 +32,38 @@ public async Task TerminateSession() { await Parent.TerminateSession(Model.Identifier); } + + // Check the comments in FileSyncListMainPage.xaml to see why this tooltip + // stuff is necessary. + private void SetToolTip(FrameworkElement element, string text) + { + // Get current tooltip and compare the text. Setting the tooltip with + // the same text causes it to dismiss itself. + var currentToolTip = ToolTipService.GetToolTip(element) as ToolTip; + if (currentToolTip?.Content as string == text) return; + + ToolTipService.SetToolTip(element, new ToolTip { Content = text }); + } + + public void OnStatusTextLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement element) return; + SetToolTip(element, Model.StatusDetails); + } + + public void OnStatusTextDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + SetToolTip(sender, Model.StatusDetails); + } + + public void OnSizeTextLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement element) return; + SetToolTip(element, Model.SizeDetails); + } + + public void OnSizeTextDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + SetToolTip(sender, Model.SizeDetails); + } } diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index 17009da..d38bc29 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -127,7 +127,8 @@ - + +