Fix MAUI launch queue handoff and OTLP tunnels - #18591
Fix MAUI launch queue handoff and OTLP tunnels#18591Gerald Versluis (jfversluis) wants to merge 26 commits into
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 18591Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 18591" |
There was a problem hiding this comment.
Pull request overview
This PR fixes the MAUI platform launch queue so multiple targets for the same MAUI project keep serialized builds without blocking on already-running apps, and it repairs MAUI OTLP dev-tunnel telemetry. It also teaches the hosting and dashboard state model to recognize DCP's Terminated state so queued/building resources and terminal-host diagnostics release/wake correctly.
Changes:
- Introduces
KnownResourceStates.Terminated, adds it toTerminalStates/BuildableStates, and threads recognition of that state through wait-for-dependency logic, terminal-host diagnostics, and the dashboard (KnownResourceState.Terminated,IsStopped()). - Reworks the MAUI launch handoff: non-Android platforms launch with
/t:Run -p:BuildDependsOn= -p:NoBuild=trueand release the build lock onRunning, while Android keeps/t:Runand holds the lock until its short-lived Run process exits (MauiBuildInfoAnnotation.ReleaseBuildLockOnResourceRunning). - Fixes MAUI OTLP dev-tunnel resolution to prefer the dashboard OTLP/HTTP endpoint, use the target listener port for tunnel forwarding, and set
OTEL_EXPORTER_OTLP_PROTOCOLfrom the resolved endpoint transport.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
src/Aspire.Hosting/ApplicationModel/CustomResourceSnapshot.cs |
Adds public Terminated state and includes it in TerminalStates/BuildableStates. |
src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs |
Adds IsUnavailableState helper and Terminated handling in wait-for-dependency logic. |
src/Aspire.Hosting/Lifecycle/TerminalHostFailureDiagnosticService.cs |
Replaces local terminal-state array with KnownResourceStates.TerminalStates. |
src/Aspire.Dashboard/Model/KnownResourceState.cs |
Adds Terminated enum value. |
src/Aspire.Dashboard/Extensions/ResourceViewModelExtensions.cs |
Treats Terminated as a stopped state. |
src/Aspire.Hosting.Maui/MauiPlatformHelper.cs |
Branches launch args and build-lock release on Android vs. other platforms. |
src/Aspire.Hosting.Maui/Annotations/MauiBuildInfoAnnotation.cs |
Adds ReleaseBuildLockOnResourceRunning flag. |
src/Aspire.Hosting.Maui/Lifecycle/MauiBuildQueueEventSubscriber.cs |
Adds releaseOnRunning parameter and terminal-state-aware release logic. |
src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs |
Prefers OTLP/HTTP endpoint, resolves target port, and sets OTLP protocol/transport. |
src/Aspire.Hosting.Maui/Annotations/OtlpDevTunnelConfigurationAnnotation.cs |
Adds IsOtlpEndpointResolved/MarkOtlpEndpointResolved. |
src/Aspire.Hosting.Maui/README.md |
Documents OTLP protocol selection and the DCP launch-handoff behavior. |
tests/Aspire.Hosting.Maui.Tests/*, tests/Aspire.Hosting.Tests/*, tests/Aspire.Dashboard.Components.Tests/* |
Adds coverage for terminated-state handling, build-lock release, and OTLP resolution. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Adam Ratzman (adamint)
left a comment
There was a problem hiding this comment.
I found one MAUI OTLP tunnel issue and am checking whether I can push the focused fix.
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Validation/review report for the latest push:
Local validation after the final changes: dotnet test --project tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj --no-launch-profile -- --filter-class "*.ResourceSnapshotBuilderTests" --filter-class "*.TerminalHostFailureDiagnosticServiceTests" --filter-class "*.ResourceNotificationTests" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true"
dotnet test --project tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj --no-launch-profile -- --filter-class "*.MauiBuildQueueTests" --filter-class "*.MauiPlatformExtensionsTests" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true"
dotnet test --project tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj --no-launch-profile -- --filter-class "*.ResourceViewModelExtensionsTests" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true"
git diff --checkAll passed. I also reran local code review and Aspire architecture review agents on the final local diff; both reported no findings. |
|
Follow-up after CI: Hosting-3 initially exposed two existing WaitForCompletion integration tests that were still publishing |
|
Adam Ratzman (@adamint) Ella Hathaway (@ellahathaway) The latest rebased head ( |
There was a problem hiding this comment.
Review details
Suppressed comments (4)
src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs:741
- Inside the retry pipeline, the watcher loop uses the outer
cancellationTokeninstead of the per-attemptctpassed toExecuteAsync. This can prevent a failed attempt from being cleanly canceled between retries (and may keepWatchAsyncalive longer than intended). Use thectpassed into the pipeline delegate when callingWatchAsync(and for downstream awaits that should be scoped to the attempt).
await foreach (var resourceEvent in notifications.WatchAsync(cancellationToken).ConfigureAwait(false))
{
var matchingPorts = tunnelResource.Ports
.Where(port => ReferenceEquals(port.TargetEndpoint.Resource, resourceEvent.Resource))
.ToArray();
src/Aspire.Hosting.DevTunnels/DevTunnelResource.cs:66
- The watcher CTS is canceled and disposed immediately, but the watcher task is not awaited. Disposing a
CancellationTokenSourcewhile consumers may still be registering/awaiting on its token can surfaceObjectDisposedExceptionin unrelated code paths. Consider canceling without disposing until the watcher task has observed cancellation (e.g., keep a reference to the watcherTaskand dispose in a continuation), or avoid disposing CTS instances that are only used for cancellation signaling.
internal CancellationTokenSource ResetTargetEndpointWatcher(CancellationToken cancellationToken)
{
lock (_targetEndpointWatcherLock)
{
_targetEndpointWatcherCts?.Cancel();
_targetEndpointWatcherCts?.Dispose();
_targetEndpointWatcherCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
return _targetEndpointWatcherCts;
}
}
internal void StopTargetEndpointWatcher()
{
lock (_targetEndpointWatcherLock)
{
_targetEndpointWatcherCts?.Cancel();
_targetEndpointWatcherCts?.Dispose();
_targetEndpointWatcherCts = null;
}
}
src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs:107
QueueTunnelAndPortRefreshschedules a newTask.Runfor each health check invocation without any in-flight deduplication. Under frequent health checks or slow network responses, this can lead to overlapping refreshes and increased load on the devtunnel CLI/API (and threadpool churn), even with the 2s timeout. Consider adding a simple gate (e.g.,Interlocked/SemaphoreSlim) per tunnel to ensure only one refresh runs at a time, and coalesce subsequent requests.
return Task.Run(async () =>
{
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(s_refreshTimeout);
var tunnelAccessStatus = await devTunnelClient.GetAccessAsync(tunnelResource.ResolvedTunnelId, portNumber: null, logger, timeoutCts.Token).ConfigureAwait(false);
tunnelResource.LastKnownAccessStatus = tunnelAccessStatus;
foreach (var portResource in tunnelResource.Ports)
{
int? tunnelPort = null;
try
{
tunnelPort = await portResource.GetTunnelPortAsync(timeoutCts.Token).ConfigureAwait(false);
var portAccessStatus = await devTunnelClient.GetAccessAsync(tunnelResource.ResolvedTunnelId, tunnelPort, logger, timeoutCts.Token).ConfigureAwait(false);
if (portResource.ActiveTunnelPort is null || portResource.ActiveTunnelPort == tunnelPort)
{
portResource.LastKnownAccessStatus = portAccessStatus;
}
}
src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs:1166
IndexOf('.')can return-1if the tunnel host does not contain a dot (e.g., unexpected host formats or test doubles), which will throw during slicing. Guard againsthostPrefixLength <= 0and either skip adding the Inspect URL or fall back to a safer transformation.
private static Uri CreateInspectUri(Uri portUri)
{
// If tunnel host is sdfdff-3456.usw.devtunnels.ms, the inspect host is sdfdff-3456-inspect.usw.devtunnels.ms
var hostPrefixLength = portUri.Host.IndexOf('.');
var hostPrefix = portUri.Host[..hostPrefixLength];
var hostSuffix = portUri.Host[hostPrefixLength..];
return new UriBuilder(portUri) { Host = $"{hostPrefix}-inspect{hostSuffix}" }.Uri;
}
- Files reviewed: 33/33 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
Review details
Suppressed comments (3)
src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs:161
ExecutableStatus.ExitCodecan useConventions.UnknownExitCodewhen the code has not arrived yet. Keeping that sentinel as-1causes completion waits to yield immediately and fail instead of waiting for the real exit-code snapshot. Normalize it tonull, matching the container conversion above.
var exitCode = executable.Status?.ExitCode;
src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs:105
ContainerExecStatus.ExitCodemay beConventions.UnknownExitCodewhile the real code is unavailable (as documented on that model). Preserving-1here makesHasPendingDcpExitCodefalse and letsWaitForCompletiontreat the placeholder as a real mismatching exit code. Normalize the sentinel tonull, as the container snapshot path already does.
This issue also appears on line 161 of the same file.
var exitCode = executable.Status?.ExitCode;
src/Aspire.Hosting/Health/ResourceHealthCheckService.cs:77
- This later-generation readiness path is still guarded by the exact
state == KnownResourceStates.Runningcomparison above (and monitor teardown uses an exact terminal-state lookup).ResourceNotificationServicenow recognizes states such as"running"case-insensitively and advances their generation, but the health service ignores those snapshots, so it never starts/fires readiness for that generation. Use the resource-state comparer for the outer Running and terminal checks as well.
else if (!resourceEvent.Resource.TryGetAnnotationsIncludingAncestorsOfType<HealthCheckAnnotation>(out _))
{
// Resources without health checks finish their monitor after the first ready
// event, so a later replica generation must trigger readiness from this loop.
FireResourceReadyEvent(state);
- Files reviewed: 33/33 changed files
- Comments generated: 1
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6cc2c61d-a000-410e-ae07-0d6482c410b7
There was a problem hiding this comment.
Review details
Suppressed comments (1)
src/Aspire.Hosting/Health/ResourceHealthCheckService.cs:193
- A stale health probe can still mark a newer process generation ready.
IsHealthCheckGenerationCurrentreleases the state lock before this call, so a restart can increment_readyGenerationSignalVersionin between;FireResourceReadyEventthen captures the new running generation and publishes readiness using the old probe result. Pass the expected signal version into target capture and reject it atomically under the same lock (and similarly avoid publishing the stale health report).
FireResourceReadyEvent(state);
- Files reviewed: 34/34 changed files
- Comments generated: 1
- Review effort level: Balanced
Ensure delayed DCP exit codes, DevTunnel reconciliation, MAUI restart configuration, and Foundry Local reloads remain correct across repeated resource generations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6cc2c61d-a000-410e-ae07-0d6482c410b7
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Reconcile replica generations directly from notification state before accepting health-check results so a queued restart cannot inherit readiness from an older check. Add deterministic coverage for notification-store updates that arrive before the health-monitor watcher. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6cc2c61d-a000-410e-ae07-0d6482c410b7
There was a problem hiding this comment.
Review details
Suppressed comments (1)
src/Aspire.Hosting.DevTunnels/DevTunnelHealthCheck.cs:46
- This periodic health path no longer updates the published access-policy property. After the initial
QueuePortRefreshsucceeds and exits, a laterGetAccessAsyncfailure here only clearsLastKnownAccessStatus; the port snapshot keeps the old “Anonymous access” value, gets no synthetic unhealthy report, and receives no retry until unrelated endpoint reconciliation occurs. Route periodic port refreshes through the notification-aware retry path (or otherwise publishUnknown/health and retry) so the dashboard cannot retain stale access policy.
_ = DevTunnelAccessStatusRefresh.QueueTunnelAndPortRefresh(_devTunnelClient, _tunnelResource, logger, cancellationToken);
- Files reviewed: 42/42 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Marking this PR as draft because I let the scope creep beyond the original MAUI launch/build queue fix while addressing issues found during validation and review. The additional fixes are valid and the branch is green, but combining MAUI queue handoff, OTLP/DevTunnel lifecycle, global DCP/wait semantics, replica readiness generations, and Foundry restart behavior made this PR much broader than it should be. I am preserving this branch as a validated reference and replacing it with smaller, focused changes. The first replacement will contain only the minimum MAUI queue functionality: the corrected Please do not merge this PR in its current form. |
Focused replacement PRs
This broad draft has been split into smaller reviewable changes:
The remaining DevTunnels watcher/reconciliation, Aspire.Hosting core/DCP lifecycle, readiness, Foundry, Dashboard, and playground work is intentionally not included in either focused PR.
Description
Fixes the MAUI platform launch queue so multiple targets for the same MAUI project keep serialized builds without blocking on apps that are already running.
Current
mainlaunched MAUI platform resources withdotnet build --no-restore /t:Run -p:NoBuild=true. SDK10.0.201rejects that shape withNETSDK1085because theBuildtarget is still invoked whileNoBuild=true. This keeps the intendeddotnet build /t:Runhandoff while avoiding duplicate build work on platforms that can launch from the queued build output.User-facing usage
Users can keep adding multiple MAUI platform resources for the same project. Builds are serialized, but Mac Catalyst, iOS, and Windows-style launch resources release the queue once DCP reports the launch process as running, so another platform can start building while the first app remains open. Android keeps the normal Run target because it performs deploy/runtime upload work after build, and releases the queue when that short-lived Run process exits.
The change also hardens the related runtime lifecycle:
Terminatedis normalized to Aspire's existingExitedstate. Completion waits pause only for DCP executable/container-execFinishedorExitedsnapshots whose exit code is still pending; containers and custom resources without a future exit-code snapshot still complete deterministically.ResourceReadyEventcallbacks, while stale callback and health-probe results cannot mark a newer generation ready.Validation:
./build.sh --build /p:SkipNativeBuild=true /m:1— succeeded with 0 warnings and 0 errorsAspire.Hosting.DevTunnels.Tests— 64 passedAspire.Hosting.Maui.Tests— 200 passedAspire.Hosting.Foundry.Tests— 120 passedResourceHealthCheckServiceTests— 25 passedBuildingwhile iOS remainedQueued; iOS moved toBuildingafter Mac reachedRunning; both then remainedRunningdotnet build --no-restore /t:Run -p:BuildDependsOn= -p:NoBuild=truewith noNETSDK1085201bc863e610cdb1658da676461fdf6cfb701c70: 334 successful jobs, 4 skipped, 0 failedChecklist
<remarks />and<code />elements on your triple slash comments?