File: src\Aspire.Tray\Common\TrayController.cs
Web Access
Project: src\tests\Aspire.Tray.Tests\Aspire.Tray.Tests.csproj (Aspire.Tray.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
 
namespace Aspire.Tray;
 
internal sealed class TrayController : IAsyncDisposable
{
    private readonly IAppHostClient _client;
    private readonly ITraySavedStateStore _savedStateStore;
    private readonly int _recentAppHostLimit;
    private readonly TimeProvider _timeProvider;
    internal static TimeSpan StartReconciliationTimeout => TimeSpan.FromSeconds(60);
    private readonly object _gate = new();
    private readonly CancellationTokenSource _shutdown = new();
    private readonly Dictionary<AppHostId, StopOperation> _stops = [];
    private readonly Dictionary<string, StartOperation> _starts = new(TrayAppHostPath.Comparer);
    private HashSet<AppHostId> _lastLiveIds = [];
    private TraySavedState _savedState = TraySavedState.Empty;
    private AppHostSnapshot _snapshot = new([], DiscoveryState.Connecting);
    private TrayViewState _state = new(DiscoveryState.Connecting, [], "Connecting to Aspire...");
    private Task? _watcher;
    private Task? _disposeTask;
    private string? _actionError;
    private string? _savedStateError;
    private string? _pinProbeError;
    private bool _savedStateDirty;
    private bool _disposed;
 
    public TrayController(IAppHostClient client) : this(client, new MemoryTraySavedStateStore())
    {
    }
 
    public TrayController(IAppHostClient client, ITraySavedStateStore savedStateStore)
        : this(client, savedStateStore, TraySavedState.DefaultRecentAppHostLimit)
    {
    }
 
    public TrayController(IAppHostClient client, ITraySavedStateStore savedStateStore, int recentAppHostLimit)
        : this(client, savedStateStore, recentAppHostLimit, TimeProvider.System)
    {
    }
 
    internal TrayController(IAppHostClient client, ITraySavedStateStore savedStateStore, int recentAppHostLimit, TimeProvider timeProvider)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(recentAppHostLimit);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(recentAppHostLimit, TraySavedState.MaximumRecentAppHosts);
        _client = client;
        _savedStateStore = savedStateStore;
        _recentAppHostLimit = recentAppHostLimit;
        _timeProvider = timeProvider;
        try
        {
            _savedState = savedStateStore.Load();
        }
        catch (Exception ex) when (IsSavedStateException(ex))
        {
            Console.Error.WriteLine($"Saved AppHost state could not be loaded ({ex.GetType().Name}).");
            _savedStateError = "Unable to load saved AppHosts. The saved file was left unchanged.";
        }
        var trimmed = _savedState.Trim(_recentAppHostLimit);
        if (!TrySaveStateLocked(trimmed))
        {
            // The configured limit also applies when storage is temporarily unavailable.
            // Keep the original file untouched and show the existing persistence error.
            _savedState = trimmed;
            _savedStateDirty = true;
        }
        PublishLocked();
    }
 
    // Raised on the publishing thread. Native frontends coalesce and post a main-thread refresh.
    public event Action? Changed;
 
    public TrayViewState State => Volatile.Read(ref _state);
 
    /// <summary>
    /// Gets whether the native frontend must confirm an AppHost stop.
    /// </summary>
    public bool ConfirmStop
    {
        get
        {
            lock (_gate)
            {
                return _savedState.ConfirmStop;
            }
        }
    }
 
    /// <summary>
    /// Persists the stop-confirmation preference before changing its in-memory value.
    /// </summary>
    public void SetConfirmStop(bool confirmStop)
        => UpdateSavedState(state => state with { ConfirmStop = confirmStop });
 
    public void Start()
    {
        lock (_gate)
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            if (_watcher is not null)
            {
                throw new InvalidOperationException("The tray controller is already started.");
            }
            _watcher = Task.Run(WatchAsync);
        }
    }
 
    public AppHostInfo RequireLiveInstance(AppHostId id)
    {
        lock (_gate)
        {
            return RequireLiveInstanceLocked(id);
        }
    }
 
    public Uri GetDashboardUri(AppHostId id)
    {
        Uri uri;
        lock (_gate)
        {
            uri = RequireLiveInstanceLocked(id).DashboardUri
                ?? throw new InvalidOperationException("The selected dashboard is unavailable.");
            _actionError = null;
            PublishLocked();
        }
        Changed?.Invoke();
        return uri;
    }
 
    public void RequestStop(AppHostId id)
    {
        lock (_gate)
        {
            // Confirmation is owned by the native frontend. Revalidate here because discovery
            // can change while that modal dialog is open, including reuse of a process ID.
            var host = RequireLiveInstanceLocked(id);
            if (host.ProcessStartTimeUnixMilliseconds is not > 0)
            {
                throw new InvalidOperationException("The process identity is unavailable. Stop is disabled.");
            }
            if (_stops.TryGetValue(id, out var existing) && existing.Result is null)
            {
                throw new InvalidOperationException("A stop request is already running for this AppHost.");
            }
 
            var operation = new StopOperation();
            _stops[id] = operation;
            _actionError = null;
            operation.Task = Task.Run(() => StopAsync(id, operation));
            PublishLocked();
        }
        Changed?.Invoke();
    }
 
    public void SetPinned(string appHostPath, bool pinned)
    {
        var path = TrayAppHostPath.Normalize(appHostPath);
        UpdateSavedState(state => state.SetPinned(path, pinned));
    }
 
    public void ClearRecent() => UpdateSavedState(state => state.ClearRecent());
 
    public void RemoveRecent(string appHostPath)
    {
        var path = TrayAppHostPath.Normalize(appHostPath);
        UpdateSavedState(state => state.RemoveRecent(path));
    }
 
    /// <summary>
    /// Removes missing offline pins after discovery has established which paths are running.
    /// </summary>
    public void PruneMissingPinnedAppHosts()
    {
        lock (_gate)
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            var previous = (_savedState, _savedStateError, _pinProbeError);
            PruneMissingPinsLocked();
            if (previous == (_savedState, _savedStateError, _pinProbeError))
            {
                return;
            }
            PublishLocked();
        }
        Changed?.Invoke();
    }
 
    private void UpdateSavedState(Func<TraySavedState, TraySavedState> update)
    {
        try
        {
            lock (_gate)
            {
                ObjectDisposedException.ThrowIf(_disposed, this);
                PruneMissingPinsLocked();
                var next = update(_savedState);
                if (!TrySaveStateLocked(next))
                {
                    PublishLocked();
                    throw new InvalidOperationException(_savedStateError);
                }
                PruneMissingPinsLocked();
                _actionError = null;
                PublishLocked();
            }
        }
        finally
        {
            Changed?.Invoke();
        }
    }
 
    public void RequestStart(string appHostPath)
    {
        var path = TrayAppHostPath.Normalize(appHostPath);
        try
        {
            lock (_gate)
            {
                try
                {
                    RequireStartablePathLocked(path);
                    if (_starts.TryGetValue(path, out var existing) && existing.BlocksStart)
                    {
                        throw new InvalidOperationException("A start request is already running for this AppHost. Wait for discovery.");
                    }
                    if (PruneMissingPinsLocked().Contains(path))
                    {
                        return;
                    }
                    TrayAppHostPath.RequireExistingFile(path);
                    RememberLocked([path]);
                    var operation = new StartOperation();
                    _starts[path] = operation;
                    _actionError = null;
                    operation.Task = Task.Run(() => StartAsync(path, operation));
                }
                finally
                {
                    PublishLocked();
                }
            }
        }
        finally
        {
            Changed?.Invoke();
        }
    }
 
    private void RequireStartablePathLocked(string path)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (_snapshot.Discovery != DiscoveryState.Live)
        {
            throw new InvalidOperationException("AppHost discovery is disconnected. Wait for it to reconnect before starting.");
        }
        // Even a successfully stopped row remains a live identity until discovery removes it.
        // Never interpret stale discovery or unknown resource health as permission to start.
        if (_snapshot.AppHosts.Any(host => TrayAppHostPath.Comparer.Equals(TrayAppHostPath.Normalize(host.AppHostPath), path)))
        {
            throw new InvalidOperationException("This AppHost is already listed. Wait for discovery before starting it again.");
        }
    }
 
    public void ReportActionError(string message)
    {
        lock (_gate)
        {
            if (_disposed)
            {
                return;
            }
            _actionError = message;
            PublishLocked();
        }
        Changed?.Invoke();
    }
 
    private AppHostInfo RequireLiveInstanceLocked(AppHostId id)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (_snapshot.Discovery != DiscoveryState.Live)
        {
            throw new InvalidOperationException("AppHost discovery is disconnected. Wait for it to reconnect.");
        }
        if (_stops.TryGetValue(id, out var operation) && operation.Result?.Outcome == StopOutcome.Stopped)
        {
            throw new InvalidOperationException("The selected AppHost instance is no longer running.");
        }
        return _snapshot.AppHosts.SingleOrDefault(host => host.Id == id)
            ?? throw new InvalidOperationException("The selected AppHost instance is no longer running.");
    }
 
    private async Task WatchAsync()
    {
        try
        {
            await foreach (var snapshot in _client.WatchAsync(_shutdown.Token).ConfigureAwait(false))
            {
                SetSnapshot(snapshot);
            }
            if (!_shutdown.IsCancellationRequested && State.Discovery is not (DiscoveryState.Incompatible or DiscoveryState.LimitExceeded))
            {
                Console.Error.WriteLine("AppHost discovery ended unexpectedly.");
                SetDisconnected();
            }
        }
        catch (OperationCanceledException) when (_shutdown.IsCancellationRequested)
        {
        }
        catch (Exception ex)
        {
            // This is the background-worker boundary. Payloads and exception messages may
            // contain dashboard login tokens; only expose the failure category.
            Console.Error.WriteLine($"AppHost discovery failed ({ex.GetType().Name}).");
            SetDisconnected();
        }
    }
 
    private void SetDisconnected()
    {
        AppHostSnapshot snapshot;
        lock (_gate)
        {
            snapshot = _snapshot with { Discovery = DiscoveryState.Disconnected };
        }
        SetSnapshot(snapshot);
    }
 
    private void SetSnapshot(AppHostSnapshot snapshot)
    {
        lock (_gate)
        {
            if (_disposed)
            {
                return;
            }
            if (snapshot.Discovery == DiscoveryState.Live)
            {
                var arrivingPaths = snapshot.AppHosts.Where(host => !_lastLiveIds.Contains(host.Id))
                    .Select(host => TrayAppHostPath.Normalize(host.AppHostPath))
                    .Distinct(TrayAppHostPath.Comparer).ToArray();
                _lastLiveIds = snapshot.AppHosts.Select(host => host.Id).ToHashSet();
                _snapshot = snapshot;
                // Only real instance arrivals update history. Heartbeats and reconnects must
                // not restore a recent entry the user cleared while its AppHost was running.
                RememberLocked(arrivingPaths.Reverse());
                foreach (var host in snapshot.AppHosts)
                {
                    var path = TrayAppHostPath.Normalize(host.AppHostPath);
                    if (_starts.TryGetValue(path, out var start))
                    {
                        if (_actionError == GetStartError(start))
                        {
                            _actionError = null;
                        }
                        start.Discovered = true;
                        if (start.Task.IsCompleted)
                        {
                            _starts.Remove(path);
                        }
                    }
                }
                PruneMissingPinsLocked();
            }
            else
            {
                // A disconnected stream has no authority to declare existing rows stopped.
                _snapshot = _snapshot with { Discovery = snapshot.Discovery };
            }
            var liveIds = _snapshot.AppHosts.Select(host => host.Id).ToHashSet();
            foreach (var (id, operation) in _stops.ToArray())
            {
                if (operation.Task.IsCompleted && !liveIds.Contains(id))
                {
                    _stops.Remove(id);
                }
            }
            PublishLocked();
        }
        Changed?.Invoke();
    }
 
    private async Task StopAsync(AppHostId id, StopOperation operation)
    {
        StopResult result;
        try
        {
            result = await _client.StopAsync(id, _shutdown.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (_shutdown.IsCancellationRequested)
        {
            Console.Error.WriteLine("The tray closed while waiting for stop; the AppHost shutdown may still complete.");
            return;
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"The exact-instance stop command failed ({ex.GetType().Name}).");
            result = new(StopOutcome.Failed, null);
        }
 
        if (result.Outcome != StopOutcome.Stopped)
        {
            Console.Error.WriteLine($"The exact-instance stop command did not succeed ({result.Outcome}, exit {result.ExitCode}).");
        }
        lock (_gate)
        {
            operation.Result = result;
            if (_disposed)
            {
                return;
            }
            PublishLocked();
        }
        Changed?.Invoke();
    }
 
    private async Task StartAsync(string path, StartOperation operation)
    {
        StartResult result;
        try
        {
            Task<StartResult> command;
            lock (_gate)
            {
                // Discovery may have changed after the menu gesture but before this worker.
                RequireStartablePathLocked(path);
                TrayAppHostPath.RequireExistingFile(path);
                command = _client.StartAsync(path, _shutdown.Token);
            }
            result = await command.ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (_shutdown.IsCancellationRequested)
        {
            Console.Error.WriteLine("The tray closed while waiting for start; the AppHost may still start.");
            return;
        }
        catch (FileNotFoundException)
        {
            result = new(StartOutcome.NotFound, null);
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"The AppHost start command failed ({ex.GetType().Name}).");
            result = new(StartOutcome.Failed, null);
        }
        if (result.Outcome != StartOutcome.Started)
        {
            Console.Error.WriteLine($"The AppHost start command did not succeed ({result.Outcome}, exit {result.ExitCode}).");
        }
        Task? reconciliation = null;
        lock (_gate)
        {
            operation.Result = result;
            if (_disposed)
            {
                return;
            }
            // A CLI success is not proof that discovery will ever see the AppHost: it
            // can exit between snapshots. Bound only reconciliation, not the CLI call,
            // and permit an explicit retry only through the usual live-discovery guard.
            if (!operation.Discovered && result.Outcome is StartOutcome.Started or StartOutcome.TimedOut)
            {
                reconciliation = Task.Delay(StartReconciliationTimeout, _timeProvider, _shutdown.Token);
            }
            var removedPins = PruneMissingPinsLocked();
            if ((result.Outcome != StartOutcome.NotFound || !removedPins.Contains(path))
                && !_savedState.AppHosts.Any(host => TrayAppHostPath.Comparer.Equals(host.AppHostPath, path)))
            {
                _actionError = GetStartError(operation);
            }
            PublishLocked();
        }
        Changed?.Invoke();
        if (reconciliation is null)
        {
            return;
        }
        try
        {
            await reconciliation.ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (_shutdown.IsCancellationRequested)
        {
            return;
        }
        lock (_gate)
        {
            if (_disposed || operation.Discovered || !_starts.TryGetValue(path, out var current) || current != operation)
            {
                return;
            }
            operation.ReconciliationExpired = true;
            Console.Error.WriteLine("The AppHost was not observed before the start reconciliation deadline.");
            if (!_savedState.AppHosts.Any(host => TrayAppHostPath.Comparer.Equals(host.AppHostPath, path)))
            {
                _actionError = GetStartError(operation);
            }
            PublishLocked();
        }
        Changed?.Invoke();
    }
 
    private HashSet<string> PruneMissingPinsLocked()
    {
        var missing = new HashSet<string>(TrayAppHostPath.Comparer);
        // Loading preferences alone cannot tell whether a source-deleted AppHost is still
        // running. Wait for the first live snapshot, and never prune during a disconnect.
        if (_snapshot.Discovery != DiscoveryState.Live)
        {
            return missing;
        }
        var protectedPaths = _snapshot.AppHosts.Select(host => TrayAppHostPath.Normalize(host.AppHostPath))
            .ToHashSet(TrayAppHostPath.Comparer);
        protectedPaths.UnionWith(_starts.Where(pair => pair.Value.BlocksStart).Select(pair => pair.Key));
        var probeFailed = false;
        foreach (var host in _savedState.AppHosts.Where(host => host.IsPinned && !protectedPaths.Contains(host.AppHostPath)))
        {
            try
            {
                if (TrayAppHostPath.IsMissing(host.AppHostPath))
                {
                    missing.Add(host.AppHostPath);
                }
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
            {
                Console.Error.WriteLine($"A saved AppHost source path could not be checked ({ex.GetType().Name}).");
                probeFailed = true;
            }
        }
        _pinProbeError = probeFailed ? "Unable to check saved AppHost paths. Unavailable pins were kept." : null;
        if (missing.Count > 0)
        {
            // Forget both flags in one durable update, rather than unpinning into recents.
            // A failed write keeps the old in-memory state and its visible storage error.
            TrySaveStateLocked(_savedState.RemoveMissingPins(missing));
        }
        return missing;
    }
 
    private void RememberLocked(IEnumerable<string> paths)
    {
        var state = _savedState;
        foreach (var path in paths)
        {
            state = state.Remember(path, _recentAppHostLimit);
        }
        if (!TrySaveStateLocked(state))
        {
            // Discovery still works in memory after a storage failure, but its persistent
            // error stays visible and the store will not overwrite an unreadable file.
            _savedState = state;
            _savedStateDirty = true;
        }
    }
 
    private bool TrySaveStateLocked(TraySavedState state)
    {
        if (!_savedStateDirty && _savedState.ConfirmStop == state.ConfirmStop && _savedState.AppHosts.SequenceEqual(state.AppHosts))
        {
            return true;
        }
        try
        {
            _savedStateStore.Save(state);
            _savedState = state;
            _savedStateDirty = false;
            _savedStateError = null;
            return true;
        }
        catch (Exception ex) when (IsSavedStateException(ex))
        {
            Console.Error.WriteLine($"Saved AppHost state could not be written ({ex.GetType().Name}).");
            _savedStateError = "Unable to save AppHost history. The saved file was left unchanged.";
            return false;
        }
    }
 
    private static bool IsSavedStateException(Exception ex)
        => ex is IOException or InvalidDataException or UnauthorizedAccessException or JsonException or ArgumentException
            or NotSupportedException or System.Security.SecurityException;
 
    private void PublishLocked()
    {
        var live = _snapshot.Discovery == DiscoveryState.Live;
        var pins = _savedState.AppHosts.Where(host => host.IsPinned)
            .Select(host => host.AppHostPath).ToHashSet(TrayAppHostPath.Comparer);
        var rows = _snapshot.AppHosts.Select(host =>
        {
            _stops.TryGetValue(host.Id, out var stop);
            var stopping = stop is { Result: null };
            var stopped = stop?.Result?.Outcome == StopOutcome.Stopped;
            var error = GetStopError(stop?.Result)
                ?? (host.ProcessStartTimeUnixMilliseconds is null ? "Process identity unavailable; Stop is disabled." : null);
            var subtitle = error ?? (stopping ? "Stopping AppHost..."
                : stopped ? "Stopped; waiting for discovery." : AppHostPresentation.GetSubtitle(host));
            return new AppHostMenuItem(host.Id, AppHostPresentation.GetTitle(host), subtitle,
                AppHostPresentation.GetDisplayName(host),
                live && !stopped && host.DashboardUri is not null,
                live && !stopped && !stopping && host.ProcessStartTimeUnixMilliseconds is > 0,
                stopping, error)
            {
                IsPinned = pins.Contains(TrayAppHostPath.Normalize(host.AppHostPath)),
                IsRunning = !stopped,
                Health = live && !stopped ? host.Health : AppHostHealth.Unknown
            };
        }).ToList();
        var listedPaths = _snapshot.AppHosts.Select(host => TrayAppHostPath.Normalize(host.AppHostPath))
            .ToHashSet(TrayAppHostPath.Comparer);
        foreach (var saved in _savedState.AppHosts.Where(host => host.IsPinned && !listedPaths.Contains(host.AppHostPath)))
        {
            rows.Add(CreateSavedMenuItem(saved, live));
            listedPaths.Add(saved.AppHostPath);
        }
        var recent = _savedState.AppHosts.Where(host => host.IsRecent && !listedPaths.Contains(host.AppHostPath)
                && (!_starts.TryGetValue(host.AppHostPath, out var operation)
                    || operation.Result?.Outcome != StartOutcome.Started || operation.Discovered || operation.ReconciliationExpired))
            .Select(host => CreateSavedMenuItem(host, live)).ToArray();
        var pending = rows.Count(row => row.IsStopping);
        var starting = _starts.Values.Count(operation => operation.IsPending);
        var failures = rows.Concat(recent).Count(row => row.Error is not null);
        var running = rows.Count(row => row.IsRunning);
        var status = _snapshot.Discovery switch
        {
            _ when _savedStateError is not null => _savedStateError,
            _ when _pinProbeError is not null => _pinProbeError,
            DiscoveryState.Connecting => "Connecting to Aspire...",
            DiscoveryState.Disconnected => "Discovery unavailable. Reconnecting...",
            DiscoveryState.Incompatible => "Incompatible CLI. Use the matching Aspire build.",
            DiscoveryState.LimitExceeded => "Discovery limit exceeded. Too many AppHosts or too much metadata.",
            _ when _actionError is not null => _actionError,
            _ when failures > 0 => $"{failures} AppHost{(failures == 1 ? "" : "s")} need{(failures == 1 ? "s" : "")} attention",
            _ when pending > 0 => $"Stopping {pending} AppHost{(pending == 1 ? "" : "s")}...",
            _ when starting > 0 => $"Starting {starting} AppHost{(starting == 1 ? "" : "s")}...",
            _ => running switch { 0 => "No AppHosts running", 1 => "1 AppHost", _ => $"{running} AppHosts" }
        };
        Volatile.Write(ref _state, new(_snapshot.Discovery, rows.ToArray(), status)
        {
            RecentAppHosts = recent,
            CanClearRecent = _savedState.AppHosts.Any(host => host.IsRecent),
            HasActiveAppHosts = running > 0 || starting > 0,
            ShowStatus = rows.Count == 0 || !live || _actionError is not null || _savedStateError is not null
                || _pinProbeError is not null || failures > 0
        });
    }
 
    private AppHostMenuItem CreateSavedMenuItem(SavedAppHost saved, bool live)
    {
        var host = new AppHostInfo(saved.AppHostPath, 0, null);
        _starts.TryGetValue(saved.AppHostPath, out var start);
        var starting = start?.IsPending == true;
        var error = GetStartError(start);
        var subtitle = error ?? (starting ? "Starting AppHost; waiting for discovery..."
            : saved.IsPinned ? "Stopped" : GetUnpinnedSourceStatus(saved.AppHostPath));
        return new(host.Id, AppHostPresentation.GetTitle(host), subtitle, AppHostPresentation.GetDisplayName(host),
            false, false, false, error)
        {
            IsPinned = saved.IsPinned,
            CanStart = live && start?.BlocksStart != true,
            IsStarting = starting,
            IsRunning = false,
            Health = AppHostHealth.Unknown
        };
    }
 
    private static string GetUnpinnedSourceStatus(string path)
    {
        // File.Exists reports false for access and I/O failures too; only claim the source
        // was deleted when the probe positively observes it missing.
        try
        {
            return TrayAppHostPath.IsMissing(path) ? "AppHost source file not found" : "Stopped";
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
        {
            return "AppHost source path unavailable";
        }
    }
 
    private static string? GetStartError(StartOperation? operation) => operation?.Discovered == true
        ? null
        : operation?.ReconciliationExpired == true
        ? "Start was not confirmed by discovery. The AppHost may still start; check before retrying."
        : operation?.Result?.Outcome switch
    {
        null or StartOutcome.Started => null,
        StartOutcome.NotFound => "The AppHost source file no longer exists.",
        StartOutcome.TimedOut => "Start timed out. The AppHost may still start; wait for discovery before retrying.",
        _ => $"Unable to start AppHost{(operation.Result.ExitCode is int code ? $" (CLI exit {code})" : "")}."
    };
 
    private static string? GetStopError(StopResult? result) => result?.Outcome switch
    {
        null or StopOutcome.Stopped => null,
        StopOutcome.NotFound => "This AppHost is no longer running.",
        StopOutcome.Ambiguous => "The CLI could not identify a unique AppHost.",
        StopOutcome.IdentityMismatch => "The AppHost process changed. Refresh before stopping it.",
        StopOutcome.IdentityUnavailable => "The process identity could not be verified.",
        StopOutcome.TimedOut => "Stop timed out. The shutdown may still complete.",
        StopOutcome.Incompatible => "Incompatible CLI stop response. Use the matching Aspire build.",
        _ => $"Unable to stop AppHost{(result.ExitCode is int code ? $" (CLI exit {code})" : "")}."
    };
 
    public ValueTask DisposeAsync()
    {
        lock (_gate)
        {
            return new(_disposeTask ??= DisposeCoreAsync());
        }
    }
 
    private async Task DisposeCoreAsync()
    {
        _disposed = true;
        var tasks = _stops.Values.Select(operation => operation.Task).ToList();
        tasks.AddRange(_starts.Values.Select(operation => operation.Task));
        if (_watcher is not null)
        {
            tasks.Add(_watcher);
        }
        try
        {
            await _shutdown.CancelAsync().ConfigureAwait(false);
            await Task.WhenAll(tasks).ConfigureAwait(false);
        }
        finally
        {
            _shutdown.Dispose();
        }
    }
 
    private sealed class StopOperation
    {
        public Task Task { get; set; } = Task.CompletedTask;
        public StopResult? Result { get; set; }
    }
 
    private sealed class StartOperation
    {
        public Task Task { get; set; } = Task.CompletedTask;
        public StartResult? Result { get; set; }
        public bool Discovered { get; set; }
        public bool ReconciliationExpired { get; set; }
        public bool IsPending => Result is null || (Result.Outcome == StartOutcome.Started && !Discovered && !ReconciliationExpired);
        public bool BlocksStart => IsPending || (Result?.Outcome == StartOutcome.TimedOut && !Discovered && !ReconciliationExpired);
    }
}