From c5898093de62d3f3abaff6e11210bd483744215d Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 8 Apr 2026 14:57:28 +0200 Subject: [PATCH] Add MAUI build queue to serialize platform builds Adds build queue serialization for MAUI platform resources (Android, iOS, Mac Catalyst, Windows) that share the same project file. MSBuild cannot handle concurrent builds of the same project, causing intermittent file locking and XamlC assembly resolution failures. Key changes: - MauiBuildQueueEventSubscriber: per-project SemaphoreSlim(1,1) serializes dotnet build commands with Queued/Building dashboard states - ProjectLaunchArgsOverrideAnnotation: overrides DCP dotnet run with dotnet build /t:Run for MAUI device deployment - Stop-while-queued support with queue-aware stop command replacement - Post-build semaphore hold until DCP launch reaches stable state - 94 unit tests covering serialization, cancellation, timeouts, restart Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AspireWithMaui.MauiClient.csproj | 8 +- .../AspireWithMaui.MauiClient/MauiProgram.cs | 4 +- .../Annotations/MauiBuildInfoAnnotation.cs | 32 + .../Annotations/MauiBuildQueueAnnotation.cs | 50 + .../Aspire.Hosting.Maui.csproj | 1 + .../MauiBuildQueueEventSubscriber.cs | 452 ++++++++++ .../MauiHostingExtensions.cs | 4 + src/Aspire.Hosting.Maui/MauiPlatformHelper.cs | 21 +- .../MauiProjectResourceExtensions.cs | 6 + src/Aspire.Hosting.Maui/README.md | 29 + .../ProjectLaunchArgsOverrideAnnotation.cs | 48 + src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 27 +- .../Aspire.Hosting.Maui.Tests.csproj | 1 + .../MauiBuildQueueTests.cs | 853 ++++++++++++++++++ 14 files changed, 1524 insertions(+), 12 deletions(-) create mode 100644 src/Aspire.Hosting.Maui/Annotations/MauiBuildInfoAnnotation.cs create mode 100644 src/Aspire.Hosting.Maui/Annotations/MauiBuildQueueAnnotation.cs create mode 100644 src/Aspire.Hosting.Maui/Lifecycle/MauiBuildQueueEventSubscriber.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/ProjectLaunchArgsOverrideAnnotation.cs create mode 100644 tests/Aspire.Hosting.Maui.Tests/MauiBuildQueueTests.cs diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/AspireWithMaui.MauiClient.csproj b/playground/AspireWithMaui/AspireWithMaui.MauiClient/AspireWithMaui.MauiClient.csproj index 1073a214499..dad65be42d6 100644 --- a/playground/AspireWithMaui/AspireWithMaui.MauiClient/AspireWithMaui.MauiClient.csproj +++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/AspireWithMaui.MauiClient.csproj @@ -68,10 +68,10 @@ - - - - + + + + diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/MauiProgram.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/MauiProgram.cs index 8cc82666213..ac70e1ae804 100644 --- a/playground/AspireWithMaui/AspireWithMaui.MauiClient/MauiProgram.cs +++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/MauiProgram.cs @@ -28,7 +28,9 @@ public static MauiApp CreateMauiApp() builder.Services.AddHttpClient(client => { // This will be resolved via service discovery when running with Aspire - client.BaseAddress = new Uri("https://webapi"); + // Use https+http:// scheme to allow fallback to HTTP when HTTPS is not available + // (e.g., when running on Android emulator without dev tunnels) + client.BaseAddress = new Uri("https+http://webapi"); }); #if DEBUG diff --git a/src/Aspire.Hosting.Maui/Annotations/MauiBuildInfoAnnotation.cs b/src/Aspire.Hosting.Maui/Annotations/MauiBuildInfoAnnotation.cs new file mode 100644 index 00000000000..ccb4ff92188 --- /dev/null +++ b/src/Aspire.Hosting.Maui/Annotations/MauiBuildInfoAnnotation.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Maui.Annotations; + +/// +/// Annotation carrying the build parameters for a MAUI platform resource, used by +/// to run the Build target +/// before DCP launches the Run target. +/// +internal sealed class MauiBuildInfoAnnotation( + string projectPath, + string workingDirectory, + string? targetFramework) : IResourceAnnotation +{ + /// + /// Gets the absolute path to the project file. + /// + public string ProjectPath { get; } = projectPath; + + /// + /// Gets the working directory for the build process. + /// + public string WorkingDirectory { get; } = workingDirectory; + + /// + /// Gets the target framework moniker (e.g., net10.0-android). + /// + public string? TargetFramework { get; } = targetFramework; +} diff --git a/src/Aspire.Hosting.Maui/Annotations/MauiBuildQueueAnnotation.cs b/src/Aspire.Hosting.Maui/Annotations/MauiBuildQueueAnnotation.cs new file mode 100644 index 00000000000..32366c9e822 --- /dev/null +++ b/src/Aspire.Hosting.Maui/Annotations/MauiBuildQueueAnnotation.cs @@ -0,0 +1,50 @@ +// 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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Maui.Annotations; + +/// +/// Annotation added to to serialize builds across +/// platform resources that share the same project. +/// +internal sealed class MauiBuildQueueAnnotation : IResourceAnnotation +{ + /// + /// Gets the semaphore used to serialize builds for this project. + /// + public SemaphoreSlim BuildSemaphore { get; } = new(1, 1); + + /// + /// Per-resource CTS that allows the stop command to cancel a queued or building resource. + /// The key is the resource name. + /// + public ConcurrentDictionary ResourceCancellations { get; } = new(); + + /// + /// Cancels a resource's queued or building state. + /// + /// true if the resource was found and cancelled; false otherwise. + public bool CancelResource(string resourceName) + { + if (ResourceCancellations.TryRemove(resourceName, out var cts)) + { + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // The CTS was disposed by the event handler's using block after the build + // completed naturally at the exact moment the user clicked stop. + return false; + } + + return true; + } + + return false; + } +} diff --git a/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj b/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj index f396a6e8011..d842820bc4d 100644 --- a/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj +++ b/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj @@ -9,6 +9,7 @@ true aspire maui hosting $(SharedDir)Maui_265x.png + $(NoWarn);ASPIREMAUI001 diff --git a/src/Aspire.Hosting.Maui/Lifecycle/MauiBuildQueueEventSubscriber.cs b/src/Aspire.Hosting.Maui/Lifecycle/MauiBuildQueueEventSubscriber.cs new file mode 100644 index 00000000000..d0b52cddfe0 --- /dev/null +++ b/src/Aspire.Hosting.Maui/Lifecycle/MauiBuildQueueEventSubscriber.cs @@ -0,0 +1,452 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Lifecycle; +using Aspire.Hosting.Maui.Annotations; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Maui.Lifecycle; + +/// +/// Event subscriber that serializes MAUI platform resource builds per-project. +/// +/// +/// 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 dotnet build 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. +/// +internal class MauiBuildQueueEventSubscriber( + ResourceNotificationService notificationService, + ResourceLoggerService loggerService) : IDistributedApplicationEventingSubscriber +{ + 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); + + /// + /// Maximum time to wait for a dotnet build process before cancelling. + /// Prevents a hung build from blocking the queue indefinitely. + /// + internal TimeSpan BuildTimeout { get; set; } = TimeSpan.FromMinutes(10); + + /// + public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + eventing.Subscribe(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(out var queueAnnotation)) + { + return; + } + + // 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 (!semaphore.Wait(TimeSpan.Zero, CancellationToken.None)) + { + 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 finishes launching the app. + // After this handler returns, DCP invokes `dotnet build /t:Run` which also runs + // MSBuild. Releasing the semaphore now would let the next queued build start while + // DCP's MSBuild is still touching shared dependency outputs (e.g., MauiServiceDefaults), + // causing intermittent XamlC assembly resolution failures. + releaseInFinally = false; + _ = ReleaseSemaphoreAfterLaunchAsync(resource, semaphore, s_buildingState.Text, logger, cancellationToken); + } + 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 _); + } + } + + /// + /// Runs dotnet build as a subprocess and pipes its output to the resource logger. + /// + internal virtual async Task RunBuildAsync(IResource resource, ILogger logger, CancellationToken cancellationToken) + { + if (!resource.TryGetLastAnnotation(out var buildInfo)) + { + logger.LogWarning("No build info annotation found for resource '{ResourceName}'. Skipping build.", resource.Name); + return; + } + + var args = new List { "build", buildInfo.ProjectPath }; + + if (!string.IsNullOrEmpty(buildInfo.TargetFramework)) + { + args.Add("-f"); + args.Add(buildInfo.TargetFramework); + } + + 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."); + } + } + + /// + /// Releases the build semaphore after DCP has finished launching the app. DCP invokes + /// dotnet build /t:Run after this handler returns, which also runs MSBuild. + /// Holding the semaphore until the resource reaches a stable state prevents concurrent + /// MSBuild operations on shared dependency outputs. + /// + /// + /// + /// The predicate requires the state to differ from + /// (typically "Building") so that the replayed snapshot from + /// does not immediately satisfy it. + /// Without this guard a restart could match on a stale snapshot. + /// + /// + /// Including "Running" in the predicate is intentional: the pre-build step already compiled + /// the project, so DCP's dotnet build /t:Run performs only a fast incremental (no-op) + /// check before launching the app. Waiting for a terminal state would hold the semaphore for + /// the entire app lifetime, blocking other platforms from starting. + /// + /// + internal virtual async Task ReleaseSemaphoreAfterLaunchAsync( + IResource resource, SemaphoreSlim semaphore, string? stateAtCallTime, + ILogger logger, CancellationToken cancellationToken) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(5)); + await notificationService.WaitForResourceAsync( + resource.Name, + e => + { + var text = e.Snapshot.State?.Text; + // Skip the replayed snapshot that matches the state when we were called. + if (string.Equals(text, stateAtCallTime, StringComparison.Ordinal)) + { + return false; + } + + return text == KnownResourceStates.Running + || text == KnownResourceStates.FailedToStart + || text == KnownResourceStates.Exited + || text == KnownResourceStates.Finished; + }, + cts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Timed out waiting for resource '{ResourceName}' to reach a stable state; releasing build lock.", resource.Name); + } + finally + { + ReleaseSemaphoreSafely(semaphore); + logger.LogDebug("Released build lock (resource '{ResourceName}').", resource.Name); + } + } + + /// + /// Releases the semaphore, guarding against + /// which can occur during rapid AppHost shutdown if the annotation is disposed concurrently. + /// + private static void ReleaseSemaphoreSafely(SemaphoreSlim semaphore) + { + try + { + semaphore.Release(); + } + catch (ObjectDisposedException) + { + // The semaphore was disposed during shutdown — safe to ignore. + } + } + + /// + /// Replaces the default stop command with one that can cancel queued/building resources + /// via the method, while delegating + /// to the original stop command for the Running state. + /// + private void EnsureStopCommandReplaced(IResource resource, MauiBuildQueueAnnotation queueAnnotation) + { + // Only replace once per resource (supports restart). + if (resource.Annotations.OfType().Any()) + { + return; + } + + var originalStop = resource.Annotations + .OfType() + .SingleOrDefault(a => a.Name == KnownResourceCommands.StopCommand); + + if (originalStop is null) + { + return; + } + + // Mark as replaced only after confirming the stop command exists, + // so a retry on restart can succeed if it was missing initially. + resource.Annotations.Add(new MauiStopCommandReplacedAnnotation()); + + // Remove the original and add our replacement. + 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 = 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, StringComparison.Ordinal), + 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 && s.State.Text != KnownResourceStates.FailedToStart) + { + 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)); + } + + /// + /// Marker annotation to prevent replacing lifecycle commands more than once. + /// + private sealed class MauiStopCommandReplacedAnnotation : IResourceAnnotation; +} diff --git a/src/Aspire.Hosting.Maui/MauiHostingExtensions.cs b/src/Aspire.Hosting.Maui/MauiHostingExtensions.cs index d2053ce5744..ac65d8e899b 100644 --- a/src/Aspire.Hosting.Maui/MauiHostingExtensions.cs +++ b/src/Aspire.Hosting.Maui/MauiHostingExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Lifecycle; +using Aspire.Hosting.Maui.Lifecycle; using Aspire.Hosting.Maui.Utilities; namespace Aspire.Hosting.Maui; @@ -15,6 +16,9 @@ internal static class MauiHostingExtensions [AspireExportIgnore(Reason = "Use AddMauiProject() instead.")] public static void AddMauiHostingServices(this IDistributedApplicationBuilder builder) { + // Register the build queue subscriber to serialize builds per-project + builder.Services.TryAddEventingSubscriber(); + // Register the Android environment variable eventing subscriber builder.Services.TryAddEventingSubscriber(); diff --git a/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs index 2e287b53bc1..884f3be4a36 100644 --- a/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs +++ b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs @@ -60,16 +60,31 @@ internal static void ConfigurePlatformResource( // Check if the project has the platform TFM and get the actual TFM value var platformTfm = ProjectFileReader.GetPlatformTargetFramework(projectPath, platformName); - // Set the command line arguments with the detected TFM if available + // Override the default DCP launch command from 'dotnet run' to 'dotnet build /t:Run'. + // The Build target is run separately by MauiBuildQueueEventSubscriber before DCP starts + // the process, giving reliable exit-code-based build completion detection and allowing + // the "Building" state to persist in the dashboard. DCP only needs to invoke the Run + // target, which launches the already-built app. + resourceBuilder.WithAnnotation(new ProjectLaunchArgsOverrideAnnotation(["build", "/t:Run"])); + + // Store build parameters so the event subscriber can run 'dotnet build' before launch. + // The annotation captures the project path, working directory, and target framework used + // to build the MAUI app before DCP invokes the Run target. + var workingDir = Path.GetDirectoryName(projectPath) + ?? throw new InvalidOperationException($"Unable to determine directory from project path: {projectPath}"); + resourceBuilder.WithAnnotation(new MauiBuildInfoAnnotation(projectPath, workingDir, platformTfm)); + + // Set the command line arguments with the detected TFM and platform-specific args. + // These are appended AFTER the DCP-generated project args. resourceBuilder.WithArgs(context => { - context.Args.Add("run"); if (!string.IsNullOrEmpty(platformTfm)) { context.Args.Add("-f"); context.Args.Add(platformTfm); } - // Add any additional platform-specific arguments + + // Add any additional platform-specific arguments (e.g., -p:AdbTarget=...) foreach (var arg in additionalArgs) { context.Args.Add(arg); diff --git a/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs b/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs index 467fbf87f74..b4b87eb8bf3 100644 --- a/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs +++ b/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Maui.Annotations; using Aspire.Hosting.Maui; namespace Aspire.Hosting; @@ -60,6 +61,11 @@ public static IResourceBuilder AddMauiProject( // Do not register the logical grouping resource with AddResource so it stays invisible in the dashboard // Only MAUI project targets added through their extension methods will show up var resource = new MauiProjectResource(name, projectPath); + + // Add the build queue annotation eagerly so it's ready before any platform resources start. + // This avoids a race condition when multiple platforms start concurrently. + resource.Annotations.Add(new MauiBuildQueueAnnotation()); + return builder.CreateResourceBuilder(resource); } } diff --git a/src/Aspire.Hosting.Maui/README.md b/src/Aspire.Hosting.Maui/README.md index c3985fb704e..dd9f79df0bd 100644 --- a/src/Aspire.Hosting.Maui/README.md +++ b/src/Aspire.Hosting.Maui/README.md @@ -186,6 +186,35 @@ builder.Build().Run(); adb devices ``` +## Build Queue + +When multiple MAUI platform targets reference the same project (e.g., Android, iOS, and Mac Catalyst all using the same `.csproj`), MSBuild cannot handle concurrent builds of the same project file. The hosting integration automatically serializes these builds using a per-project queue. + +### How It Works + +1. When you start multiple platform resources simultaneously, only one builds at a time +2. Other platforms show a **"Queued"** state in the dashboard while waiting +3. Each build shows a **"Building"** state with live MSBuild output in the resource logs +4. After a build completes and the app launches, the next queued build starts +5. You can click **Stop** on a queued or building resource to cancel it — the resource shows an **"Exited"** state with an orange indicator + +### Key Behaviors + +- **Per-project serialization**: The queue is scoped to each `MauiProjectResource`. If you have two separate MAUI projects, they build in parallel. Only platform targets sharing the same project are serialized. +- **Cancel support**: Clicking Stop on a Queued resource removes it from the queue. Clicking Stop on a Building resource kills the `dotnet build` process. +- **Restart after cancel**: You can start a cancelled resource again — it re-enters the queue. +- **Build timeout**: Builds that take longer than 10 minutes are automatically cancelled to prevent a hung build from blocking the queue. +- **DCP launch hold**: The build lock is held until the app reaches Running state, preventing MSBuild concurrency between the explicit build and DCP's app launch phase. + +### Architecture + +The build queue is implemented via: + +- **`MauiBuildQueueAnnotation`**: Added to the parent `MauiProjectResource`, holds a `SemaphoreSlim(1,1)` and per-resource cancellation tokens +- **`MauiBuildQueueEventSubscriber`**: Subscribes to `BeforeResourceStartedEvent`, manages the queue, runs `dotnet build` as a subprocess, and replaces the default Stop command with a queue-aware version +- **`MauiBuildInfoAnnotation`**: Attached to each platform resource with the project path, working directory, and target framework used for the build subprocess +- **`ProjectLaunchArgsOverrideAnnotation`**: A core `Aspire.Hosting` annotation that overrides DCP's default `dotnet run` args, enabling `dotnet build /t:Run` for MAUI projects + ## Requirements - .NET 10.0 or later diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectLaunchArgsOverrideAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ProjectLaunchArgsOverrideAnnotation.cs new file mode 100644 index 00000000000..9c311ef1f19 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ProjectLaunchArgsOverrideAnnotation.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents an annotation that overrides the default project launch arguments +/// generated by DCP for a . +/// +/// +/// +/// When this annotation is present on a , DCP will use the provided +/// arguments as the base command (e.g., build /t:Run) instead of the default run verb. +/// DCP still appends the project path and --configuration automatically; only the verb and +/// its options are overridden. The --no-launch-profile flag is omitted when this annotation +/// is present. +/// +/// +/// +/// Override DCP's default launch to use dotnet build /t:Run: +/// +/// builder.WithAnnotation(new ProjectLaunchArgsOverrideAnnotation(["build", "/t:Run"])); +/// +/// +[Experimental("ASPIREMAUI001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +[DebuggerDisplay("Type = {GetType().Name,nq}, Args = {string.Join(\" \", Args)}")] +public sealed class ProjectLaunchArgsOverrideAnnotation(IReadOnlyList args) : IResourceAnnotation +{ + /// + /// Gets the custom launch arguments to use instead of the default project args. + /// + public IReadOnlyList Args { get; } = ValidateArgs(args); + + private static IReadOnlyList ValidateArgs(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + + if (args.Count == 0) + { + throw new ArgumentException("Launch arguments must contain at least one entry.", nameof(args)); + } + + return args; + } +} diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 6cbcd05e5cc..27e6da8fbdd 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -4,6 +4,7 @@ #pragma warning disable ASPIREEXTENSION001 #pragma warning disable ASPIRECERTIFICATES001 #pragma warning disable ASPIREDOTNETTOOL +#pragma warning disable ASPIREMAUI001 using System.Diagnostics; using System.Globalization; @@ -203,8 +204,21 @@ private void PrepareProjectExecutables() var projectLaunchConfiguration = new ProjectLaunchConfiguration(); projectLaunchConfiguration.ProjectPath = projectMetadata.ProjectPath; + // Check if the resource has a custom launch args override (e.g., MAUI projects using 'dotnet build /t:Run'). + // When present, the override args replace the standard 'dotnet run' / 'dotnet watch' invocation. + if (project.TryGetLastAnnotation(out var launchOverride)) + { + projectArgs.AddRange(launchOverride.Args); + // 'dotnet build' takes the project path as a positional argument (not --project) + projectArgs.Add(projectMetadata.ProjectPath); + + if (!string.IsNullOrEmpty(_distributedApplicationOptions.Configuration)) + { + projectArgs.AddRange(["--configuration", _distributedApplicationOptions.Configuration]); + } + } // `dotnet watch` does not work with file-based apps yet, so we have to use `dotnet run` in that case - if (_configuration.GetBool("DOTNET_WATCH") is not true || projectMetadata.IsFileBasedApp) + else if (_configuration.GetBool("DOTNET_WATCH") is not true || projectMetadata.IsFileBasedApp) { projectArgs.Add("run"); projectArgs.Add(projectMetadata.IsFileBasedApp ? "--file" : "--project"); @@ -229,9 +243,11 @@ private void PrepareProjectExecutables() ]); } - if (!string.IsNullOrEmpty(_distributedApplicationOptions.Configuration)) + // 'launchOverride' is declared via out-var in the TryGetLastAnnotation call above; + // it is null when the annotation is absent, so this guard prevents double-adding --configuration. + if (launchOverride is null && !string.IsNullOrEmpty(_distributedApplicationOptions.Configuration)) { - projectArgs.AddRange(new[] { "--configuration", _distributedApplicationOptions.Configuration }); + projectArgs.AddRange(["--configuration", _distributedApplicationOptions.Configuration]); } // We pretty much always want to suppress the normal launch profile handling @@ -239,7 +255,10 @@ private void PrepareProjectExecutables() // (the ambient environment settings for service processes come from the application model // and should be HIGHER priority than the launch profile settings). // This means we need to apply the launch profile settings manually inside CreateExecutableAsync(). - projectArgs.Add("--no-launch-profile"); + if (launchOverride is null) + { + projectArgs.Add("--no-launch-profile"); + } // We want this annotation even if we are not using IDE execution; see ToSnapshot() for details. exe.AnnotateAsObjectList(Executable.LaunchConfigurationsAnnotation, projectLaunchConfiguration); diff --git a/tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj b/tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj index 4d0aa5ad81c..46c5dccdfe6 100644 --- a/tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj +++ b/tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj @@ -3,6 +3,7 @@ $(DefaultTargetFramework) false + $(NoWarn);ASPIREMAUI001 diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiBuildQueueTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiBuildQueueTests.cs new file mode 100644 index 00000000000..2aeb89221f4 --- /dev/null +++ b/tests/Aspire.Hosting.Maui.Tests/MauiBuildQueueTests.cs @@ -0,0 +1,853 @@ +// 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 Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Maui; +using Aspire.Hosting.Maui.Annotations; +using Aspire.Hosting.Maui.Lifecycle; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Tests; + +/// +/// Tests for the MAUI build queue that serializes builds per-project. +/// Uses a that overrides +/// with a +/// controllable per resource. +/// +public class MauiBuildQueueTests +{ + [Fact] + public void BuildQueueAnnotation_SemaphoreInitializedToOne() + { + var annotation = new MauiBuildQueueAnnotation(); + Assert.Equal(1, annotation.BuildSemaphore.CurrentCount); + } + + [Fact] + public void BuildQueueAnnotation_AddedByAddMauiProject() + { + var parent = new MauiProjectResource("mauiapp", "/fake/path.csproj"); + parent.Annotations.Add(new MauiBuildQueueAnnotation()); + Assert.True(parent.TryGetLastAnnotation(out _)); + } + + [Fact] + public async Task SingleResource_AcquiresSemaphore() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + // Start the event but don't complete the build yet — semaphore should be held. + var eventTask = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + + Assert.True(env.Parent.TryGetLastAnnotation(out var annotation)); + Assert.Equal(0, annotation!.BuildSemaphore.CurrentCount); + + env.Subscriber.CompleteBuild(env.Android); + await eventTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task SingleResource_ReleasesSemaphoreAfterBuild() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + env.Subscriber.CompleteBuildImmediately(env.Android); + + await env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None); + + Assert.True(env.Parent.TryGetLastAnnotation(out var annotation)); + Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount); + } + + [Fact] + public async Task SecondResource_BlocksUntilBuildCompletes() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + var task1 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + + var task2 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.MacCatalyst, env.Services), + CancellationToken.None)); + + // Give task2 a moment to enter the queue, then verify it's blocked. + await Task.Delay(100); + Assert.False(task2.IsCompleted, "Second resource should be blocked by the queue."); + + // Complete first build — second should start. + env.Subscriber.CompleteBuild(env.Android); + await task1.WaitAsync(TimeSpan.FromSeconds(5)); + + // Complete second build. + env.Subscriber.CompleteBuild(env.MacCatalyst); + await task2.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task SecondResource_ShowsQueuedState() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + var task1 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + + var queuedSeen = new TaskCompletionSource(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + _ = Task.Run(async () => + { + await foreach (var evt in env.NotificationService.WatchAsync(cts.Token)) + { + if (evt.Resource.Name == env.MacCatalyst.Name && evt.Snapshot.State?.Text == "Queued") + { + queuedSeen.TrySetResult(true); + return; + } + } + }, cts.Token); + + var task2 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.MacCatalyst, env.Services), + CancellationToken.None)); + + var result = await queuedSeen.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(result); + + // Clean up: complete both builds and await their tasks. + env.Subscriber.CompleteBuild(env.Android); + await task1.WaitAsync(TimeSpan.FromSeconds(5)); + env.Subscriber.CompleteBuild(env.MacCatalyst); + await task2.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task SingleResource_ShowsBuildingState() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + var buildingSeen = new TaskCompletionSource(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + _ = Task.Run(async () => + { + await foreach (var evt in env.NotificationService.WatchAsync(cts.Token)) + { + if (evt.Resource.Name == env.Android.Name && evt.Snapshot.State?.Text == "Building") + { + buildingSeen.TrySetResult(true); + return; + } + } + }, cts.Token); + + var eventTask = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + var result = await buildingSeen.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(result); + + env.Subscriber.CompleteBuild(env.Android); + await eventTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task ResourcesFromDifferentProjects_RunConcurrently() + { + await using var env = await BuildQueueTestEnvironment.CreateWithTwoProjectsAsync(); + + // Start both events WITHOUT completing builds — both should enter Building concurrently. + var task1 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + var task2 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android2!, env.Services), + CancellationToken.None)); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + await env.Subscriber.WaitForBuildStartedAsync(env.Android2!); + + // Both should be building simultaneously — neither should have completed, + // proving they are NOT serialized across different projects. + Assert.False(task1.IsCompleted, "Task1 should still be building."); + Assert.False(task2.IsCompleted, "Task2 should still be building."); + + // Complete both builds. + env.Subscriber.CompleteBuild(env.Android); + env.Subscriber.CompleteBuild(env.Android2!); + + await Task.WhenAll(task1, task2).WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task FailedBuild_ReleasesQueueAndThrows() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + env.Subscriber.FailBuild(env.Android, "Compilation error"); + + await Assert.ThrowsAsync( + () => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + // Semaphore should be released even after failure. + Assert.True(env.Parent.TryGetLastAnnotation(out var annotation)); + Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount); + } + + [Fact] + public async Task CancelledQueuedResource_DoesNotDeadlock() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + var task1 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + + using var cts = new CancellationTokenSource(); + var task2 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.MacCatalyst, env.Services), + cts.Token)); + + await Task.Delay(100); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => task2.WaitAsync(TimeSpan.FromSeconds(5))); + + // Complete first build — semaphore should still work for a third resource. + env.Subscriber.CompleteBuild(env.Android); + await task1.WaitAsync(TimeSpan.FromSeconds(5)); + + env.Subscriber.CompleteBuildImmediately(env.IOSSimulator); + var task3 = env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.IOSSimulator, env.Services), + CancellationToken.None); + await task3.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task ThreeResources_ExecuteInSequence() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + var completionOrder = new List(); + + var task1 = Task.Run(async () => + { + await env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None); + lock (completionOrder) { completionOrder.Add("android"); } + }); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + + var task2 = Task.Run(async () => + { + await env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.MacCatalyst, env.Services), + CancellationToken.None); + lock (completionOrder) { completionOrder.Add("maccatalyst"); } + }); + + var task3 = Task.Run(async () => + { + await env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.IOSSimulator, env.Services), + CancellationToken.None); + lock (completionOrder) { completionOrder.Add("ios"); } + }); + + // Give tasks 2 and 3 a moment to enter the queue. + await Task.Delay(100); + + Assert.Empty(completionOrder); + Assert.False(task2.IsCompleted); + Assert.False(task3.IsCompleted); + + // Complete Android — one of the queued resources will acquire the semaphore next. + env.Subscriber.CompleteBuild(env.Android); + await task1.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Single(completionOrder); + Assert.Equal("android", completionOrder[0]); + + // After Android's event handler completes, the semaphore is released. + // One of the two waiting tasks acquires it next — order is non-deterministic + // since SemaphoreSlim doesn't guarantee FIFO. Wait for either to start. + var macTask = env.Subscriber.WaitForBuildStartedAsync(env.MacCatalyst, TimeSpan.FromSeconds(30)); + var iosTask = env.Subscriber.WaitForBuildStartedAsync(env.IOSSimulator, TimeSpan.FromSeconds(30)); + var secondStarted = await Task.WhenAny(macTask, iosTask); + await secondStarted; // propagate exceptions + + // Complete whichever started, then wait for the other. + if (secondStarted == macTask) + { + env.Subscriber.CompleteBuild(env.MacCatalyst); + await task2.WaitAsync(TimeSpan.FromSeconds(5)); + + await iosTask; + env.Subscriber.CompleteBuild(env.IOSSimulator); + await task3.WaitAsync(TimeSpan.FromSeconds(5)); + } + else + { + env.Subscriber.CompleteBuild(env.IOSSimulator); + await task3.WaitAsync(TimeSpan.FromSeconds(5)); + + await macTask; + env.Subscriber.CompleteBuild(env.MacCatalyst); + await task2.WaitAsync(TimeSpan.FromSeconds(5)); + } + + // All three completed in sequence (android first, then the other two in either order). + Assert.Equal(3, completionOrder.Count); + Assert.Equal("android", completionOrder[0]); + Assert.Contains("maccatalyst", completionOrder); + Assert.Contains("ios", completionOrder); + } + + [Fact] + public async Task NonMauiResource_IsNotAffected() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + var task1 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + + // Parent MauiProjectResource is NOT IMauiPlatformResource — should pass through. + var parentTask = env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Parent, env.Services), + CancellationToken.None); + + await parentTask.WaitAsync(TimeSpan.FromSeconds(2)); + + env.Subscriber.CompleteBuild(env.Android); + await task1.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task ResourceRestart_CanBuildSameResourceTwice() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + // First build + env.Subscriber.CompleteBuildImmediately(env.Android); + await env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None); + + // Semaphore released after first build + Assert.True(env.Parent.TryGetLastAnnotation(out var annotation)); + Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount); + + // Second build of same resource (restart scenario) + env.Subscriber.CompleteBuildImmediately(env.Android); + await env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None); + + Assert.Equal(1, annotation.BuildSemaphore.CurrentCount); + } + + [Fact] + public async Task MissingBuildQueueAnnotation_SkipsQueue() + { + // Create a parent without MauiBuildQueueAnnotation + var appBuilder = DistributedApplication.CreateBuilder(); + var parent = new MauiProjectResource("mauiapp-no-annotation", "/fake/path.csproj"); + appBuilder.CreateResourceBuilder(parent); + + var android = new MauiAndroidEmulatorResource("android-no-annotation", parent); + appBuilder.AddResource(android); + + var app = appBuilder.Build(); + var notificationService = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var eventing = app.Services.GetRequiredService(); + var execContext = app.Services.GetRequiredService(); + + var subscriber = new TestableBuildQueueSubscriber( + notificationService, + loggerService); + await subscriber.SubscribeAsync(eventing, execContext, CancellationToken.None); + + // Should return immediately — no annotation means no queue + await eventing.PublishAsync( + new BeforeResourceStartedEvent(android, app.Services), + CancellationToken.None); + + await app.DisposeAsync(); + } + + [Fact] + public async Task MissingBuildInfoAnnotation_SkipsBuildAndReleasesQueue() + { + // Use the real subscriber (not testable) to exercise the RunBuildAsync path + // where MauiBuildInfoAnnotation is absent. + var appBuilder = DistributedApplication.CreateBuilder(); + var parent = new MauiProjectResource("mauiapp", "/fake/path.csproj"); + parent.Annotations.Add(new MauiBuildQueueAnnotation()); + appBuilder.CreateResourceBuilder(parent); + + var android = new MauiAndroidEmulatorResource("android", parent); + appBuilder.AddResource(android); + + var app = appBuilder.Build(); + var notificationService = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var eventing = app.Services.GetRequiredService(); + var execContext = app.Services.GetRequiredService(); + + // Use real subscriber — android has no MauiBuildInfoAnnotation. + // Override ReleaseSemaphoreAfterLaunchAsync to release immediately since + // there is no DCP launch phase in the test environment. + var subscriber = new RealBuildQueueSubscriberWithImmediateRelease( + notificationService, + loggerService); + await subscriber.SubscribeAsync(eventing, execContext, CancellationToken.None); + + // Should complete without error — build is skipped, semaphore released + await eventing.PublishAsync( + new BeforeResourceStartedEvent(android, app.Services), + CancellationToken.None); + + Assert.True(parent.TryGetLastAnnotation(out var annotation)); + Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount); + + await app.DisposeAsync(); + } + + [Fact] + public async Task UnexpectedException_ReleasesSemaphore() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + env.Subscriber.FailBuildWith(env.Android, new ArgumentException("Unexpected error")); + + await Assert.ThrowsAsync( + () => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + // Semaphore should be released even for unexpected exception types. + Assert.True(env.Parent.TryGetLastAnnotation(out var annotation)); + Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount); + } + + [Fact] + public void BuildInfoAnnotation_StoresAllProperties() + { + var annotation = new MauiBuildInfoAnnotation( + "/path/to/project.csproj", + "/path/to", + "net10.0-android"); + + Assert.Equal("/path/to/project.csproj", annotation.ProjectPath); + Assert.Equal("/path/to", annotation.WorkingDirectory); + Assert.Equal("net10.0-android", annotation.TargetFramework); + } + + [Fact] + public void BuildInfoAnnotation_NullableProperties() + { + var annotation = new MauiBuildInfoAnnotation( + "/path/to/project.csproj", + "/path/to", + targetFramework: null); + + Assert.Null(annotation.TargetFramework); + } + + [Fact] + public async Task CancelQueuedResource_CompletesGracefullyAndDoesNotAcquireSemaphore() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + // Hold the semaphore with Android. + var task1 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + + // Queue MacCatalyst — it should be stuck waiting. + var task2 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.MacCatalyst, env.Services), + CancellationToken.None)); + + await Task.Delay(100); + Assert.False(task2.IsCompleted, "MacCatalyst should be queued."); + + // Cancel the queued resource via the subscriber's CancelResource method. + env.CancelResource(env.MacCatalyst.Name); + + // The handler re-throws the OCE to prevent DCP from starting the resource. + var ex = await Assert.ThrowsAnyAsync( + () => task2.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.IsType(ex); + + // Semaphore should still be held (count 0) — cancellation of queued resource + // should NOT release the semaphore because it never acquired it. + Assert.True(env.Parent.TryGetLastAnnotation(out var annotation)); + Assert.Equal(0, annotation!.BuildSemaphore.CurrentCount); + + // Clean up: complete Android build. + env.Subscriber.CompleteBuild(env.Android); + await task1.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task CancelBuildingResource_ReleasesSemaphore() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + // Start Android build (it will hold the semaphore and wait for TCS). + var task1 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + + // Android should be building (semaphore held). + Assert.True(env.Parent.TryGetLastAnnotation(out var annotation)); + Assert.Equal(0, annotation!.BuildSemaphore.CurrentCount); + + // Cancel via the subscriber — this cancels the CTS, which cancels the TCS via the registration. + env.CancelResource(env.Android.Name); + + // The handler re-throws the OCE to prevent DCP from starting the resource. + // TaskCanceledException is a subclass of OperationCanceledException, thrown by TCS. + await Assert.ThrowsAnyAsync( + () => task1.WaitAsync(TimeSpan.FromSeconds(5))); + + // Semaphore should be released after the building resource is cancelled. + Assert.Equal(1, annotation.BuildSemaphore.CurrentCount); + } + + [Fact] + public async Task CancelQueuedResource_NextResourceProceeds() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + // Hold the semaphore with Android. + var task1 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + await env.Subscriber.WaitForBuildStartedAsync(env.Android); + + // Queue MacCatalyst and iOS. + var task2 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.MacCatalyst, env.Services), + CancellationToken.None)); + + var task3 = Task.Run(() => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.IOSSimulator, env.Services), + CancellationToken.None)); + + // Give both a moment to enter the queue. + await Task.Delay(100); + + // Cancel MacCatalyst. + env.CancelResource(env.MacCatalyst.Name); + // The handler re-throws the OCE to prevent DCP from starting the resource. + await Assert.ThrowsAnyAsync( + () => task2.WaitAsync(TimeSpan.FromSeconds(5))); + + // Complete Android — iOS should proceed (MacCatalyst was cancelled without acquiring semaphore). + env.Subscriber.CompleteBuild(env.Android); + await task1.WaitAsync(TimeSpan.FromSeconds(5)); + + // iOS should now be building. + await env.Subscriber.WaitForBuildStartedAsync(env.IOSSimulator); + Assert.False(task3.IsCompleted, "iOS should be building now."); + + env.Subscriber.CompleteBuild(env.IOSSimulator); + await task3.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task BuildTimeout_ThrowsTimeoutException() + { + await using var env = await BuildQueueTestEnvironment.CreateAsync(); + + // Set a very short timeout for testing. + env.Subscriber.BuildTimeout = TimeSpan.FromMilliseconds(100); + + // Add a MauiBuildInfoAnnotation so RunBuildAsync doesn't skip the build. + env.Android.Annotations.Add(new MauiBuildInfoAnnotation( + "/nonexistent/project.csproj", + "/nonexistent", + "net10.0-android")); + + env.Subscriber.UseRealBuild = true; + + // The real RunBuildAsync will try to start a dotnet process that either fails + // immediately or gets killed by the timeout. Either results in an exception. + await Assert.ThrowsAnyAsync( + () => env.Eventing.PublishAsync( + new BeforeResourceStartedEvent(env.Android, env.Services), + CancellationToken.None)); + + // Semaphore should be released regardless. + Assert.True(env.Parent.TryGetLastAnnotation(out var annotation)); + Assert.Equal(1, annotation!.BuildSemaphore.CurrentCount); + } + + // ─────────────────────────────────────────────────────────────────── + // Test infrastructure + // ─────────────────────────────────────────────────────────────────── + + /// + /// A subscriber that overrides with a controllable + /// so tests can decide when (and whether) each + /// resource's build completes. Exposes a WaitForBuildStartedAsync method for + /// deterministic synchronization instead of Task.Delay. + /// + private sealed class TestableBuildQueueSubscriber( + ResourceNotificationService notificationService, + ResourceLoggerService loggerService) : MauiBuildQueueEventSubscriber(notificationService, loggerService) + { + private readonly ConcurrentDictionary _buildCompletions = new(); + private readonly ConcurrentDictionary _buildStarted = new(); + private readonly ConcurrentDictionary _buildFailures = new(); + + /// When true, delegates to the real . + public bool UseRealBuild { get; set; } + + /// Waits until is entered for the given resource. + public Task WaitForBuildStartedAsync(IResource resource, TimeSpan? timeout = null) + { + return GetOrCreateStarted(resource.Name).Task.WaitAsync(timeout ?? TimeSpan.FromSeconds(30)); + } + + /// Completes the build for the given resource, unblocking the event handler. + public void CompleteBuild(IResource resource) + { + GetOrCreateCompletion(resource.Name).TrySetResult(); + } + + /// Pre-registers a resource whose build should complete immediately. + public void CompleteBuildImmediately(IResource resource) + { + GetOrCreateCompletion(resource.Name).TrySetResult(); + } + + /// Pre-registers a resource whose build should fail with an exception. + public void FailBuild(IResource resource, string message) + { + _buildFailures[resource.Name] = new InvalidOperationException(message); + GetOrCreateCompletion(resource.Name).TrySetResult(); + } + + /// Pre-registers a resource whose build should fail with a specific exception. + public void FailBuildWith(IResource resource, Exception exception) + { + _buildFailures[resource.Name] = exception; + GetOrCreateCompletion(resource.Name).TrySetResult(); + } + + internal override async Task RunBuildAsync(IResource resource, ILogger logger, CancellationToken cancellationToken) + { + if (UseRealBuild) + { + GetOrCreateStarted(resource.Name).TrySetResult(); + await base.RunBuildAsync(resource, logger, cancellationToken).ConfigureAwait(false); + return; + } + + var tcs = GetOrCreateCompletion(resource.Name); + + try + { + // Signal that the build has started (deterministic sync point for tests). + GetOrCreateStarted(resource.Name).TrySetResult(); + + using var reg = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)); + await tcs.Task.ConfigureAwait(false); + + if (_buildFailures.TryRemove(resource.Name, out var ex)) + { + throw ex; + } + } + finally + { + // Always clean up so the same resource can be restarted. + _buildCompletions.TryRemove(resource.Name, out _); + _buildStarted.TryRemove(resource.Name, out _); + } + } + + /// + /// In tests there is no DCP launch phase, so release the semaphore immediately. + /// + internal override Task ReleaseSemaphoreAfterLaunchAsync(IResource resource, SemaphoreSlim semaphore, string? stateAtCallTime, ILogger logger, CancellationToken cancellationToken) + { + semaphore.Release(); + return Task.CompletedTask; + } + + private TaskCompletionSource GetOrCreateCompletion(string name) + { + return _buildCompletions.GetOrAdd(name, _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + } + + private TaskCompletionSource GetOrCreateStarted(string name) + { + return _buildStarted.GetOrAdd(name, _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + } + } + + /// + /// A subscriber that uses the real + /// but overrides + /// to release immediately since there is no DCP launch phase in tests. + /// + private sealed class RealBuildQueueSubscriberWithImmediateRelease( + ResourceNotificationService notificationService, + ResourceLoggerService loggerService) : MauiBuildQueueEventSubscriber(notificationService, loggerService) + { + internal override Task ReleaseSemaphoreAfterLaunchAsync(IResource resource, SemaphoreSlim semaphore, string? stateAtCallTime, ILogger logger, CancellationToken cancellationToken) + { + semaphore.Release(); + return Task.CompletedTask; + } + } + + /// + /// Test environment that creates resources manually and registers only the + /// , avoiding the Android/iOS + /// environment subscribers that require services unavailable in unit tests. + /// + private sealed class BuildQueueTestEnvironment : IAsyncDisposable + { + public required DistributedApplication App { get; init; } + public required MauiProjectResource Parent { get; init; } + public required MauiAndroidEmulatorResource Android { get; init; } + public required MauiMacCatalystPlatformResource MacCatalyst { get; init; } + public required MauiiOSSimulatorResource IOSSimulator { get; init; } + public required TestableBuildQueueSubscriber Subscriber { get; init; } + public MauiAndroidEmulatorResource? Android2 { get; init; } + + public IServiceProvider Services => App.Services; + public IDistributedApplicationEventing Eventing => App.Services.GetRequiredService(); + public ResourceNotificationService NotificationService => App.Services.GetRequiredService(); + + /// Cancels a resource via the annotation's CancelResource method. + public bool CancelResource(string resourceName) + { + Parent.TryGetLastAnnotation(out var annotation); + return annotation!.CancelResource(resourceName); + } + + public static async Task CreateAsync() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + var parent = new MauiProjectResource("mauiapp", "/fake/path.csproj"); + parent.Annotations.Add(new MauiBuildQueueAnnotation()); + appBuilder.CreateResourceBuilder(parent); + + var android = new MauiAndroidEmulatorResource("android", parent); + appBuilder.AddResource(android); + + var macCatalyst = new MauiMacCatalystPlatformResource("maccatalyst", parent); + appBuilder.AddResource(macCatalyst); + + var iosSimulator = new MauiiOSSimulatorResource("ios-simulator", parent); + appBuilder.AddResource(iosSimulator); + + var app = appBuilder.Build(); + var subscriber = await InitializeSubscriberAsync(app); + + return new BuildQueueTestEnvironment + { + App = app, + Parent = parent, + Android = android, + MacCatalyst = macCatalyst, + IOSSimulator = iosSimulator, + Subscriber = subscriber + }; + } + + public static async Task CreateWithTwoProjectsAsync() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + var parent1 = new MauiProjectResource("mauiapp1", "/fake/path1.csproj"); + parent1.Annotations.Add(new MauiBuildQueueAnnotation()); + appBuilder.CreateResourceBuilder(parent1); + var android1 = new MauiAndroidEmulatorResource("android1", parent1); + appBuilder.AddResource(android1); + + var parent2 = new MauiProjectResource("mauiapp2", "/fake/path2.csproj"); + parent2.Annotations.Add(new MauiBuildQueueAnnotation()); + appBuilder.CreateResourceBuilder(parent2); + var android2 = new MauiAndroidEmulatorResource("android2", parent2); + appBuilder.AddResource(android2); + + var macCatalyst = new MauiMacCatalystPlatformResource("maccatalyst", parent1); + appBuilder.AddResource(macCatalyst); + + var iosSimulator = new MauiiOSSimulatorResource("ios-simulator", parent1); + appBuilder.AddResource(iosSimulator); + + var app = appBuilder.Build(); + var subscriber = await InitializeSubscriberAsync(app); + + return new BuildQueueTestEnvironment + { + App = app, + Parent = parent1, + Android = android1, + MacCatalyst = macCatalyst, + IOSSimulator = iosSimulator, + Android2 = android2, + Subscriber = subscriber + }; + } + + private static async Task InitializeSubscriberAsync(DistributedApplication app) + { + var notificationService = app.Services.GetRequiredService(); + var loggerService = app.Services.GetRequiredService(); + var eventing = app.Services.GetRequiredService(); + var execContext = app.Services.GetRequiredService(); + + var subscriber = new TestableBuildQueueSubscriber(notificationService, loggerService); + await subscriber.SubscribeAsync(eventing, execContext, CancellationToken.None); + return subscriber; + } + + public async ValueTask DisposeAsync() + { + await App.DisposeAsync(); + } + } +}