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 752e219..324d6fe 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 class CreateSyncSessionRequest 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 d2414d4..871377e 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 CreateSyncSessionRequestEndpoint @@ -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 @@ - + +