File: Lifecycle\MauiBuildQueueEventSubscriber.cs
Web Access
Project: src\src\Aspire.Hosting.Maui\Aspire.Hosting.Maui.csproj (Aspire.Hosting.Maui)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.Diagnostics;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Lifecycle;
using Aspire.Hosting.Maui.Annotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Maui.Lifecycle;
 
/// <summary>
/// Event subscriber that serializes MAUI platform resource builds per-project.
/// </summary>
/// <remarks>
/// Multiple MAUI platform resources (Android, iOS, Mac Catalyst, Windows) can reference
/// the same project. MSBuild cannot handle concurrent builds of the same project file,
/// so this subscriber uses a semaphore to ensure only one platform builds at a time.
/// Resources waiting for their turn show a "Queued" state in the dashboard.
/// The build is run as a separate <c>dotnet build</c> subprocess so that the exit code
/// provides reliable build-completion detection and the "Building" state persists in the
/// dashboard for the full build duration. Once the build completes, DCP launches the app
/// with just the Run target.
/// </remarks>
internal class MauiBuildQueueEventSubscriber(
    ResourceNotificationService notificationService,
    ResourceLoggerService loggerService,
    ResourceCommandService resourceCommandService) : IDistributedApplicationEventingSubscriber
{
    private const string DcpTerminatedState = "Terminated";
 
    private static readonly ResourceStateSnapshot s_queuedState = new("Queued", KnownResourceStateStyles.Info);
    private static readonly ResourceStateSnapshot s_buildingState = new("Building", KnownResourceStateStyles.Info);
    private static readonly ResourceStateSnapshot s_cancelledState = new(KnownResourceStates.Exited, KnownResourceStateStyles.Warn);
 
    private readonly AsyncLocal<bool> _isLaunchTimeoutStop = new();
    private readonly ConcurrentDictionary<string, TaskCompletionSource> _nextStartSignals = new(StringComparers.ResourceName);
 
    /// <summary>
    /// Maximum time to wait for a <c>dotnet build</c> process before cancelling.
    /// Prevents a hung build from blocking the queue indefinitely.
    /// </summary>
    internal TimeSpan BuildTimeout { get; set; } = TimeSpan.FromMinutes(10);
 
    /// <summary>
    /// Maximum time to wait for DCP's launch process to reach a queue handoff state after the build succeeds.
    /// Prevents a hung deploy or runtime upload from blocking the queue indefinitely.
    /// </summary>
    internal TimeSpan LaunchHandoffTimeout { get; set; } = TimeSpan.FromMinutes(10);
 
    /// <summary>
    /// Provides time for the launch handoff timeout.
    /// </summary>
    internal TimeProvider LaunchHandoffTimeProvider { get; set; } = TimeProvider.System;
 
    /// <inheritdoc/>
    public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
    {
        eventing.Subscribe<BeforeResourceStartedEvent>(OnBeforeResourceStartedAsync);
        return Task.CompletedTask;
    }
 
    private async Task OnBeforeResourceStartedAsync(BeforeResourceStartedEvent @event, CancellationToken cancellationToken)
    {
        if (@event.Resource is not IMauiPlatformResource mauiResource)
        {
            return;
        }
 
        var resource = @event.Resource;
        var parent = mauiResource.Parent;
        var logger = loggerService.GetLogger(resource);
 
        if (!parent.TryGetLastAnnotation<MauiBuildQueueAnnotation>(out var queueAnnotation))
        {
            return;
        }
 
        // DCP deletes an executable before publishing BeforeResourceStartedEvent for its replacement.
        // Signal an older handoff now so it can release the project lock without trying to stop through
        // DCP while this new start already owns DCP's serialized-operation lock.
        if (_nextStartSignals.TryGetValue(resource.Name, out var nextStartSignal))
        {
            nextStartSignal.TrySetResult();
        }
 
        // Replace the default stop command with one that can cancel queued/building resources.
        // This must happen here (not at app model build time) because the default lifecycle
        // commands are added by DcpExecutor.EnsureRequiredAnnotations AFTER app model building.
        EnsureStopCommandReplaced(resource, queueAnnotation);
 
        var semaphore = queueAnnotation.BuildSemaphore;
 
        // Create a per-resource CTS so the stop command can cancel a queued/building resource.
        using var resourceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        queueAnnotation.ResourceCancellations[resource.Name] = resourceCts;
 
        var semaphoreAcquired = false;
        var releaseInFinally = true;
 
        try
        {
            // Try to acquire the semaphore without blocking. If it's already held,
            // show "Queued" state and then do the real wait.
            if (!await semaphore.WaitAsync(TimeSpan.Zero, CancellationToken.None).ConfigureAwait(false))
            {
                logger.LogInformation("Queued — waiting for another build of project '{ProjectName}' to complete.", parent.Name);
 
                await notificationService.PublishUpdateAsync(resource, s => s with
                {
                    State = s_queuedState
                }).ConfigureAwait(false);
 
                await semaphore.WaitAsync(resourceCts.Token).ConfigureAwait(false);
            }
 
            semaphoreAcquired = true;
 
            logger.LogInformation("Building project '{ProjectName}' for {ResourceName}.", parent.Name, resource.Name);
 
            await notificationService.PublishUpdateAsync(resource, s => s with
            {
                State = s_buildingState
            }).ConfigureAwait(false);
 
            await RunBuildAsync(resource, logger, resourceCts.Token).ConfigureAwait(false);
 
            // Build succeeded. Keep the semaphore held until DCP starts the launch process.
            // Non-Android platforms launch without rebuilding and can release when DCP reports Running.
            // Android Run still performs fast-deploy/runtime upload work, so it must retain the lock
            // until the short-lived Run process reaches a terminal state.
            var releaseBuildLockOnResourceRunning = !resource.TryGetLastAnnotation<MauiBuildInfoAnnotation>(out var buildInfo)
                || buildInfo.ReleaseBuildLockOnResourceRunning;
            releaseInFinally = false;
            _ = ReleaseSemaphoreAfterLaunchAsync(
                resource,
                semaphore,
                s_buildingState.Text,
                releaseBuildLockOnResourceRunning,
                logger,
                @event.Services.GetRequiredService<IHostApplicationLifetime>().ApplicationStopping);
        }
        catch (OperationCanceledException) when (resourceCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
        {
            // The per-resource CTS was cancelled by CancelResource (user clicked stop).
            // Re-throw so DCP does not proceed to create/start the executable.
            // The stop command handler sets the final "Exited" state.
            logger.LogInformation("Build cancelled for resource '{ResourceName}'.", resource.Name);
            throw;
        }
        finally
        {
            if (semaphoreAcquired && releaseInFinally)
            {
                ReleaseSemaphoreSafely(semaphore);
                logger.LogDebug("Released build lock (resource '{ResourceName}').", resource.Name);
            }
 
            queueAnnotation.ResourceCancellations.TryRemove(resource.Name, out _);
        }
    }
 
    /// <summary>
    /// Runs <c>dotnet build</c> as a subprocess and pipes its output to the resource logger.
    /// </summary>
    internal virtual async Task RunBuildAsync(IResource resource, ILogger logger, CancellationToken cancellationToken)
    {
        if (!resource.TryGetLastAnnotation<MauiBuildInfoAnnotation>(out var buildInfo))
        {
            logger.LogWarning("No build info annotation found for resource '{ResourceName}'. Startup cannot proceed.", resource.Name);
            throw new InvalidOperationException(
                $"Resource '{resource.Name}' is missing MauiBuildInfoAnnotation. " +
                "Cannot proceed with build — the semaphore would be held indefinitely.");
        }
 
        // Match DCP's launch configuration so the Run target starts the exact outputs produced
        // by this serialized build.
        var args = new List<string> { "build", buildInfo.ProjectPath };
 
        if (!string.IsNullOrEmpty(buildInfo.TargetFramework))
        {
            args.Add("-f");
            args.Add(buildInfo.TargetFramework);
        }
 
        if (!string.IsNullOrEmpty(buildInfo.Configuration))
        {
            args.Add("--configuration");
            args.Add(buildInfo.Configuration);
        }
 
        args.AddRange(buildInfo.AdditionalBuildArguments);
 
        var psi = new ProcessStartInfo("dotnet")
        {
            WorkingDirectory = buildInfo.WorkingDirectory,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
        };
 
        foreach (var arg in args)
        {
            psi.ArgumentList.Add(arg);
        }
 
        logger.LogInformation("Running: dotnet {Arguments}", string.Join(" ", args));
 
        // Apply a timeout so that a hung build does not block the queue forever.
        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeoutCts.CancelAfter(BuildTimeout);
        var token = timeoutCts.Token;
 
        using var process = new Process { StartInfo = psi };
 
        process.Start();
 
        // Pipe stdout/stderr to the resource logger so output is visible in the dashboard.
        var stdoutTask = PipeOutputAsync(process.StandardOutput, logger, LogLevel.Information, token);
        var stderrTask = PipeOutputAsync(process.StandardError, logger, LogLevel.Warning, token);
 
        try
        {
            await process.WaitForExitAsync(token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            // The timeout CTS fired, not the caller's token — this is a build timeout.
            TryKillProcess(process, logger);
            throw new TimeoutException(
                $"Build for resource '{resource.Name}' timed out after {BuildTimeout:c}.");
        }
        catch (OperationCanceledException)
        {
            TryKillProcess(process, logger);
            throw;
        }
        finally
        {
            // Always drain remaining output — even on cancellation the process was killed
            // and the streams will reach EOF, so the tasks will complete promptly.
            await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
        }
 
        if (process.ExitCode != 0)
        {
            throw new InvalidOperationException(
                $"Build failed for resource '{resource.Name}' with exit code {process.ExitCode}.");
        }
 
        logger.LogInformation("Build succeeded for resource '{ResourceName}'.", resource.Name);
    }
 
    private static async Task PipeOutputAsync(System.IO.StreamReader reader, ILogger logger, LogLevel level, CancellationToken cancellationToken)
    {
        try
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
                if (line is null)
                {
                    break;
                }
 
                logger.Log(level, "{Line}", line);
            }
        }
        catch (OperationCanceledException)
        {
            // Expected when the build is cancelled or timed out.
        }
        catch (System.IO.IOException)
        {
            // Broken pipe after the process is killed.
        }
    }
 
    private static void TryKillProcess(Process process, ILogger logger)
    {
        try
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
            }
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Failed to kill build process.");
        }
    }
 
    /// <summary>
    /// Releases the build semaphore after DCP starts or completes the app launch process.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The predicate requires the state to differ from <paramref name="stateAtCallTime"/>
    /// (typically "Building") so that the replayed snapshot from
    /// <see cref="ResourceNotificationService.WatchAsync"/> does not immediately satisfy it.
    /// Without this guard a restart could match on a stale snapshot.
    /// </para>
    /// <para>
    /// Including "Running" in the predicate is intentional when <paramref name="releaseOnRunning"/>
    /// is <see langword="true"/>: the pre-build step already compiled the project for the same
    /// configuration that DCP will pass to the launch command. Waiting for a terminal state on
    /// those platforms would hold the semaphore for the entire app lifetime. Android does not
    /// release on Running because its Run target performs required deploy/runtime upload work
    /// after Build.
    /// </para>
    /// </remarks>
    internal virtual async Task ReleaseSemaphoreAfterLaunchAsync(
        IResource resource, SemaphoreSlim semaphore, string? stateAtCallTime,
        bool releaseOnRunning,
        ILogger logger, CancellationToken cancellationToken)
    {
        var releaseSemaphore = true;
        var nextStartSignal = _nextStartSignals.GetOrAdd(
            resource.Name,
            static _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
 
        try
        {
            using var timeoutCts = new CancellationTokenSource(LaunchHandoffTimeout, LaunchHandoffTimeProvider);
            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
            await notificationService.WaitForResourceAsync(
                resource.Name,
                e => ShouldReleaseBuildLockForLaunchState(e.Snapshot.State?.Text, stateAtCallTime, releaseOnRunning),
                cts.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
        {
            if (releaseOnRunning)
            {
                logger.LogWarning(ex, "Timed out waiting for resource '{ResourceName}' to reach a launch handoff state after {Timeout}; releasing build lock.", resource.Name, LaunchHandoffTimeout);
            }
            else
            {
                // Android's Run target can still be writing shared build outputs after DCP reports Running.
                // Stop it before releasing the queue so another platform build cannot overlap that work.
                releaseSemaphore = false;
                logger.LogWarning(ex, "Timed out waiting for Android resource '{ResourceName}' to finish its launch work after {Timeout}; stopping the resource before releasing the build lock.", resource.Name, LaunchHandoffTimeout);
 
                try
                {
                    releaseSemaphore = await StopResourceAfterLaunchTimeoutAsync(resource, nextStartSignal.Task, cancellationToken).ConfigureAwait(false);
                    if (!releaseSemaphore)
                    {
                        logger.LogError("Failed to stop Android resource '{ResourceName}' after its launch handoff timed out. Holding the build lock until the launch terminates, a replacement start takes over, or the application stops.", resource.Name);
                        await WaitForSafeReleaseAfterFailedStopAsync(
                            resource,
                            stateAtCallTime,
                            nextStartSignal.Task,
                            cancellationToken).ConfigureAwait(false);
                        releaseSemaphore = true;
                    }
                }
                catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
                {
                    // Application shutdown terminates DCP's resources, so it is safe to release the in-process lock.
                    releaseSemaphore = true;
                }
                catch (Exception stopException)
                {
                    logger.LogError(stopException, "Failed to stop Android resource '{ResourceName}' after its launch handoff timed out. The build lock will remain held until the application stops.", resource.Name);
                }
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            logger.LogDebug("Application stopping while waiting for MAUI resource '{ResourceName}' to reach a launch handoff state; releasing build lock.", resource.Name);
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "Failed to wait for resource '{ResourceName}' to reach a stable state; releasing build lock.", resource.Name);
        }
        finally
        {
            _ = ((ICollection<KeyValuePair<string, TaskCompletionSource>>)_nextStartSignals)
                .Remove(new(resource.Name, nextStartSignal));
 
            if (releaseSemaphore)
            {
                ReleaseSemaphoreSafely(semaphore);
                logger.LogDebug("Released build lock (resource '{ResourceName}').", resource.Name);
            }
        }
    }
 
    private async Task WaitForSafeReleaseAfterFailedStopAsync(
        IResource resource,
        string? stateAtCallTime,
        Task nextStartObserved,
        CancellationToken cancellationToken)
    {
        using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        var terminalStateTask = notificationService.WaitForResourceAsync(
            resource.Name,
            e => ShouldReleaseBuildLockForLaunchState(e.Snapshot.State?.Text, stateAtCallTime, releaseOnRunning: false),
            waitCts.Token);
 
        if (await Task.WhenAny(terminalStateTask, nextStartObserved).ConfigureAwait(false) == nextStartObserved)
        {
            await waitCts.CancelAsync().ConfigureAwait(false);
        }
 
        try
        {
            await terminalStateTask.ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (nextStartObserved.IsCompleted || cancellationToken.IsCancellationRequested)
        {
            // A replacement start is raised after DCP removes the prior executable. Application
            // shutdown also terminates DCP resources, so either condition makes release safe.
        }
    }
 
    internal virtual async Task<bool> StopResourceAfterLaunchTimeoutAsync(IResource resource, Task nextStartObserved, CancellationToken cancellationToken)
    {
        // The queue-aware stop command delegates to DCP after the pre-build completes. DCP does not
        // report success until the executable is terminal, making command completion the handoff barrier.
        // Bypass its queued-build cancellation branch because a newer start may already be waiting on
        // the semaphore; timeout recovery must stop the older DCP launch without cancelling that attempt.
        if (nextStartObserved.IsCompleted)
        {
            return true;
        }
 
        using var stopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        var stopTask = ExecuteStopCommandAsync(stopCts.Token);
        if (await Task.WhenAny(stopTask, nextStartObserved).ConfigureAwait(false) == nextStartObserved)
        {
            await stopCts.CancelAsync().ConfigureAwait(false);
            await stopTask.ConfigureAwait(false);
            return true;
        }
 
        return await stopTask.ConfigureAwait(false);
 
        async Task<bool> ExecuteStopCommandAsync(CancellationToken stopCancellationToken)
        {
            var wasLaunchTimeoutStop = _isLaunchTimeoutStop.Value;
            _isLaunchTimeoutStop.Value = true;
            try
            {
                var result = await resourceCommandService.ExecuteCommandAsync(resource, KnownResourceCommands.StopCommand, stopCancellationToken).ConfigureAwait(false);
                return result.Success || (result.Canceled && cancellationToken.IsCancellationRequested);
            }
            finally
            {
                _isLaunchTimeoutStop.Value = wasLaunchTimeoutStop;
            }
        }
    }
 
    internal static bool ShouldReleaseBuildLockForLaunchState(string? state, string? stateAtCallTime, bool releaseOnRunning)
    {
        // WatchAsync first replays the current snapshot, which is still the Building state published above.
        if (string.Equals(state, stateAtCallTime, StringComparisons.ResourceState))
        {
            return false;
        }
 
        return (releaseOnRunning && string.Equals(state, KnownResourceStates.Running, StringComparisons.ResourceState))
            || string.Equals(state, KnownResourceStates.RuntimeUnhealthy, StringComparisons.ResourceState)
            || KnownResourceStates.TerminalStates.Contains(state, StringComparers.ResourceState)
            // Executables can expose the raw DCP state before it is mapped to a public terminal state.
            || string.Equals(state, DcpTerminatedState, StringComparisons.ResourceState);
    }
 
    /// <summary>
    /// Releases the semaphore, guarding against <see cref="ObjectDisposedException"/>
    /// which can occur during rapid AppHost shutdown if the annotation is disposed concurrently.
    /// </summary>
    private static void ReleaseSemaphoreSafely(SemaphoreSlim semaphore)
    {
        try
        {
            semaphore.Release();
        }
        catch (ObjectDisposedException)
        {
            // The semaphore was disposed during shutdown — safe to ignore.
        }
    }
 
    /// <summary>
    /// Replaces the default stop command with one that can cancel queued/building resources
    /// via the <see cref="MauiBuildQueueAnnotation.CancelResource"/> method, while delegating
    /// to the original stop command for the Running state.
    /// </summary>
    private void EnsureStopCommandReplaced(IResource resource, MauiBuildQueueAnnotation queueAnnotation)
    {
        // Only replace once per resource (supports restart).
        if (resource.Annotations.OfType<MauiStopCommandReplacedAnnotation>().Any())
        {
            return;
        }
 
        var originalStop = resource.Annotations
            .OfType<ResourceCommandAnnotation>()
            .SingleOrDefault(a => a.Name == KnownResourceCommands.StopCommand);
 
        if (originalStop is null)
        {
            return;
        }
 
        // Mark as replaced only after confirming the stop command exists
        // and the replacement is fully in place, so a retry on restart can
        // succeed if any step fails.
        resource.Annotations.Remove(originalStop);
 
        resource.Annotations.Add(new ResourceCommandAnnotation(
            name: KnownResourceCommands.StopCommand,
            displayName: "Stop",
            updateState: context =>
            {
                var state = context.ResourceSnapshot.State?.Text;
 
                // Show stop for Queued/Building states.
                if (state == s_queuedState.Text || state == s_buildingState.Text)
                {
                    return ResourceCommandState.Enabled;
                }
 
                // For all other states, delegate to original logic.
                return originalStop.UpdateState(context);
            },
            executeCommand: async context =>
            {
                // Cancel via the annotation — works for both Queued and Building.
                // Use resource.Name (the model name) because the CTS dictionary is keyed
                // by model name, while context.ResourceName is the DCP-resolved name
                // (e.g., "mauiapp-maccatalyst-vqfdyejk" vs "mauiapp-maccatalyst").
                var wasCancelled = !_isLaunchTimeoutStop.Value && queueAnnotation.CancelResource(resource.Name);
 
                if (wasCancelled)
                {
                    var logger = loggerService.GetLogger(resource);
 
                    // The BeforeResourceStartedEvent handler re-throws the OCE, which causes
                    // DCP to set FailedToStart. We reactively wait for that state and immediately
                    // override it since this was a user-initiated stop, not a build failure.
                    _ = Task.Run(async () =>
                    {
                        try
                        {
                            try
                            {
                                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
                                await notificationService.WaitForResourceAsync(
                                    resource.Name,
                                    e => string.Equals(e.Snapshot.State?.Text, KnownResourceStates.FailedToStart, StringComparisons.ResourceState),
                                    cts.Token).ConfigureAwait(false);
                            }
                            catch (OperationCanceledException)
                            {
                                // Timeout — override anyway in case FailedToStart was never published.
                            }
 
                            // Only override if DCP set FailedToStart and the user hasn't already clicked
                            // Start again. If a new start registered a CTS, a new build attempt is underway
                            // and we must not overwrite it. Note: there is a narrow TOCTOU window between
                            // this check and PublishUpdateAsync, but the inner guard on FailedToStart state
                            // makes it benign — in the worst case, a concurrent restart's state wins.
                            if (queueAnnotation.ResourceCancellations.ContainsKey(resource.Name))
                            {
                                return;
                            }
 
                            await notificationService.PublishUpdateAsync(resource, s =>
                            {
                                if (s.State?.Text is not null &&
                                    !string.Equals(s.State.Text, KnownResourceStates.FailedToStart, StringComparisons.ResourceState))
                                {
                                    return s;
                                }
 
                                return s with
                                {
                                    State = s_cancelledState,
                                    StartTimeStamp = null,
                                    StopTimeStamp = null,
                                    ExitCode = null,
                                };
                            }).ConfigureAwait(false);
                        }
                        catch (Exception ex)
                        {
                            logger.LogDebug(ex, "Failed to override state to Exited for resource '{ResourceName}'.", resource.Name);
                        }
                    });
 
                    return CommandResults.Success();
                }
 
                // Resource is past the queue (Running) — delegate to original stop.
                return await originalStop.ExecuteCommand(context).ConfigureAwait(false);
            },
            displayDescription: null,
            parameter: null,
            confirmationMessage: null,
            iconName: "Stop",
            iconVariant: IconVariant.Filled,
            isHighlighted: true));
 
        resource.Annotations.Add(new MauiStopCommandReplacedAnnotation());
    }
 
    /// <summary>
    /// Marker annotation to prevent replacing lifecycle commands more than once.
    /// </summary>
    private sealed class MauiStopCommandReplacedAnnotation : IResourceAnnotation;
}