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