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(); + } + } +}