Skip to content

Add MAUI build queue to serialize platform builds#14945

Open
jfversluis wants to merge 14 commits intomainfrom
jfversluis/maui-build-queue
Open

Add MAUI build queue to serialize platform builds#14945
jfversluis wants to merge 14 commits intomainfrom
jfversluis/maui-build-queue

Conversation

@jfversluis
Copy link
Member

Summary

Adds build queue serialization for MAUI platform resources (Android, iOS, Mac Catalyst, Windows) that share the same project file. MSBuild cannot handle concurrent builds of the same project, causing intermittent file locking and XamlC assembly resolution failures. This PR serializes builds per-project using a semaphore.

Key Changes

Build Queue (MauiBuildQueueEventSubscriber)

  • Subscribes to BeforeResourceStartedEvent for MAUI platform resources
  • Uses a per-project SemaphoreSlim(1,1) to serialize dotnet build commands
  • Shows Queued and Building states in the dashboard while waiting/building
  • Runs dotnet build as a subprocess with output piped to the resource logger
  • 10-minute build timeout prevents hung builds from blocking the queue

DCP Launch Override (ProjectLaunchArgsOverrideAnnotation)

  • New annotation overrides DCP's default dotnet run with dotnet build /t:Run
  • MAUI requires build /t:Run because dotnet run doesn't support the Run target needed for device deployment
  • DCP still appends project path and --configuration automatically

Stop-While-Queued Support

  • Replaces the default stop command with a queue-aware version
  • Users can stop resources that are in Queued or Building state
  • Cancels the per-resource CTS, which unblocks the semaphore wait or kills the build process
  • Overrides DCP's FailedToStart state to show Exited (user-initiated stop, not failure)

Post-Build Semaphore Hold

  • After pre-build succeeds, the semaphore is held until DCP's dotnet build /t:Run reaches a stable state
  • Prevents concurrent MSBuild on shared dependency outputs (e.g., MauiServiceDefaults)
  • Replay-safe predicate skips stale snapshots from WatchAsync on resource restart

Testing

  • 93 unit tests covering queue serialization, cancellation, timeouts, restart, multi-project concurrency, and edge cases
  • Manual verification with the AspireWithMaui playground app

Files Changed

  • src/Aspire.Hosting.Maui/Lifecycle/MauiBuildQueueEventSubscriber.cs — Core queue logic
  • src/Aspire.Hosting.Maui/Annotations/MauiBuildQueueAnnotation.cs — Per-project queue state
  • src/Aspire.Hosting.Maui/Annotations/MauiBuildInfoAnnotation.cs — Build metadata per resource
  • src/Aspire.Hosting/ApplicationModel/ProjectLaunchArgsOverrideAnnotation.cs — DCP launch override
  • src/Aspire.Hosting/Dcp/DcpExecutor.cs — Reads override annotation
  • tests/Aspire.Hosting.Maui.Tests/MauiBuildQueueTests.cs — 93 tests

jfversluis and others added 13 commits March 4, 2026 15:43
- Add ProjectLaunchArgsOverrideAnnotation for DCP launch arg override
- Modify DcpExecutor to check for override before default run/watch args
- Change MauiPlatformHelper to use dotnet build /t:Run instead of dotnet run
- Fix NuGet RC package versions in MauiClient.csproj
- Fix service discovery URI scheme (https -> https+http)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The MAUI SDK's Run target does not depend on Build, so using just
/t:Run skipped the build step entirely. Mac Catalyst failed because
the .app bundle didn't exist when 'open' tried to launch it, and
iOS failed with 'app must be built before launch args can be computed'.

Using /t:Build;Run ensures the Build target runs first, then the Run
target launches the app. This also has the benefit of keeping the
process alive (Mac Catalyst uses 'open -W', iOS uses 'mlaunch
--wait-for-exit:true'), so resources show as Running instead of
immediately going to Finished.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MAUI uses single-project architecture where multiple platform resources
(Android, iOS, Mac Catalyst, Windows) can reference the same .csproj.
MSBuild cannot handle concurrent builds of the same project file.

This adds a SemaphoreSlim-based build queue that:
- Serializes builds per-project using MauiBuildQueueAnnotation
- Shows 'Queued' state in the dashboard while waiting
- Releases the semaphore when a resource reaches a terminal state
- Handles cancellation and failure gracefully

The annotation is added eagerly in AddMauiProject() to avoid race
conditions when multiple platform resources start concurrently.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two improvements to the MAUI build queue:

1. Show 'Building' state in dashboard when a resource acquires the
   build lock, so users see Building → Running instead of immediately
   Running while MSBuild is still compiling.

2. Release the semaphore when a resource reaches 'Running' state
   (not just terminal states). iOS and Mac Catalyst stay Running
   indefinitely after launch, so waiting for terminal states would
   block the queue until the user manually stops the app.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous approach released the semaphore when the resource reached
'Running' state, but DCP sets Running immediately when the process
starts — while MSBuild is still compiling. This caused the queue to
release too early, allowing concurrent builds.

Now the subscriber watches the resource's log stream for MSBuild
completion output ('Build succeeded' or 'Build FAILED') to detect
when the build phase actually finishes. A terminal state fallback
ensures the semaphore is always released if the process exits before
producing build output.

Also shows 'Building' state in the dashboard while MSBuild compiles,
giving users clear visibility into: Queued → Building → Running.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Instead of relying on log output parsing to detect when MSBuild
finishes, run 'dotnet build' as a separate subprocess during
BeforeResourceStartedEvent. This provides:

- Reliable exit-code-based build completion detection (0 = success)
- 'Building' state persists in the dashboard for the entire build
  (the event handler blocks DCP from starting the process)
- Clean separation: Build runs in event handler, DCP only runs /t:Run

The MauiBuildQueueEventSubscriber now spawns a 'dotnet build' process,
pipes its stdout/stderr to the resource logger (visible in dashboard),
and uses the exit code to determine success or failure. The semaphore
is released in a finally block, ensuring the queue always progresses.

MauiPlatformHelper now sets /t:Run (not /t:Build;Run) since the Build
target is handled by the event subscriber. A MauiBuildInfoAnnotation
carries the project path, TFM, and working directory to the subscriber.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix #1: Drain stdout/stderr tasks in a finally block so they are
always awaited, even when WaitForExitAsync throws on cancellation.
PipeOutputAsync now catches OperationCanceledException internally.

Fix #2: Resolve build configuration from the AppHost's
AssemblyConfigurationAttribute (same as DcpExecutor) and pass it to
MauiBuildInfoAnnotation, so the pre-build and DCP's Run target use
the same configuration and MSBuild's incremental build is effective.

Fix #3: Add 10-minute build timeout via CancellationTokenSource so
a hung dotnet build process cannot block the semaphore forever.
BuildTimeout is an internal settable property for testability.

Fix #4: Add tests for MauiBuildInfoAnnotation (properties, nullable),
missing MauiBuildQueueAnnotation (skip), resource restart (build same
resource twice), and unexpected exception types (semaphore release).

Total: 90 tests passing (18 build queue + 72 existing).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix #1: Move _buildCompletions.TryRemove into finally block in
TestableBuildQueueSubscriber so cancelled/failed builds clean up
their TCS entries, preventing stale entries on resource restart.

Fix #3: Add MissingBuildInfoAnnotation_SkipsBuildAndReleasesQueue
test that uses the real MauiBuildQueueEventSubscriber to verify the
RunBuildAsync warning-and-skip path when MauiBuildInfoAnnotation is
absent, and confirms the semaphore is properly released.

Fix #5: Remove IDisposable from MauiBuildQueueAnnotation — no other
annotation in Aspire implements IDisposable and the semaphore is
garbage-collected on app shutdown. Remove the Dispose test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix test issues found in third review pass:

- ThreeResources_ExecuteInSequence now asserts actual execution order
  (android → maccatalyst → ios), not just completion counts
- SecondResource_ShowsQueuedState and SingleResource_ShowsBuildingState
  now properly await all event tasks before test cleanup
- ResourcesFromDifferentProjects_RunConcurrently now verifies both
  tasks are actively building simultaneously (not pre-completed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The root cause was timing: WithCommand in MauiPlatformHelper ran at app
model build time, but the default lifecycle commands (start/stop/restart)
are added later by DcpExecutor.EnsureRequiredAnnotations. This caused
duplicate stop command annotations, making SingleOrDefault return null
and the stop button to fail with 'command not available'.

Fix: Move the stop command replacement into BeforeResourceStartedEvent
handler in MauiBuildQueueEventSubscriber, which fires AFTER DcpExecutor
has added the lifecycle commands. Key changes:

- EnsureStopCommandReplaced() in subscriber replaces the default stop
  command annotation with one that calls CancelResource for Queued/Building
  and delegates to the original for Running state
- MauiStopCommandReplacedAnnotation marker prevents double replacement
- CancelResource() on MauiBuildQueueAnnotation with ConcurrentDictionary
  for per-resource CTS tracking
- Graceful cancellation: catches OCE from our CTS, sets Exited state
- semaphoreAcquired flag for correct semaphore release
- Removed WithCommand from MauiPlatformHelper (was running too early)
- 3 tests: cancel queued, cancel building, next proceeds after cancel

93 tests passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix two bugs in the MAUI build queue stop command:

1. Resource name mismatch: The stop command used context.ResourceName
   (DCP-resolved name like 'mauiapp-maccatalyst-vqfdyejk') but the CTS
   dictionary is keyed by resource.Name (model name 'mauiapp-maccatalyst').

2. OCE swallowing: The catch block swallowed OperationCanceledException
   instead of re-throwing, causing DCP to proceed with CreateExecutableAsync
   and start the resource despite cancellation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add stateAtCallTime parameter to ReleaseSemaphoreAfterLaunchAsync to
  skip the replayed snapshot from WatchAsync on resource restart, preventing
  premature semaphore release from stale state matches.
- Implement IDisposable on MauiBuildQueueAnnotation to properly dispose
  the SemaphoreSlim when the annotation is cleaned up.
- Document why 'Running' in the predicate is acceptable (pre-build already
  compiled; dotnet build /t:Run only does fast incremental check).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Contributor

github-actions bot commented Mar 4, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 14945

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 14945"

@github-actions
Copy link
Contributor

github-actions bot commented Mar 4, 2026

🎬 CLI E2E Test Recordings

The following terminal recordings are available for commit 2511f99:

Test Recording
AddPackageInteractiveWhileAppHostRunningDetached ▶️ View Recording
AddPackageWhileAppHostRunningDetached ▶️ View Recording
AgentCommands_AllHelpOutputs_AreCorrect ▶️ View Recording
AgentInitCommand_MigratesDeprecatedConfig ▶️ View Recording
AgentInitCommand_WithMalformedMcpJson_ShowsErrorAndExitsNonZero ▶️ View Recording
AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps ▶️ View Recording
Banner_DisplayedOnFirstRun ▶️ View Recording
Banner_DisplayedWithExplicitFlag ▶️ View Recording
CreateAndDeployToDockerCompose ▶️ View Recording
CreateAndDeployToDockerComposeInteractive ▶️ View Recording
CreateAndPublishToKubernetes ▶️ View Recording
CreateAndRunAspireStarterProject ▶️ View Recording
CreateAndRunAspireStarterProjectWithBundle ▶️ View Recording
CreateAndRunJsReactProject ▶️ View Recording
CreateAndRunPythonReactProject ▶️ View Recording
CreateAndRunTypeScriptStarterProject ▶️ View Recording
CreateEmptyAppHostProject ▶️ View Recording
CreateStartAndStopAspireProject ▶️ View Recording
CreateStartWaitAndStopAspireProject ▶️ View Recording
CreateTypeScriptAppHostWithViteApp ▶️ View Recording
DescribeCommandResolvesReplicaNames ▶️ View Recording
DescribeCommandShowsRunningResources ▶️ View Recording
DetachFormatJsonProducesValidJson ▶️ View Recording
DoctorCommand_DetectsDeprecatedAgentConfig ▶️ View Recording
DoctorCommand_WithSslCertDir_ShowsTrusted ▶️ View Recording
DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted ▶️ View Recording
LogsCommandShowsResourceLogs ▶️ View Recording
PsCommandListsRunningAppHost ❌ Upload failed
PsFormatJsonOutputsOnlyJsonToStdout ▶️ View Recording
SecretCrudOnDotNetAppHost ▶️ View Recording
SecretCrudOnTypeScriptAppHost ❌ Upload failed
StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels ▶️ View Recording
StopAllAppHostsFromAppHostDirectory ▶️ View Recording
StopAllAppHostsFromUnrelatedDirectory ▶️ View Recording
StopNonInteractiveMultipleAppHostsShowsError ❌ Upload failed
StopNonInteractiveSingleAppHost ❌ Upload failed
StopWithNoRunningAppHostExitsSuccessfully ❌ Upload failed

📹 Recordings uploaded automatically from CI run #22685752770

@jfversluis jfversluis marked this pull request as ready for review March 4, 2026 19:15
Copilot AI review requested due to automatic review settings March 4, 2026 19:15
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a per-project build serialization mechanism for MAUI platform resources to avoid concurrent MSBuild invocations on the same .csproj, integrating with DCP launch argument generation and exposing queue/building states in the dashboard.

Changes:

  • Introduces a MAUI build-queue event subscriber (semaphore-based) plus supporting annotations to serialize builds and enable queued/building UX + cancellation.
  • Adds a new ProjectLaunchArgsOverrideAnnotation and updates DCP to honor it (enabling dotnet build /t:Run for MAUI).
  • Adds a dedicated MAUI build-queue test suite and documents the behavior in the MAUI hosting README.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/Aspire.Hosting.Maui/Lifecycle/MauiBuildQueueEventSubscriber.cs Core queue/semaphore logic, dashboard state updates, pre-build subprocess, queue-aware Stop command
src/Aspire.Hosting.Maui/Annotations/MauiBuildQueueAnnotation.cs Per-project semaphore + per-resource cancellation CTS storage
src/Aspire.Hosting.Maui/Annotations/MauiBuildInfoAnnotation.cs Build metadata used by the pre-build subprocess
src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs Ensures queue annotation is added up-front when creating MAUI project resources
src/Aspire.Hosting.Maui/MauiPlatformHelper.cs Attaches build metadata + DCP launch override annotation for MAUI platform resources
src/Aspire.Hosting.Maui/MauiHostingExtensions.cs Registers the new build-queue subscriber
src/Aspire.Hosting/ApplicationModel/ProjectLaunchArgsOverrideAnnotation.cs New core annotation for overriding DCP project launch args
src/Aspire.Hosting/Dcp/DcpExecutor.cs Implements the override annotation when constructing dotnet args for project executables
tests/Aspire.Hosting.Maui.Tests/MauiBuildQueueTests.cs Unit tests for queue serialization, cancellation, failure/timeout, and concurrency behavior
src/Aspire.Hosting.Maui/README.md Documents build queue behavior and architecture
playground/AspireWithMaui/AspireWithMaui.MauiClient/MauiProgram.cs Updates sample HttpClient base address scheme
playground/AspireWithMaui/AspireWithMaui.MauiClient/AspireWithMaui.MauiClient.csproj Updates sample package versions
Comments suppressed due to low confidence (1)

src/Aspire.Hosting.Maui/Lifecycle/MauiBuildQueueEventSubscriber.cs:292

  • ReleaseSemaphoreAfterLaunchAsync compares resource states using string literals ("Running", "FailedToStart", "Exited", "Finished"). Since the rest of the file already uses KnownResourceStates, using the constants here would avoid drift/typos and keep refactors safe.
                    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 is "Running" or "FailedToStart" or "Exited" or "Finished";
                },

- Use non-blocking semaphore.Wait(0) instead of CurrentCount check to
  determine if a resource should show 'Queued' state, eliminating TOCTOU
  race where a resource could block without showing queued.
- Move MauiStopCommandReplacedAnnotation marker to after confirming the
  stop command exists, so retry on restart works if it was missing initially.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants