From 537f7bec25dc9fd2b826f3ed4c46d0592ce87a9c Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 1 Jun 2026 17:25:17 -0700 Subject: [PATCH 01/56] Unify graceful shutdown ladder for aspire run on Windows On Windows, the TypeScript guest apphost runs under tsx, which swallows Ctrl+Break. Our graceful-shutdown paths never succeed and the process is eventually force-terminated without a chance to drain DCP-managed resources. This change introduces a unified termination ladder (cooperative cancel -> graceful expire -> final drain -> force terminate) shared by every aspire run child process, and routes guest-apphost shutdown through DCP stop-process-tree on Windows instead of relying on Ctrl+Break propagation. Key components: - ConsoleCancellationManager: process-level signal handler with a three-stage escalation (Ctrl+C, second Ctrl+C, third Ctrl+C / drain timeout). RequestShutdown lets internal teardown drive the same ladder without consuming a user signal. - GracefulShutdownService: shared graceful-window token, expired by the cancellation manager when the user's grace period elapses or escalates. - IsolatedConsoleSpawner / IsolatedProcess: console-isolated child spawn used for aspire run, so Ctrl+C in the CLI does not propagate uncontrolled into the apphost system. - WindowsConsoleProcessJob: Windows-only job-object safety net (KILL_ON_JOB_CLOSE + BREAKAWAY_OK so DCP can fork its own descendants). - ProcessTerminator + ProcessLifetimeAdapter + ProcessPump: shared shutdown primitives consumed by AppHostServerSession and ProcessGuestLauncher. - DetachedAppHostShutdownService (renamed from ProcessShutdownService) now implements IProcessTreeGracefulShutdownSignaler, the abstraction that AppHostServerSession + ProcessGuestLauncher call to ask DCP to drive graceful resource teardown. aspire stop is left untouched - the new machinery is scoped to the run path. Short-lived RPC sessions (publish, sdk generate, sdk dump, scaffolding) keep the inherited-console default so Ctrl+C continues to exit immediately. Also fixed three issues caught in pre-PR review: - ConsoleCancellationManager separated _ladderStarted (CAS gate) from _userSignalCount so internal RequestShutdown no longer makes the user's first Ctrl+C collapse the graceful window. - GuestAppHostProject backchannel-fault continuation now uses CompareExchange on runEnded to race safely with the finally block. - ProcessTerminator.ShutdownAsync(int pid, ...) is now async so the underlying Process handle outlives the inner WaitForExitAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AppHostLauncher.cs | 2 +- src/Aspire.Cli/Commands/RunCommand.cs | 50 +- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 18 +- .../Commands/Sdk/SdkGenerateCommand.cs | 9 +- src/Aspire.Cli/Commands/StopCommand.cs | 4 +- src/Aspire.Cli/ConsoleCancellationManager.cs | 275 +++++++- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 6 + src/Aspire.Cli/DotNet/ProcessExecution.cs | 38 +- src/Aspire.Cli/GracefulShutdownService.cs | 62 ++ ...e.cs => DetachedAppHostShutdownService.cs} | 38 +- .../DetachedProcessLauncher.Windows.cs | 414 ++--------- .../IProcessTreeGracefulShutdownSignaler.cs | 27 + .../Processes/IsolatedConsoleSpawner.cs | 73 ++ .../Processes/IsolatedProcess.Unix.cs | 74 ++ .../Processes/IsolatedProcess.Windows.cs | 171 +++++ src/Aspire.Cli/Processes/IsolatedProcess.cs | 346 ++++++++++ .../Processes/ProcessLifetimeAdapter.cs | 38 + src/Aspire.Cli/Processes/ProcessPump.cs | 94 +++ src/Aspire.Cli/Processes/ProcessTerminator.cs | 116 ++++ .../Processes/WindowsConsoleProcessJob.cs | 118 ++++ .../Processes/WindowsProcessInterop.cs | 594 ++++++++++++++++ src/Aspire.Cli/Program.cs | 54 +- .../Projects/AppHostServerSession.cs | 652 +++++++++++++----- .../Projects/DotNetAppHostProject.cs | 5 +- .../DotNetBasedAppHostServerProject.cs | 55 +- .../Projects/ExtensionGuestLauncher.cs | 6 +- .../Projects/GuestAppHostProject.cs | 306 ++++---- src/Aspire.Cli/Projects/GuestRuntime.cs | 17 +- .../Projects/IAppHostServerProject.cs | 56 +- .../Projects/IAppHostServerSession.cs | 78 --- .../Projects/IGuestProcessLauncher.cs | 47 +- .../Projects/PrebuiltAppHostServer.cs | 72 +- .../Projects/ProcessGuestLauncher.cs | 404 ++++++++--- .../Scaffolding/ScaffoldingService.cs | 9 +- .../Commands/AppHostLauncherTests.cs | 4 +- .../ConsoleCancellationManagerTests.cs | 290 +++++++- .../GracefulShutdownServiceTests.cs | 59 ++ ...=> DetachedAppHostShutdownServiceTests.cs} | 10 +- .../Processes/IsolatedProcessTests.cs | 135 ++++ .../WindowsConsoleProcessJobTests.cs | 71 ++ .../Projects/AppHostServerSessionTests.cs | 516 +++++++++++++- .../Projects/DotNetAppHostProjectTests.cs | 10 + .../Projects/GuestAppHostProjectTests.cs | 20 +- .../Projects/GuestRuntimeTests.cs | 5 +- .../Projects/ProcessGuestLauncherTests.cs | 252 +++++++ .../Scaffolding/ChannelReseedTests.cs | 9 +- .../FakeFailingAppHostServerProject.cs | 9 +- .../TestAppHostServerSessionFactory.cs | 31 - tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 20 +- 49 files changed, 4647 insertions(+), 1122 deletions(-) create mode 100644 src/Aspire.Cli/GracefulShutdownService.cs rename src/Aspire.Cli/Processes/{ProcessShutdownService.cs => DetachedAppHostShutdownService.cs} (87%) create mode 100644 src/Aspire.Cli/Processes/IProcessTreeGracefulShutdownSignaler.cs create mode 100644 src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs create mode 100644 src/Aspire.Cli/Processes/IsolatedProcess.Unix.cs create mode 100644 src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs create mode 100644 src/Aspire.Cli/Processes/IsolatedProcess.cs create mode 100644 src/Aspire.Cli/Processes/ProcessLifetimeAdapter.cs create mode 100644 src/Aspire.Cli/Processes/ProcessPump.cs create mode 100644 src/Aspire.Cli/Processes/ProcessTerminator.cs create mode 100644 src/Aspire.Cli/Processes/WindowsConsoleProcessJob.cs create mode 100644 src/Aspire.Cli/Processes/WindowsProcessInterop.cs delete mode 100644 src/Aspire.Cli/Projects/IAppHostServerSession.cs create mode 100644 tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs rename tests/Aspire.Cli.Tests/Processes/{ProcessShutdownServiceTests.cs => DetachedAppHostShutdownServiceTests.cs} (96%) create mode 100644 tests/Aspire.Cli.Tests/Processes/IsolatedProcessTests.cs create mode 100644 tests/Aspire.Cli.Tests/Processes/WindowsConsoleProcessJobTests.cs create mode 100644 tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs delete mode 100644 tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 8c6cf4c11ee..1a650605015 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -33,7 +33,7 @@ internal sealed class AppHostLauncher( AspireCliTelemetry telemetry, ProfilingTelemetry profilingTelemetry, FileLoggerProvider fileLoggerProvider, - ProcessShutdownService processShutdownService, + DetachedAppHostShutdownService processShutdownService, IDetachedProcessLauncher detachedProcessLauncher, ILogger logger, TimeProvider timeProvider) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 6399edd8d0a..f776676ec32 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -73,11 +73,17 @@ internal sealed class RunCommand : BaseCommand private readonly ICliHostEnvironment _hostEnvironment; private readonly ProfilingTelemetry _profilingTelemetry; private readonly TimeProvider _timeProvider; + private readonly ConsoleCancellationManager _cancellationManager; private bool _isDetachMode; private const int MaxDisplayedAppHostStartupOutputLines = 80; private static readonly TimeSpan s_appHostStartupCancellationTimeout = TimeSpan.FromSeconds(5); + // Graceful shutdown budget for `aspire run`. DCP gets a cooperative window to drain DCP + // resources before the central drain budget arms and ladders escalate to forceful kill. + // See plan §10 for the budget rationale. + internal static readonly TimeSpan s_gracefulShutdownBudget = TimeSpan.FromSeconds(5); + // Guest AppHosts can bring up the temporary server/backchannel and then fail immediately // afterward when the guest startup process hits a syntax, pre-execute, or model validation // error. Keep guest AppHost startup waits alive briefly so those failures are reported instead of hidden. @@ -111,7 +117,8 @@ public RunCommand( FileLoggerProvider fileLoggerProvider, ICliHostEnvironment hostEnvironment, ProfilingTelemetry profilingTelemetry, - TimeProvider timeProvider) + TimeProvider timeProvider, + ConsoleCancellationManager cancellationManager) : base("run", RunCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _runner = runner; @@ -128,6 +135,7 @@ public RunCommand( _hostEnvironment = hostEnvironment; _profilingTelemetry = profilingTelemetry; _timeProvider = timeProvider; + _cancellationManager = cancellationManager; Options.Add(s_detachOption); Options.Add(s_noBuildOption); @@ -138,6 +146,12 @@ public RunCommand( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { + // Give DCP a cooperative window to drain resources before the central drain budget + // arms and shutdown ladders escalate to forceful kill. Without this, GracefulShutdownToken + // fires immediately on the first signal/RequestShutdown and isolation/AttachConsole buys + // nothing because every ladder sees the graceful window already expired. + _cancellationManager.ConfigureForCommand(s_gracefulShutdownBudget); + var passedAppHostProjectFile = parseResult.GetValue(AppHostLauncher.s_appHostOption); var detach = parseResult.GetValue(s_detachOption); _isDetachMode = detach; @@ -510,10 +524,10 @@ await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTar { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); - // The user cancelled (e.g. Ctrl+C); the linked CTS we passed to project.RunAsync - // propagated the cancellation and the OCE bubbled out with the linked token. - // Treat as successful exit since the user intentionally stopped the AppHost. - return CommandResult.Cancelled(CliExitCodes.Success); + // The user cancelled (e.g. Ctrl+C) OR an internal RequestShutdown fired the central + // token — distinguishing them by RequestedExitCode so internal-failure exit codes + // (e.g. guest exited non-zero) surface instead of being masked as success. + return MapCancellationToResult(); } finally { @@ -535,9 +549,10 @@ await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTar { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); - // Command is designed to be cancellable by the user (e.g. Ctrl+C) at any time. - // Treat cancellation as a successful exit since the user intentionally stopped the AppHost. - return CommandResult.Cancelled(CliExitCodes.Success); + // Command is designed to be cancellable by the user (e.g. Ctrl+C) at any time, but also + // by internal RequestShutdown(failureCode). Distinguish via CCM.RequestedExitCode so + // internal-failure exit codes are surfaced rather than swallowed as success. + return MapCancellationToResult(); } catch (ProjectLocatorException ex) { @@ -617,6 +632,25 @@ private static CommandResult CreateRunExitResult(int exitCode, string? errorMess : CommandResult.Failure(exitCode, errorMessage); } + /// + /// Maps a cancellation captured by an OCE catch to a CommandResult, consulting + /// so internal + /// RequestShutdown(failureCode) calls surface as failures rather than being masked as + /// success by the user-cancel translation. Returns success when no exit code was captured or + /// when the captured code matches the conventional Cancelled (130) signal exit. + /// + private CommandResult MapCancellationToResult() + { + var requested = _cancellationManager.RequestedExitCode; + if (requested is null or CliExitCodes.Cancelled) + { + // Ambient cancellation or user Ctrl+C — preserve today's "stopping intentionally is OK" UX. + return CommandResult.Cancelled(CliExitCodes.Success); + } + + return CommandResult.FromExitCode(requested.Value); + } + private static async Task ObserveEarlyDetachedStartupExitAsync(Task pendingRun, CancellationToken cancellationToken) { var completedTask = await Task.WhenAny( diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index ef3fcb185d3..c3759143f59 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -196,11 +196,16 @@ private async Task DumpCapabilitiesAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = AppHostServerSession.Start( + await using var serverSession = new AppHostServerSession( appHostServerProject, environmentVariables: null, debug: false, - _logger); + _logger, + cancellationToken); + // Short-lived RPC session: discard the completion task; disposal flows the exit code + // through the activity scope and the only failure mode we care about surfaces via the + // RPC call below. + _ = serverSession.StartAsync(); // Connect and get capabilities var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -295,11 +300,16 @@ private async Task DumpCapabilitiesToDirectoryAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = AppHostServerSession.Start( + await using var serverSession = new AppHostServerSession( appHostServerProject, environmentVariables: null, debug: false, - _logger); + _logger, + cancellationToken); + // Short-lived RPC session: discard the completion task; disposal flows the exit code + // through the activity scope and the only failure mode we care about surfaces via the + // RPC call below. + _ = serverSession.StartAsync(); var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); outputDirectory.Create(); diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index af047419cd5..3f098d1ad4d 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -157,11 +157,16 @@ private async Task GenerateSdkAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = AppHostServerSession.Start( + await using var serverSession = new AppHostServerSession( appHostServerProject, environmentVariables: null, debug: false, - _logger); + _logger, + cancellationToken); + // Short-lived RPC session: discard the completion task; disposal flows the exit code + // through the activity scope and the only failure mode we care about surfaces via the + // RPC call below. + _ = serverSession.StartAsync(); // Connect and generate code var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index 8287799c59b..f0e12f047ef 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -25,7 +25,7 @@ internal sealed class StopCommand : BaseCommand private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; private readonly ICliHostEnvironment _hostEnvironment; - private readonly ProcessShutdownService _processShutdownService; + private readonly DetachedAppHostShutdownService _processShutdownService; private readonly ProfilingTelemetry _profilingTelemetry; private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", StopCommandStrings.ProjectArgumentDescription); @@ -43,7 +43,7 @@ public StopCommand( CliExecutionContext executionContext, IProjectLocator projectLocator, ICliHostEnvironment hostEnvironment, - ProcessShutdownService processShutdownService, + DetachedAppHostShutdownService processShutdownService, ILogger logger, AspireCliTelemetry telemetry, ProfilingTelemetry profilingTelemetry) diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index 74a6c3d16b0..c0b7f07afd4 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -9,12 +9,35 @@ namespace Aspire.Cli; /// -/// Manages Ctrl+C, SIGINT, and SIGTERM signal handling with a shared CancellationTokenSource. -/// After cancellation is requested, schedules an asynchronous timeout for the running handler -/// to complete before signaling forced termination via . -/// A second signal forces immediate termination without waiting for the timeout. -/// Disposing this instance unregisters all signal handlers and disposes the token source. +/// Manages Ctrl+C, SIGINT, and SIGTERM signal handling with a shared . +/// On the first termination signal it requests cooperative cancellation; after an optional graceful +/// window elapses it expires so long-running ladders escalate to +/// forceful termination; after a final drain budget it signals +/// so Program.Main abandons the handler task and +/// returns the captured exit code. /// +/// +/// +/// The three-stage signal counter mirrors the same ladder: +/// +/// +/// First signal — primary cancels and the graceful watcher starts. +/// Second signal — graceful window is collapsed via ; +/// ladders see the graceful token fire immediately and escalate. +/// Third (or later) signal — fires; Main exits NOW. +/// +/// +/// The completion source completing is treated as a strict superset of graceful expiration: +/// when the source completes for any reason (drain timeout, third signal, future external triggers), +/// is invoked synchronously so ladders observing only +/// the graceful token unblock in time to issue a kill before Main abandons them. +/// +/// +/// Disposing this instance unregisters all signal handlers and disposes the internal token source. +/// The is owned by the caller (typically Program.Main) +/// and is not disposed here. +/// +/// internal sealed class ConsoleCancellationManager : IDisposable { // Standard Unix exit codes: 128 + signal number (SIGINT=2, SIGTERM=15). @@ -23,27 +46,46 @@ internal sealed class ConsoleCancellationManager : IDisposable private const int SigIntExitCode = 130; private const int SigTermExitCode = 143; + // Sentinel for "no exit code captured yet". int.MinValue is unreachable for any real + // process exit code we surface and lets us use a single Interlocked.CompareExchange + // for first-writer-wins semantics without a separate "is set" flag. + private const int NoExitCodeSentinel = int.MinValue; + private readonly CancellationTokenSource _cts = new(); - private readonly TimeSpan _processTerminationTimeout; + private readonly GracefulShutdownService _gracefulService; + private readonly TimeSpan _finalDrainBudget; private readonly PosixSignalRegistration? _sigIntRegistration; private readonly PosixSignalRegistration? _sigTermRegistration; private readonly PosixSignalRegistration? _sigQuitRegistration; private readonly CancellationToken _token; private ILogger _logger; private Task? _startedHandler; - private int _cancelCalled; + // Gate that ensures the shutdown ladder (CTS cancel + graceful watcher) is started exactly once. + // CAS-flipped 0→1 by whichever of RequestShutdown or first-time Cancel arrives first; subsequent + // callers on either path observe the gate already taken and avoid double-starting the watcher. + // Kept separate from _userSignalCount so internal RequestShutdown does NOT consume a user signal — + // otherwise the user's first real Ctrl+C after an internal request would be observed as the second + // signal and collapse the graceful window immediately. + private int _ladderStarted; + // Number of real user-initiated termination signals received (Ctrl+C, SIGINT, SIGTERM, SIGQUIT, + // ProcessExit). Drives the second-signal (Expire) and third-signal (force exit) escalations. + // Only Cancel touches this — RequestShutdown does not. + private int _userSignalCount; + private int _requestedExitCode = NoExitCodeSentinel; + private TimeSpan _gracefulBudget = TimeSpan.Zero; private readonly TaskCompletionSource _processTerminationCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); /// /// A completion source that is signaled with a native exit code when the running handler - /// does not complete within the configured timeout after a termination signal. + /// does not complete within the configured drain budget after a termination signal, + /// or when a third Ctrl+C arrives. /// internal TaskCompletionSource ProcessTerminationCompletionSource => _processTerminationCompletionSource; /// /// Sets the handler task that represents the currently executing command. When a termination - /// signal arrives, the manager will wait for this task to complete within the configured timeout. + /// signal arrives, the manager will wait for this task to complete within the configured budgets. /// internal void SetStartedHandler(Task handler) => Volatile.Write(ref _startedHandler, handler); @@ -53,14 +95,30 @@ internal sealed class ConsoleCancellationManager : IDisposable /// internal void SetLogger(ILogger logger) => Volatile.Write(ref _logger, logger); - public ConsoleCancellationManager(TimeSpan processTerminationTimeout) + public ConsoleCancellationManager(GracefulShutdownService gracefulService, TimeSpan finalDrainBudget) { - _processTerminationTimeout = processTerminationTimeout; + ArgumentNullException.ThrowIfNull(gracefulService); + + _gracefulService = gracefulService; + _finalDrainBudget = finalDrainBudget; _logger = NullLogger.Instance; // Set to a field so getting the token doesn't error after dispose. _token = _cts.Token; + // Phase 3 → Phase 2 fallthrough. When the termination completion source completes for any reason + // (drain timeout, third Ctrl+C, future external triggers), any ladder still observing only the + // graceful token would otherwise sit on a Task.Delay(budget, gracefulService.Token) and miss its + // last chance to issue a kill before Main abandons it. Cancel synchronously so this fires before + // continuations of the completion source observe completion. Expire() is idempotent — multiple + // calls across the watcher (Phase 1 end), the 2nd-signal branch, and this continuation are safe. + _processTerminationCompletionSource.Task.ContinueWith( + static (_, state) => ((GracefulShutdownService)state!).Expire(), + _gracefulService, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + // Prefer PosixSignalRegistration for both SIGINT and SIGTERM as it handles // both signals uniformly and allows cancelling SIGTERM (which Console.CancelKeyPress cannot). // Despite the name, PosixSignalRegistration is supported on Windows: the runtime maps @@ -93,8 +151,93 @@ public ConsoleCancellationManager(TimeSpan processTerminationTimeout) public CancellationToken Token => _token; + /// + /// Token that fires when the graceful-shutdown window has been exhausted (graceful budget elapsed, + /// second termination signal, or process-termination completion). Convenience accessor — callers + /// that already have a reference to can read its + /// directly. + /// + public CancellationToken GracefulShutdownToken => _gracefulService.Token; + public bool IsCancellationRequested => _cts.IsCancellationRequested; + /// + /// Exit code captured at the moment cancellation was first requested. First writer wins; set by + /// either an external termination signal (with the OS-conventional signal exit code) or an internal + /// caller via . Reads return until the first + /// caller writes. + /// + public int? RequestedExitCode + { + get + { + var value = Volatile.Read(ref _requestedExitCode); + + return value == NoExitCodeSentinel ? null : value; + } + } + + /// + /// Sets the graceful-shutdown budget for the currently-executing command. Default is zero, meaning + /// ladders that consume fall through to escalation immediately + /// (preserving today's behavior for every command that doesn't opt in). The aspire run handler + /// calls this with five seconds so DCP and the AppHost get a real cooperative-shutdown window before + /// escalation. + /// + public void ConfigureForCommand(TimeSpan gracefulBudget) + { + if (gracefulBudget < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(gracefulBudget), "Graceful budget cannot be negative."); + } + + _gracefulBudget = gracefulBudget; + } + + /// + /// Requests cooperative cancellation with the given exit code. Used by internal teardown callers + /// (guest exited non-zero, backchannel never completed, normal completion) that need to drive the + /// same shutdown ladder a user signal would, without tampering with private token sources. + /// + /// + /// Unlike , this does NOT contribute to the user-signal escalation counter. + /// Multiple concurrent internal callers (e.g. backchannel fault racing with guest non-zero exit) + /// would otherwise be treated as "second Ctrl+C" and collapse the graceful window immediately — + /// defeating the budget. Instead, the first call captures the exit code and fires the primary CT + /// exactly once; subsequent calls are no-ops (first-writer-wins on the exit code via + /// ). A real Ctrl+C from the user still escalates correctly + /// because drives _userSignalCount independently of _ladderStarted. + /// + public void RequestShutdown(int exitCode) + { + // First-writer-wins on the exit code, regardless of whether we end up firing the ladder. + // (A late internal request after Ctrl+C already started the ladder still tries to register, + // but the SIGINT code stays in place.) + TrySetRequestedExitCode(exitCode); + + // CAS the ladder-started gate 0→1. If a signal handler or prior internal call already started + // the ladder, no-op. Critically: we DO NOT touch _userSignalCount here, so a subsequent user + // Ctrl+C is still seen as the first user signal — not the second. + if (Interlocked.CompareExchange(ref _ladderStarted, 1, 0) != 0) + { + return; + } + + _logger.LogInformation("Internal shutdown requested with exit code {ExitCode}.", exitCode); + + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + // Process is already tearing down; nothing more to do. + return; + } + + _ = ExpireGracefulThenFinalDrainAsync(exitCode); + } + private void OnPosixSignal(PosixSignalContext context) { context.Cancel = true; @@ -115,14 +258,23 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) private void OnProcessExit(object? sender, EventArgs e) => Cancel(SigTermExitCode); - internal void Cancel(int forcedTerminationExitCode) + internal void Cancel(int exitCode) { - var signalCount = Interlocked.Increment(ref _cancelCalled); + // _userSignalCount tracks real user signals only; _ladderStarted is the one-shot gate that + // tells us whether THIS Cancel is also the call that needs to start the ladder. Keeping the + // two counters separate is what prevents internal RequestShutdown from consuming a user + // signal — without that separation, "internal RequestShutdown + first user Ctrl+C" would + // collapse the graceful window immediately. + var userSignalCount = Interlocked.Increment(ref _userSignalCount); + var ladderJustStarted = Interlocked.CompareExchange(ref _ladderStarted, 1, 0) == 0; - if (signalCount == 1) + if (ladderJustStarted) { - // First signal: request cooperative cancellation and schedule an async timeout - // that will force-terminate if the handler doesn't complete in time. + // First signal AND ladder not already started by an internal RequestShutdown: capture the + // exit code, request cooperative cancellation, and schedule the graceful-then-drain watcher. + // The signal handler returns immediately so Program.Main's Task.WhenAny observes handler + // completion without being blocked by the handler thread. + TrySetRequestedExitCode(exitCode); _logger.LogInformation("Termination signal received, requesting cancellation."); try @@ -135,52 +287,104 @@ internal void Cancel(int forcedTerminationExitCode) return; } - // Schedule the forced-completion timeout asynchronously so the signal handler - // returns immediately. This allows Program.Main's Task.WhenAny to observe - // handlerTask completion without being blocked by the signal handler thread. - _ = ForceTerminationAfterTimeoutAsync(forcedTerminationExitCode); + _ = ExpireGracefulThenFinalDrainAsync(exitCode); + return; + } + + if (userSignalCount == 1) + { + // First user signal but the ladder was already started by an internal RequestShutdown + // (e.g. normal guest exit, backchannel fault). The user has only pressed Ctrl+C once, + // so we do NOT escalate — the graceful watcher continues running. Capture the exit + // code for first-writer-wins (the internal value normally still wins). A second user + // Ctrl+C below will escalate as expected. + TrySetRequestedExitCode(exitCode); + _logger.LogInformation("Termination signal received while shutdown ladder already running."); + } + else if (userSignalCount == 2) + { + // Second user signal: collapse Phase 1 immediately. Ladders observing the graceful token + // unblock and escalate to forceful termination; the watcher's Task.Delay(graceful) gets + // cancelled and moves on to Phase 2 (final drain). + _logger.LogWarning("Second termination signal received, expiring graceful shutdown window."); + _gracefulService.Expire(); } else { - // Second (or subsequent) signal: force immediate termination without waiting. - _logger.LogWarning("Second termination signal received, forcing immediate exit."); - _processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode); + // Third (or later) user signal: caller wants out NOW. Skip both graceful and drain budgets. + _logger.LogWarning("Third termination signal received, forcing immediate exit."); + _processTerminationCompletionSource.TrySetResult(exitCode); + } + } + + private void TrySetRequestedExitCode(int exitCode) + { + // Normalize the pathological caller that happens to pass the sentinel value so we never + // confuse "no exit code captured" with "exit code is int.MinValue". + if (exitCode == NoExitCodeSentinel) + { + exitCode = -1; } + + Interlocked.CompareExchange(ref _requestedExitCode, exitCode, NoExitCodeSentinel); } - private async Task ForceTerminationAfterTimeoutAsync(int forcedTerminationExitCode) + private async Task ExpireGracefulThenFinalDrainAsync(int forcedTerminationExitCode) { try { - // When a debugger is attached, don't force-terminate — the developer needs - // unlimited time to step through cancellation/cleanup logic. + // When a debugger is attached, don't escalate or force-terminate — the developer needs + // unlimited time to step through cancellation/cleanup logic. The graceful token therefore + // never fires under the debugger via this watcher; ladders that observe it sit indefinitely + // (the right behavior for stepping). A manual second Ctrl+C still works because it calls + // _gracefulService.Expire() synchronously via the signal counter, bypassing this method. if (Debugger.IsAttached) { return; } + // Phase 1: graceful window. Delay is cancellable via the graceful token so a 2nd Ctrl+C + // (which calls _gracefulService.Expire from the signal counter) drops us straight into + // Phase 2 without waiting out the remaining budget. Zero-budget commands skip the delay + // entirely and fall through to the unconditional Expire() below. + if (_gracefulBudget > TimeSpan.Zero) + { + try + { + await Task.Delay(_gracefulBudget, _gracefulService.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // 2nd Ctrl+C arrived; fall through to Phase 2. + } + } + + // Guarantees the graceful token fires before Phase 2 starts, even when the budget was zero + // (no Phase 1 delay) or when the delay elapsed without a 2nd Ctrl+C. Idempotent. + _gracefulService.Expire(); + + // Phase 2: final drain. Give the handler a chance to finish gracefully within the configured + // drain budget. Task.WhenAny completes when either the handler or the delay finishes first, + // without propagating exceptions from the losing task. It's ok that this delay isn't + // cancellable — the process is ending. var startedHandler = Volatile.Read(ref _startedHandler); if (startedHandler is not null) { - // Give the handler a chance to finish gracefully within the configured timeout. - // Task.WhenAny completes when either the handler or the delay finishes first, - // without propagating exceptions from the losing task. - // It's ok that this delay isn't cancellable. The process is ending. - var timeoutTask = Task.Delay(_processTerminationTimeout); - if (await Task.WhenAny(startedHandler, timeoutTask).ConfigureAwait(false) == startedHandler) + var drainTask = Task.Delay(_finalDrainBudget); + + if (await Task.WhenAny(startedHandler, drainTask).ConfigureAwait(false) == startedHandler) { - // Handler finished within the timeout; no forced termination needed. return; } } - _logger.LogWarning("Handler did not complete within {Timeout}s, forcing termination.", _processTerminationTimeout.TotalSeconds); + _logger.LogWarning("Handler did not complete within {Timeout}s after graceful expiration, forcing termination.", _finalDrainBudget.TotalSeconds); _processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode); } catch (Exception) { - // Any failure in the timeout path should still force termination rather than hang. + // Any failure in the watcher path should still force termination rather than hang. _processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode); } } @@ -197,3 +401,4 @@ public void Dispose() _cts.Dispose(); } } + diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 56ffdb74fcb..3d19d32ae5e 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -59,6 +59,11 @@ internal sealed class ProcessInvocationOptions /// Useful for background operations like NuGet package cache refreshes. /// public bool SuppressLogging { get; set; } + + /// + /// Controls whether cancellation should terminate the whole process tree or only the root process. + /// + public bool KillEntireProcessTreeOnCancel { get; set; } = true; } internal sealed class DotNetCliRunner( @@ -270,6 +275,7 @@ private static ProcessInvocationOptions CreateInstrumentedProcessOptions( StartDebugSession = options.StartDebugSession, Debug = options.Debug, SuppressLogging = options.SuppressLogging, + KillEntireProcessTreeOnCancel = options.KillEntireProcessTreeOnCancel, StandardOutputCallback = line => { var lineCount = Interlocked.Increment(ref outputCounters.StdoutLineCount); diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 1a1f71ecd09..763b4ed6304 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Cli.Processes; using Microsoft.Extensions.Logging; namespace Aspire.Cli.DotNet; @@ -90,26 +91,21 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) { await _process.WaitForExitAsync(cancellationToken); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) { - if (!_process.HasExited) - { - _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, killing it", FileName, _process.Id); - TryKillProcessTree(); - } + _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, stopping it", FileName, _process.Id); + await ProcessTerminator.ShutdownAsync( + _process, + requestGracefulShutdown: !OperatingSystem.IsWindows(), + _options.KillEntireProcessTreeOnCancel, + _logger, + FileName, + gracefulShutdownCancellationToken: cancellationToken).ConfigureAwait(false); throw; } - if (!_process.HasExited) - { - _logger.LogDebug("{FileName}({ProcessId}) has not exited, killing it", FileName, _process.Id); - _process.Kill(false); - } - else - { - _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", FileName, _process.Id, _process.ExitCode); - } + _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", FileName, _process.Id, _process.ExitCode); // Give the forwarders a fresh idle window to consume any buffered tail output produced right before exit. RecordForwarderActivity(); @@ -217,16 +213,4 @@ private void RecordForwarderActivity() { Interlocked.Exchange(ref _lastForwarderActivityTimestamp, Stopwatch.GetTimestamp()); } - - private void TryKillProcessTree() - { - try - { - _process.Kill(entireProcessTree: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "{FileName}({ProcessId}) failed to kill process tree after wait cancellation", FileName, _process.Id); - } - } } diff --git a/src/Aspire.Cli/GracefulShutdownService.cs b/src/Aspire.Cli/GracefulShutdownService.cs new file mode 100644 index 00000000000..d88d1c5365a --- /dev/null +++ b/src/Aspire.Cli/GracefulShutdownService.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli; + +/// +/// Thin facade over a single that signals when the +/// CLI's graceful-shutdown window has expired. Owned and timed exclusively by +/// ; consumed by long-running ladders +/// (e.g. AppHostServerSession, ProcessGuestLauncher) that need to know +/// when to stop waiting for a cooperative shutdown and escalate to forceful termination. +/// +/// +/// +/// The service intentionally exposes no timing or budget configuration of its own — CCM +/// is the sole timing authority. Multiple calls are safe and +/// idempotent; the token transitions from un-cancelled to cancelled exactly once. +/// +/// +/// Registered as a DI singleton via services.AddSingleton(instance) so the +/// container does not take disposal ownership; the bootstrap path (Program.Main) +/// owns the instance lifetime alongside CCM. +/// +/// +internal sealed class GracefulShutdownService : IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private readonly CancellationToken _token; + + public GracefulShutdownService() + { + // Capture the token up front so callers can still observe its (final) state + // after Dispose, matching the pattern used by ConsoleCancellationManager. + _token = _cts.Token; + } + + /// + /// Fires when the CLI's graceful-shutdown budget has been exhausted (or when an + /// external signal has determined that further waiting is no longer useful). + /// + public CancellationToken Token => _token; + + /// + /// Signals that the graceful-shutdown window is over. Safe to call multiple times + /// from any thread; the underlying token transitions to cancelled at most once. + /// + public void Expire() + { + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + // Expire can race with process shutdown after dispose; swallow rather + // than propagating so callers (signal handlers, watcher continuations) + // never have to guard against it. + } + } + + public void Dispose() => _cts.Dispose(); +} diff --git a/src/Aspire.Cli/Processes/ProcessShutdownService.cs b/src/Aspire.Cli/Processes/DetachedAppHostShutdownService.cs similarity index 87% rename from src/Aspire.Cli/Processes/ProcessShutdownService.cs rename to src/Aspire.Cli/Processes/DetachedAppHostShutdownService.cs index d0089a68d54..38f18b250fe 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownService.cs +++ b/src/Aspire.Cli/Processes/DetachedAppHostShutdownService.cs @@ -13,13 +13,13 @@ namespace Aspire.Cli.Processes; /// /// Coordinates graceful process shutdown requests, termination monitoring, and force-kill fallback. /// -internal sealed class ProcessShutdownService( +internal sealed class DetachedAppHostShutdownService( ILayoutDiscovery layoutDiscovery, IBundleService bundleService, LayoutProcessRunner layoutProcessRunner, CliExecutionContext executionContext, - ILogger logger, - TimeProvider timeProvider) + ILogger logger, + TimeProvider timeProvider) : IProcessTreeGracefulShutdownSignaler { private static readonly TimeSpan s_processTerminationTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan s_processTerminationPollInterval = TimeSpan.FromMilliseconds(250); @@ -83,23 +83,17 @@ private async Task StopProcessesAsync( var gracefulShutdownRequested = await TryRequestGracefulShutdownAsync(requestGracefulShutdownAsync, cancellationToken).ConfigureAwait(false); if (gracefulShutdownRequested && await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false)) { - ForceKillRemainingProcesses(processesToForceKill.Except(processesToMonitor), afterTimeout: false); + await ForceKillRemainingProcessesAsync(processesToForceKill.Except(processesToMonitor), afterTimeout: false).ConfigureAwait(false); return true; } - ForceKillRemainingProcesses(processesToForceKill, afterTimeout: true); + await ForceKillRemainingProcessesAsync(processesToForceKill, afterTimeout: true).ConfigureAwait(false); return await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false); } - private void ForceKillRemainingProcesses(IEnumerable processes, bool afterTimeout) + private async Task ForceKillRemainingProcessesAsync(IEnumerable processes, bool afterTimeout) { - // On Unix the AppHost's process tree does not include DCP (it is launched in its own - // session/process group), so a tree kill of the AppHost is safe: DCP will detect the - // AppHost exiting and gracefully tear down its own children. The same applies to the - // launcher CLI handle - any leftover `dotnet run` / AppHost descendants get cleaned up. - // On Windows DCP is an in-tree descendant of the AppHost, so we must single-process-kill - // here and rely on the graceful DCP `stop-process-tree` path for orderly resource cleanup. var killEntireProcessTree = !OperatingSystem.IsWindows(); foreach (var process in processes.Distinct()) @@ -113,7 +107,14 @@ private void ForceKillRemainingProcesses(IEnumerable processes, b logger.LogDebug("Forcing remaining shutdown handle process {Pid} to terminate.", process.Pid); } - ProcessSignaler.ForceKill(process.Pid, process.StartTime, logger, killEntireProcessTree); + await ProcessTerminator.ShutdownAsync( + process.Pid, + process.StartTime, + requestGracefulShutdown: false, + killEntireProcessTree, + logger, + "shutdown target", + gracefulShutdownCancellationToken: CancellationToken.None).ConfigureAwait(false); } } @@ -189,7 +190,16 @@ private async Task TryRequestRpcStopAsync(Func RequestProcessTreeGracefulShutdownAsync( + /// + /// Issues an OS-correct graceful shutdown signal to the entire process tree rooted at : + /// on Windows, shells out to DCP's stop-process-tree (which performs the AttachConsole + + /// GenerateConsoleCtrlEvent dance against a child running in its own console group); + /// on Unix, sends SIGTERM via . This method does NOT wait for exit or + /// escalate to Kill — callers are expected to own that ladder (bounded by a central graceful + /// shutdown budget) and force-kill on escalation. Used by both the detached aspire stop path + /// and the in-process aspire run shutdown ladders for AppHost server + guest siblings. + /// + public async Task RequestProcessTreeGracefulShutdownAsync( int pid, DateTimeOffset? startTime, bool includeStartTimeForDcp, diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs index f197f51d04e..50487e86f0b 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -1,32 +1,34 @@ // 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; using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using System.Runtime.Versioning; -using System.Text; -using Microsoft.Win32.SafeHandles; namespace Aspire.Cli.Processes; internal static partial class DetachedProcessLauncher { /// - /// Windows implementation using CreateProcess with CREATE_NEW_CONSOLE, - /// STARTUPINFOEX, SW_HIDE, and PROC_THREAD_ATTRIBUTE_HANDLE_LIST - /// to detach from the launching console and prevent handle inheritance to grandchildren. + /// Windows implementation using + /// with NUL bound to stdout and stderr (stdin is left unset). The detached child is NOT + /// assigned to the CLI's kill-on-close job — the entire point of aspire start is + /// that the AppHost outlives the launching CLI. /// [SupportedOSPlatform("windows")] private static Process StartWindows(string fileName, IReadOnlyList arguments, string workingDirectory, Func? shouldRemoveEnvironmentVariable, IReadOnlyDictionary? additionalEnvironmentVariables) { - // Open NUL device for stdout/stderr — child writes go nowhere - using var nulHandle = CreateFileW( + // Open NUL for the child's stdout/stderr — child writes go nowhere. The handle must be + // inheritable (PROC_THREAD_ATTRIBUTE_HANDLE_LIST whitelists but does NOT promote + // non-inheritable handles). + using var nulHandle = WindowsProcessInterop.CreateFileW( "NUL", - GenericWrite, - FileShareWrite, + WindowsProcessInterop.GenericWrite, + WindowsProcessInterop.FileShareWrite, nint.Zero, - OpenExisting, + WindowsProcessInterop.OpenExisting, 0, nint.Zero); @@ -35,379 +37,67 @@ private static Process StartWindows(string fileName, IReadOnlyList argum throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to open NUL device"); } - // Mark the NUL handle as inheritable (required for STARTUPINFO hStdOutput assignment) - if (!SetHandleInformation(nulHandle, HandleFlagInherit, HandleFlagInherit)) + if (!WindowsProcessInterop.SetHandleInformation(nulHandle, WindowsProcessInterop.HandleFlagInherit, WindowsProcessInterop.HandleFlagInherit)) { throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to set NUL handle inheritance"); } - // Initialize a process thread attribute list with 1 slot (HANDLE_LIST) - var attrListSize = nint.Zero; - InitializeProcThreadAttributeList(nint.Zero, 1, 0, ref attrListSize); - - var attrList = Marshal.AllocHGlobal(attrListSize); - try + var nulRawHandle = nulHandle.DangerousGetHandle(); + var stdio = new WindowsProcessInterop.StdioHandles( + Stdin: nint.Zero, + Stdout: nulRawHandle, + Stderr: nulRawHandle); + + // The detached launcher's caller-facing surface takes (predicate, additional) — translate + // here into the single-dict shape that SpawnConsoleIsolatedProcess now expects. When the + // caller passes neither, leave environment null so the child inherits the parent env + // verbatim (no allocation). + IReadOnlyDictionary? environment = null; + if (shouldRemoveEnvironmentVariable is not null || additionalEnvironmentVariables is not null) { - if (!InitializeProcThreadAttributeList(attrList, 1, 0, ref attrListSize)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize process thread attribute list"); - } - - try + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) { - // Whitelist only the NUL handle for inheritance. - // The grandchild (AppHost) will inherit this harmless handle instead of - // any pipes from the caller's process tree. - var handles = new[] { nulHandle.DangerousGetHandle() }; - var pinnedHandles = GCHandle.Alloc(handles, GCHandleType.Pinned); - try + var key = (string)entry.Key; + if (shouldRemoveEnvironmentVariable is null || !shouldRemoveEnvironmentVariable(key)) { - if (!UpdateProcThreadAttribute( - attrList, - 0, - s_procThreadAttributeHandleList, - pinnedHandles.AddrOfPinnedObject(), - (nint)(nint.Size * handles.Length), - nint.Zero, - nint.Zero)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to update process thread attribute list"); - } - - var nulRawHandle = nulHandle.DangerousGetHandle(); - - var si = new STARTUPINFOEX(); - si.cb = Marshal.SizeOf(); - si.dwFlags = StartfUseStdHandles | StartfUseShowWindow; - si.hStdInput = nint.Zero; - si.hStdOutput = nulRawHandle; - si.hStdError = nulRawHandle; - si.lpAttributeList = attrList; - // CREATE_NO_WINDOW is ignored with CREATE_NEW_CONSOLE; hide the independent - // console through STARTUPINFO instead. - si.wShowWindow = ShowWindowHide; - - // Build the command line string: "fileName" arg1 arg2 ... - var commandLine = BuildCommandLine(fileName, arguments); - - var flags = WindowsDetachedProcessCreationFlags; - - // Build a custom environment block if variables need to be removed or added. - // CreateProcessW with lpEnvironment=nint.Zero inherits the parent's - // environment, so we only build a custom block when customization is needed. - var envBlockHandle = nint.Zero; - try - { - if (shouldRemoveEnvironmentVariable is not null || additionalEnvironmentVariables is not null) - { - envBlockHandle = BuildCustomEnvironmentBlock(shouldRemoveEnvironmentVariable, additionalEnvironmentVariables); - } - - if (!CreateProcessW( - null, - commandLine, - nint.Zero, - nint.Zero, - bInheritHandles: true, // TRUE but HANDLE_LIST restricts what's actually inherited - flags, - envBlockHandle, - workingDirectory, - ref si, - out var pi)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create detached process"); - } - - Process detachedProcess; - try - { - detachedProcess = Process.GetProcessById(pi.dwProcessId); - } - finally - { - // Close the process and thread handles returned by CreateProcess. - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - } - - return detachedProcess; - } - finally - { - if (envBlockHandle != nint.Zero) - { - Marshal.FreeHGlobal(envBlockHandle); - } - } - } - finally - { - pinnedHandles.Free(); + resolved[key] = entry.Value as string ?? string.Empty; } } - finally - { - DeleteProcThreadAttributeList(attrList); - } - } - finally - { - Marshal.FreeHGlobal(attrList); - } - } - - /// - /// Builds a Windows command line string with correct quoting rules. - /// Adapted from dotnet/runtime PasteArguments.AppendArgument. - /// - private static StringBuilder BuildCommandLine(string fileName, IReadOnlyList arguments) - { - var sb = new StringBuilder(); - - // Quote the executable path - sb.Append('"').Append(fileName).Append('"'); - - foreach (var arg in arguments) - { - sb.Append(' '); - AppendArgument(sb, arg); - } - - return sb; - } - /// - /// Appends a correctly-quoted argument to the command line. - /// Copied from dotnet/runtime src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs - /// - private static void AppendArgument(StringBuilder sb, string argument) - { - // Windows command-line parsing rules: - // - Backslash is normal except when followed by a quote - // - 2N backslashes + quote → N literal backslashes + unescaped quote - // - 2N+1 backslashes + quote → N literal backslashes + literal quote - if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) - { - sb.Append(argument); - return; - } - - sb.Append('"'); - var idx = 0; - while (idx < argument.Length) - { - var c = argument[idx++]; - if (c == '\\') + if (additionalEnvironmentVariables is not null) { - var numBackslash = 1; - while (idx < argument.Length && argument[idx] == '\\') - { - idx++; - numBackslash++; - } - - if (idx == argument.Length) - { - // Trailing backslashes before closing quote — must double them - sb.Append('\\', numBackslash * 2); - } - else if (argument[idx] == '"') - { - // Backslashes followed by quote — double them + escape the quote - sb.Append('\\', numBackslash * 2 + 1); - sb.Append('"'); - idx++; - } - else + foreach (var (key, value) in additionalEnvironmentVariables) { - // Backslashes not followed by quote — emit as-is - sb.Append('\\', numBackslash); + resolved[key] = value; } - - continue; - } - - if (c == '"') - { - sb.Append('\\'); - sb.Append('"'); - continue; } - sb.Append(c); - } - - sb.Append('"'); - } - - /// - /// Builds a Unicode environment block for CreateProcessW with specified variables - /// removed and/or added. The block is sorted by variable name (case-insensitive, - /// as required by Windows) and double-null-terminated. The caller must free the - /// returned pointer with Marshal.FreeHGlobal. - /// - [SupportedOSPlatform("windows")] - private static nint BuildCustomEnvironmentBlock(Func? shouldRemove, IReadOnlyDictionary? additionalVariables) - { - // Collect current environment variables, excluding the ones to remove. - var envVars = new SortedDictionary(StringComparer.OrdinalIgnoreCase); - foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) - { - var key = (string)entry.Key; - if (shouldRemove is null || !shouldRemove(key)) - { - envVars[key] = (string?)entry.Value ?? string.Empty; - } - } - - // Add additional variables (overwrites any existing keys with the same name). - if (additionalVariables is not null) - { - foreach (var (key, value) in additionalVariables) - { - envVars[key] = value; - } - } - - // Build the double-null-terminated Unicode environment block: - // KEY1=VALUE1\0KEY2=VALUE2\0...\0\0 - var blockBuilder = new StringBuilder(); - foreach (var kvp in envVars) - { - blockBuilder.Append(kvp.Key); - blockBuilder.Append('='); - blockBuilder.Append(kvp.Value); - blockBuilder.Append('\0'); + environment = resolved; } - if (envVars.Count == 0) + // jobHandle: null — detached children must survive a CLI crash. Anything assigned to + // the CLI's kill-on-close job dies with the CLI, which is the opposite of what + // `aspire start` wants. + var pi = WindowsProcessInterop.SpawnConsoleIsolatedProcess( + fileName, + arguments, + workingDirectory, + stdio, + environment, + jobHandle: null); + + Process detachedProcess; + try { - blockBuilder.Append('\0'); + detachedProcess = Process.GetProcessById(pi.dwProcessId); } - - blockBuilder.Append('\0'); // Final terminator - - var blockString = blockBuilder.ToString(); - var byteCount = Encoding.Unicode.GetByteCount(blockString); - var ptr = Marshal.AllocHGlobal(byteCount); - unsafe + finally { - fixed (char* pStr = blockString) - { - Encoding.Unicode.GetBytes(pStr, blockString.Length, (byte*)ptr, byteCount); - } + WindowsProcessInterop.CloseHandle(pi.hProcess); + WindowsProcessInterop.CloseHandle(pi.hThread); } - return ptr; - } - - // --- Constants --- - private const uint GenericWrite = 0x40000000; - private const uint FileShareWrite = 0x00000002; - private const uint OpenExisting = 3; - private const uint HandleFlagInherit = 0x00000001; - private const uint StartfUseStdHandles = 0x00000100; - private const uint StartfUseShowWindow = 0x00000001; - private const uint CreateUnicodeEnvironment = 0x00000400; - private const uint ExtendedStartupInfoPresent = 0x00080000; - private const uint CreateNewConsole = 0x00000010; - private const uint WindowsDetachedProcessCreationFlags = - CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNewConsole; - private const ushort ShowWindowHide = 0x0000; - private static readonly nint s_procThreadAttributeHandleList = (nint)0x00020002; - - // --- Structs --- - - [StructLayout(LayoutKind.Sequential)] - private struct STARTUPINFOEX - { - public int cb; - public nint lpReserved; - public nint lpDesktop; - public nint lpTitle; - public int dwX; - public int dwY; - public int dwXSize; - public int dwYSize; - public int dwXCountChars; - public int dwYCountChars; - public int dwFillAttribute; - public uint dwFlags; - public ushort wShowWindow; - public ushort cbReserved2; - public nint lpReserved2; - public nint hStdInput; - public nint hStdOutput; - public nint hStdError; - public nint lpAttributeList; - } - - [StructLayout(LayoutKind.Sequential)] - private struct PROCESS_INFORMATION - { - public nint hProcess; - public nint hThread; - public int dwProcessId; - public int dwThreadId; + return detachedProcess; } - - // --- P/Invoke declarations --- - - [LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] - private static partial SafeFileHandle CreateFileW( - string lpFileName, - uint dwDesiredAccess, - uint dwShareMode, - nint lpSecurityAttributes, - uint dwCreationDisposition, - uint dwFlagsAndAttributes, - nint hTemplateFile); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool SetHandleInformation( - SafeFileHandle hObject, - uint dwMask, - uint dwFlags); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool InitializeProcThreadAttributeList( - nint lpAttributeList, - int dwAttributeCount, - int dwFlags, - ref nint lpSize); - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool UpdateProcThreadAttribute( - nint lpAttributeList, - uint dwFlags, - nint attribute, - nint lpValue, - nint cbSize, - nint lpPreviousValue, - nint lpReturnSize); - - [LibraryImport("kernel32.dll", SetLastError = true)] - private static partial void DeleteProcThreadAttributeList(nint lpAttributeList); - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] -#pragma warning disable CA1838 // CreateProcessW requires a mutable command line buffer - private static extern bool CreateProcessW( - string? lpApplicationName, - StringBuilder lpCommandLine, - nint lpProcessAttributes, - nint lpThreadAttributes, - bool bInheritHandles, - uint dwCreationFlags, - nint lpEnvironment, - string? lpCurrentDirectory, - ref STARTUPINFOEX lpStartupInfo, - out PROCESS_INFORMATION lpProcessInformation); -#pragma warning restore CA1838 - - [LibraryImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool CloseHandle(nint hObject); } diff --git a/src/Aspire.Cli/Processes/IProcessTreeGracefulShutdownSignaler.cs b/src/Aspire.Cli/Processes/IProcessTreeGracefulShutdownSignaler.cs new file mode 100644 index 00000000000..be1bb36c67f --- /dev/null +++ b/src/Aspire.Cli/Processes/IProcessTreeGracefulShutdownSignaler.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Processes; + +/// +/// Minimal abstraction over the per-OS "ask this process tree to shut down gracefully" primitive +/// used by the in-process aspire run shutdown ladders (AppHost server + guest siblings). +/// On Windows it shells out to DCP's stop-process-tree (the AttachConsole + +/// GenerateConsoleCtrlEvent dance); on Unix it sends SIGTERM via ProcessSignaler. +/// Callers own the wait/escalate cadence; this method does not wait for exit or force-kill. +/// +/// +/// Existence as an interface (rather than a direct dependency on +/// ) keeps the in-process Run shutdown ladders +/// testable: tests can inject a fake that simulates "signal failed," "signal returned false," +/// or "signal was issued and observed by a fake process" without needing real DCP layout +/// discovery or platform-specific signal plumbing. +/// +internal interface IProcessTreeGracefulShutdownSignaler +{ + Task RequestProcessTreeGracefulShutdownAsync( + int pid, + DateTimeOffset? startTime, + bool includeStartTimeForDcp, + CancellationToken cancellationToken); +} diff --git a/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs b/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs new file mode 100644 index 00000000000..b51876403e6 --- /dev/null +++ b/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs @@ -0,0 +1,73 @@ +// 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; + +namespace Aspire.Cli.Processes; + +/// +/// Shared helper for the isolated-console spawn path: translates a fully-populated +/// into the shape +/// expects, then spawns the child. Centralizes the +/// translation so every caller (AppHost server, guest apphost, future spawners) sees the +/// same env/arg shape and the same fail-fast contract on Windows. +/// +internal static class IsolatedConsoleSpawner +{ + /// + /// Spawns the process described by into an isolated console + /// group (new hidden console on Windows; effectively a thin + /// wrapper on Unix), optionally bound to the supplied Windows kill-on-close job. + /// + /// + /// On Windows, throws if + /// is . Isolation without the kill-on-close job means the spawned process + /// can survive a CLI crash as an orphan in its new console group, defeating the entire point + /// of the safety net the new-console isolation is supposed to enable. + /// + public static IsolatedProcess StartIsolated( + ProcessStartInfo startInfo, + WindowsConsoleProcessJob? consoleProcessJob, + Action standardOutputHandler, + Action standardErrorHandler) + { + ArgumentNullException.ThrowIfNull(startInfo); + ArgumentNullException.ThrowIfNull(standardOutputHandler); + ArgumentNullException.ThrowIfNull(standardErrorHandler); + + if (OperatingSystem.IsWindows() && consoleProcessJob is null) + { + throw new ArgumentNullException( + nameof(consoleProcessJob), + "consoleProcessJob is required when spawning into an isolated console on Windows so the spawned process is bound to the CLI's kill-on-close job."); + } + + var isolatedStartInfo = new IsolatedProcessStartInfo + { + FileName = startInfo.FileName, + WorkingDirectory = startInfo.WorkingDirectory, + JobHandle = OperatingSystem.IsWindows() ? consoleProcessJob?.Handle : null, + }; + + foreach (var arg in startInfo.ArgumentList) + { + isolatedStartInfo.ArgumentList.Add(arg); + } + + // Mirror the ProcessStartInfo.Environment overlays. Touching IsolatedProcessStartInfo's + // Environment property snapshots the current process env (same semantics as ProcessStartInfo), + // and we then overlay every key set on the source. We can't just copy over startInfo.Environment + // wholesale because that would re-overlay the inherited block onto itself; only iterate the + // entries the caller actually mutated. ProcessStartInfo doesn't expose a "touched" set, so we + // overlay everything — extra writes of identical values are harmless. + foreach (var key in startInfo.Environment.Keys) + { + isolatedStartInfo.Environment[key] = startInfo.Environment[key]; + } + + return IsolatedProcess.Start( + isolatedStartInfo, + (sender, line) => standardOutputHandler(sender.Id, line), + (sender, line) => standardErrorHandler(sender.Id, line)); + } +} diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.Unix.cs b/src/Aspire.Cli/Processes/IsolatedProcess.Unix.cs new file mode 100644 index 00000000000..47e700b9ceb --- /dev/null +++ b/src/Aspire.Cli/Processes/IsolatedProcess.Unix.cs @@ -0,0 +1,74 @@ +// 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.Text; + +namespace Aspire.Cli.Processes; + +internal sealed partial class IsolatedProcess +{ + /// + /// Unix implementation — a thin wrapper. + /// SIGTERM / process groups handle cooperative shutdown on Unix, so we do not need + /// the new-console gymnastics the Windows partial uses. + /// is ignored on Unix (Unix process-group reparenting + signal delivery cover the + /// crash-time case that JobHandle exists to address on Windows). + /// + private static IsolatedProcess StartUnix( + IsolatedProcessStartInfo startInfo, + Action standardOutputHandler, + Action standardErrorHandler) + { + var psi = new ProcessStartInfo + { + FileName = startInfo.FileName, + WorkingDirectory = startInfo.WorkingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = false, + // Pin encodings so the new pump matches the existing ProcessGuestLauncher behavior + // regardless of the ambient Console.OutputEncoding (e.g. on container hosts that + // leave it set to ASCII). + StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false), + StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false), + }; + + foreach (var arg in startInfo.ArgumentList) + { + psi.ArgumentList.Add(arg); + } + + // Only mutate the ProcessStartInfo env block when the caller actually touched + // IsolatedProcessStartInfo.Environment. Otherwise leave ProcessStartInfo to inherit + // the parent's env verbatim — saves a snapshot-and-copy round trip for the common + // case where nothing was customized. + if (startInfo.HasCustomEnvironment) + { + psi.Environment.Clear(); + foreach (var (key, value) in startInfo.Environment) + { + // Match ProcessStartInfo.Environment semantics: a null value means "do not + // set this variable in the child" — we get there by simply not adding it. + if (value is not null) + { + psi.Environment[key] = value; + } + } + } + + var process = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start interactive child process: {startInfo.FileName}"); + + return WrapStartedProcess( + startInfo, + process, + process.StandardOutput, + process.StandardError, + standardOutputHandler, + standardErrorHandler, + extraDispose: null); + } +} diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs b/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs new file mode 100644 index 00000000000..2704942d774 --- /dev/null +++ b/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Diagnostics; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +namespace Aspire.Cli.Processes; + +internal sealed partial class IsolatedProcess +{ + /// + /// Windows implementation. Opens NUL for stdin and anonymous pipes for stdout/stderr, + /// then delegates to for + /// the actual CreateProcessW ceremony. When + /// is supplied, the spawn primitive does the suspended-create / assign / resume dance so + /// the child cannot escape the CLI's kill-on-close job between spawn and assignment. + /// + /// + /// Unlike , this launcher consumes the child's + /// stdout/stderr line-by-line via anonymous pipes. stdin is wired to NUL because we + /// don't supply input to the interactive child, but Windows still requires a valid + /// handle when STARTF_USESTDHANDLES is set and the other two stdio handles are real + /// pipes — passing IntPtr.Zero in that combination leaves child stdin referencing + /// whatever default the loader picks, which has tripped up some test runners in the + /// past. + /// + [SupportedOSPlatform("windows")] + private static IsolatedProcess StartWindows( + IsolatedProcessStartInfo startInfo, + Action standardOutputHandler, + Action standardErrorHandler) + { + var nulStdinHandle = WindowsProcessInterop.CreateFileW( + "NUL", + WindowsProcessInterop.GenericRead, + WindowsProcessInterop.FileShareRead, + nint.Zero, + WindowsProcessInterop.OpenExisting, + 0, + nint.Zero); + + if (nulStdinHandle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to open NUL device for stdin"); + } + + AnonymousPipeServerStream? stdoutPipe = null; + AnonymousPipeServerStream? stderrPipe = null; + + try + { + if (!WindowsProcessInterop.SetHandleInformation(nulStdinHandle, WindowsProcessInterop.HandleFlagInherit, WindowsProcessInterop.HandleFlagInherit)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to set NUL stdin handle inheritance"); + } + + // PipeDirection.In = server reads, client writes. Inheritable is REQUIRED: + // PROC_THREAD_ATTRIBUTE_HANDLE_LIST restricts WHICH handles get inherited but does + // NOT promote non-inheritable handles to inheritable ones. Without this flag the + // child would see ERROR_INVALID_HANDLE on its stdout/stderr writes. + stdoutPipe = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable); + stderrPipe = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable); + + var stdio = new WindowsProcessInterop.StdioHandles( + Stdin: nulStdinHandle.DangerousGetHandle(), + Stdout: stdoutPipe.ClientSafePipeHandle.DangerousGetHandle(), + Stderr: stderrPipe.ClientSafePipeHandle.DangerousGetHandle()); + + // Pass the caller's environment (if touched) verbatim to the spawn primitive; + // null = inherit parent env block. + var environment = startInfo.GetEnvironmentForSpawn(); + + var pi = WindowsProcessInterop.SpawnConsoleIsolatedProcess( + startInfo.FileName, + startInfo.ArgumentList, + startInfo.WorkingDirectory, + stdio, + environment, + startInfo.JobHandle); + + // CreateProcess succeeded; from here, any failure must terminate the just-created + // child instead of letting it run orphaned. Drop the parent-side copy of the + // client write ends so EOF reaches the StreamReader pumps when the child closes + // its handle on exit — without this, the pump would never see EOF and disposal + // would always have to wait for the drain timeout. + try + { + stdoutPipe.DisposeLocalCopyOfClientHandle(); + stderrPipe.DisposeLocalCopyOfClientHandle(); + + // Stdin NUL is no longer needed in the parent — only the child needs it. + // Releasing here avoids a fd leak per spawned child. + nulStdinHandle.Dispose(); + + // Process.GetProcessById can race a sub-millisecond-exit child: pi.hProcess + // is the only thing keeping the OS process object alive at this moment. + // Hold it open until after GetProcessById returns so a recycled PID can't + // redirect us to a different process. + var process = Process.GetProcessById(pi.dwProcessId); + + // Managed Process now owns its own handle to the child — release the raw + // CreateProcess handles. + WindowsProcessInterop.CloseHandle(pi.hThread); + WindowsProcessInterop.CloseHandle(pi.hProcess); + + // UTF-8 with non-throwing fallback — a stray OEM-encoded byte from a tsx + // warning shouldn't kill the pump. Mojibake is the documented tradeoff. + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false); + var stdoutReader = new StreamReader(stdoutPipe, encoding, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true); + var stderrReader = new StreamReader(stderrPipe, encoding, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true); + + // Capture these for the extraDispose closure below — the shared + // WrapStartedProcess helper owns the Process and the drain orchestration, + // but the pipe/reader resources are launcher-local and must be torn down + // after the pumps finish. + var capturedStdoutReader = stdoutReader; + var capturedStderrReader = stderrReader; + var capturedStdoutPipe = stdoutPipe; + var capturedStderrPipe = stderrPipe; + + ValueTask ExtraDispose() + { + try { capturedStdoutReader.Dispose(); } catch { } + try { capturedStderrReader.Dispose(); } catch { } + try { capturedStdoutPipe.Dispose(); } catch { } + try { capturedStderrPipe.Dispose(); } catch { } + return ValueTask.CompletedTask; + } + + // From here, pipe/reader ownership has transferred to ExtraDispose; clear + // the local variables so the catch{} cleanup below doesn't double-dispose. + stdoutPipe = null; + stderrPipe = null; + + return WrapStartedProcess( + startInfo, + process, + stdoutReader, + stderrReader, + standardOutputHandler, + standardErrorHandler, + ExtraDispose); + } + catch + { + // Anything between CreateProcess returning and the wrapper being handed off + // failed — terminate the just-started child so we don't orphan it. + try { WindowsProcessInterop.TerminateProcess(pi.hProcess, 1); } catch { } + try { WindowsProcessInterop.CloseHandle(pi.hThread); } catch { } + try { WindowsProcessInterop.CloseHandle(pi.hProcess); } catch { } + throw; + } + } + catch + { + stdoutPipe?.Dispose(); + stderrPipe?.Dispose(); + // nulStdinHandle disposal: if we got past SetHandleInformation it's still alive; + // if SetHandleInformation threw it's already failed. Dispose either way — it's + // idempotent. (When success path reaches the inner try, it sets the local to + // null-equivalent by calling Dispose() inline; the SafeFileHandle still tracks + // disposed state so a second Dispose call is a no-op.) + nulStdinHandle.Dispose(); + throw; + } + } +} diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.cs b/src/Aspire.Cli/Processes/IsolatedProcess.cs new file mode 100644 index 00000000000..6e1f24aadfb --- /dev/null +++ b/src/Aspire.Cli/Processes/IsolatedProcess.cs @@ -0,0 +1,346 @@ +// 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.ObjectModel; +using System.Diagnostics; +using Microsoft.Win32.SafeHandles; + +namespace Aspire.Cli.Processes; + +/// +/// Mirrors . Differences from the BCL shape: +/// stdout/stderr are always redirected (so there is no RedirectStandardOutput +/// flag — see ), and adds +/// the Windows-only kill-on-close safety net described in +/// . +/// +internal sealed class IsolatedProcessStartInfo +{ + private Dictionary? _environment; + + /// Required. Executable path or PATH-resolved name. + public required string FileName { get; init; } + + /// Required. Working directory for the child. + public required string WorkingDirectory { get; init; } + + /// + /// The argument list. Mirrors — each entry + /// is one logical argument, and the launcher handles per-OS quoting. + /// + public Collection ArgumentList { get; } = new(); + + /// + /// Environment variables for the child. Lazily pre-populated with the current process's + /// environment on first access — mirrors . + /// Add an entry to overlay, set an entry's value to to remove it + /// from the inherited block. Untouched entries are inherited verbatim. + /// + public IDictionary Environment => _environment ??= LoadParentEnvironment(); + + /// + /// Windows-only crash-time safety net. When set, the spawned child is atomically + /// assigned to this job object via the suspended-create / assign / resume dance in + /// . Set to + /// from the DI singleton on Windows + /// hosts; on non-Windows hosts (Unix process-group semantics + /// cover the equivalent case). + /// + public SafeFileHandle? JobHandle { get; init; } + + /// + /// Returns true when the caller has read or modified . The + /// spawn paths use this to decide whether to inherit the parent env verbatim + /// (no-touch) or build a fresh custom env block from the dictionary. + /// + internal bool HasCustomEnvironment => _environment is not null; + + /// + /// Internal accessor returning the environment dictionary in the read-only shape the + /// Windows spawn primitive consumes. Returns when the caller never + /// touched , signalling the spawn path to inherit the parent env + /// verbatim (no allocation of an env block). + /// + internal IReadOnlyDictionary? GetEnvironmentForSpawn() + => _environment; + + private static Dictionary LoadParentEnvironment() + { + // Snapshot the parent env on first access. ProcessStartInfo.Environment has the + // same semantics — touching the property materializes the inherited block so the + // caller can mutate it freely without affecting the parent process. + var parent = System.Environment.GetEnvironmentVariables(); + // OrdinalIgnoreCase mirrors ProcessStartInfo's behavior on Windows (env vars are + // case-insensitive). Using it on all platforms is slightly less strict than the + // Unix kernel (which treats env names as bytes) but it matches what ProcessStartInfo + // does and prevents the trap of accidentally having both "Path" and "PATH" entries. + var dict = new Dictionary(parent.Count, StringComparer.OrdinalIgnoreCase); + foreach (System.Collections.DictionaryEntry entry in parent) + { + dict[(string)entry.Key] = entry.Value as string; + } + return dict; + } +} + +/// +/// Mirrors for a child spawned by . +/// On Windows the child gets its own hidden console (CREATE_NEW_CONSOLE | SW_HIDE) so DCP's +/// stop-process-tree can AttachConsole + post CTRL_C_EVENT at it without +/// also signalling the CLI; on Unix it's a thin +/// wrapper because SIGTERM via the process group is enough. +/// +/// +/// Differences from the BCL shape worth knowing about: +/// +/// Stdout/stderr handlers are required at time — no +/// OutputDataReceived event you can forget to subscribe, no BeginOutputReadLine +/// to forget to call. Handlers receive (sender, line), mirroring +/// . +/// / are separate +/// from so callers can wait for the pipes to fully drain after +/// the child exits — can return with data still queued. +/// +/// +internal sealed partial class IsolatedProcess : IAsyncDisposable +{ + private readonly Func _disposeAsync; + private int _disposed; + + private IsolatedProcess( + Process process, + string fileName, + IReadOnlyList arguments, + Task standardOutputClosed, + Task standardErrorClosed, + Func disposeAsync) + { + Process = process; + Id = process.Id; + FileName = fileName; + Arguments = arguments; + StandardOutputClosed = standardOutputClosed; + StandardErrorClosed = standardErrorClosed; + _disposeAsync = disposeAsync; + } + + /// The underlying for escape-hatch scenarios. + public Process Process { get; } + + /// + /// The child's process id. Captured eagerly at spawn time because + /// throws once the underlying handle is closed, which can race a fast-exiting child. + /// + public int Id { get; } + + /// + /// The original executable path. on a + /// obtained from + /// is empty, so callers (telemetry, error messages) read it from here. + /// + public string FileName { get; } + + /// The original argument list. Same rationale as . + public IReadOnlyList Arguments { get; } + + /// Mirrors . + public bool HasExited => Process.HasExited; + + /// Mirrors . + public int ExitCode => Process.ExitCode; + + /// + /// Completes when the stdout pump finishes (pipe EOF — i.e. child closed the stream + /// or exited). Faults if any stdout handler threw at any point during draining; the + /// pump still drains to EOF before surfacing the fault so a hostile handler cannot + /// back-pressure the child via a full pipe. + /// + public Task StandardOutputClosed { get; } + + /// Stderr counterpart of . + public Task StandardErrorClosed { get; } + + /// Mirrors . + public Task WaitForExitAsync(CancellationToken cancellationToken = default) + => Process.WaitForExitAsync(cancellationToken); + + /// + /// Mirrors . Defaults to + /// because every consumer of this type needs tree-kill semantics + /// (graceful-shutdown failures escalate to tree kill). + /// + public void Kill(bool entireProcessTree = true) => Process.Kill(entireProcessTree); + + /// + public ValueTask DisposeAsync() + { + // The caller is expected to have terminated the process by now, but we guard + // against double-dispose anyway because this object can land in `using` blocks + // and explicit cleanup paths at the same time during error recovery. + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return ValueTask.CompletedTask; + } + + return _disposeAsync(TimeSpan.FromSeconds(5)); + } + + /// + /// Mirrors . Spawns the child, wires the + /// handlers, and starts the stdout/stderr pumps before returning. Throws if the child + /// fails to spawn. + /// + /// Process launch parameters. + /// + /// Invoked once per line read from the child's stdout on a background pump. A throw + /// is captured and surfaced via ; the pump keeps + /// draining so the child cannot back-pressure on a full pipe. + /// + /// Stderr counterpart of . + public static IsolatedProcess Start( + IsolatedProcessStartInfo startInfo, + Action standardOutputHandler, + Action standardErrorHandler) + { + ArgumentNullException.ThrowIfNull(startInfo); + ArgumentNullException.ThrowIfNull(standardOutputHandler); + ArgumentNullException.ThrowIfNull(standardErrorHandler); + + if (OperatingSystem.IsWindows()) + { + return StartWindows(startInfo, standardOutputHandler, standardErrorHandler); + } + + return StartUnix(startInfo, standardOutputHandler, standardErrorHandler); + } + + /// + /// Shared post-spawn wiring: build the wrapper, start + /// the pumps with the wrapper bound as the handler "sender", and bridge pump + /// completion to the wrapper's / + /// tasks. + /// + /// + /// The wrapper must exist before the pumps start so the handlers can receive it as + /// their "sender" parameter. The pumps must produce real Task completions before + /// the wrapper exposes them on its + /// surface. We resolve the chicken-and-egg with a pair of + /// instances: the wrapper holds tcs.Task; pump completion drives the TCS via + /// . No assignment race exists because the TCS task + /// is fully constructed before the pumps start reading. + /// + /// The original start info — used for snapshotting FileName/Arguments onto the wrapper. + /// The already-started underlying . + /// Reader fed from the child's stdout pipe. + /// Reader fed from the child's stderr pipe. + /// Per-line callback for stdout; receives the wrapper as sender. + /// Per-line callback for stderr; receives the wrapper as sender. + /// + /// Optional extra cleanup to run as part of after the + /// pump-drain window expires but before the wrapped is disposed. + /// The Windows path uses this slot to dispose the anonymous pipes and the NUL stdin + /// handle that are owned by the spawn path, not by the Process. + /// + private static IsolatedProcess WrapStartedProcess( + IsolatedProcessStartInfo startInfo, + Process process, + TextReader standardOutput, + TextReader standardError, + Action standardOutputHandler, + Action standardErrorHandler, + Func? extraDispose) + { + // Snapshot identity off startInfo now — the caller may mutate the startInfo after + // we return and we don't want the wrapper to observe those changes. + var argumentsSnapshot = startInfo.ArgumentList.ToArray(); + + var outputTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + ProcessPump? outputPump = null; + ProcessPump? errorPump = null; + + async ValueTask DisposeAsync(TimeSpan drainTimeout) + { + // Give the pumps a bounded window to finish. They normally complete when the + // child exits and the pipes hit EOF. If a caller disposes before the child + // exits, the readers stay blocked until the OS tears the pipes down (Process + // disposal on Unix, pipe disposal on Windows) — the timeout keeps us from + // hanging on bugs. + using var timeoutCts = new CancellationTokenSource(drainTimeout); + try + { + if (outputPump is not null && errorPump is not null) + { + await Task.WhenAll(outputPump.Completion, errorPump.Completion).WaitAsync(timeoutCts.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Drain timed out — fall through to extraDispose so it unblocks the pumps + // by tearing the pipes down (Windows path). The TCSs are completed below + // via the bridge once the pumps actually return. + } + catch + { + // Pump faults surface via StandardOutputClosed / StandardErrorClosed; swallow + // here so dispose still tears the rest down cleanly. + } + + if (extraDispose is not null) + { + try { await extraDispose().ConfigureAwait(false); } catch { } + } + + try + { + process.Dispose(); + } + catch + { + // Best effort. + } + } + + var isolated = new IsolatedProcess( + process, + startInfo.FileName, + argumentsSnapshot, + standardOutputClosed: outputTcs.Task, + standardErrorClosed: errorTcs.Task, + DisposeAsync); + + // The pumps capture 'isolated' as the handler's "sender". The assignment is fully + // visible to the pump's Task.Run worker by happens-before semantics. + outputPump = ProcessPump.Start(standardOutput, line => standardOutputHandler(isolated, line)); + errorPump = ProcessPump.Start(standardError, line => standardErrorHandler(isolated, line)); + + _ = ForwardPumpAsync(outputPump.Completion, outputTcs); + _ = ForwardPumpAsync(errorPump.Completion, errorTcs); + + return isolated; + } + + /// + /// Bridges a task into a + /// the wrapper exposes. Preserves fault/cancel + /// semantics so consumers of see the same outcome + /// the pump produced. + /// + private static async Task ForwardPumpAsync(Task pumpCompletion, TaskCompletionSource tcs) + { + try + { + await pumpCompletion.ConfigureAwait(false); + tcs.TrySetResult(); + } + catch (OperationCanceledException oce) + { + tcs.TrySetCanceled(oce.CancellationToken); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + } +} diff --git a/src/Aspire.Cli/Processes/ProcessLifetimeAdapter.cs b/src/Aspire.Cli/Processes/ProcessLifetimeAdapter.cs new file mode 100644 index 00000000000..846e30dc590 --- /dev/null +++ b/src/Aspire.Cli/Processes/ProcessLifetimeAdapter.cs @@ -0,0 +1,38 @@ +// 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; + +namespace Aspire.Cli.Processes; + +/// +/// Tiny adapter that wraps a in an so callers +/// of both isolated and non-isolated spawn paths can hold a uniformly non-null disposable +/// lifetime handle and have a single disposal site. Isolated spawns return the +/// wrapper directly (which already implements +/// and owns extra resources like anonymous pipes); non-isolated +/// spawns wrap the bare through this adapter. +/// +internal static class ProcessLifetimeAdapter +{ + public static IAsyncDisposable ForProcess(Process process) => new ProcessOnlyLifetime(process); + + private sealed class ProcessOnlyLifetime(Process process) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + try + { + process.Dispose(); + } + catch + { + // Best-effort: disposal may race a concurrent kill / handle close. The caller + // already awaited the shutdown ladder, so by here the process is either exited + // or being killed; throwing from a disposal path is never useful. + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/src/Aspire.Cli/Processes/ProcessPump.cs b/src/Aspire.Cli/Processes/ProcessPump.cs new file mode 100644 index 00000000000..7e92e4cd0cc --- /dev/null +++ b/src/Aspire.Cli/Processes/ProcessPump.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Processes; + +/// +/// Shared line-pump used by both Unix and Windows variants of +/// . A pump reads complete lines from a +/// and dispatches them to a callback, completing when the +/// reader hits EOF. +/// +/// +/// Callback exceptions do NOT terminate the drain — the pump continues reading until +/// EOF so that a verbose child cannot back-pressure into a full pipe and block on +/// every subsequent write. The first exception is recorded and surfaced via the +/// returned task after the pump finishes draining. +/// +internal sealed class ProcessPump +{ + private ProcessPump(Task completion) + { + Completion = completion; + } + + /// Completes (or faults) when the underlying reader hits EOF. + public Task Completion { get; } + + /// + /// Starts a pump that reads lines from and invokes + /// for each non-null line. The pump runs on a background + /// task and stops when the reader returns null (EOF) or throws. + /// + public static ProcessPump Start(TextReader reader, Action onLine) + { + var completion = Task.Run(() => RunAsync(reader, onLine)); + return new ProcessPump(completion); + } + + private static async Task RunAsync(TextReader reader, Action onLine) + { + Exception? firstCallbackException = null; + + while (true) + { + string? line; + try + { + line = await reader.ReadLineAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // The reader's underlying stream was torn down (typically because the + // child process was disposed while a read was in flight). Treat as EOF + // and let the pump exit cleanly — surfacing a previously-recorded + // callback exception at this point would just confuse error attribution. + return; + } + catch (IOException) + { + // The pipe was broken — Windows surfaces "pipe is broken" / Unix surfaces + // EBADF when the underlying handle is reaped during a read. Treat as EOF. + return; + } + + if (line is null) + { + break; + } + + try + { + onLine(line); + } + catch (Exception ex) when (firstCallbackException is null) + { + // Record but keep draining — the pipe MUST be drained so the child can + // continue to write without blocking. The recorded exception is + // re-thrown after EOF so callers can observe it via Completion. + firstCallbackException = ex; + } + catch + { + // Subsequent callback failures are dropped; the first one is enough + // signal for callers. + } + } + + if (firstCallbackException is not null) + { + // Preserve the original stack trace when faulting the pump task. + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(firstCallbackException).Throw(); + } + } +} diff --git a/src/Aspire.Cli/Processes/ProcessTerminator.cs b/src/Aspire.Cli/Processes/ProcessTerminator.cs new file mode 100644 index 00000000000..165a836fabb --- /dev/null +++ b/src/Aspire.Cli/Processes/ProcessTerminator.cs @@ -0,0 +1,116 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Processes; + +/// +/// Provides child process shutdown primitives shared by CLI process owners. +/// +internal static class ProcessTerminator +{ + public static async Task ShutdownAsync( + Process process, + bool requestGracefulShutdown, + bool entireProcessTree, + ILogger logger, + string processDescription, + CancellationToken gracefulShutdownCancellationToken) + { + try + { + if (process.HasExited) + { + logger.LogDebug("{ProcessDescription} process {ProcessId} already exited.", processDescription, process.Id); + return true; + } + + if (requestGracefulShutdown) + { + logger.LogDebug("Requesting graceful shutdown of {ProcessDescription} process {ProcessId}.", processDescription, process.Id); + ProcessSignaler.RequestGracefulShutdown(process.Id, expectedStartTime: null, logger); + + try + { + await process.WaitForExitAsync(gracefulShutdownCancellationToken).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) + { + if (process.HasExited) + { + logger.LogDebug("{ProcessDescription} process {ProcessId} exited while graceful shutdown was being cancelled.", processDescription, process.Id); + return true; + } + } + + logger.LogWarning( + "{ProcessDescription} process {ProcessId} did not stop gracefully before cancellation. Forcing process to terminate.", + processDescription, + process.Id); + } + + if (process.HasExited) + { + return true; + } + + logger.LogDebug( + "Sending kill to {ProcessDescription} process {ProcessId} (entireProcessTree={EntireProcessTree}).", + processDescription, + process.Id, + entireProcessTree); + + process.Kill(entireProcessTree); + return false; + } + catch (InvalidOperationException ex) + { + logger.LogDebug( + ex, + "{ProcessDescription} process exited before termination could complete (entireProcessTree={EntireProcessTree}).", + processDescription, + entireProcessTree); + return true; + } + catch (Exception ex) + { + logger.LogDebug( + ex, + "Failed to terminate {ProcessDescription} process (entireProcessTree={EntireProcessTree}).", + processDescription, + entireProcessTree); + return false; + } + } + + public static async Task ShutdownAsync( + int pid, + DateTimeOffset? expectedStartTime, + bool requestGracefulShutdown, + bool entireProcessTree, + ILogger logger, + string processDescription, + CancellationToken gracefulShutdownCancellationToken) + { + // Resolve the pid to a live Process handle here so the inner overload can stay agnostic of + // how it was obtained. The `using` must extend across the await — otherwise the Process is + // disposed while the inner overload still depends on the handle (e.g. for WaitForExitAsync + // on the graceful path). Hence the explicit async + await, not a Task-returning passthrough. + using var process = ProcessSignaler.TryGetRunningProcess(pid, expectedStartTime, logger); + if (process is null) + { + return true; + } + + return await ShutdownAsync( + process, + requestGracefulShutdown, + entireProcessTree, + logger, + processDescription, + gracefulShutdownCancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Aspire.Cli/Processes/WindowsConsoleProcessJob.cs b/src/Aspire.Cli/Processes/WindowsConsoleProcessJob.cs new file mode 100644 index 00000000000..c6ab4df422a --- /dev/null +++ b/src/Aspire.Cli/Processes/WindowsConsoleProcessJob.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32.SafeHandles; + +namespace Aspire.Cli.Processes; + +/// +/// Owns a Windows job object that is used as the crash-time safety net for interactive +/// children spawned by . The job is created +/// once per CLI process, registered as a singleton, and held for the CLI's entire lifetime. +/// +/// +/// +/// The job is configured with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so that when the +/// last handle to the job is released — which happens automatically when the parent CLI +/// process exits, even via SIGKILL / power loss — the OS kernel terminates every process +/// currently assigned to the job. This is the only reliable way to prevent orphaned guest +/// AppHosts (which live in their own console group via CREATE_NEW_CONSOLE) from +/// surviving a parent crash on Windows. The Unix equivalent (process-group reparenting +/// to init) provides no equivalent kill behavior, but on Unix the parent's normal +/// SIGINT/SIGTERM signalling reaches the whole process group, so no safety net is needed. +/// +/// +/// The job is also configured with JOB_OBJECT_LIMIT_BREAKAWAY_OK so that DCP (and +/// anything else that needs to outlive the CLI for its own cleanup) can opt out by spawning +/// itself with CREATE_BREAKAWAY_FROM_JOB. Our job only grants permission to break +/// away — the breakaway itself is the responsibility of the spawning code. +/// +/// +/// IMPORTANT: do NOT dispose this service during the normal shutdown ladder. The graceful +/// shutdown path is responsible for cooperatively terminating its children; closing the +/// job handle while they are still alive would convert clean shutdown into a hard kill. +/// Let the OS close the handle on process exit; the kill-on-close behavior is exactly the +/// crash-safety net we want and is harmless when the children have already exited. +/// +/// +[SupportedOSPlatform("windows")] +internal sealed class WindowsConsoleProcessJob : IDisposable +{ + private readonly SafeFileHandle _jobHandle; + private int _disposed; + + public WindowsConsoleProcessJob() + { + _jobHandle = WindowsProcessInterop.CreateJobObjectW(nint.Zero, null); + if (_jobHandle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create CLI console-isolation job object"); + } + + try + { + // BREAKAWAY_OK is required so DCP can fork itself with CREATE_BREAKAWAY_FROM_JOB + // and survive the CLI exiting; KILL_ON_JOB_CLOSE catches everything else. + var info = new WindowsProcessInterop.JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + BasicLimitInformation = + { + LimitFlags = WindowsProcessInterop.JobObjectLimitKillOnJobClose + | WindowsProcessInterop.JobObjectLimitBreakawayOk, + }, + }; + + var infoSize = Marshal.SizeOf(); + var infoPtr = Marshal.AllocHGlobal(infoSize); + try + { + Marshal.StructureToPtr(info, infoPtr, fDeleteOld: false); + + if (!WindowsProcessInterop.SetInformationJobObject( + _jobHandle, + WindowsProcessInterop.JobObjectInfoClass.ExtendedLimitInformation, + infoPtr, + (uint)infoSize)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to configure CLI console-isolation job object limits"); + } + } + finally + { + Marshal.FreeHGlobal(infoPtr); + } + } + catch + { + // Constructor failure must not leave the partial job alive; closing the handle + // would also fire KILL_ON_JOB_CLOSE harmlessly (zero processes assigned yet) but + // mainly we just don't want to leak a kernel object. + _jobHandle.Dispose(); + throw; + } + } + + /// + /// The job handle. Pass directly to + /// so the spawn primitive can do the suspended-create / assign / resume dance atomically. + /// + public SafeFileHandle Handle => _jobHandle; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + // Closing the job's last handle fires KILL_ON_JOB_CLOSE on any still-assigned processes. + // This is intentional as a final safety net for tests and abnormal shutdown — production + // disposal happens at process exit via the OS, so any process that actually reaches this + // call is either a test fixture tearing down or a misbehaving consumer; either way killing + // the stragglers is the correct outcome. + _jobHandle.Dispose(); + } +} diff --git a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs new file mode 100644 index 00000000000..8eaa458be07 --- /dev/null +++ b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs @@ -0,0 +1,594 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace Aspire.Cli.Processes; + +/// +/// Helpers and Win32 interop declarations shared by Windows process launchers that hand off +/// raw command lines, environment blocks, and STARTUPINFO structures to CreateProcessW. +/// Both and +/// open the same console-isolation flags, attribute-list shape, and stdio-handle plumbing, so +/// the constants, structs, and P/Invoke declarations live here to prevent the two callers from +/// silently drifting apart on something like "this one accidentally lacks +/// CREATE_UNICODE_ENVIRONMENT" or "these struct layouts diverged after a Win32 SDK update". +/// +internal static partial class WindowsProcessInterop +{ + // === Constants === + // See https://learn.microsoft.com/windows/win32/api/fileapi/nf-fileapi-createfilew for the + // dwDesiredAccess / dwShareMode / dwCreationDisposition flag values, and + // https://learn.microsoft.com/windows/win32/procthread/process-creation-flags for the + // creation flags consumed by CreateProcessW. + + public const uint GenericRead = 0x80000000; + public const uint GenericWrite = 0x40000000; + public const uint FileShareRead = 0x00000001; + public const uint FileShareWrite = 0x00000002; + public const uint OpenExisting = 3; + + public const uint HandleFlagInherit = 0x00000001; + + public const uint StartfUseStdHandles = 0x00000100; + public const uint StartfUseShowWindow = 0x00000001; + + public const uint CreateUnicodeEnvironment = 0x00000400; + public const uint ExtendedStartupInfoPresent = 0x00080000; + public const uint CreateNewConsole = 0x00000010; + + /// + /// Composite creation flags shared by every CLI process launcher that spawns into its own + /// hidden console group: CREATE_UNICODE_ENVIRONMENT (we always build a Unicode env block + /// ourselves) | EXTENDED_STARTUPINFO_PRESENT (we always pass STARTUPINFOEX with an attribute + /// list) | CREATE_NEW_CONSOLE (the entire point — detach from the parent's console). + /// Centralizing the composite prevents the two launchers from drifting (e.g. one path + /// accidentally dropping CREATE_UNICODE_ENVIRONMENT and silently truncating non-ASCII env + /// values). + /// + public const uint NewConsoleCreationFlags = + CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNewConsole; + + public const ushort ShowWindowHide = 0x0000; + + // PROC_THREAD_ATTRIBUTE_HANDLE_LIST — see + // https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute + public static readonly nint ProcThreadAttributeHandleList = (nint)0x00020002; + + // === Structs === + + /// + /// STARTUPINFOEX — see + /// https://learn.microsoft.com/windows/win32/api/winbase/ns-winbase-startupinfoexw. + /// Layout-equivalent to STARTUPINFOW with a trailing PPROC_THREAD_ATTRIBUTE_LIST pointer. + /// We always use this variant (not plain STARTUPINFOW) because both launchers pass + /// EXTENDED_STARTUPINFO_PRESENT and PROC_THREAD_ATTRIBUTE_HANDLE_LIST. + /// + [StructLayout(LayoutKind.Sequential)] + public struct STARTUPINFOEX + { + public int cb; + public nint lpReserved; + public nint lpDesktop; + public nint lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public nint lpReserved2; + public nint hStdInput; + public nint hStdOutput; + public nint hStdError; + public nint lpAttributeList; + } + + /// + /// PROCESS_INFORMATION — see + /// https://learn.microsoft.com/windows/win32/api/processthreadsapi/ns-processthreadsapi-process_information. + /// + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public nint hProcess; + public nint hThread; + public int dwProcessId; + public int dwThreadId; + } + + // === P/Invoke declarations === + + [LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + public static partial SafeFileHandle CreateFileW( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + nint lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + nint hTemplateFile); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool SetHandleInformation( + SafeFileHandle hObject, + uint dwMask, + uint dwFlags); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool InitializeProcThreadAttributeList( + nint lpAttributeList, + int dwAttributeCount, + int dwFlags, + ref nint lpSize); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool UpdateProcThreadAttribute( + nint lpAttributeList, + uint dwFlags, + nint attribute, + nint lpValue, + nint cbSize, + nint lpPreviousValue, + nint lpReturnSize); + + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial void DeleteProcThreadAttributeList(nint lpAttributeList); + + // CreateProcessW must remain on DllImport (not LibraryImport): the source generator does + // not produce a marshaller for mutable StringBuilder command-line buffers, and Win32 + // requires lpCommandLine to point at writable memory. See + // https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw. + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] +#pragma warning disable CA1838 // CreateProcessW requires a mutable command line buffer + public static extern bool CreateProcessW( + string? lpApplicationName, + StringBuilder lpCommandLine, + nint lpProcessAttributes, + nint lpThreadAttributes, + bool bInheritHandles, + uint dwCreationFlags, + nint lpEnvironment, + string? lpCurrentDirectory, + ref STARTUPINFOEX lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); +#pragma warning restore CA1838 + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool CloseHandle(nint hObject); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool TerminateProcess(nint hProcess, uint uExitCode); + + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial uint ResumeThread(nint hThread); + + // Job-object APIs — see + // https://learn.microsoft.com/windows/win32/procthread/job-objects. We use a job to + // guarantee that interactive children (and their grandchildren) are killed when the CLI + // process exits (clean or crash), so an orphaned guest AppHost in its own console group + // can't survive a parent SIGKILL/segfault and leak. DCP is expected to use + // CREATE_BREAKAWAY_FROM_JOB to escape this job before the kill fires. + + [LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + public static partial SafeFileHandle CreateJobObjectW(nint lpJobAttributes, string? lpName); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool SetInformationJobObject( + SafeFileHandle hJob, + JobObjectInfoClass JobObjectInformationClass, + nint lpJobObjectInformation, + uint cbJobObjectInformationLength); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool AssignProcessToJobObject(SafeFileHandle hJob, nint hProcess); + + // === Job-object constants === + + public const uint CreateSuspended = 0x00000004; + + // https://learn.microsoft.com/windows/win32/api/winnt/ns-winnt-jobobject_basic_limit_information + public const uint JobObjectLimitBreakawayOk = 0x00000800; + public const uint JobObjectLimitKillOnJobClose = 0x00002000; + + /// + /// JOBOBJECTINFOCLASS values consumed by SetInformationJobObject. We currently use only + /// ; the rest are intentionally omitted until needed. + /// See https://learn.microsoft.com/windows/win32/api/winnt/ne-winnt-jobobjectinfoclass. + /// + public enum JobObjectInfoClass + { + ExtendedLimitInformation = 9, + } + + // === Job-object structs === + + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public nuint MinimumWorkingSetSize; + public nuint MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public nuint Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + public struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public nuint ProcessMemoryLimit; + public nuint JobMemoryLimit; + public nuint PeakProcessMemoryUsed; + public nuint PeakJobMemoryUsed; + } + + /// + /// Per-spawn stdio handle layout — the three handles that will be inherited by the child + /// via PROC_THREAD_ATTRIBUTE_HANDLE_LIST. A handle of + /// means "do not assign this slot" (Windows will treat it as no stdio for that fd) and + /// causes the slot to be skipped in the inheritance whitelist. + /// + public readonly record struct StdioHandles(nint Stdin, nint Stdout, nint Stderr); + + /// + /// Spawns a child process in its own hidden console group with exactly the stdio handles + /// in made inheritable through PROC_THREAD_ATTRIBUTE_HANDLE_LIST. + /// Used by both (NUL-only handles) and + /// (NUL stdin + anonymous pipes for stdout/stderr) + /// so the console-isolation ceremony lives in one place. + /// + /// Full path to the executable to launch. + /// Arguments to pass to the child. Quoted via . + /// Working directory for the child. + /// + /// Stdio handle slots. Each non-zero slot is wired to the corresponding child handle and + /// added to the inheritance whitelist; zero slots are skipped (e.g. detached children leave + /// Stdin as nint.Zero). + /// + /// + /// Optional complete environment for the child. When , the parent's + /// environment is inherited verbatim (no env block allocated). When non-null, the dictionary + /// supplies the entire child env block — entries with values are + /// omitted (matches semantics). Callers that want + /// to remove a subset of parent variables must materialize the parent env, apply their + /// removals/overlays, and pass the resulting dictionary here. + /// + /// + /// Optional kill-on-close job object. When supplied, the child is created suspended, + /// assigned to the job, then resumed — so there is no instruction-level window where the + /// child could spawn a grandchild that escapes the job. + /// passes because detached children must outlive the CLI; + /// passes the singleton CLI job so children + /// die with a parent crash. + /// + /// + /// The raw from CreateProcessW. The caller owns + /// hProcess and hThread and must close both once it has extracted whatever + /// it needs (e.g. obtained a managed handle via + /// ). + /// + [SupportedOSPlatform("windows")] + public static PROCESS_INFORMATION SpawnConsoleIsolatedProcess( + string fileName, + IReadOnlyList arguments, + string workingDirectory, + StdioHandles stdio, + IReadOnlyDictionary? environment, + SafeFileHandle? jobHandle) + { + // Build the handle whitelist from the non-zero stdio slots. We pass exactly the handles + // the child is supposed to inherit and nothing else — this is the entire point of + // PROC_THREAD_ATTRIBUTE_HANDLE_LIST: deny inheritance of every other inheritable handle + // open on any parent thread (DCP socket fds, pipe fds, etc.). + var inheritable = new List(3); + if (stdio.Stdin != nint.Zero) + { + inheritable.Add(stdio.Stdin); + } + if (stdio.Stdout != nint.Zero) + { + inheritable.Add(stdio.Stdout); + } + if (stdio.Stderr != nint.Zero) + { + inheritable.Add(stdio.Stderr); + } + + var attrListSize = nint.Zero; + InitializeProcThreadAttributeList(nint.Zero, 1, 0, ref attrListSize); + + var attrList = Marshal.AllocHGlobal(attrListSize); + try + { + if (!InitializeProcThreadAttributeList(attrList, 1, 0, ref attrListSize)) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize process thread attribute list"); + } + + try + { + var handles = inheritable.ToArray(); + var pinnedHandles = GCHandle.Alloc(handles, GCHandleType.Pinned); + try + { + if (!UpdateProcThreadAttribute( + attrList, + 0, + ProcThreadAttributeHandleList, + pinnedHandles.AddrOfPinnedObject(), + (nint)(nint.Size * handles.Length), + nint.Zero, + nint.Zero)) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Failed to update process thread attribute list"); + } + + var si = new STARTUPINFOEX + { + cb = Marshal.SizeOf(), + dwFlags = StartfUseStdHandles | StartfUseShowWindow, + hStdInput = stdio.Stdin, + hStdOutput = stdio.Stdout, + hStdError = stdio.Stderr, + lpAttributeList = attrList, + // CREATE_NO_WINDOW is ignored with CREATE_NEW_CONSOLE; SW_HIDE keeps + // the new console window off-screen. + wShowWindow = ShowWindowHide, + }; + + var commandLine = BuildCommandLine(fileName, arguments); + + // Only suspend when a job is involved: we need the child frozen between + // CreateProcess and AssignProcessToJobObject so it cannot fork-and-breakaway + // before we put the safety net under it. Without a job, the original + // behavior is preserved bit-for-bit (no suspend, no resume). + var flags = NewConsoleCreationFlags; + if (jobHandle is not null) + { + flags |= CreateSuspended; + } + + var envBlockHandle = nint.Zero; + try + { + if (environment is not null) + { + envBlockHandle = BuildEnvironmentBlock(environment); + } + + if (!CreateProcessW( + null, + commandLine, + nint.Zero, + nint.Zero, + bInheritHandles: true, + flags, + envBlockHandle, + workingDirectory, + ref si, + out var pi)) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), $"Failed to create process: {fileName}"); + } + + if (jobHandle is not null) + { + // Wrap the post-CreateProcess steps so any failure between here and + // ResumeThread kills the suspended child — otherwise we'd leak a + // process stuck in initial-suspend state. + try + { + if (!AssignProcessToJobObject(jobHandle, pi.hProcess)) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Failed to assign child process to job object"); + } + + // ResumeThread returns the previous suspend count, or 0xFFFFFFFF on failure. + if (ResumeThread(pi.hThread) == uint.MaxValue) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Failed to resume suspended child thread"); + } + } + catch + { + try { TerminateProcess(pi.hProcess, 1); } catch { } + try { CloseHandle(pi.hThread); } catch { } + try { CloseHandle(pi.hProcess); } catch { } + throw; + } + } + + return pi; + } + finally + { + if (envBlockHandle != nint.Zero) + { + Marshal.FreeHGlobal(envBlockHandle); + } + } + } + finally + { + pinnedHandles.Free(); + } + } + finally + { + DeleteProcThreadAttributeList(attrList); + } + } + finally + { + Marshal.FreeHGlobal(attrList); + } + } + + /// + /// Builds a Windows command line string with correct quoting rules. + /// Adapted from dotnet/runtime PasteArguments.AppendArgument. + /// + public static StringBuilder BuildCommandLine(string fileName, IReadOnlyList arguments) + { + var sb = new StringBuilder(); + + sb.Append('"').Append(fileName).Append('"'); + + foreach (var arg in arguments) + { + sb.Append(' '); + AppendArgument(sb, arg); + } + + return sb; + } + + /// + /// Appends a correctly-quoted argument to the command line. + /// Copied from dotnet/runtime src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs + /// + public static void AppendArgument(StringBuilder sb, string argument) + { + // Windows command-line parsing rules: + // - Backslash is normal except when followed by a quote + // - 2N backslashes + quote → N literal backslashes + unescaped quote + // - 2N+1 backslashes + quote → N literal backslashes + literal quote + if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) + { + sb.Append(argument); + return; + } + + sb.Append('"'); + var idx = 0; + while (idx < argument.Length) + { + var c = argument[idx++]; + if (c == '\\') + { + var numBackslash = 1; + while (idx < argument.Length && argument[idx] == '\\') + { + idx++; + numBackslash++; + } + + if (idx == argument.Length) + { + // Trailing backslashes before closing quote — must double them + sb.Append('\\', numBackslash * 2); + } + else if (argument[idx] == '"') + { + // Backslashes followed by quote — double them + escape the quote + sb.Append('\\', numBackslash * 2 + 1); + sb.Append('"'); + idx++; + } + else + { + // Backslashes not followed by quote — emit as-is + sb.Append('\\', numBackslash); + } + + continue; + } + + if (c == '"') + { + sb.Append('\\'); + sb.Append('"'); + continue; + } + + sb.Append(c); + } + + sb.Append('"'); + } + + /// + /// Builds a Unicode environment block for CreateProcessW from a fully-resolved + /// environment dictionary. The block is sorted by variable name (case-insensitive, + /// as required by Windows) and double-null-terminated. The caller must free the + /// returned pointer with Marshal.FreeHGlobal. + /// + /// + /// The complete environment for the child. Entries with a + /// value are omitted (mirrors semantics). + /// + [SupportedOSPlatform("windows")] + public static nint BuildEnvironmentBlock(IReadOnlyDictionary environment) + { + var envVars = new SortedDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in environment) + { + if (value is not null) + { + envVars[key] = value; + } + } + + // Build the double-null-terminated Unicode environment block: + // KEY1=VALUE1\0KEY2=VALUE2\0...\0\0 + var blockBuilder = new StringBuilder(); + foreach (var kvp in envVars) + { + blockBuilder.Append(kvp.Key); + blockBuilder.Append('='); + blockBuilder.Append(kvp.Value); + blockBuilder.Append('\0'); + } + + if (envVars.Count == 0) + { + blockBuilder.Append('\0'); + } + + blockBuilder.Append('\0'); + + var blockString = blockBuilder.ToString(); + var byteCount = Encoding.Unicode.GetByteCount(blockString); + var ptr = Marshal.AllocHGlobal(byteCount); + unsafe + { + fixed (char* pStr = blockString) + { + Encoding.Unicode.GetBytes(pStr, blockString.Length, (byte*)ptr, byteCount); + } + } + + return ptr; + } +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 4cb97e53d8c..9c675fd3f86 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -277,7 +277,7 @@ internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProv return (factory, fileLoggerProvider); } - internal static async Task BuildApplicationAsync(string[] args, CliStartupContext startupContext, Dictionary? configurationValues = null) + internal static async Task BuildApplicationAsync(string[] args, CliStartupContext startupContext, Dictionary? configurationValues = null, ConsoleCancellationManager? cancellationManager = null, GracefulShutdownService? gracefulShutdownService = null) { // Check for --non-interactive flag early var nonInteractive = args?.Any(a => a == CommonOptionNames.NonInteractive) ?? false; @@ -331,6 +331,20 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu // Register logging options so components can read the user's chosen log level builder.Services.AddSingleton(startupContext.LoggingOptions); + // Register CCM and the graceful shutdown service as instance singletons so the DI container + // does not take disposal ownership — Program.Main owns the lifetime via `using` statements. + // Tests that drive BuildApplicationAsync directly without passing them in still work because + // the registrations are gated on non-null arguments. + if (cancellationManager is not null) + { + builder.Services.AddSingleton(cancellationManager); + } + + if (gracefulShutdownService is not null) + { + builder.Services.AddSingleton(gracefulShutdownService); + } + // Configure OpenTelemetry tracing. TelemetryManager reads configuration and creates // separate TracerProvider instances: // - Azure Monitor provider with filtering (only exports activities with EXTERNAL_TELEMETRY=true) @@ -383,8 +397,21 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddTelemetryServices(); builder.Services.AddTransient(); builder.Services.AddSingleton(); + // Windows-only crash-time safety net for interactive children spawned by + // IsolatedProcess. Holding this as a CLI-lifetime singleton means the + // OS closes the job handle automatically on process exit, firing KILL_ON_JOB_CLOSE on + // any assigned children that haven't already exited (e.g. orphaned tsx after the CLI + // crashes). On non-Windows, process-group reparenting + ordinary signal delivery cover + // the same case, so no registration is needed. + if (OperatingSystem.IsWindows()) + { + builder.Services.AddSingleton(); + } builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); + // Forward the interface to the existing concrete service so consumers can depend on the + // abstraction (used by AppHostServerSession + GuestLaunchOptions in the aspire run path). + builder.Services.AddTransient(sp => sp.GetRequiredService()); // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed) builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService>())); @@ -488,9 +515,6 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu // Language discovery for polyglot support. builder.Services.AddSingleton(); - // AppHost server session factory for RPC communication. - builder.Services.AddSingleton(); - // AppHost project handlers. builder.Services.AddSingleton(); builder.Services.AddSingleton>(sp => @@ -783,8 +807,13 @@ public static async Task Main(string[] args) { // Setup handling of CTRL-C and SIGTERM as early as possible so that if // we get a signal anywhere that is not handled by Spectre Console - // already that we know to trigger cancellation. - using var cancellationManager = new ConsoleCancellationManager(processTerminationTimeout: TimeSpan.FromSeconds(5)); + // already that we know to trigger cancellation. The graceful service is + // constructed first and handed to CCM so the two share the same lifetime + // and CCM can drive timing from the signal handler. Both are registered + // as DI singletons below via AddSingleton(instance) so the container does + // not take disposal ownership. + using var gracefulShutdownService = new GracefulShutdownService(); + using var cancellationManager = new ConsoleCancellationManager(gracefulShutdownService, finalDrainBudget: TimeSpan.FromSeconds(5)); Console.OutputEncoding = Encoding.UTF8; @@ -815,7 +844,7 @@ public static async Task Main(string[] args) IHost? app = null; try { - app = await BuildApplicationAsync(args, startupContext); + app = await BuildApplicationAsync(args, startupContext, cancellationManager: cancellationManager, gracefulShutdownService: gracefulShutdownService); await app.StartAsync().ConfigureAwait(false); } catch (Exception ex) @@ -923,6 +952,15 @@ public static async Task Main(string[] args) // Log exit code for debugging logger.LogInformation("Exit code: {ExitCode}", exitCode); } + catch (OperationCanceledException) + { + // When the command observed cancellation and propagated OCE rather than returning + // a normal exit code, surface the code declared by whichever caller initiated + // shutdown — Ctrl+C (SIGINT/SIGTERM code) or an internal RequestShutdown(failureCode). + // Fall back to the generic Cancelled code only when nothing was declared. + exitCode = cancellationManager.RequestedExitCode ?? CliExitCodes.Cancelled; + logger.LogInformation("Command cancelled. Exit code: {ExitCode}", exitCode); + } catch (Exception ex) { // Should never get here because RootCommand's handler should catch all exceptions, but log just in case. diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 654212b2985..3c06eb94c3d 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using Aspire.Cli.Configuration; +using Aspire.Cli.Processes; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Aspire.Hosting; @@ -11,237 +11,569 @@ namespace Aspire.Cli.Projects; /// -/// Implementation of that manages an AppHost server process. +/// Owns the lifetime of an AppHost server child process. Construction stashes configuration +/// (including the stop token) without launching anything; launches the +/// process, wires lifecycle observation, and returns a task that completes with the process exit +/// code. /// -internal sealed class AppHostServerSession : IAppHostServerSession +/// +/// The session is the only class that calls on its child. +/// Termination is requested either by cancelling the stopRequested token passed to the +/// constructor, or by calling . Both routes flow through the same +/// internal linked CTS, so there is exactly one kill site (the registered callback). Callers +/// should not reach into to drive lifecycle. +/// +internal sealed class AppHostServerSession : IAsyncDisposable { - private readonly string _authenticationToken; + private const string ProcessDescription = "AppHost server"; + + private readonly IAppHostServerProject _project; + private readonly Dictionary? _callerEnvironmentVariables; + private readonly bool _debug; private readonly ILogger _logger; - private readonly Process _serverProcess; - private readonly OutputCollector _output; - private readonly string _socketPath; - private readonly ProfilingTelemetry.ActivityScope _activity; private readonly ProfilingTelemetry? _profilingTelemetry; - private readonly IDisposable? _projectLifetime; - private IAppHostRpcClient? _rpcClient; + private readonly string _authenticationToken; + private readonly CancellationTokenSource _stopCts; + private readonly CancellationToken _externalStopToken; + private readonly IProcessTreeGracefulShutdownSignaler? _gracefulShutdownSignaler; + private readonly GracefulShutdownService? _shutdownService; + private readonly bool _isolateConsole; + private readonly WindowsConsoleProcessJob? _consoleProcessJob; + + private readonly object _startGate = new(); + private bool _startInvoked; private bool _disposed; + private int _stopRequested; - internal AppHostServerSession( - Process serverProcess, - OutputCollector output, - string socketPath, - string authenticationToken, + private Process? _serverProcess; + private IAsyncDisposable? _processLifetime; + private string? _socketPath; + private OutputCollector? _output; + private TaskCompletionSource? _completion; + private ProfilingTelemetry.ActivityScope _activity; + private CancellationTokenRegistration _stopRegistration; + private IAppHostRpcClient? _rpcClient; + private Task? _shutdownTask; + + public AppHostServerSession( + IAppHostServerProject project, + Dictionary? environmentVariables, + bool debug, ILogger logger, - ProfilingTelemetry.ActivityScope activity = default, + CancellationToken stopRequested, ProfilingTelemetry? profilingTelemetry = null, - IDisposable? projectLifetime = null) + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler = null, + GracefulShutdownService? shutdownService = null, + bool isolateConsole = false, + WindowsConsoleProcessJob? consoleProcessJob = null) { - _serverProcess = serverProcess; - _output = output; - _socketPath = socketPath; - _authenticationToken = authenticationToken; - _logger = logger; - _activity = activity; + _project = project ?? throw new ArgumentNullException(nameof(project)); + _callerEnvironmentVariables = environmentVariables; + _debug = debug; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _profilingTelemetry = profilingTelemetry; - _projectLifetime = projectLifetime; + _authenticationToken = TokenGenerator.GenerateToken(); + _externalStopToken = stopRequested; + _gracefulShutdownSignaler = gracefulShutdownSignaler; + _shutdownService = shutdownService; + _isolateConsole = isolateConsole; + _consoleProcessJob = consoleProcessJob; + + // Fail fast on misconfigured isolation: on Windows the kill-on-close job is the safety + // net that ensures the AppHost server doesn't outlive a CLI crash as an orphan in its + // new console group. Without the job the new-console isolation is a downgrade, not a + // safety net. Mirrored in IsolatedConsoleSpawner as defense-in-depth. + if (isolateConsole && OperatingSystem.IsWindows() && consoleProcessJob is null) + { + throw new ArgumentNullException( + nameof(consoleProcessJob), + "consoleProcessJob is required when isolateConsole is true on Windows."); + } + + // Linked CTS so caller-initiated cancellation AND DisposeAsync both flow through the + // same stop trigger. The registered callback on _stopCts.Token (wired in StartAsync) is + // the single kill site for the process. OnStopRequested reads _externalStopToken to + // distinguish "caller-initiated stop" (run the graceful ladder) from "dispose-only stop" + // (force-kill immediately — graceful ladder would hang because nothing started the + // central GracefulShutdownService timer). + _stopCts = CancellationTokenSource.CreateLinkedTokenSource(stopRequested); } - /// - public string SocketPath => _socketPath; + /// + /// Gets the authentication token injected into the server environment. Available before + /// so callers can plumb it into the guest AppHost environment. + /// + public string AuthenticationToken => _authenticationToken; - /// - public Process ServerProcess => _serverProcess; + /// + /// Gets the RPC socket path, or if has not + /// been called (or threw before the process was published). + /// + public string? SocketPath => _socketPath; - /// - public OutputCollector Output => _output; + /// + /// Gets the output collector for the server's stdout/stderr, or if + /// has not been called (or threw before the process was published). + /// + public OutputCollector? Output => _output; /// - /// Gets the authentication token for the server session. + /// Gets the underlying server process for read-only observation by the backchannel polling + /// loop, or if has not been called (or threw + /// before the process was published). /// - public string AuthenticationToken => _authenticationToken; + /// + /// This is intentionally narrow: the only legitimate consumer is + /// StartBackchannelConnectionAsync's catch (SocketException) when (process.HasExited) + /// filter, which distinguishes "server died" from "server still starting up". Callers must not + /// invoke , , + /// or other lifecycle APIs on the returned instance — those belong to the session. + /// + public Process? ServerProcess => _serverProcess; /// - /// Starts an AppHost server process with an authentication token injected into the server environment. + /// Launches the AppHost server process. The returned task completes with the process exit + /// code when the process exits (either on its own, or because the stop token supplied to the + /// constructor was cancelled and the session killed it). /// - /// The server project to run. - /// The environment variables to pass to the server. - /// Whether to enable debug logging for the server. - /// The logger to use for lifecycle diagnostics. - /// Optional profiling telemetry for the server process lifetime. - /// The started AppHost server session. - internal static AppHostServerSession Start( - IAppHostServerProject appHostServerProject, - Dictionary? environmentVariables, - bool debug, - ILogger logger, - ProfilingTelemetry? profilingTelemetry = null) + /// Thrown if has already been called. + /// Thrown if the session has been disposed. + public Task StartAsync() { - var currentPid = Environment.ProcessId; - var serverEnvironmentVariables = environmentVariables is null - ? new Dictionary() - : new Dictionary(environmentVariables); + // Hold _startGate across the entire startup body — env build, _project.Run, field + // publication, Exited wiring, and stop registration. DisposeAsync's top-of-method lock + // then either runs before us (and StartAsync sees _disposed and throws) or after us + // (and Dispose sees a fully-published process + registration). Without this widening + // there is a window between _project.Run returning and the stop registration completing + // where a concurrent Dispose would orphan the just-launched process. Every operation + // below is synchronous, so a Monitor lock is safe (no await inside). + lock (_startGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); - var authenticationToken = TokenGenerator.GenerateToken(); - serverEnvironmentVariables[KnownConfigNames.RemoteAppHostToken] = authenticationToken; + if (_startInvoked) + { + throw new InvalidOperationException("AppHostServerSession has already been started."); + } - var activity = profilingTelemetry is null - ? default - : profilingTelemetry.StartAppHostServerLifetime(appHostServerProject.GetType().Name); - if (activity.IsRunning) - { - activity.AddContextToEnvironment(serverEnvironmentVariables); - } - else - { - // Profiling may be disabled even when an upstream CLI span is active. Still pass that - // ambient context through so the AppHostServer can join the existing startup trace. - ProfilingTelemetry.AddCurrentContextToEnvironment(serverEnvironmentVariables); - } + _startInvoked = true; - string socketPath; - Process serverProcess; - OutputCollector serverOutput; - try - { - (socketPath, serverProcess, serverOutput) = appHostServerProject.Run( - currentPid, - serverEnvironmentVariables, - debug: debug); - } - catch (Exception ex) - { - activity.SetError(ex.Message); - activity.Dispose(); - (appHostServerProject as IDisposable)?.Dispose(); - throw; - } + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _completion = completion; + + var serverEnvironmentVariables = _callerEnvironmentVariables is null + ? new Dictionary() + : new Dictionary(_callerEnvironmentVariables); + serverEnvironmentVariables[KnownConfigNames.RemoteAppHostToken] = _authenticationToken; - activity.SetProcessId(serverProcess.Id); - activity.SetProcessInvocation(serverProcess.StartInfo.FileName, serverProcess.StartInfo.ArgumentList); + _activity = _profilingTelemetry is null + ? default + : _profilingTelemetry.StartAppHostServerLifetime(_project.GetType().Name); + if (_activity.IsRunning) + { + _activity.AddContextToEnvironment(serverEnvironmentVariables); + } + else + { + // Profiling may be disabled even when an upstream CLI span is active. Still pass that + // ambient context through so the AppHostServer can join the existing startup trace. + ProfilingTelemetry.AddCurrentContextToEnvironment(serverEnvironmentVariables); + } - return new AppHostServerSession( - serverProcess, - serverOutput, - socketPath, - authenticationToken, - logger, - activity, - profilingTelemetry, - appHostServerProject as IDisposable); + AppHostServerRunResult result; + try + { + result = _project.Run( + Environment.ProcessId, + serverEnvironmentVariables, + debug: _debug, + isolateConsole: _isolateConsole, + consoleProcessJob: _consoleProcessJob); + } + catch (Exception ex) + { + _activity.SetError(ex.Message); + _activity.Dispose(); + (_project as IDisposable)?.Dispose(); + completion.TrySetException(ex); + throw; + } + + // Publish the lifetime BEFORE any further work so a fault in the wiring below still + // routes the spawned process through normal DisposeAsync cleanup (which awaits the + // shutdown ladder and then disposes the lifetime). Anything between here and the + // stop registration that throws will fall into the catch below and tear the just- + // launched process down explicitly. + _processLifetime = result.ProcessLifetime; + _serverProcess = result.Process; + _socketPath = result.SocketPath; + _output = result.OutputCollector; + + try + { + var process = result.Process; + + _activity.SetProcessId(process.Id); + + // Read identity from the result, not process.StartInfo: on the isolated Windows + // path the Process is obtained via Process.GetProcessById and its StartInfo is + // empty, so reading from there would lose the telemetry signal. + _activity.SetProcessInvocation(result.FileName, result.Arguments); + + // Hook Exited before we check HasExited to close the race window where the process + // could exit between Start returning and our subscription being wired up. Capture the + // process + completion locals in the closure so the handler doesn't need to read + // mutable fields when it fires from the thread pool. + process.EnableRaisingEvents = true; + process.Exited += (_, _) => TrySetExitCode(completion, process); + + // If the process exited before we hooked Exited (or before EnableRaisingEvents was set), + // the event will not fire. Trip the completion ourselves in that case. + if (process.HasExited) + { + TrySetExitCode(completion, process); + } + + // Register on the internal linked CTS. If the caller's token already fired (or DisposeAsync + // raced us to Cancel) the registration's callback fires synchronously here, killing the + // just-launched process. CT.Register handles already-cancelled tokens via inline invocation. + _stopRegistration = _stopCts.Token.Register(static state => ((AppHostServerSession)state!).OnStopRequested(), this); + } + catch (Exception ex) + { + // Post-spawn wiring failed (Exited hook, HasExited probe, stop registration). + // The lifetime is published, so DisposeAsync would normally clean it up — but the + // caller is about to receive an exception from StartAsync and may not call + // DisposeAsync. Tear the process down here so we don't leak the just-launched + // child + (on the isolated Windows path) its anonymous pipes and NUL stdin handle. + _activity.SetError(ex.Message); + _activity.Dispose(); + try + { + if (!result.Process.HasExited) + { + result.Process.Kill(entireProcessTree: true); + } + } + catch + { + // Best effort — the lifetime disposal below handles the handles either way. + } + try + { + // Synchronously wait for the disposal: StartAsync's contract is synchronous + // until the returned Task is observed, and the cleanup window for the freshly + // spawned child should be milliseconds (the kill above unblocks the pipes). + result.ProcessLifetime.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch + { + // Best effort. + } + _processLifetime = null; + _serverProcess = null; + (_project as IDisposable)?.Dispose(); + completion.TrySetException(ex); + throw; + } + + return completion.Task; + } } - /// + /// + /// Returns an RPC client connected to the server. Must be called after . + /// public async Task GetRpcClientAsync(CancellationToken cancellationToken) { - ObjectDisposedException.ThrowIf(_disposed, nameof(AppHostServerSession)); + ObjectDisposedException.ThrowIf(_disposed, this); - return _rpcClient ??= await AppHostRpcClient.ConnectAsync(_socketPath, _authenticationToken, _profilingTelemetry, cancellationToken); + var socketPath = _socketPath ?? throw NotStarted(); + return _rpcClient ??= await AppHostRpcClient.ConnectAsync(socketPath, _authenticationToken, _profilingTelemetry, cancellationToken).ConfigureAwait(false); } - /// public async ValueTask DisposeAsync() { - if (_disposed) + lock (_startGate) { - return; + if (_disposed) + { + return; + } + _disposed = true; } - _disposed = true; + // Trigger the registered stop callback (idempotent — if the caller's token already fired + // earlier this is a no-op). The callback runs ProcessTerminator.ShutdownAsync, which is + // synchronous when requestGracefulShutdown:false, so by the time Cancel returns the kill + // signal has been sent. + try + { + _stopCts.Cancel(); + } + catch (ObjectDisposedException) + { + // CTS was already disposed by a prior partial teardown; the stop callback (if any) + // would have fired then. + } - if (_rpcClient != null) + // Detach the registration before disposing the CTS so no late callback fires while the + // rest of the teardown progresses. + await _stopRegistration.DisposeAsync().ConfigureAwait(false); + + // Await the shutdown ladder before observing completion: when external stop fired, the + // ladder is bounded by the central token (so it cannot hang past the central budget); + // when dispose-only fired, the ladder force-killed near-instantly. Either way, awaiting + // here keeps the kill sequence ordered ahead of completion + RPC teardown so the process + // is dead (or definitely going to die) before we touch its handles. + if (_shutdownTask is { } shutdownTask) { - await _rpcClient.DisposeAsync(); + try + { + await shutdownTask.ConfigureAwait(false); + } + catch + { + // The ladder swallows expected errors internally; defensively swallow anything + // else so disposal stays best-effort. + } + } + + if (_rpcClient is not null) + { + await _rpcClient.DisposeAsync().ConfigureAwait(false); _rpcClient = null; } - if (!_serverProcess.HasExited) + // Observe the completion task unconditionally to prevent UnobservedTaskException if + // StartAsync's _project.Run threw synchronously and faulted the completion before the + // process handle was assigned. When the process did start, this also waits for the + // Exited handler (or post-Run HasExited check) to flow the exit code through. + if (_completion is { } completion) { try { - _serverProcess.Kill(entireProcessTree: true); - _activity.SetError("AppHost server process was terminated during session disposal."); + await completion.Task.ConfigureAwait(false); } - catch (Exception ex) + catch { - _logger.LogDebug(ex, "Error killing AppHost server process"); + // Exceptions surface to the StartAsync caller; swallow during disposal. } } - if (_serverProcess.HasExited) + if (_serverProcess is { } process) { - _activity.SetProcessExitCode(_serverProcess.ExitCode); + if (process.HasExited) + { + _activity.SetProcessExitCode(process.ExitCode); + } } - _serverProcess.Dispose(); - _projectLifetime?.Dispose(); + // Dispose via the lifetime, not the process. On the isolated Windows path the lifetime is + // the IsolatedProcess wrapper which drains stdout/stderr pumps (bounded by an internal 5s + // timeout) and releases the anonymous pipes + NUL stdin handle that the Process doesn't + // own. On the non-isolated path the lifetime is a thin adapter that just disposes the + // Process. Either way, this is the single disposal site for the spawned child. + if (_processLifetime is { } lifetime) + { + try + { + await lifetime.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Best-effort: the shutdown ladder has already run, so the process is exited or + // being killed. Throwing from a disposal path is never useful. + } + _processLifetime = null; + } + + _stopCts.Dispose(); + (_project as IDisposable)?.Dispose(); _activity.Dispose(); } -} -/// -/// Factory for creating instances. -/// -internal sealed class AppHostServerSessionFactory : IAppHostServerSessionFactory -{ - private readonly IAppHostServerProjectFactory _projectFactory; - private readonly ILogger _logger; - private readonly ProfilingTelemetry _profilingTelemetry; - - public AppHostServerSessionFactory( - IAppHostServerProjectFactory projectFactory, - ILogger logger, - ProfilingTelemetry profilingTelemetry) + private static void TrySetExitCode(TaskCompletionSource completion, Process process) { - _projectFactory = projectFactory; - _logger = logger; - _profilingTelemetry = profilingTelemetry; + try + { + completion.TrySetResult(process.ExitCode); + } + catch (InvalidOperationException) + { + // Process handle was closed concurrently (e.g., during DisposeAsync teardown). The + // completion task is the contract callers observe; without an exit code there is + // nothing meaningful to surface, so just leave the completion open — DisposeAsync + // swallows the awaited exception. + } } - /// - public async Task CreateAsync( - string appHostPath, - string sdkVersion, - IEnumerable integrations, - Dictionary? launchSettingsEnvVars, - bool debug, - CancellationToken cancellationToken) + private void OnStopRequested() + { + // CT registration callbacks must be synchronous. We stash the started ShutdownAsync task + // in _shutdownTask so DisposeAsync can await it instead of leaving it dangling. The kill + // path inside ShutdownAsync is bounded (either an immediate force-kill on the no-graceful + // path, or by the central GracefulShutdownService.Token on the graceful path), so the + // task cannot hang past the central budget. + // + // Intentionally no `_disposed` check: DisposeAsync sets `_disposed = true` before calling + // `_stopCts.Cancel()`, so an early return here would suppress the kill that disposal is + // trying to trigger. + // + // Defense in depth against the helper being called more than once — a CT registration only + // fires once today, but the kill path should never depend on that contract holding through + // future refactors (e.g. if disposal grows additional cancel paths). + if (Interlocked.Exchange(ref _stopRequested, 1) != 0) + { + return; + } + + var process = _serverProcess; + if (process is null) + { + return; + } + + // _externalStopToken.IsCancellationRequested distinguishes "caller cancelled the external + // token" (we're on the propagation path and the token reads as cancelled) from + // "DisposeAsync cancelled the internal linked CTS" (the external token never fired). + // Only the former should run the graceful ladder — the latter must force-kill because + // nothing started the central GracefulShutdownService timer. + _shutdownTask = ShutdownAsync(process, externalStopFired: _externalStopToken.IsCancellationRequested); + } + + private async Task ShutdownAsync(Process process, bool externalStopFired) { - var appHostServerProject = await _projectFactory.CreateAsync(appHostPath, cancellationToken); + // Force-kill path: take this when graceful infra isn't wired (non-Run callers — SDK gen, + // scaffolding, publish, dump) OR when the dispose-only path was taken (external token + // never fired). The dispose-only case is the critical reason for the externalStopFired + // check: if we observed _shutdownService.Token in that case, the ladder would hang + // indefinitely because nothing started the central timer. + if (_gracefulShutdownSignaler is null || _shutdownService is null || !externalStopFired) + { + try + { + await ProcessTerminator.ShutdownAsync( + process, + requestGracefulShutdown: false, + entireProcessTree: !OperatingSystem.IsWindows(), + _logger, + ProcessDescription, + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to shut down {ProcessDescription} (pid {Pid}).", ProcessDescription, SafePid(process)); + } + return; + } + + var gracefulToken = _shutdownService.Token; + + // Phase 1: best-effort graceful signal bounded by the central token. The DCP path on + // Windows shells out (layout discovery + DCP process launch + wait) and could consume the + // entire graceful window before we ever reach WaitForExitAsync. Sharing the central token + // means a 2nd-Ctrl+C Expire() interrupts the slow DCP shell-out exactly the same way it + // interrupts the WaitForExitAsync below. + try + { + DateTimeOffset? startTime = TryGetStartTime(process); + await _gracefulShutdownSignaler.RequestProcessTreeGracefulShutdownAsync( + process.Id, + startTime, + includeStartTimeForDcp: false, + gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (gracefulToken.IsCancellationRequested) + { + // Graceful budget expired before the signal could be issued; fall through to kill. + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to issue graceful shutdown to {ProcessDescription} (pid {Pid}); escalating to kill.", ProcessDescription, SafePid(process)); + } - // Prepare the server (create files + build for dev mode, restore packages for prebuilt mode) - AppHostServerPrepareResult prepareResult; + // Phase 2: wait for exit bounded by the same central token. Whoever initiated shutdown + // (CCM via RequestShutdown/Cancel) already started the clock; we only consume the token. try { - prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken: cancellationToken); + await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); } - catch + catch (OperationCanceledException) { - (appHostServerProject as IDisposable)?.Dispose(); - throw; + // Graceful budget expired; fall through to kill. } - if (!prepareResult.Success) + if (process.HasExited) { - (appHostServerProject as IDisposable)?.Dispose(); + return; + } - return new AppHostServerSessionResult( - Success: false, - Session: null, - BuildOutput: prepareResult.Output, - ChannelName: prepareResult.ChannelName); + // Phase 3: ALWAYS tree-kill on escalation. The previous "Windows uses entireProcessTree: + // false because DCP already did the tree work" optimization was wrong: if the graceful + // signal was cancelled mid-flight, never reached DCP, failed layout discovery, or DCP + // itself was killed during cancellation, descendants are still alive and skipping + // tree-kill orphans them. + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // Process exited between HasExited check and Kill — nothing to do. + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to kill {ProcessDescription} (pid {Pid}).", ProcessDescription, SafePid(process)); + return; + } + + // Brief separately-bounded drain after kill — independent of central token because by now + // the central budget has already expired. 1 s is enough for the OS to reap the process. + try + { + using var killDrain = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await process.WaitForExitAsync(killDrain.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Best-effort; nothing more we can do. + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error draining killed {ProcessDescription} (pid {Pid}).", ProcessDescription, SafePid(process)); } + } - var session = AppHostServerSession.Start( - appHostServerProject, - launchSettingsEnvVars, - debug, - _logger, - _profilingTelemetry); + private static DateTimeOffset? TryGetStartTime(Process process) + { + // Process.StartTime can throw InvalidOperationException on a process whose handle was + // closed or that has exited in some states. Treat as unavailable rather than aborting + // the graceful path — the Unix branch ignores StartTime entirely, and the Windows DCP + // branch only uses it when includeStartTimeForDcp is true. + try + { + return process.StartTime; + } + catch (Exception) + { + return null; + } + } - return new AppHostServerSessionResult( - Success: true, - Session: session, - BuildOutput: prepareResult.Output, - ChannelName: prepareResult.ChannelName); + private static int SafePid(Process process) + { + try + { + return process.Id; + } + catch (Exception) + { + return -1; + } } + + private static InvalidOperationException NotStarted() => + new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); } diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 115a16fd3b4..0e4db4caf15 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -320,7 +320,8 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken StandardOutputCallback = runOutputCollector.AppendOutput, StandardErrorCallback = runOutputCollector.AppendError, StartDebugSession = context.StartDebugSession, - Debug = context.Debug + Debug = context.Debug, + KillEntireProcessTreeOnCancel = ShouldKillEntireProcessTreeOnCancel(OperatingSystem.IsWindows()) }; // The backchannel completion source is the contract with RunCommand @@ -385,6 +386,8 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken } } + internal static bool ShouldKillEntireProcessTreeOnCancel(bool isWindows) => !isWindows; + private async Task EnsureDevCertificatesTrustedAsync(AppHostProjectContext context, Dictionary env, CancellationToken cancellationToken) { try diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 1d431a973a6..55cfe723d47 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -9,6 +9,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Packaging; +using Aspire.Cli.Processes; using Aspire.Cli.Utils; using Aspire.Hosting; using Microsoft.Extensions.Logging; @@ -467,15 +468,20 @@ public async Task PrepareAsync( public string GetInstanceIdentifier() => GetProjectFilePath(); /// - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public AppHostServerRunResult Run( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, - bool debug = false) + bool debug = false, + bool isolateConsole = false, + WindowsConsoleProcessJob? consoleProcessJob = null) { var assemblyPath = Path.Combine(BuildPath, ProjectDllName); var dotnetExe = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + // Build the canonical ProcessStartInfo first, then translate to IsolatedProcessStartInfo + // only if the isolated path is requested. Sharing the env/arg construction avoids drift + // between the two branches — every env var and argument lives in exactly one place. var startInfo = new ProcessStartInfo(dotnetExe) { WorkingDirectory = _projectModelPath, @@ -521,29 +527,62 @@ public async Task PrepareAsync( startInfo.RedirectStandardOutput = true; startInfo.RedirectStandardError = true; + var outputCollector = new OutputCollector(); + var arguments = startInfo.ArgumentList.ToArray(); + + // Pulled out so the inherited-console (event-based) and isolated (handler-based) paths + // produce identical log/collector output. Keeps the per-line behavior in one place even + // if we end up with a third spawn variant later. + void OnStdout(int pid, string line) + { + _logger.LogTrace("AppHostServer({ProcessId}) stdout: {Line}", pid, line); + outputCollector.AppendOutput(line); + } + + void OnStderr(int pid, string line) + { + _logger.LogTrace("AppHostServer({ProcessId}) stderr: {Line}", pid, line); + outputCollector.AppendError(line); + } + + if (isolateConsole) + { + var isolated = IsolatedConsoleSpawner.StartIsolated(startInfo, consoleProcessJob, OnStdout, OnStderr); + return new AppHostServerRunResult( + _socketPath, + isolated.Process, + outputCollector, + startInfo.FileName, + arguments, + isolated); + } + var process = Process.Start(startInfo)!; - var outputCollector = new OutputCollector(); process.OutputDataReceived += (sender, e) => { if (e.Data is not null) { - _logger.LogTrace("AppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data); - outputCollector.AppendOutput(e.Data); + OnStdout(process.Id, e.Data); } }; process.ErrorDataReceived += (sender, e) => { if (e.Data is not null) { - _logger.LogTrace("AppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data); - outputCollector.AppendError(e.Data); + OnStderr(process.Id, e.Data); } }; process.BeginOutputReadLine(); process.BeginErrorReadLine(); - return (_socketPath, process, outputCollector); + return new AppHostServerRunResult( + _socketPath, + process, + outputCollector, + startInfo.FileName, + arguments, + ProcessLifetimeAdapter.ForProcess(process)); } private static string? FindNuGetConfig(string workingDirectory) diff --git a/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs b/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs index e9ba0c3c641..fa911add0de 100644 --- a/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs @@ -34,8 +34,12 @@ public ExtensionGuestLauncher( DirectoryInfo workingDirectory, IDictionary environmentVariables, CancellationToken cancellationToken, - Func? afterLaunchAsync = null) + Func? afterLaunchAsync = null, + GuestLaunchOptions? options = null) { + // GuestLaunchOptions is intentionally ignored — the extension owns the debug-session + // lifecycle and has its own teardown path (it does not spawn a child process here, so + // there's nothing for the console-isolation / graceful-then-tree-kill ladder to target). // Prepend the runtime command (e.g., "npx") as the first argument so the // extension can extract it as the runtimeExecutable for the debug session. var allArgs = new List { command }; diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 08c79b2bdb6..317b8ecd0bc 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -11,6 +11,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; +using Aspire.Cli.Processes; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; @@ -47,6 +48,10 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly TimeProvider _timeProvider; private readonly RunningInstanceManager _runningInstanceManager; private readonly ProfilingTelemetry _profilingTelemetry; + private readonly ConsoleCancellationManager _cancellationManager; + private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; + private readonly GracefulShutdownService _shutdownService; + private readonly WindowsConsoleProcessJob? _consoleProcessJob; // Language is always resolved via constructor private readonly LanguageInfo _resolvedLanguage; @@ -67,7 +72,11 @@ public GuestAppHostProject( ILogger logger, FileLoggerProvider fileLoggerProvider, ProfilingTelemetry profilingTelemetry, - TimeProvider? timeProvider = null) + ConsoleCancellationManager cancellationManager, + IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, + GracefulShutdownService shutdownService, + TimeProvider? timeProvider = null, + WindowsConsoleProcessJob? consoleProcessJob = null) { _resolvedLanguage = language; _interactionService = interactionService; @@ -85,6 +94,10 @@ public GuestAppHostProject( _profilingTelemetry = profilingTelemetry; _timeProvider = timeProvider ?? TimeProvider.System; _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); + _cancellationManager = cancellationManager; + _gracefulShutdownSignaler = gracefulShutdownSignaler; + _shutdownService = shutdownService; + _consoleProcessJob = consoleProcessJob; } // ═══════════════════════════════════════════════════════════════ @@ -267,12 +280,17 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir } // Step 2: Start the AppHost server temporarily for code generation - await using var serverSession = AppHostServerSession.Start( + await using var serverSession = new AppHostServerSession( appHostServerProject, environmentVariables: null, debug: false, _logger, + cancellationToken, _profilingTelemetry); + // Fire-and-forget the completion task: for short-lived RPC sessions like this one we + // observe failures via the RPC call below, and disposal flows the exit code through the + // activity scope. Same observation model as the prior static AppHostServerSession.Start. + _ = serverSession.StartAsync(); // Step 3: Connect to server var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -341,6 +359,11 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken _logger.LogDebug("Running {Language} AppHost: {AppHostFile}", DisplayName, appHostFile.FullName); var startProjectContext = Activity.Current?.Context ?? default; + // Guard for the backchannel continuation below. Declared at method scope so the finally + // (which sets it) and the inner-try declarations of serverStopCts/etc remain consistent + // regardless of where we exit. + var runEnded = 0; + try { // Step 1: Ensure certificates are trusted @@ -356,7 +379,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken throw; } - // Build phase: build AppHost server (dependency install happens after server starts) + // Step 2: Build/prepare the AppHost server (dependency install happens after server starts) var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); // Load config - source of truth for SDK version and packages @@ -393,6 +416,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Signal that build/preparation is complete context.BuildCompletionSource?.TrySetResult(true); + // Step 3: Configure launch environment for the AppHost server // Read launch settings once and reuse them for both the temporary server and guest AppHost. var launchProfileEnvironmentVariables = ReadLaunchSettingsEnvironmentVariables(directory); var launchSettingsEnvVars = GetServerEnvironmentVariables( @@ -418,61 +442,60 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Check if hot reload (watch mode) is enabled var enableHotReload = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false); - // Start the AppHost server process - AppHostServerSession serverSession; + // Step 4: Start the AppHost server process. The linked stop CTS is the only termination + // trigger we hand to the session; cancelling it (here, via outer cancellationToken, or + // via CCM.RequestShutdown/Cancel) is how we ask the session to kill its child process. + // Linking to both the outer CT and CCM token ensures internal RequestShutdown + // propagates to the locally-scoped session token even when tests pass a separate token. + using var serverStopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationManager.Token); + await using var serverSession = new AppHostServerSession( + appHostServerProject, + launchSettingsEnvVars, + context.Debug, + _logger, + serverStopCts.Token, + _profilingTelemetry, + gracefulShutdownSignaler: _gracefulShutdownSignaler, + shutdownService: _shutdownService, + isolateConsole: true, + consoleProcessJob: _consoleProcessJob); + Task serverCompletion; IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverSession = AppHostServerSession.Start( - appHostServerProject, - launchSettingsEnvVars, - context.Debug, - _logger, - _profilingTelemetry); - try - { - // Give the server a moment to start - await Task.Delay(500, cancellationToken); + serverCompletion = serverSession.StartAsync(); - if (serverSession.ServerProcess.HasExited) - { - _interactionService.DisplayLines(serverSession.Output.GetLines()); - _interactionService.DisplayError("App host exited unexpectedly."); - await serverSession.DisposeAsync(); - return CliExitCodes.FailedToDotnetRunAppHost; - } + // Give the server a moment to start + await Task.Delay(500, cancellationToken); - // Step 5: Connect to server for RPC calls - rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); + if (serverCompletion.IsCompleted) + { + _interactionService.DisplayLines(serverSession.Output!.GetLines()); + _interactionService.DisplayError("App host exited unexpectedly."); + return CliExitCodes.FailedToDotnetRunAppHost; + } - // Step 6: Generate SDK code via RPC if needed - // This must happen before dependency installation because the generated - // code directory (.aspire/modules) may not exist yet (e.g., freshly cloned project) - // and dependency files (pylock.toml, requirements.txt) reference it. - if (buildResult.NeedsCodeGen) - { - await GenerateCodeViaRpcAsync( - directory.FullName, - appHostFile, - rpcClient, - integrations, - cancellationToken); - } + // Step 5: Connect to server for RPC calls + rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); - await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); - } - catch + // Step 6: Generate SDK code via RPC if needed + // This must happen before dependency installation because the generated + // code directory (.aspire/modules) may not exist yet (e.g., freshly cloned project) + // and dependency files (pylock.toml, requirements.txt) reference it. + if (buildResult.NeedsCodeGen) { - // Once Start() succeeds we own the server process, so dispose it here when - // post-start work fails - the `await using` below isn't in scope yet. - await serverSession.DisposeAsync(); - throw; + await GenerateCodeViaRpcAsync( + directory.FullName, + appHostFile, + rpcClient, + integrations, + cancellationToken); } + + await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); } - await using var serverSessionScope = serverSession; - var socketPath = serverSession.SocketPath; - var appHostServerProcess = serverSession.ServerProcess; - var appHostServerOutputCollector = serverSession.Output; + var socketPath = serverSession.SocketPath!; + var appHostServerOutputCollector = serverSession.Output!; var authenticationToken = serverSession.AuthenticationToken; // The backchannel completion source is the contract with RunCommand @@ -484,28 +507,32 @@ await GenerateCodeViaRpcAsync( // 60s timeout, the server exits unexpectedly) so the remaining process gets torn down // promptly. Without this, a hung guest can keep pendingRun alive forever after the CLI // has already given up on the backchannel, causing aspire run/start to hang instead of - // surfacing the failure. Linked to the outer cancellationToken so user Ctrl+C still - // propagates. - using var appHostSystemCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + // surfacing the failure. Linked to the outer cancellationToken AND CCM token so user + // Ctrl+C and internal RequestShutdown both propagate. + using var appHostSystemCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationManager.Token); var appHostSystemToken = appHostSystemCts.Token; + // Guard the backchannel continuation from firing after RunAsync has already returned. + // The continuation can fault late and would otherwise mutate the process-wide CCM + // outside the run scope, e.g. cancelling the next command's CT in a long-lived host. + // The continuation uses an atomic CAS on runEnded (rather than a plain read) so it races + // safely with the finally below — exactly one of (continuation, finally) flips the gate + // 0→1, and the loser observes the flag already set and no-ops. + // When the backchannel polling task gives up (timeout, server process exit, or other - // fatal connection error), escalate to tearing down the whole AppHost system. The + // fatal connection error), escalate to tearing down the whole AppHost system via CCM + // so every shutdown trigger drives the same graceful ladder. The // BackchannelCompletionSource only signals readiness/connectivity - it never causes the // server or guest to be killed on its own, so we wire that here. _ = backchannelCompletionSource.Task.ContinueWith( t => { - if (t.IsFaulted && !appHostSystemCts.IsCancellationRequested) + // CAS-flip runEnded 0→1 atomically with the fault check. If the finally below + // wins the race the CAS fails and we no-op; otherwise this is the authoritative + // shutdown driver for the backchannel-fault path. + if (t.IsFaulted && Interlocked.CompareExchange(ref runEnded, 1, 0) == 0) { - try - { - appHostSystemCts.Cancel(); - } - catch (ObjectDisposedException) - { - // RunAsync already returned and disposed the CTS; nothing to do. - } + _cancellationManager.RequestShutdown(CliExitCodes.FailedToDotnetRunAppHost); } }, CancellationToken.None, @@ -525,17 +552,8 @@ await GenerateCodeViaRpcAsync( context.BackchannelCompletionSource?.TrySetException( new InvalidOperationException($"Failed to install {DisplayName} dependencies.")); - if (!appHostServerProcess.HasExited) - { - try - { - appHostServerProcess.Kill(entireProcessTree: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error killing AppHost server process after dependency install failure"); - } - } + // Drive shared graceful ladder so server teardown matches user-initiated Ctrl+C. + _cancellationManager.RequestShutdown(installResult); return installResult; } @@ -594,15 +612,22 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() // Use the AppHost system token so that a guest-side failure (which faults the // backchannel completion source and cancels appHostSystemCts) stops the polling loop // promptly. - _ = StartBackchannelConnectionAsync(appHostServerProcess, backchannelSocketPath, backchannelCompletionSource, enableHotReload, startProjectContext, appHostSystemToken); + _ = StartBackchannelConnectionAsync(serverSession.ServerProcess!, backchannelSocketPath, backchannelCompletionSource, enableHotReload, startProjectContext, appHostSystemToken); return Task.CompletedTask; } // Pass appHostSystemToken so a fatal backchannel failure (or user cancellation, since - // appHostSystemCts is linked to cancellationToken) tears down the guest process. The - // launcher will kill the guest's process tree when this token cancels. + // appHostSystemCts is linked to cancellationToken AND CCM.Token) tears down the guest + // process. The launcher will kill the guest's process tree when this token cancels. + // Pass GuestLaunchOptions so the launcher uses an isolated console + DCP graceful + // shutdown on Windows, matching what the AppHost server already does. + var guestLaunchOptions = new GuestLaunchOptions( + IsolateConsoleForGracefulShutdown: true, + GracefulShutdownSignaler: _gracefulShutdownSignaler, + ShutdownService: _shutdownService, + ConsoleProcessJob: _consoleProcessJob); (guestExitCode, guestOutput) = await ExecuteGuestAppHostAsync( - appHostFile, directory, environmentVariables, enableHotReload, rpcClient, launcher, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, appHostSystemToken); + appHostFile, directory, environmentVariables, enableHotReload, rpcClient, launcher, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, appHostSystemToken, guestLaunchOptions); } // If the user cancelled (Ctrl+C), surface that as cancellation instead of a "guest failed" @@ -622,8 +647,7 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() // guest's own output may be empty - the relevant diagnostics are in the server output // (e.g. DCP model validation errors). // Surface the failure regardless of launcher type, otherwise the extension flow would - // silently hang in appHostServerProcess.WaitForExitAsync waiting for an apphost that was - // never started. + // silently hang awaiting serverCompletion for an apphost that was never started. if (guestExitCode != 0) { _logger.LogError("{Language} apphost exited with code {ExitCode}", DisplayName, guestExitCode); @@ -652,18 +676,9 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() var error = new InvalidOperationException($"The {DisplayName} apphost failed."); context.BackchannelCompletionSource?.TrySetException(error); - // Kill the AppHost server since the apphost failed - if (!appHostServerProcess.HasExited) - { - try - { - appHostServerProcess.Kill(entireProcessTree: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error killing AppHost server process after {Language} failure", DisplayName); - } - } + // Drive shared graceful ladder; surfaces guestExitCode via CCM.RequestedExitCode + // even if an OCE wins the race in a higher layer. + _cancellationManager.RequestShutdown(guestExitCode); return guestExitCode; } @@ -672,33 +687,26 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() { // Extension manages the guest app host lifecycle via VS Code debug session. // Wait for the AppHost server to exit (Ctrl+C or extension termination). - await appHostServerProcess.WaitForExitAsync(cancellationToken); - return appHostServerProcess.ExitCode; + return await serverCompletion.WaitAsync(cancellationToken); } // In watch mode, wait for server to exit (Ctrl+C or orphan detection) - // In non-watch mode, kill the server now that the apphost has exited - if (!enableHotReload && !appHostServerProcess.HasExited) + // In non-watch mode, ask the session to terminate now that the apphost has exited + if (!enableHotReload) { - try - { - appHostServerProcess.Kill(entireProcessTree: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error killing AppHost server process"); - } + _cancellationManager.RequestShutdown(0); } - await appHostServerProcess.WaitForExitAsync(cancellationToken); - - return appHostServerProcess.ExitCode; + return await serverCompletion.WaitAsync(cancellationToken); } catch (OperationCanceledException) { // Signal that build/preparation failed so RunCommand doesn't hang waiting context.BuildCompletionSource?.TrySetResult(false); - return CliExitCodes.Cancelled; + // If an internal RequestShutdown captured an exit code (or a signal handler set one), + // surface it rather than the generic Cancelled. Falls back to Cancelled (130) for + // ambient cancellation paths that never touched CCM. + return _cancellationManager.RequestedExitCode ?? CliExitCodes.Cancelled; } catch (AppHostCodeGenerationException ex) { @@ -716,6 +724,13 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() _interactionService.DisplayError($"Failed to run {DisplayName} AppHost: {ex.Message}"); return CliExitCodes.FailedToDotnetRunAppHost; } + finally + { + // Mark the run as ended so the backchannel ContinueWith no-ops if it fires late. + // Uses CompareExchange to race safely with the continuation: whichever side flips the + // gate first wins; the loser observes the gate already set and skips RequestShutdown. + Interlocked.CompareExchange(ref runEnded, 1, 0); + } } internal Dictionary GetServerEnvironmentVariables( @@ -999,22 +1014,26 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca launchSettingsEnvVars[KnownConfigNames.AspireUserSecretsId] = UserSecretsPathHelper.ComputeSyntheticUserSecretsId(appHostFile.FullName); // Step 2: Start the AppHost server process(it opens the backchannel for progress reporting) - AppHostServerSession serverSession; + // Linked stop CTS is the only termination trigger we hand to the session. + using var serverStopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + await using var serverSession = new AppHostServerSession( + appHostServerProject, + launchSettingsEnvVars, + context.Debug, + _logger, + serverStopCts.Token, + _profilingTelemetry); + Task serverCompletion; IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverSession = AppHostServerSession.Start( - appHostServerProject, - launchSettingsEnvVars, - context.Debug, - _logger, - _profilingTelemetry); + serverCompletion = serverSession.StartAsync(); // Start connecting to the backchannel (fire-and-forget) so the caller is unblocked // as soon as the server is reachable; the post-start work below races alongside it. if (context.BackchannelCompletionSource is not null) { - _ = StartBackchannelConnectionAsync(serverSession.ServerProcess, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, startProjectContext, cancellationToken); + _ = StartBackchannelConnectionAsync(serverSession.ServerProcess!, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, startProjectContext, cancellationToken); } try @@ -1022,11 +1041,10 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca // Give the server a moment to start await Task.Delay(500, cancellationToken); - if (serverSession.ServerProcess.HasExited) + if (serverCompletion.IsCompleted) { - _interactionService.DisplayLines(serverSession.Output.GetLines()); + _interactionService.DisplayLines(serverSession.Output!.GetLines()); _interactionService.DisplayError("App host exited unexpectedly."); - await serverSession.DisposeAsync(); return CliExitCodes.FailedToDotnetRunAppHost; } @@ -1054,18 +1072,13 @@ await GenerateCodeViaRpcAsync( // The backchannel connection task was started before code generation // (see StartBackchannelConnectionAsync above); fault it eagerly so the // caller doesn't wait out the connection timeout when generateCode fails. + // The `await using` declaration above disposes the session on the rethrow. context.BackchannelCompletionSource?.TrySetException(ex); - - // Once Start() succeeds we own the server process, so dispose it here when - // post-start work fails - the `await using` below isn't in scope yet. - await serverSession.DisposeAsync(); throw; } } - await using var serverSessionScope = serverSession; - var jsonRpcSocketPath = serverSession.SocketPath; - var appHostServerProcess = serverSession.ServerProcess; - var appHostServerOutputCollector = serverSession.Output; + var jsonRpcSocketPath = serverSession.SocketPath!; + var appHostServerOutputCollector = serverSession.Output!; var authenticationToken = serverSession.AuthenticationToken; int guestExitCode; @@ -1080,17 +1093,7 @@ await GenerateCodeViaRpcAsync( context.BackchannelCompletionSource?.TrySetException( new InvalidOperationException($"Failed to install {DisplayName} dependencies.")); - if (!appHostServerProcess.HasExited) - { - try - { - appHostServerProcess.Kill(entireProcessTree: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error killing AppHost server process after dependency install failure"); - } - } + serverStopCts.Cancel(); return installResult; } @@ -1132,35 +1135,15 @@ await GenerateCodeViaRpcAsync( context.BackchannelCompletionSource?.TrySetException(error); // Kill the AppHost server since the apphost failed - if (!appHostServerProcess.HasExited) - { - try - { - appHostServerProcess.Kill(entireProcessTree: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error killing AppHost server process after {Language} failure", DisplayName); - } - } + serverStopCts.Cancel(); return guestExitCode; } // Kill the server after the guest apphost exits - if (!appHostServerProcess.HasExited) - { - try - { - appHostServerProcess.Kill(entireProcessTree: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error killing AppHost server process"); - } - } + serverStopCts.Cancel(); - await appHostServerProcess.WaitForExitAsync(cancellationToken); + await serverCompletion.WaitAsync(cancellationToken); // The guest apphost's publish result determines command success. // The helper server may be terminated by the CLI as part of normal cleanup, @@ -1878,7 +1861,8 @@ private async Task InstallDependenciesAsync( IAppHostRpcClient rpcClient, IGuestProcessLauncher launcher, Func? afterAppHostLaunchedAsync, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + GuestLaunchOptions? appHostLaunchOptions = null) { await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); @@ -1888,7 +1872,7 @@ private async Task InstallDependenciesAsync( return (CliExitCodes.FailedToDotnetRunAppHost, new OutputCollector()); } - return await _guestRuntime.RunAsync(appHostFile, directory, environmentVariables, watchMode, launcher, cancellationToken, afterAppHostLaunchedAsync: afterAppHostLaunchedAsync); + return await _guestRuntime.RunAsync(appHostFile, directory, environmentVariables, watchMode, launcher, cancellationToken, afterAppHostLaunchedAsync: afterAppHostLaunchedAsync, appHostLaunchOptions: appHostLaunchOptions); } /// diff --git a/src/Aspire.Cli/Projects/GuestRuntime.cs b/src/Aspire.Cli/Projects/GuestRuntime.cs index f2c892c55dd..4f354f51b1d 100644 --- a/src/Aspire.Cli/Projects/GuestRuntime.cs +++ b/src/Aspire.Cli/Projects/GuestRuntime.cs @@ -145,6 +145,11 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo /// Strategy for launching the process. /// Cancellation token. /// Callback invoked after the AppHost execute command has launched. + /// + /// Optional launch options forwarded to the launcher for the long-running AppHost execute command only. + /// Pre-execute commands (e.g. tsc --noEmit) and dependency installation are short-lived and + /// keep today's force-kill behavior, so this is not passed there. + /// /// A tuple of the exit code and captured output (null when launched via extension). public async Task<(int ExitCode, OutputCollector? Output)> RunAsync( FileInfo appHostFile, @@ -153,7 +158,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo bool watchMode, IGuestProcessLauncher launcher, CancellationToken cancellationToken, - Func? afterAppHostLaunchedAsync = null) + Func? afterAppHostLaunchedAsync = null, + GuestLaunchOptions? appHostLaunchOptions = null) { var useWatchCommand = watchMode && _spec.WatchExecute is not null; var commandSpec = useWatchCommand @@ -173,7 +179,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo var phase = useWatchCommand ? ProfilingTelemetry.Values.GuestCommandPhaseWatchExecute : ProfilingTelemetry.Values.GuestCommandPhaseExecute; - return await ExecuteCommandAsync(commandSpec, appHostFile, directory, environmentVariables, null, phase, launcher, cancellationToken, afterLaunchAsync: afterAppHostLaunchedAsync); + return await ExecuteCommandAsync(commandSpec, appHostFile, directory, environmentVariables, null, phase, launcher, cancellationToken, afterLaunchAsync: afterAppHostLaunchedAsync, launchOptions: appHostLaunchOptions); } /// @@ -252,7 +258,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo string phase, IGuestProcessLauncher launcher, CancellationToken cancellationToken, - Func? afterLaunchAsync = null) + Func? afterLaunchAsync = null, + GuestLaunchOptions? launchOptions = null) { var args = ReplacePlaceholders(commandSpec.Args, appHostFile, directory, additionalArgs); @@ -262,7 +269,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo using var activity = _profilingTelemetry is null ? default : _profilingTelemetry.StartGuestExecuteCommand(_spec.Language, _spec.DisplayName, commandSpec.Command, args, directory, phase); - var (exitCode, output) = await launcher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, cancellationToken, afterLaunchAsync: afterLaunchAsync); + var (exitCode, output) = await launcher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, cancellationToken, afterLaunchAsync: afterLaunchAsync, options: launchOptions); activity.SetProcessExitCode(exitCode); if (exitCode != 0) { @@ -313,7 +320,7 @@ private async Task EnsureMigrationFilesExistAsync(DirectoryInfo directory, Cance /// /// Creates the default process-based launcher for this runtime. /// - public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _fileLoggerProvider, _commandResolver); + public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, fileLoggerProvider: _fileLoggerProvider, commandResolver: _commandResolver); /// /// Replaces placeholders in command arguments with actual values. diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index 216dccd42a8..fe385cc1310 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Aspire.Cli.Configuration; +using Aspire.Cli.Processes; using Aspire.Cli.Utils; namespace Aspire.Cli.Projects; @@ -20,6 +21,40 @@ internal sealed record AppHostServerPrepareResult( string? ChannelName = null, bool NeedsCodeGeneration = false); +/// +/// Result of — a launched AppHost server process plus +/// the cleanup handle that owns it. +/// +/// RPC socket the server is publishing on. +/// +/// The underlying . Callers may observe state +/// (, , etc.) and drive lifecycle APIs +/// (, ), +/// but must dispose via instead of the +/// directly so the isolated-spawn path can release its anonymous pipes and stdin handle. +/// +/// Captured stdout/stderr for failure display. +/// +/// The launched executable, captured at spawn time. Reading +/// off is unreliable on the isolated Windows path (the Process is obtained +/// via , which returns an empty ), +/// so telemetry should read identity from this field instead. +/// +/// The argument list, captured at spawn time. Same rationale as . +/// +/// Disposes the process and any associated isolated-spawn resources. Always non-null; for the +/// non-isolated path this is a thin adapter that just disposes , for +/// the isolated path it is the wrapper that also drains the +/// stdout/stderr pumps and closes the anonymous pipes + NUL stdin handle on Windows. +/// +internal sealed record AppHostServerRunResult( + string SocketPath, + Process Process, + OutputCollector OutputCollector, + string FileName, + IReadOnlyList Arguments, + IAsyncDisposable ProcessLifetime); + /// /// Represents an AppHost server that can be prepared and run. /// This abstraction allows for different implementations: @@ -58,12 +93,27 @@ Task PrepareAsync( /// Environment variables to pass to the server. /// Additional command-line arguments. /// Whether to enable debug logging. - /// The socket path, server process, and an output collector for stdout/stderr. - (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + /// + /// When , on Windows the server is spawned via + /// into its own hidden console (CREATE_NEW_CONSOLE | SW_HIDE) + /// so DCP's stop-process-tree can AttachConsole + post CTRL_C_EVENT + /// against the server without also signalling the CLI. On Unix the flag is observed but the + /// resulting spawn is effectively the same as today's path (a thin + /// wrapper) because SIGTERM via the process group is enough. When on + /// Windows, must be non-null. + /// + /// + /// Windows-only kill-on-close safety net. Required when is + /// on Windows; ignored on Unix and on the non-isolated path. + /// + /// The launched server process and its associated cleanup handle. + AppHostServerRunResult Run( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, - bool debug = false); + bool debug = false, + bool isolateConsole = false, + WindowsConsoleProcessJob? consoleProcessJob = null); /// /// Gets a unique identifier path for this AppHost, used for running instance detection. diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs deleted file mode 100644 index 8fce43a2ec0..00000000000 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ /dev/null @@ -1,78 +0,0 @@ -// 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.Cli.Utils; - -using Aspire.Cli.Configuration; - -namespace Aspire.Cli.Projects; - -/// -/// Represents a running AppHost server session. -/// Manages server lifecycle and provides RPC client access. -/// -internal interface IAppHostServerSession : IAsyncDisposable -{ - /// - /// Gets the socket path for RPC communication. - /// - string SocketPath { get; } - - /// - /// Gets the server process. - /// - Process ServerProcess { get; } - - /// - /// Gets the output collector for server stdout/stderr. - /// - OutputCollector Output { get; } - - /// - /// Gets the authentication token for the server session. - /// - string AuthenticationToken { get; } - - /// - /// Gets an RPC client connected to this session. - /// - Task GetRpcClientAsync(CancellationToken cancellationToken); -} - -/// -/// Factory for building and starting AppHost server sessions. -/// -internal interface IAppHostServerSessionFactory -{ - /// - /// Builds and starts an AppHost server session. - /// - /// The path to the AppHost project directory. - /// The Aspire SDK version to use. - /// The integration references to include. - /// Optional environment variables from launch settings. - /// Whether to enable debug logging. - /// Cancellation token. - /// The result containing the session if successful. - Task CreateAsync( - string appHostPath, - string sdkVersion, - IEnumerable integrations, - Dictionary? launchSettingsEnvVars, - bool debug, - CancellationToken cancellationToken); -} - -/// -/// Result of creating an AppHost server session. -/// -/// Whether the build was successful. -/// The session if successful, null otherwise. -/// The build output for error diagnostics, may be null. -/// The NuGet channel name used, if any. -internal record AppHostServerSessionResult( - bool Success, - IAppHostServerSession? Session, - OutputCollector? BuildOutput, - string? ChannelName); diff --git a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs index 1b0d5bc4e83..7850e7e81e8 100644 --- a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs +++ b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs @@ -1,10 +1,54 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Processes; using Aspire.Cli.Utils; namespace Aspire.Cli.Projects; +/// +/// Optional knobs that govern how a guest process is spawned and torn down. Defaults +/// preserve today's behavior for non-Run callers (publish, scaffolding) — they spawn +/// inheriting the parent console and fall back to force-kill on cancellation. The +/// aspire run path passes a populated record so the launcher uses the same +/// new-console + graceful-then-tree-kill ladder that AppHostServerSession uses +/// for the AppHost server child. +/// +/// +/// When , spawn the guest via +/// so it lands in its own hidden console +/// group. Required on Windows for the AttachConsole + GenerateConsoleCtrlEvent dance +/// (executed by ) to target the guest +/// without also signalling the CLI itself. No-op on Unix where SIGTERM is sufficient. +/// +/// +/// The per-OS "ask this process tree to shut down" primitive (DCP stop-process-tree +/// on Windows, SIGTERM via ProcessSignaler on Unix). When non- +/// (and is non-), the launcher's +/// cancellation path issues this signal before escalating to Process.Kill. +/// +/// +/// The central graceful-shutdown budget. Its +/// bounds both the graceful-signal call and the post-signal wait-for-exit, so a 2nd Ctrl+C +/// (which calls via the cancellation manager) +/// interrupts both immediately and the ladder escalates to Kill(entireProcessTree: true). +/// +/// +/// Windows-only crash-time safety net. When supplied (and +/// is ), the +/// guest process is atomically assigned to this kill-on-close job at spawn time so a CLI +/// crash takes the guest with it instead of leaving an orphaned process in its own console +/// group. on non-Windows hosts (Unix process-group semantics handle +/// the equivalent case via normal signal delivery). DCP and other infrastructure that needs +/// to outlive the CLI for its own cleanup is expected to use CREATE_BREAKAWAY_FROM_JOB +/// to opt out of this kill. +/// +internal sealed record GuestLaunchOptions( + bool IsolateConsoleForGracefulShutdown = false, + IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler = null, + GracefulShutdownService? ShutdownService = null, + WindowsConsoleProcessJob? ConsoleProcessJob = null); + /// /// Strategy for launching a guest language process. /// @@ -19,5 +63,6 @@ internal interface IGuestProcessLauncher DirectoryInfo workingDirectory, IDictionary environmentVariables, CancellationToken cancellationToken, - Func? afterLaunchAsync = null); + Func? afterLaunchAsync = null, + GuestLaunchOptions? options = null); } diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index d87fc185867..63e555d5813 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -13,6 +13,7 @@ using Aspire.Cli.Layout; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; +using Aspire.Cli.Processes; using Aspire.Cli.Utils; using Aspire.Hosting; using Aspire.Shared; @@ -846,48 +847,83 @@ private static string GetRestoreVersion(string packageName, string version, bool internal static string RedactSourceForDisplay(string source) => PackageSourceRedactor.RedactForDisplay(source); /// - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public AppHostServerRunResult Run( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, - bool debug = false) + bool debug = false, + bool isolateConsole = false, + WindowsConsoleProcessJob? consoleProcessJob = null) { var startInfo = CreateStartInfo(hostPid, environmentVariables, additionalArgs, debug); + var outputCollector = new OutputCollector(); + var arguments = startInfo.ArgumentList.ToArray(); + + // Pulled out so the inherited-console (event-based) and isolated (handler-based) paths + // produce identical log/collector output. The log level + prefix differ from the dotnet-based + // server (see DotNetBasedAppHostServerProject.Run) — keeping them here keeps both spawn + // variants on the same per-line behavior for this server. + void OnStdout(int pid, string line) + { + // Promoted from LogTrace to LogDebug so that apphost-server stdout reaches the + // CLI's on-disk log under the default file-logger filter (Debug). Previously + // these lines were dropped entirely, which made apphost-side warnings + // (for example, "LoaderExceptions" from the type-discovery path) invisible to + // anyone diagnosing a "no code generator found" / "no language support found" + // error. See https://github.com/microsoft/aspire/issues/16729. + _logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", pid, line); + outputCollector.AppendOutput(line); + } + + void OnStderr(int pid, string line) + { + // Promoted from LogTrace to LogInformation so that apphost-server stderr is + // visible at the default console log level (Information). Stderr is reserved + // for genuine problems in well-behaved server processes, so surfacing it + // by default is appropriate. See https://github.com/microsoft/aspire/issues/16729. + _logger.LogInformation("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", pid, line); + outputCollector.AppendError(line); + } + + if (isolateConsole) + { + var isolated = IsolatedConsoleSpawner.StartIsolated(startInfo, consoleProcessJob, OnStdout, OnStderr); + return new AppHostServerRunResult( + _socketPath, + isolated.Process, + outputCollector, + startInfo.FileName, + arguments, + isolated); + } var process = Process.Start(startInfo)!; - var outputCollector = new OutputCollector(); process.OutputDataReceived += (_, e) => { if (e.Data is not null) { - // Promoted from LogTrace to LogDebug so that apphost-server stdout reaches the - // CLI's on-disk log under the default file-logger filter (Debug). Previously - // these lines were dropped entirely, which made apphost-side warnings - // (for example, "LoaderExceptions" from the type-discovery path) invisible to - // anyone diagnosing a "no code generator found" / "no language support found" - // error. See https://github.com/microsoft/aspire/issues/16729. - _logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data); - outputCollector.AppendOutput(e.Data); + OnStdout(process.Id, e.Data); } }; process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { - // Promoted from LogTrace to LogInformation so that apphost-server stderr is - // visible at the default console log level (Information). Stderr is reserved - // for genuine problems in well-behaved server processes, so surfacing it - // by default is appropriate. See https://github.com/microsoft/aspire/issues/16729. - _logger.LogInformation("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data); - outputCollector.AppendError(e.Data); + OnStderr(process.Id, e.Data); } }; process.BeginOutputReadLine(); process.BeginErrorReadLine(); - return (_socketPath, process, outputCollector); + return new AppHostServerRunResult( + _socketPath, + process, + outputCollector, + startInfo.FileName, + arguments, + ProcessLifetimeAdapter.ForProcess(process)); } internal ProcessStartInfo CreateStartInfo( diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 3df478d333a..6174800a1db 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Aspire.Cli.Diagnostics; +using Aspire.Cli.Processes; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -19,7 +20,11 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher private readonly FileLoggerProvider? _fileLoggerProvider; private readonly Func _commandResolver; - public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, Func? commandResolver = null) + public ProcessGuestLauncher( + string language, + ILogger logger, + FileLoggerProvider? fileLoggerProvider = null, + Func? commandResolver = null) { _language = language; _logger = logger; @@ -33,7 +38,8 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? DirectoryInfo workingDirectory, IDictionary environmentVariables, CancellationToken cancellationToken, - Func? afterLaunchAsync = null) + Func? afterLaunchAsync = null, + GuestLaunchOptions? options = null) { var activity = GetCurrentProfilingActivity(); AddEvent(activity, ProfilingTelemetry.Events.GuestProcessResolveStart); @@ -53,29 +59,8 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? AddEvent(activity, ProfilingTelemetry.Events.GuestProcessResolved, TelemetryConstants.Tags.ProcessExecutablePath, resolvedCommandPath); _logger.LogDebug("{ExecutingCommandPrefix}{Command} {Args}", CliLogFormat.MessagePrefixes.Executing, resolvedCommandPath, string.Join(" ", args)); - var startInfo = new ProcessStartInfo - { - FileName = resolvedCommandPath, - WorkingDirectory = workingDirectory.FullName, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - foreach (var arg in args) - { - startInfo.ArgumentList.Add(arg); - } - var effectiveEnvironmentVariables = environmentVariables.ToDictionary(); ProfilingTelemetry.AddActivityContextToEnvironment(activity, effectiveEnvironmentVariables); - foreach (var (key, value) in effectiveEnvironmentVariables) - { - startInfo.EnvironmentVariables[key] = value; - } - - using var process = new Process { StartInfo = startInfo }; var outputCollector = new OutputCollector(_fileLoggerProvider, CliLogFormat.Categories.AppHost); var stdoutCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -83,117 +68,335 @@ public ProcessGuestLauncher(string language, ILogger logger, FileLoggerProvider? var firstStdoutSeen = 0; var firstStderrSeen = 0; - process.OutputDataReceived += (sender, e) => + // Per-line handlers are shared between the two spawn paths. The pid arrives via parameter + // rather than being closed over because the isolated path uses an Action + // at construction time, and we want both paths to fire telemetry/log lines against the same + // pid the line actually came from (no race against the Process variable assignment). + void HandleStdoutLine(int pid, string line) { - if (e.Data is null) + if (Interlocked.Exchange(ref firstStdoutSeen, 1) == 0) { - // ProcessDataReceivedEventArgs.Data is null when the redirected stdout stream closes. - stdoutCompleted.TrySetResult(); + AddEvent(activity, ProfilingTelemetry.Events.GuestFirstStdout, TelemetryConstants.Tags.ProcessPid, pid); } - else - { - if (Interlocked.Exchange(ref firstStdoutSeen, 1) == 0) - { - AddEvent(activity, ProfilingTelemetry.Events.GuestFirstStdout, TelemetryConstants.Tags.ProcessPid, process.Id); - } - _logger.LogTrace("{Language}({ProcessId}) stdout: {Line}", _language, process.Id, e.Data); - outputCollector.AppendOutput(e.Data); + _logger.LogTrace("{Language}({ProcessId}) stdout: {Line}", _language, pid, line); + outputCollector.AppendOutput(line); + } + + void HandleStderrLine(int pid, string line) + { + if (Interlocked.Exchange(ref firstStderrSeen, 1) == 0) + { + AddEvent(activity, ProfilingTelemetry.Events.GuestFirstStderr, TelemetryConstants.Tags.ProcessPid, pid); } - }; - process.ErrorDataReceived += (sender, e) => + _logger.LogTrace("{Language}({ProcessId}) stderr: {Line}", _language, pid, line); + outputCollector.AppendError(line); + } + + Process process; + IAsyncDisposable? lifetime = null; + Task stdoutDrain; + Task stderrDrain; + + AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); + + try { - if (e.Data is null) + if (options?.IsolateConsoleForGracefulShutdown == true) { - // ProcessDataReceivedEventArgs.Data is null when the redirected stderr stream closes. - stderrCompleted.TrySetResult(); + // Run-path spawn: isolated console group + anonymous-pipe stdio so DCP's + // AttachConsole + GenerateConsoleCtrlEvent dance can target the guest without + // also signalling the CLI itself. Build the canonical ProcessStartInfo first so + // env/arg shape stays identical to the inherited branch; IsolatedConsoleSpawner + // translates to IsolatedProcessStartInfo and fail-fasts on Windows + null job. + var startInfo = new ProcessStartInfo + { + FileName = resolvedCommandPath, + WorkingDirectory = workingDirectory.FullName, + }; + + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + foreach (var (key, value) in effectiveEnvironmentVariables) + { + startInfo.Environment[key] = value; + } + + var isolatedChild = IsolatedConsoleSpawner.StartIsolated( + startInfo, + options.ConsoleProcessJob, + HandleStdoutLine, + HandleStderrLine); + process = isolatedChild.Process; + stdoutDrain = isolatedChild.StandardOutputClosed; + stderrDrain = isolatedChild.StandardErrorClosed; + lifetime = isolatedChild; } else { - if (Interlocked.Exchange(ref firstStderrSeen, 1) == 0) + // Inherited-console spawn — today's behavior, retained for non-Run callers + // (publish, scaffolding) where the new-console dance is unnecessary. + var startInfo = new ProcessStartInfo + { + FileName = resolvedCommandPath, + WorkingDirectory = workingDirectory.FullName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + foreach (var (key, value) in effectiveEnvironmentVariables) { - AddEvent(activity, ProfilingTelemetry.Events.GuestFirstStderr, TelemetryConstants.Tags.ProcessPid, process.Id); + startInfo.EnvironmentVariables[key] = value; } - _logger.LogTrace("{Language}({ProcessId}) stderr: {Line}", _language, process.Id, e.Data); - outputCollector.AppendError(e.Data); + var inheritedProcess = new Process { StartInfo = startInfo }; + // Publish the lifetime immediately so a fault between here and the end of the + // wiring block (Start, BeginOutputReadLine, BeginErrorReadLine) still runs disposal + // through the finally — Process owns an OS handle even before Start. + lifetime = ProcessLifetimeAdapter.ForProcess(inheritedProcess); + inheritedProcess.OutputDataReceived += (sender, e) => + { + if (e.Data is null) + { + // ProcessDataReceivedEventArgs.Data is null when the redirected stdout stream closes. + stdoutCompleted.TrySetResult(); + } + else + { + HandleStdoutLine(inheritedProcess.Id, e.Data); + } + }; + inheritedProcess.ErrorDataReceived += (sender, e) => + { + if (e.Data is null) + { + // ProcessDataReceivedEventArgs.Data is null when the redirected stderr stream closes. + stderrCompleted.TrySetResult(); + } + else + { + HandleStderrLine(inheritedProcess.Id, e.Data); + } + }; + inheritedProcess.Start(); + inheritedProcess.BeginOutputReadLine(); + inheritedProcess.BeginErrorReadLine(); + process = inheritedProcess; + stdoutDrain = stdoutCompleted.Task; + stderrDrain = stderrCompleted.Task; } - }; - AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); - process.Start(); - _logger.LogDebug("{Language} guest process {ProcessId} started: {Command}", _language, process.Id, resolvedCommandPath); - activity?.SetTag(TelemetryConstants.Tags.ProcessPid, process.Id); - AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStarted, TelemetryConstants.Tags.ProcessPid, process.Id); - if (afterLaunchAsync is not null) - { - await afterLaunchAsync().ConfigureAwait(false); - } + _logger.LogDebug("{Language} guest process {ProcessId} started: {Command}", _language, process.Id, resolvedCommandPath); + activity?.SetTag(TelemetryConstants.Tags.ProcessPid, process.Id); + AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStarted, TelemetryConstants.Tags.ProcessPid, process.Id); + if (afterLaunchAsync is not null) + { + await afterLaunchAsync().ConfigureAwait(false); + } - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + try + { + using var _ = cancellationToken.Register(() => + _logger.LogInformation("Cancellation requested while waiting for {Language} guest process {ProcessId} to exit", _language, process.Id)); - try - { - var waitForExitTask = process.WaitForExitAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The guest process is the AppHost's primary process for this language. When the caller + // cancels - either because the user pressed Ctrl+C or because a fatal startup condition + // (e.g. the AppHost server backchannel timed out) escalated into a teardown - we must kill + // the process tree, otherwise the AppHost stays alive after the CLI returns and the run + // appears to hang from the user's perspective. + // + // We don't rethrow the OperationCanceledException because the caller in GuestAppHostProject + // uses the returned exit code to distinguish user cancellation from internal teardown + // (e.g. surfacing captured output when the guest was killed because the AppHost system + // failed). Wait without honoring cancellation so the OS reports the final exit code and + // the redirected output streams have time to drain. + await ShutdownGuestProcessAsync(process, options, cancellationToken).ConfigureAwait(false); + } - using var _ = cancellationToken.Register(() => - _logger.LogInformation("Cancellation requested while waiting for {Language} guest process {ProcessId} to exit", _language, process.Id)); + _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, process.ExitCode); + + activity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); + AddEvent(activity, ProfilingTelemetry.Events.GuestProcessExited, TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); + + // Wait for the redirected streams to finish draining so no trailing lines are lost. + // Pass a fresh token rather than the outer cancellation token: when WaitForExitAsync + // above was canceled we deliberately killed the process and want to give the streams + // their full 5s grace period to flush trailing lines, otherwise drain would short-circuit + // immediately and we'd both drop output and log a misleading "drain timeout" warning. + if (!await WaitForDrainAsync(Task.WhenAll(stdoutDrain, stderrDrain))) + { + AddEvent(activity, ProfilingTelemetry.Events.GuestOutputDrainTimeout, TelemetryConstants.Tags.ProcessPid, process.Id); + _logger.LogWarning("{Language}({ProcessId}): Timed out waiting for output streams to drain after process exit", _language, process.Id); + } - await waitForExitTask.ConfigureAwait(false); + return (process.ExitCode, outputCollector); } - catch (OperationCanceledException) + finally { - // The guest process is the AppHost's primary process for this language. When the caller - // cancels - either because the user pressed Ctrl+C or because a fatal startup condition - // (e.g. the AppHost server backchannel timed out) escalated into a teardown - we must kill - // the process tree, otherwise the AppHost stays alive after the CLI returns and the run - // appears to hang from the user's perspective. - // - // We don't rethrow the OperationCanceledException because the caller in GuestAppHostProject - // uses the returned exit code to distinguish user cancellation from internal teardown - // (e.g. surfacing captured output when the guest was killed because the AppHost system - // failed). Wait without honoring cancellation so the OS reports the final exit code and - // the redirected output streams have time to drain. - if (!process.HasExited) + // Single disposal site for both spawn paths. The lifetime is either an IsolatedProcess + // (which drains pumps + closes the anonymous pipes + NUL stdin handle on top of + // disposing the Process) or a ProcessLifetimeAdapter that just disposes the Process. + // Null when the spawn itself threw (e.g. IsolatedConsoleSpawner fail-fast) — nothing + // to dispose in that case. + if (lifetime is not null) { - _logger.LogInformation("Killing {Language} guest process tree {ProcessId}", _language, process.Id); - try - { - process.Kill(entireProcessTree: true); - } - catch (Exception killEx) - { - _logger.LogDebug(killEx, "Failed to kill guest process {ProcessId} after cancellation", process.Id); - } + await lifetime.DisposeAsync().ConfigureAwait(false); } - else + } + } + + private async Task ShutdownGuestProcessAsync( + Process process, + GuestLaunchOptions? options, + CancellationToken cancellationToken) + { + // Force-kill path: take this when graceful infra isn't wired (non-Run callers — publish, + // extension adapter, anyone not opting into the central shutdown budget). We don't have + // a central token to bound a graceful wait against, so kill the tree and let + // WaitForDrainAsync surface any trailing output. This preserves the previous tactical + // behavior (graceful on Unix, force-kill on Windows) for callers that pass no options. + if (options?.GracefulShutdownSignaler is null || options.ShutdownService is null) + { + _logger.LogInformation("Stopping {Language} guest process tree {ProcessId}", _language, process.Id); + try + { + await ProcessTerminator.ShutdownAsync( + process, + requestGracefulShutdown: !OperatingSystem.IsWindows(), + entireProcessTree: true, + _logger, + $"{_language} guest", + gracefulShutdownCancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) { - _logger.LogDebug("{Language} guest process {ProcessId} already exited before kill", _language, process.Id); + _logger.LogWarning(ex, "Failed to shut down {Language} guest process {ProcessId}.", _language, SafePid(process)); } - _logger.LogDebug("Waiting for {Language} guest process {ProcessId} to exit after kill", _language, process.Id); - await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + return; } - _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, process.ExitCode); + // Run-path graceful ladder — mirrors AppHostServerSession.ShutdownAsync. Whoever + // initiated shutdown (CCM via RequestShutdown/Cancel) already started the central + // graceful clock; we only consume the token. + var gracefulToken = options.ShutdownService.Token; + + // Phase 1: best-effort graceful signal bounded by the central token. The DCP path on + // Windows shells out (layout discovery + DCP process launch + wait) and could consume + // the entire graceful window before we ever reach WaitForExitAsync. Sharing the central + // token means a 2nd-Ctrl+C Expire() interrupts the slow DCP shell-out exactly the same + // way it interrupts the WaitForExitAsync below. + try + { + DateTimeOffset? startTime = TryGetStartTime(process); + await options.GracefulShutdownSignaler.RequestProcessTreeGracefulShutdownAsync( + process.Id, + startTime, + includeStartTimeForDcp: false, + gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (gracefulToken.IsCancellationRequested) + { + // Graceful budget expired before the signal could be issued; fall through to kill. + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to issue graceful shutdown to {Language} guest (pid {Pid}); escalating to kill.", _language, SafePid(process)); + } + + // Phase 2: wait for exit bounded by the same central token. + try + { + await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Graceful budget expired; fall through to kill. + } + + if (process.HasExited) + { + return; + } + + // Phase 3: ALWAYS tree-kill on escalation. Even when the graceful signal returned + // cleanly, the tree may still be alive — e.g. on Windows, tsx wraps node and swallows + // Ctrl+C/Ctrl+Break, leaving the child node and any further descendants running after + // the tsx shell exits. Tree-kill is the only guarantee that nothing is orphaned. + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // Process exited between HasExited check and Kill — nothing to do. + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to kill {Language} guest (pid {Pid}).", _language, SafePid(process)); + return; + } - activity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); - AddEvent(activity, ProfilingTelemetry.Events.GuestProcessExited, TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); + // Phase 4: brief separately-bounded drain after kill — independent of central token + // because by now the central budget has already expired. 1 s is enough for the OS to + // reap the process so the subsequent ExitCode read succeeds. + try + { + using var killDrain = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await process.WaitForExitAsync(killDrain.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Best-effort; nothing more we can do. + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error draining killed {Language} guest (pid {Pid}).", _language, SafePid(process)); + } + } - // Wait for the redirected streams to finish draining so no trailing lines are lost. - // Pass a fresh token rather than the outer cancellation token: when WaitForExitAsync - // above was canceled we deliberately killed the process and want to give the streams - // their full 5s grace period to flush trailing lines, otherwise drain would short-circuit - // immediately and we'd both drop output and log a misleading "drain timeout" warning. - if (!await WaitForDrainAsync(Task.WhenAll(stdoutCompleted.Task, stderrCompleted.Task))) + private static DateTimeOffset? TryGetStartTime(Process process) + { + // Process.StartTime can throw InvalidOperationException on a process whose handle was + // closed or that has exited in some states. Treat as unavailable rather than aborting + // the graceful path — the Unix branch ignores StartTime entirely, and the Windows DCP + // branch only uses it when includeStartTimeForDcp is true (which it isn't here). + try { - AddEvent(activity, ProfilingTelemetry.Events.GuestOutputDrainTimeout, TelemetryConstants.Tags.ProcessPid, process.Id); - _logger.LogWarning("{Language}({ProcessId}): Timed out waiting for output streams to drain after process exit", _language, process.Id); + return process.StartTime; } + catch (Exception) + { + return null; + } + } - return (process.ExitCode, outputCollector); + private static int SafePid(Process process) + { + try + { + return process.Id; + } + catch (Exception) + { + return -1; + } } private static Activity? GetCurrentProfilingActivity() @@ -237,5 +440,6 @@ private static async Task WaitForDrainAsync(Task drainTask) { return false; } + } } diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 3e800039939..c352d7c10ec 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -136,11 +136,16 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can } // Step 2: Start the server temporarily for scaffolding and code generation - await using var serverSession = AppHostServerSession.Start( + await using var serverSession = new AppHostServerSession( appHostServerProject, environmentVariables: null, debug: false, - _logger); + _logger, + cancellationToken); + // Short-lived RPC session: discard the completion task; disposal flows the exit code + // through the activity scope and the only failure mode we care about surfaces via the + // RPC call below. + _ = serverSession.StartAsync(); // Step 3: Connect to server and get scaffold templates via RPC var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); diff --git a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs index dd0ceda244b..b2500ef7950 100644 --- a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs @@ -697,12 +697,12 @@ public static AppHostLauncherHarness Create(ITestOutputHelper outputHelper) var monitor = new TestAuxiliaryBackchannelMonitor(); var processLauncher = new TestDetachedProcessLauncher(); var fileLoggerProvider = new FileLoggerProvider(executionContext.LogFilePath, new TestStartupErrorWriter()); - var processShutdownService = new ProcessShutdownService( + var processShutdownService = new DetachedAppHostShutdownService( new FixedLayoutDiscovery(), new NullBundleService(), new LayoutProcessRunner(new TestProcessExecutionFactory()), executionContext, - NullLogger.Instance, + NullLogger.Instance, TimeProvider.System); var launcher = new AppHostLauncher( new TestProjectLocator(), diff --git a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs index 8555e99c39c..50eaf4fac76 100644 --- a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs +++ b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs @@ -7,22 +7,41 @@ namespace Aspire.Cli.Tests; public class ConsoleCancellationManagerTests { + [Fact] + public void Constructor_NullGracefulService_Throws() + { + Assert.Throws(() => new ConsoleCancellationManager(null!, TimeSpan.FromSeconds(5))); + } + + [Fact] + public void ConfigureForCommand_NegativeBudget_Throws() + { + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); + + Assert.Throws(() => manager.ConfigureForCommand(TimeSpan.FromMilliseconds(-1))); + } + [Fact] public void FirstSignal_RequestsCancellation() { - using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); Assert.False(manager.IsCancellationRequested); + Assert.Null(manager.RequestedExitCode); manager.Cancel(130); Assert.True(manager.IsCancellationRequested); + Assert.Equal(130, manager.RequestedExitCode); } [Fact] public void FirstSignal_TokenIsCancelled() { - using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); var token = manager.Token; Assert.False(token.IsCancellationRequested); @@ -33,55 +52,115 @@ public void FirstSignal_TokenIsCancelled() } [Fact] - public async Task SecondSignal_ForcesImmediateTermination() + public async Task FirstSignal_DefaultZeroBudget_ExpiresGracefulImmediately() { - using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); - // Set a handler that never completes so the first signal doesn't resolve ProcessTerminationCompletionSource + // No ConfigureForCommand — graceful budget defaults to zero. + // Set a handler that never completes so the drain budget governs forced termination. manager.SetStartedHandler(new TaskCompletionSource().Task); manager.Cancel(130); + + // The graceful token must fire essentially immediately (no Phase 1 delay to wait through). + await graceful.Token.WaitForCancellationAsync().DefaultTimeout(); + Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); + } + + [Fact] + public async Task FirstSignal_NonZeroBudget_DelaysExpireUntilBudgetElapses() + { + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + manager.ConfigureForCommand(TimeSpan.FromMilliseconds(200)); + + // Set a handler that never completes so we observe the graceful → drain timing. + manager.SetStartedHandler(new TaskCompletionSource().Task); + + var sw = System.Diagnostics.Stopwatch.StartNew(); manager.Cancel(130); - var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); - Assert.Equal(130, exitCode); + // Right after Cancel, the graceful token should NOT have fired yet (we're in Phase 1). + await Task.Delay(50); + Assert.False(graceful.Token.IsCancellationRequested, "Graceful token fired before budget elapsed."); + + // Wait for the budget to elapse. + await graceful.Token.WaitForCancellationAsync().DefaultTimeout(); + sw.Stop(); + + // We allowed 200ms of grace; allow generous slack for CI scheduling but assert we waited at least most of it. + Assert.True(sw.ElapsedMilliseconds >= 150, $"Graceful token fired after only {sw.ElapsedMilliseconds}ms (expected ~200ms)."); } [Fact] - public async Task FirstSignal_WithNoHandler_ForcesTerminationAfterTimeout() + public async Task SecondSignal_ExpiresGracefulImmediately() { - using var manager = new ConsoleCancellationManager(TimeSpan.FromMilliseconds(50)); + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + // Large graceful budget — without a 2nd signal the token would not fire for 30s. + manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); - // No handler set, so ForceTerminationAfterTimeoutAsync should complete quickly - manager.Cancel(143); + // Set a handler that never completes; 2nd signal should ONLY collapse graceful (not Phase 3). + manager.SetStartedHandler(new TaskCompletionSource().Task); + + manager.Cancel(130); + Assert.False(graceful.Token.IsCancellationRequested); + + manager.Cancel(130); + + // 2nd signal expires graceful synchronously. + Assert.True(graceful.Token.IsCancellationRequested); + + // But the completion source should NOT have fired — that's Phase 3, requires a 3rd signal or drain timeout. + await Task.Delay(100); + Assert.False(manager.ProcessTerminationCompletionSource.Task.IsCompleted); + } + + [Fact] + public async Task ThirdSignal_FiresProcessTerminationImmediately() + { + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + // Long graceful budget so the watcher would not naturally complete in the test window. + manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); + + // Handler that never completes so Phase 2's WhenAny doesn't resolve via the handler branch. + manager.SetStartedHandler(new TaskCompletionSource().Task); + + manager.Cancel(130); + manager.Cancel(130); + manager.Cancel(130); var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); - Assert.Equal(143, exitCode); + Assert.Equal(130, exitCode); } [Fact] - public async Task FirstSignal_HandlerCompletesWithinTimeout_DoesNotForceTermination() + public async Task FirstSignal_HandlerCompletesWithinDrainBudget_DoesNotForceTermination() { - using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); - // Set a handler that completes immediately + // Set a handler that completes immediately. manager.SetStartedHandler(Task.FromResult(0)); manager.Cancel(130); - // Give the async timeout path time to evaluate + // Give the async watcher time to evaluate. await Task.Delay(100); - // ProcessTerminationCompletionSource should NOT be signaled because the handler completed in time + // ProcessTerminationCompletionSource should NOT be signaled because the handler completed in time. Assert.False(manager.ProcessTerminationCompletionSource.Task.IsCompleted); } [Fact] - public async Task FirstSignal_HandlerExceedsTimeout_ForcesTermination() + public async Task FirstSignal_HandlerExceedsDrainBudget_ForcesTermination() { - using var manager = new ConsoleCancellationManager(TimeSpan.FromMilliseconds(50)); + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromMilliseconds(50)); - // Set a handler that never completes + // Set a handler that never completes. manager.SetStartedHandler(new TaskCompletionSource().Task); manager.Cancel(143); @@ -90,59 +169,200 @@ public async Task FirstSignal_HandlerExceedsTimeout_ForcesTermination() Assert.Equal(143, exitCode); } + [Fact] + public async Task FirstSignal_WithNoHandler_ForcesTerminationAfterDrainBudget() + { + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromMilliseconds(50)); + + // No handler set, watcher still has to wait out the drain budget before forcing termination. + manager.Cancel(143); + + var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); + Assert.Equal(143, exitCode); + } + + [Fact] + public async Task GracefulBudgetElapses_ThenDrainBudgetElapses_FiresProcessTermination() + { + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromMilliseconds(50)); + manager.ConfigureForCommand(TimeSpan.FromMilliseconds(50)); + + manager.SetStartedHandler(new TaskCompletionSource().Task); + manager.Cancel(130); + + var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); + Assert.Equal(130, exitCode); + Assert.True(graceful.Token.IsCancellationRequested); + } + [Fact] public void Cancel_IsNonBlocking() { - using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); - // Set a handler that never completes manager.SetStartedHandler(new TaskCompletionSource().Task); - // Cancel should return immediately without blocking (this would hang if Cancel were synchronous) var sw = System.Diagnostics.Stopwatch.StartNew(); manager.Cancel(130); sw.Stop(); - // Cancel should complete in well under a second (it's non-blocking) Assert.True(sw.ElapsedMilliseconds < 1000, $"Cancel took {sw.ElapsedMilliseconds}ms, expected < 1000ms"); Assert.True(manager.IsCancellationRequested); } [Fact] - public async Task MultipleSignals_OnlyFirstAndSecondHaveEffect() + public void RequestShutdown_RequestsCancellation_AndCapturesExitCode() { - using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); - // Set a handler that never completes + manager.RequestShutdown(42); + + Assert.True(manager.IsCancellationRequested); + Assert.Equal(42, manager.RequestedExitCode); + } + + [Fact] + public async Task RequestShutdown_RepeatedInternalCalls_DoNotCollapseGracefulWindow() + { + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); manager.SetStartedHandler(new TaskCompletionSource().Task); - // Third signal should not throw or cause issues - manager.Cancel(130); + // Two concurrent internal teardown callers (e.g. backchannel-fail + guest-non-zero) must + // NOT be treated as "second Ctrl+C" — that would collapse the graceful window and defeat + // the budget. Only a real user signal via Cancel() should escalate. + manager.RequestShutdown(42); + manager.RequestShutdown(99); + + Assert.True(manager.IsCancellationRequested); + Assert.Equal(42, manager.RequestedExitCode); + + // Graceful token should NOT be expired by repeated internal requests — the watcher's + // Task.Delay(graceful budget) is still arming. Give the scheduler a tick to prove it. + await Task.Delay(50); + Assert.False(graceful.Token.IsCancellationRequested); + } + + [Fact] + public async Task RequestShutdown_ThenSingleUserCancel_DoesNotCollapseGracefulWindow() + { + // Regression: previously RequestShutdown and Cancel shared a single counter, so the user's + // first Ctrl+C after any internal RequestShutdown was observed as signalCount == 2 and + // immediately expired the graceful window — defeating the entire ladder during the normal + // "guest exited cleanly, drive teardown" flow in GuestAppHostProject.RunAsync. + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); + manager.SetStartedHandler(new TaskCompletionSource().Task); + + // Internal request starts the ladder. + manager.RequestShutdown(0); + Assert.True(manager.IsCancellationRequested); + + // The user then hits Ctrl+C ONCE. This is the user's first signal; they must press a second + // time to expire the graceful window. manager.Cancel(130); + + await Task.Delay(50); + Assert.False( + graceful.Token.IsCancellationRequested, + "First user signal after an internal RequestShutdown must not expire the graceful window."); + + // A second user signal SHOULD escalate. manager.Cancel(130); + await Task.Delay(50); + Assert.True( + graceful.Token.IsCancellationRequested, + "Second user signal must expire the graceful window even if an internal RequestShutdown ran first."); + } - var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); - Assert.Equal(130, exitCode); + [Fact] + public void RequestedExitCode_FirstCallerWins() + { + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); + + manager.SetStartedHandler(new TaskCompletionSource().Task); + + manager.Cancel(42); + manager.RequestShutdown(99); + manager.Cancel(7); + + Assert.Equal(42, manager.RequestedExitCode); + } + + [Fact] + public async Task ProcessTermination_FiresGracefulExpiration_LaddersUnblock() + { + using var graceful = new GracefulShutdownService(); + using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + + // A ladder observing only the graceful token, awaiting a long delay. + var ladderTask = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromMinutes(5), graceful.Token); + return false; + } + catch (OperationCanceledException) + { + return true; + } + }); + + // Simulate Main deciding to leave NOW — completion source fires for reasons unrelated to a + // 2nd Ctrl+C (e.g. external runtime tear-down). The graceful token must fire too so the ladder + // unblocks in time to escalate before Main abandons it. + manager.ProcessTerminationCompletionSource.TrySetResult(99); + + var unblocked = await ladderTask.DefaultTimeout(); + Assert.True(unblocked, "Ladder did not observe graceful token cancellation after process termination fired."); } [Fact] public void Dispose_AllowsSubsequentCancelWithoutException() { - var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + using var graceful = new GracefulShutdownService(); + var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); manager.Dispose(); - // Cancel after dispose should not throw (signal can race with shutdown) + // Cancel after dispose should not throw (signal can race with shutdown). manager.Cancel(130); } [Fact] public void Token_RemainsAccessibleAfterDispose() { - var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + using var graceful = new GracefulShutdownService(); + var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); var token = manager.Token; manager.Dispose(); - // Token should still be accessible (stored in field before dispose) + // Token should still be accessible (stored in field before dispose). Assert.False(token.IsCancellationRequested); } } + +internal static class CancellationTokenTestExtensions +{ + public static Task WaitForCancellationAsync(this CancellationToken token) + { + if (token.IsCancellationRequested) + { + return Task.CompletedTask; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = token.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), tcs); + tcs.Task.ContinueWith(static (_, state) => ((CancellationTokenRegistration)state!).Dispose(), registration, TaskScheduler.Default); + return tcs.Task; + } +} diff --git a/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs b/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs new file mode 100644 index 00000000000..474042c6f97 --- /dev/null +++ b/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests; + +public class GracefulShutdownServiceTests +{ + [Fact] + public void Token_BeforeExpire_NotCancelled() + { + using var service = new GracefulShutdownService(); + + Assert.False(service.Token.IsCancellationRequested); + } + + [Fact] + public void Expire_FiresToken() + { + using var service = new GracefulShutdownService(); + + service.Expire(); + + Assert.True(service.Token.IsCancellationRequested); + } + + [Fact] + public void Expire_Idempotent() + { + using var service = new GracefulShutdownService(); + + service.Expire(); + service.Expire(); + service.Expire(); + + Assert.True(service.Token.IsCancellationRequested); + } + + [Fact] + public void Expire_AfterDispose_DoesNotThrow() + { + var service = new GracefulShutdownService(); + service.Dispose(); + + // Expire racing with dispose must not propagate to callers (signal handler / + // watcher continuation contexts have nowhere meaningful to surface this). + service.Expire(); + } + + [Fact] + public void Token_RemainsAccessibleAfterDispose() + { + var service = new GracefulShutdownService(); + var token = service.Token; + service.Dispose(); + + // Token was captured up front; reading state after dispose must not throw. + Assert.False(token.IsCancellationRequested); + } +} diff --git a/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs b/tests/Aspire.Cli.Tests/Processes/DetachedAppHostShutdownServiceTests.cs similarity index 96% rename from tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs rename to tests/Aspire.Cli.Tests/Processes/DetachedAppHostShutdownServiceTests.cs index bb0879e0d1d..afed839c641 100644 --- a/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Processes/DetachedAppHostShutdownServiceTests.cs @@ -15,7 +15,7 @@ namespace Aspire.Cli.Tests.Processes; -public class ProcessShutdownServiceTests(ITestOutputHelper outputHelper) +public class DetachedAppHostShutdownServiceTests(ITestOutputHelper outputHelper) { [Fact] public async Task TryStopProcessTreeWithDcpAsync_UsesDcpStopProcessTreeArguments() @@ -50,7 +50,7 @@ public async Task TryStopProcessTreeWithDcpAsync_UsesDcpStopProcessTreeArguments "--pid", Environment.ProcessId.ToString(CultureInfo.InvariantCulture), "--process-start-time", - ProcessShutdownService.FormatDcpProcessStartTime(startTime) + DetachedAppHostShutdownService.FormatDcpProcessStartTime(startTime) ], capturedArguments); } @@ -143,7 +143,7 @@ public async Task StopAppHostAsync_CleansUpCliProcessWithoutWaitingForItAsSucces } } - private static ProcessShutdownService CreateService( + private static DetachedAppHostShutdownService CreateService( TemporaryWorkspace workspace, string dcpDirectory, TestProcessExecutionFactory executionFactory, @@ -158,12 +158,12 @@ private static ProcessShutdownService CreateService( workspace.WorkspaceRoot.CreateSubdirectory("logs"), Path.Combine(workspace.WorkspaceRoot.FullName, "test.log")); - return new ProcessShutdownService( + return new DetachedAppHostShutdownService( new FixedLayoutDiscovery(dcpDirectory), bundleService ?? new NullBundleService(), new LayoutProcessRunner(executionFactory), executionContext, - NullLogger.Instance, + NullLogger.Instance, timeProvider ?? TimeProvider.System); } diff --git a/tests/Aspire.Cli.Tests/Processes/IsolatedProcessTests.cs b/tests/Aspire.Cli.Tests/Processes/IsolatedProcessTests.cs new file mode 100644 index 00000000000..9847ef03195 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Processes/IsolatedProcessTests.cs @@ -0,0 +1,135 @@ +// 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.Cli.Processes; + +namespace Aspire.Cli.Tests.Processes; + +public class IsolatedProcessTests +{ + [Fact] + public async Task Start_EchoesLine_InvokesOutputCallbackAndCompletesStandardOutputClosed() + { + var stdout = new ConcurrentQueue(); + var stderr = new ConcurrentQueue(); + + var (fileName, arguments) = GetEchoCommand("hello-from-launcher"); + + var startInfo = new IsolatedProcessStartInfo + { + FileName = fileName, + WorkingDirectory = Environment.CurrentDirectory, + }; + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg); + } + + await using var child = IsolatedProcess.Start( + startInfo, + standardOutputHandler: (_, line) => stdout.Enqueue(line), + standardErrorHandler: (_, line) => stderr.Enqueue(line)); + + // Both pumps complete on pipe EOF — child exits within tens of milliseconds, but + // the OS pipe close + StreamReader drain can take a bit longer under load. + await Task.WhenAll(child.StandardOutputClosed, child.StandardErrorClosed).WaitAsync(TimeSpan.FromSeconds(10)); + await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Contains("hello-from-launcher", stdout); + Assert.Equal(0, child.ExitCode); + } + + [Fact] + public async Task Start_ExposesFileNameAndArgumentsOnReturnedChild() + { + var (fileName, arguments) = GetEchoCommand("metadata-check"); + + var startInfo = new IsolatedProcessStartInfo + { + FileName = fileName, + WorkingDirectory = Environment.CurrentDirectory, + }; + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg); + } + + await using var child = IsolatedProcess.Start( + startInfo, + standardOutputHandler: static (_, _) => { }, + standardErrorHandler: static (_, _) => { }); + + // Carried explicitly because Process.GetProcessById returns a Process whose + // StartInfo is empty — telemetry callers depend on these fields. + Assert.Equal(fileName, child.FileName); + Assert.Equal(arguments, child.Arguments); + + await Task.WhenAll(child.StandardOutputClosed, child.StandardErrorClosed).WaitAsync(TimeSpan.FromSeconds(10)); + await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task Start_CallbackThrows_PumpDrainsToEndAndFaultsStandardOutputClosed() + { + // Emit two lines; callback throws on the FIRST and records every line it sees so we + // can verify the pump kept draining (i.e. did not abandon the pipe after the throw). + var seenLines = new ConcurrentQueue(); + var (fileName, arguments) = GetTwoLineCommand("line-one", "line-two"); + + var startInfo = new IsolatedProcessStartInfo + { + FileName = fileName, + WorkingDirectory = Environment.CurrentDirectory, + }; + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg); + } + + await using var child = IsolatedProcess.Start( + startInfo, + standardOutputHandler: (_, line) => + { + seenLines.Enqueue(line); + if (line.Contains("line-one")) + { + throw new InvalidOperationException("intentional callback failure"); + } + }, + standardErrorHandler: static (_, _) => { }); + + // StandardOutputClosed should fault with the recorded exception, but only AFTER + // draining every line. The first OperationCanceledException-style early-exit was the bug. + var fault = await Assert.ThrowsAsync( + async () => await child.StandardOutputClosed.WaitAsync(TimeSpan.FromSeconds(10))); + Assert.Equal("intentional callback failure", fault.Message); + + await child.StandardErrorClosed.WaitAsync(TimeSpan.FromSeconds(10)); + await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Contains(seenLines, line => line.Contains("line-one")); + Assert.Contains(seenLines, line => line.Contains("line-two")); + } + + private static (string FileName, IReadOnlyList Arguments) GetEchoCommand(string text) + { + if (OperatingSystem.IsWindows()) + { + // cmd /c echo — cmd ships with every Windows install. + return ("cmd.exe", new[] { "/c", "echo", text }); + } + + return ("/bin/sh", new[] { "-c", $"echo {text}" }); + } + + private static (string FileName, IReadOnlyList Arguments) GetTwoLineCommand(string line1, string line2) + { + if (OperatingSystem.IsWindows()) + { + return ("cmd.exe", new[] { "/c", $"echo {line1}&echo {line2}" }); + } + + return ("/bin/sh", new[] { "-c", $"echo {line1}; echo {line2}" }); + } +} diff --git a/tests/Aspire.Cli.Tests/Processes/WindowsConsoleProcessJobTests.cs b/tests/Aspire.Cli.Tests/Processes/WindowsConsoleProcessJobTests.cs new file mode 100644 index 00000000000..36a5bb9135d --- /dev/null +++ b/tests/Aspire.Cli.Tests/Processes/WindowsConsoleProcessJobTests.cs @@ -0,0 +1,71 @@ +// 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.Runtime.InteropServices; +using System.Runtime.Versioning; +using Aspire.Cli.Processes; + +namespace Aspire.Cli.Tests.Processes; + +public class WindowsConsoleProcessJobTests +{ + [Fact] + [SupportedOSPlatform("windows")] + public void Constructor_OnWindows_SucceedsAndExposesValidHandle() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test."); + + using var job = new WindowsConsoleProcessJob(); + + Assert.NotNull(job.Handle); + Assert.False(job.Handle.IsInvalid); + Assert.False(job.Handle.IsClosed); + } + + [Fact] + [SupportedOSPlatform("windows")] + public async Task Dispose_KillsAssignedChildProcess() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test."); + + var job = new WindowsConsoleProcessJob(); + Process spawnedProcess; + + // Long-running ping (~60s) so we can verify it's still alive between spawn and + // job dispose; the process exits exactly because JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + // fires when the last handle to the job closes (i.e. inside Dispose below). + var startInfo = new IsolatedProcessStartInfo + { + FileName = "cmd.exe", + WorkingDirectory = Environment.CurrentDirectory, + JobHandle = job.Handle, + }; + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("ping"); + startInfo.ArgumentList.Add("-n"); + startInfo.ArgumentList.Add("60"); + startInfo.ArgumentList.Add("127.0.0.1"); + + await using (var child = IsolatedProcess.Start( + startInfo, + standardOutputHandler: static (_, _) => { }, + standardErrorHandler: static (_, _) => { })) + { + spawnedProcess = child.Process; + + // Confirm the child is up before disposing the job — otherwise a fast spawn + // failure would look identical to a successful kill-on-close. + Assert.False(spawnedProcess.HasExited); + + job.Dispose(); + + // KILL_ON_JOB_CLOSE is reliably observable within a couple of seconds; + // give a generous window for CI under load. + await spawnedProcess.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.True(spawnedProcess.HasExited); + } + } +} + diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 97d685e9a96..dde6ebba485 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Layout; using Aspire.Cli.NuGet; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.Mcp; @@ -22,23 +23,22 @@ namespace Aspire.Cli.Tests.Projects; public class AppHostServerSessionTests(ITestOutputHelper outputHelper) { [Fact] - public async Task Start_DoesNotMutateCallerEnvironmentVariables() + public async Task StartAsync_DoesNotMutateCallerEnvironmentVariables() { - // Arrange var project = new RecordingAppHostServerProject(); var environmentVariables = new Dictionary { ["EXISTING_VALUE"] = "present" }; - // Act - await using var session = AppHostServerSession.Start( + await using var session = new AppHostServerSession( project, environmentVariables, debug: false, - NullLogger.Instance); + NullLogger.Instance, + CancellationToken.None); + _ = session.StartAsync(); - // Assert Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken)); @@ -48,7 +48,7 @@ public async Task Start_DoesNotMutateCallerEnvironmentVariables() } [Fact] - public async Task Start_PropagatesProfilingContextToServerEnvironment() + public async Task StartAsync_PropagatesProfilingContextToServerEnvironment() { var project = new RecordingAppHostServerProject(); var environmentVariables = new Dictionary @@ -60,12 +60,14 @@ public async Task Start_PropagatesProfilingContextToServerEnvironment() (ProfilingTelemetry.EnvironmentVariables.Enabled, "true"), (ProfilingTelemetry.EnvironmentVariables.SessionId, "session-1"))); - await using var session = AppHostServerSession.Start( + await using var session = new AppHostServerSession( project, environmentVariables, debug: false, NullLogger.Instance, + CancellationToken.None, profilingTelemetry); + _ = session.StartAsync(); Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken)); @@ -85,7 +87,7 @@ public async Task Start_PropagatesProfilingContextToServerEnvironment() } [Fact] - public async Task Start_DoesNotLeaveServerProcessActivityAmbient() + public async Task StartAsync_DoesNotLeaveServerProcessActivityAmbient() { var project = new RecordingAppHostServerProject(); using var parentSource = new ActivitySource("test-apphost-server-parent"); @@ -98,12 +100,14 @@ public async Task Start_DoesNotLeaveServerProcessActivityAmbient() using var parentActivity = parentSource.StartActivity("aspire/cli/run"); Assert.NotNull(parentActivity); - await using var session = AppHostServerSession.Start( + await using var session = new AppHostServerSession( project, environmentVariables: null, debug: false, NullLogger.Instance, + CancellationToken.None, profilingTelemetry); + _ = session.StartAsync(); Assert.Same(parentActivity, Activity.Current); @@ -112,29 +116,323 @@ public async Task Start_DoesNotLeaveServerProcessActivityAmbient() } [Fact] - public async Task CreateAsync_DisposesProjectWhenPrepareFails() + public void AuthenticationToken_IsAvailableBeforeStart() + { + var project = new RecordingAppHostServerProject(); + var session = new AppHostServerSession( + project, + environmentVariables: null, + debug: false, + NullLogger.Instance, + CancellationToken.None); + + Assert.False(string.IsNullOrEmpty(session.AuthenticationToken)); + } + + [Fact] + public void SessionState_BeforeStart_IsNull() { - var project = new FakeFailingAppHostServerProject(Directory.GetCurrentDirectory()); - var projectFactory = new TestAppHostServerProjectFactory + var project = new RecordingAppHostServerProject(); + var session = new AppHostServerSession( + project, + environmentVariables: null, + debug: false, + NullLogger.Instance, + CancellationToken.None); + + Assert.Null(session.SocketPath); + Assert.Null(session.Output); + Assert.Null(session.ServerProcess); + } + + [Fact] + public async Task StartAsync_CalledTwice_Throws() + { + var project = new RecordingAppHostServerProject(); + await using var session = new AppHostServerSession( + project, + environmentVariables: null, + debug: false, + NullLogger.Instance, + CancellationToken.None); + + _ = session.StartAsync(); + + Assert.Throws(() => { _ = session.StartAsync(); }); + } + + [Fact] + public async Task StartAsync_StopRequested_KillsProcessAndCompletesTask() + { + var project = new LongRunningAppHostServerProject(); + using var stopCts = new CancellationTokenSource(); + await using var session = new AppHostServerSession( + project, + environmentVariables: null, + debug: false, + NullLogger.Instance, + stopCts.Token); + + var completion = session.StartAsync(); + + // Process should be running before we ask the session to stop. + Assert.False(completion.IsCompleted); + Assert.False(session.ServerProcess!.HasExited); + + stopCts.Cancel(); + + // The session's stop registration fires Kill synchronously inline. The Exited + // event fires asynchronously, so allow the completion task to observe the result. + var exitCode = await completion.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(session.ServerProcess!.HasExited); + Assert.Equal(session.ServerProcess.ExitCode, exitCode); + } + + [Fact] + public async Task StartAsync_StopRequested_WithGracefulServices_InvokesGracefulSignalerAndExits() + { + // External-stop flow with graceful infra wired: the session's ladder must invoke the + // injected graceful signaler before falling through to wait-for-exit. Simulating "graceful + // succeeded" by having the fake signaler kill the process keeps the ladder out of the + // escalation branch and verifies the happy path end-to-end. + var project = new LongRunningAppHostServerProject(); + using var stopCts = new CancellationTokenSource(); + using var shutdownService = new GracefulShutdownService(); + + var signaler = new RecordingGracefulSignaler(onSignal: pid => { - CreateAsyncCallback = (_, _) => Task.FromResult(project) - }; - using var profilingTelemetry = new ProfilingTelemetry(CreateConfiguration()); - var sessionFactory = new AppHostServerSessionFactory( - projectFactory, + try + { + using var proc = Process.GetProcessById(pid); + proc.Kill(entireProcessTree: true); + } + catch (ArgumentException) + { + // Already exited; treat as graceful success. + } + return Task.FromResult(true); + }); + + await using var session = new AppHostServerSession( + project, + environmentVariables: null, + debug: false, NullLogger.Instance, - profilingTelemetry); + stopCts.Token, + profilingTelemetry: null, + gracefulShutdownSignaler: signaler, + shutdownService: shutdownService); + + var completion = session.StartAsync(); + Assert.False(completion.IsCompleted); + var serverPid = session.ServerProcess!.Id; + + stopCts.Cancel(); + + await completion.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.True(session.ServerProcess.HasExited); + Assert.Contains(serverPid, signaler.Pids); + // Graceful budget was never exhausted in this scenario — the signaler simulated success + // and WaitForExitAsync observed the exit before anyone called Expire(). + Assert.False(shutdownService.Token.IsCancellationRequested); + } + + [Fact] + public async Task StartAsync_StopRequested_GracefulIgnored_ExpireEscalatesToTreeKill() + { + // External stop fires, the fake signaler accepts the request but the process refuses to + // exit (simulating a child that ignores SIGTERM/Ctrl+C). Expiring the central token must + // make the ladder break out of WaitForExitAsync and escalate to Kill so the process tree + // is guaranteed to die — covering the "DCP signal didn't take" failure mode. + var project = new LongRunningAppHostServerProject(); + using var stopCts = new CancellationTokenSource(); + using var shutdownService = new GracefulShutdownService(); + + var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var signaler = new RecordingGracefulSignaler(onSignal: _ => + { + signaled.TrySetResult(); + // Pretend the OS signal was issued but the process is a bad citizen and ignores it. + return Task.FromResult(true); + }); + + await using var session = new AppHostServerSession( + project, + environmentVariables: null, + debug: false, + NullLogger.Instance, + stopCts.Token, + profilingTelemetry: null, + gracefulShutdownSignaler: signaler, + shutdownService: shutdownService); + + var completion = session.StartAsync(); + Assert.False(completion.IsCompleted); + + stopCts.Cancel(); + + // Wait until the ladder has actually called the signaler; otherwise Expire() could fire + // before the ladder reaches WaitForExitAsync and the cancellation observation race would + // get tangled with the signaler-cancellation race. + await signaled.Task.WaitAsync(TimeSpan.FromSeconds(10)); - var result = await sessionFactory.CreateAsync( - project.AppDirectoryPath, - "13.4.0", - [], - launchSettingsEnvVars: null, + shutdownService.Expire(); + + await completion.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.True(session.ServerProcess!.HasExited); + } + + [Fact] + public async Task StartAsync_StopRequested_GracefulSignalerThrows_StillEscalatesToKill() + { + // Best-effort contract: a thrown exception from the signaler (e.g. DCP layout discovery + // failed, network blip, anything) must not strand the kill ladder. The ladder logs and + // falls through to wait+escalate. Expiring the token triggers the kill exactly like the + // "signal succeeded but process ignored it" case above. + var project = new LongRunningAppHostServerProject(); + using var stopCts = new CancellationTokenSource(); + using var shutdownService = new GracefulShutdownService(); + + var signaler = new RecordingGracefulSignaler(onSignal: _ => + throw new InvalidOperationException("simulated DCP failure")); + + await using var session = new AppHostServerSession( + project, + environmentVariables: null, + debug: false, + NullLogger.Instance, + stopCts.Token, + profilingTelemetry: null, + gracefulShutdownSignaler: signaler, + shutdownService: shutdownService); + + var completion = session.StartAsync(); + Assert.False(completion.IsCompleted); + + stopCts.Cancel(); + shutdownService.Expire(); + + await completion.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.True(session.ServerProcess!.HasExited); + Assert.Single(signaler.Pids); + } + + [Fact] + public async Task DisposeAsync_WithGracefulServicesButNoExternalStop_ForceKillsWithoutSignaling() + { + // Dispose-only teardown must NOT take the graceful path: the central GracefulShutdownService + // is timed by ConsoleCancellationManager, and a dispose without an external stop means CCM + // never started that timer. Running the graceful ladder would hang on WaitForExitAsync + // until the central token fires (which it never will). The session detects this by reading + // the external token's IsCancellationRequested state inside OnStopRequested. + var project = new LongRunningAppHostServerProject(); + using var stopCts = new CancellationTokenSource(); + using var shutdownService = new GracefulShutdownService(); + var signaler = new RecordingGracefulSignaler(); + + var session = new AppHostServerSession( + project, + environmentVariables: null, debug: false, + NullLogger.Instance, + stopCts.Token, + profilingTelemetry: null, + gracefulShutdownSignaler: signaler, + shutdownService: shutdownService); + + var completion = session.StartAsync(); + Assert.False(completion.IsCompleted); + var serverProcess = session.ServerProcess!; + var pid = serverProcess.Id; + Assert.False(serverProcess.HasExited); + + // DisposeAsync must return promptly even though the graceful token will never fire and + // the process would otherwise run for a minute. The 30 s timeout is the regression check: + // if the dispose-only safety net regresses and the ladder takes the graceful path, + // WaitForExitAsync(gracefulToken) will hang and this WaitAsync will throw. + await session.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(30)); + + // After DisposeAsync, the Process handle has been disposed, so reading HasExited on the + // original reference would throw. Probe the OS instead: a force-killed process is reaped + // fast on all platforms — within ~tens of ms — but allow a few seconds of slack for + // loaded CI hosts before failing. + Assert.True(WaitForProcessExit(pid, TimeSpan.FromSeconds(5)), $"Process {pid} should be exited after DisposeAsync."); + Assert.Empty(signaler.Pids); + } + + [Fact] + public async Task StartAsync_ProcessExitsNaturally_CompletionReturnsExitCode() + { + var project = new RecordingAppHostServerProject(); + await using var session = new AppHostServerSession( + project, + environmentVariables: null, + debug: false, + NullLogger.Instance, CancellationToken.None); - Assert.False(result.Success); + var exitCode = await session.StartAsync().WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task StartAsync_ProjectRunThrows_DisposesProjectAndFaultsCompletion() + { + var project = new ThrowingAppHostServerProject(); + + // Capture unobserved task exceptions only for the sentinel message we throw, so the test + // doesn't false-positive on faulted tasks orphaned by other concurrently running xUnit + // tests sharing the process. + var unobserved = new List(); + void Handler(object? sender, UnobservedTaskExceptionEventArgs e) + { + if (e.Exception.InnerExceptions.Any(static ex => ex is InvalidOperationException { Message: "simulated launch failure" })) + { + unobserved.Add(e.Exception); + e.SetObserved(); + } + } + + TaskScheduler.UnobservedTaskException += Handler; + try + { + // Run the session in a separate async method so the session local is unreachable + // by the time we force GC — otherwise the stack-rooted reference keeps the faulted + // completion task alive and UnobservedTaskException never fires. + await RunScenarioAsync(project); + + // DisposeAsync must observe the faulted completion task created by StartAsync's catch + // path. Otherwise the orphaned faulted task surfaces as an UnobservedTaskException once + // GC reaps the TaskCompletionSource — silent corruption for short-lived RPC callers + // that intentionally discard the StartAsync result. + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + finally + { + TaskScheduler.UnobservedTaskException -= Handler; + } + Assert.True(project.Disposed); + Assert.Empty(unobserved); + + static async Task RunScenarioAsync(ThrowingAppHostServerProject project) + { + var session = new AppHostServerSession( + project, + environmentVariables: null, + debug: false, + NullLogger.Instance, + CancellationToken.None); + + Assert.Throws(() => { _ = session.StartAsync(); }); + + await session.DisposeAsync(); + } } [Fact] @@ -170,6 +468,40 @@ public void CreatePrebuiltAppHostServer_DisposesLayoutLeaseWhenConstructorFails( } } + private static bool WaitForProcessExit(int pid, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + try + { + using var probe = Process.GetProcessById(pid); + if (probe.HasExited) + { + return true; + } + } + catch (ArgumentException) + { + // GetProcessById throws when no process with the given id is running — i.e. the + // process exited and was reaped from the OS table. That is exactly the success + // signal we are waiting for. + return true; + } + catch (InvalidOperationException) + { + // HasExited can throw if the handle was opened against a process that died and + // was reaped between the GetProcessById call and the property read. Treat the + // same as the ArgumentException case. + return true; + } + + Thread.Sleep(25); + } + + return false; + } + private static ActivityListener CreateActivityListener(string sourceName) { var listener = new ActivityListener @@ -208,6 +540,46 @@ private static AppHostServerProjectFactory CreateAppHostServerProjectFactory() NullLoggerFactory.Instance); } + private sealed class RecordingGracefulSignaler : IProcessTreeGracefulShutdownSignaler + { + private readonly object _lock = new(); + private readonly Func>? _onSignal; + private readonly List _pids = new(); + + public RecordingGracefulSignaler(Func>? onSignal = null) + { + _onSignal = onSignal; + } + + public IReadOnlyList Pids + { + get + { + // The session invokes the signaler from a CT registration callback which can + // race with the test thread reading Pids; snapshot under lock to satisfy + // happens-before for the test assertions. + lock (_lock) + { + return _pids.ToArray(); + } + } + } + + public Task RequestProcessTreeGracefulShutdownAsync( + int pid, + DateTimeOffset? startTime, + bool includeStartTimeForDcp, + CancellationToken cancellationToken) + { + lock (_lock) + { + _pids.Add(pid); + } + + return _onSignal?.Invoke(pid) ?? Task.FromResult(true); + } + } + private sealed class RecordingAppHostServerProject : IAppHostServerProject { public string AppDirectoryPath => Directory.GetCurrentDirectory(); @@ -224,24 +596,108 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public AppHostServerRunResult Run( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, - bool debug = false) + bool debug = false, + bool isolateConsole = false, + WindowsConsoleProcessJob? consoleProcessJob = null) { ReceivedEnvironmentVariables = environmentVariables is null ? null : new Dictionary(environmentVariables); - var process = Process.Start(new ProcessStartInfo("dotnet", "--version") + var startInfo = new ProcessStartInfo("dotnet", "--version") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false - })!; + }; + var process = Process.Start(startInfo)!; + + return new AppHostServerRunResult( + SocketPath: "test.sock", + Process: process, + OutputCollector: new OutputCollector(), + FileName: startInfo.FileName, + Arguments: new[] { "--version" }, + ProcessLifetime: ProcessLifetimeAdapter.ForProcess(process)); + } + } + + private sealed class LongRunningAppHostServerProject : IAppHostServerProject + { + public string AppDirectoryPath => Directory.GetCurrentDirectory(); + + public string GetInstanceIdentifier() => AppDirectoryPath; + + public Task PrepareAsync( + string sdkVersion, + IEnumerable integrations, + string? requestedChannel = null, + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AppHostServerRunResult Run( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = false, + bool isolateConsole = false, + WindowsConsoleProcessJob? consoleProcessJob = null) + { + // Use a cross-platform long-running command so the test exercises the kill path + // rather than a quickly-exiting probe like `dotnet --version`. + var (fileName, arguments) = OperatingSystem.IsWindows() + ? ("cmd.exe", "/c pause") + : ("sleep", "60"); - return ("test.sock", process, new OutputCollector()); + var startInfo = new ProcessStartInfo(fileName, arguments) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + var process = Process.Start(startInfo)!; + + return new AppHostServerRunResult( + SocketPath: "test.sock", + Process: process, + OutputCollector: new OutputCollector(), + FileName: fileName, + Arguments: new[] { arguments }, + ProcessLifetime: ProcessLifetimeAdapter.ForProcess(process)); } } + + private sealed class ThrowingAppHostServerProject : IAppHostServerProject, IDisposable + { + public string AppDirectoryPath => Directory.GetCurrentDirectory(); + + public bool Disposed { get; private set; } + + public string GetInstanceIdentifier() => AppDirectoryPath; + + public Task PrepareAsync( + string sdkVersion, + IEnumerable integrations, + string? requestedChannel = null, + string? packageSourceOverride = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public AppHostServerRunResult Run( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = false, + bool isolateConsole = false, + WindowsConsoleProcessJob? consoleProcessJob = null) => + throw new InvalidOperationException("simulated launch failure"); + + public void Dispose() => Disposed = true; + } } diff --git a/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs index c8d447cf4b4..a337f4a2d84 100644 --- a/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs @@ -28,6 +28,16 @@ public void Dispose() GC.SuppressFinalize(this); } + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void ShouldKillEntireProcessTreeOnCancel_KillsOnlyTargetProcessOnWindows(bool isWindows, bool expected) + { + var result = DotNetAppHostProject.ShouldKillEntireProcessTreeOnCancel(isWindows); + + Assert.Equal(expected, result); + } + [Fact] public void ConfigureSingleFileRunEnvironment_DefaultsToDevelopmentForRun() { diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 17ad8f53aac..2a927a07364 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Diagnostics; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.TestServices; @@ -921,6 +922,14 @@ private GuestAppHostProject CreateGuestAppHostProject( identityChannel: identityChannel, logFilePath: logFilePath); + // Construct real graceful-shutdown collaborators so the contract matches production: + // GuestAppHostProject requires these services even when a test exits the Run path early + // (e.g. via FailedToBuildArtifacts) without exercising them. A no-op signaler stands in + // for DetachedAppHostShutdownService because none of the tests in this fixture drive the + // launcher or AppHostServerSession code paths that would actually invoke it. + var gracefulShutdownService = new GracefulShutdownService(); + var cancellationManager = new ConsoleCancellationManager(gracefulShutdownService, finalDrainBudget: TimeSpan.FromSeconds(5)); + return new GuestAppHostProject( language: language, interactionService: interactionService ?? new TestInteractionService(), @@ -935,7 +944,16 @@ private GuestAppHostProject CreateGuestAppHostProject( executionContext: executionContext, logger: NullLogger.Instance, fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()), - profilingTelemetry: _profilingTelemetry); + profilingTelemetry: _profilingTelemetry, + cancellationManager: cancellationManager, + gracefulShutdownSignaler: new NoOpGracefulSignaler(), + shutdownService: gracefulShutdownService); + } + + private sealed class NoOpGracefulSignaler : IProcessTreeGracefulShutdownSignaler + { + public Task RequestProcessTreeGracefulShutdownAsync(int pid, DateTimeOffset? startTime, bool includeStartTimeForDcp, CancellationToken cancellationToken) + => Task.FromResult(true); } } diff --git a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs index 4b8a5607d88..25655ca3d4d 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs @@ -614,7 +614,7 @@ public async Task ProcessGuestLauncher_WritesOutputToLogFile() var launcher = new ProcessGuestLauncher( "test", _loggerFactory.CreateLogger(), - fileLoggerProvider, + fileLoggerProvider: fileLoggerProvider, commandResolver: cmd => cmd == "dotnet" ? "dotnet" : null); var (exitCode, output) = await launcher.LaunchAsync( @@ -886,7 +886,8 @@ private sealed class RecordingLauncher : IGuestProcessLauncher DirectoryInfo workingDirectory, IDictionary environmentVariables, CancellationToken cancellationToken, - Func? afterLaunchAsync = null) + Func? afterLaunchAsync = null, + GuestLaunchOptions? options = null) { Calls.Add((command, args)); LastCommand = command; diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs new file mode 100644 index 00000000000..4da922a503b --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -0,0 +1,252 @@ +// 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.Cli.Processes; +using Aspire.Cli.Projects; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Projects; + +public class ProcessGuestLauncherTests +{ + [Fact] + public async Task LaunchAsync_NoOptions_OnCancellation_ForceKillsProcessTreeAndReturns() + { + // Baseline: when no GuestLaunchOptions are passed (publish path, scaffolding, any caller + // not opted into the central shutdown budget) the launcher must continue to force-kill on + // cancellation. This preserves today's tactical behavior for non-Run callers and exercises + // the same code path as the existing ProcessGuestLauncher_KillsProcessAndReturnsOnCancellation + // test, just with an explicit assertion on the no-options branch. + var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + + var (command, args) = GetLongRunningCommand(); + + using var cts = new CancellationTokenSource(); + var launchTask = launcher.LaunchAsync( + command, + args, + new DirectoryInfo(Path.GetTempPath()), + new Dictionary(), + cts.Token); + + // Give the OS time to spawn the child before cancelling so we exercise the kill-while-running + // path instead of the cancel-before-start short-circuit. + await Task.Delay(500); + + var stopwatch = Stopwatch.StartNew(); + cts.Cancel(); + + var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30)); + stopwatch.Stop(); + + Assert.NotEqual(0, exitCode); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15), + $"Expected force-kill ladder to return within 15s of cancellation but it took {stopwatch.Elapsed}."); + } + + [Fact] + public async Task LaunchAsync_WithGracefulServices_GracefulSucceeds_NoTreeKillEscalation() + { + // Run-path happy case: graceful signaler is invoked, simulates a successful signal by + // killing the process directly, and the ladder observes the exit via WaitForExitAsync + // before anyone calls Expire(). The central shutdown token must NOT be cancelled — this + // proves the launcher consumes the token as a deadline (not a trigger) and doesn't burn + // the central budget on successful graceful exits. + var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + var (command, args) = GetLongRunningCommand(); + + using var cts = new CancellationTokenSource(); + using var shutdownService = new GracefulShutdownService(); + var signaler = new RecordingGracefulSignaler(onSignal: pid => + { + try + { + using var proc = Process.GetProcessById(pid); + proc.Kill(entireProcessTree: true); + } + catch (ArgumentException) + { + // Already exited; treat as graceful success. + } + + return Task.FromResult(true); + }); + + var options = new GuestLaunchOptions( + IsolateConsoleForGracefulShutdown: false, + GracefulShutdownSignaler: signaler, + ShutdownService: shutdownService); + + var launchTask = launcher.LaunchAsync( + command, + args, + new DirectoryInfo(Path.GetTempPath()), + new Dictionary(), + cts.Token, + afterLaunchAsync: null, + options: options); + + await Task.Delay(500); + cts.Cancel(); + + var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.NotEqual(0, exitCode); + Assert.Single(signaler.Pids); + Assert.False(shutdownService.Token.IsCancellationRequested); + } + + [Fact] + public async Task LaunchAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEscalatesToTreeKill() + { + // Run-path bad-citizen case: graceful signaler accepts the request but the process ignores + // it (the canonical tsx-swallows-Ctrl+Break scenario on Windows). Expiring the central token + // must break the ladder out of WaitForExitAsync and escalate to Kill(entireProcessTree: true) + // so the tree dies even when the cooperative path fails. + var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + var (command, args) = GetLongRunningCommand(); + + using var cts = new CancellationTokenSource(); + using var shutdownService = new GracefulShutdownService(); + + var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var signaler = new RecordingGracefulSignaler(onSignal: _ => + { + signaled.TrySetResult(); + // Pretend the OS signal was delivered but the process is a bad citizen and refuses to exit. + return Task.FromResult(true); + }); + + var options = new GuestLaunchOptions( + IsolateConsoleForGracefulShutdown: false, + GracefulShutdownSignaler: signaler, + ShutdownService: shutdownService); + + var launchTask = launcher.LaunchAsync( + command, + args, + new DirectoryInfo(Path.GetTempPath()), + new Dictionary(), + cts.Token, + afterLaunchAsync: null, + options: options); + + await Task.Delay(500); + cts.Cancel(); + + // Wait until the ladder has actually called the signaler before expiring. Otherwise Expire() + // could fire before the ladder reaches WaitForExitAsync and we'd be testing cancellation + // ordering rather than the escalation path. + await signaled.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + shutdownService.Expire(); + + var stopwatch = Stopwatch.StartNew(); + var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30)); + stopwatch.Stop(); + + Assert.NotEqual(0, exitCode); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15), + $"Expected escalation to tree-kill within 15s of Expire() but it took {stopwatch.Elapsed}."); + } + + [Fact] + public async Task LaunchAsync_WithGracefulServices_SignalerThrows_StillEscalatesToTreeKill() + { + // Best-effort contract for the graceful signal: any exception (DCP layout discovery failed, + // network blip, anything) must be logged and swallowed so the ladder still escalates to + // Kill(entireProcessTree: true). The previous force-kill path swallowed signaler failures + // implicitly by not even calling the signaler; the new ladder needs an explicit guarantee. + var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + var (command, args) = GetLongRunningCommand(); + + using var cts = new CancellationTokenSource(); + using var shutdownService = new GracefulShutdownService(); + + var signaler = new RecordingGracefulSignaler(onSignal: _ => + throw new InvalidOperationException("simulated DCP failure")); + + var options = new GuestLaunchOptions( + IsolateConsoleForGracefulShutdown: false, + GracefulShutdownSignaler: signaler, + ShutdownService: shutdownService); + + var launchTask = launcher.LaunchAsync( + command, + args, + new DirectoryInfo(Path.GetTempPath()), + new Dictionary(), + cts.Token, + afterLaunchAsync: null, + options: options); + + await Task.Delay(500); + cts.Cancel(); + // Expire immediately — without a working signaler there's nothing to wait for; the ladder + // is going to wait on WaitForExitAsync(gracefulToken) and we want the escalation to fire. + shutdownService.Expire(); + + var stopwatch = Stopwatch.StartNew(); + var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30)); + stopwatch.Stop(); + + Assert.NotEqual(0, exitCode); + Assert.Single(signaler.Pids); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15), + $"Expected escalation to tree-kill within 15s of Expire() but it took {stopwatch.Elapsed}."); + } + + private static (string Command, string[] Args) GetLongRunningCommand() + { + // Cross-platform child that runs long enough to outlive the cancellation+kill ladder under + // realistic CI load. Matches LongRunningAppHostServerProject (AppHostServerSessionTests). + if (OperatingSystem.IsWindows()) + { + // ping with a long count keeps the child alive ~60s; tree-kill needs to traverse cmd -> ping. + return ("cmd.exe", ["/c", "ping", "-n", "60", "127.0.0.1"]); + } + + return ("sleep", ["60"]); + } + + private sealed class RecordingGracefulSignaler : IProcessTreeGracefulShutdownSignaler + { + private readonly object _lock = new(); + private readonly Func>? _onSignal; + private readonly List _pids = new(); + + public RecordingGracefulSignaler(Func>? onSignal = null) + { + _onSignal = onSignal; + } + + public IReadOnlyList Pids + { + get + { + // The launcher invokes the signaler from a background continuation that can race + // with the test thread reading Pids; snapshot under lock to satisfy happens-before + // for the test assertions. + lock (_lock) + { + return _pids.ToArray(); + } + } + } + + public Task RequestProcessTreeGracefulShutdownAsync( + int pid, + DateTimeOffset? startTime, + bool includeStartTimeForDcp, + CancellationToken cancellationToken) + { + lock (_lock) + { + _pids.Add(pid); + } + + return _onSignal?.Invoke(pid) ?? Task.FromResult(true); + } + } +} diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index e553ca6ca29..7d90b257383 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -2,13 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Configuration; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Scaffolding; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; -using Aspire.Cli.Utils; using Microsoft.Extensions.Logging.Abstractions; -using System.Diagnostics; namespace Aspire.Cli.Tests.Scaffolding; @@ -138,11 +137,13 @@ public Task PrepareAsync( return Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); } - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public AppHostServerRunResult Run( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, - bool debug = false) => + bool debug = false, + bool isolateConsole = false, + WindowsConsoleProcessJob? consoleProcessJob = null) => throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); } } diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index 3bf4fa884d4..013430320ee 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -1,10 +1,9 @@ // 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.Cli.Configuration; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; -using Aspire.Cli.Utils; namespace Aspire.Cli.Tests.TestServices; @@ -31,11 +30,13 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public AppHostServerRunResult Run( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, - bool debug = false) => + bool debug = false, + bool isolateConsole = false, + WindowsConsoleProcessJob? consoleProcessJob = null) => throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); public void Dispose() diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs deleted file mode 100644 index fcaa77b92ef..00000000000 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.Configuration; -using Aspire.Cli.Projects; -using Aspire.Cli.Utils; - -namespace Aspire.Cli.Tests.TestServices; - -/// -/// Test implementation of that returns a failure result. -/// -internal sealed class TestAppHostServerSessionFactory : IAppHostServerSessionFactory -{ - public Task CreateAsync( - string appHostPath, - string sdkVersion, - IEnumerable integrations, - Dictionary? launchSettingsEnvVars, - bool debug, - CancellationToken cancellationToken) - { - // Return a failure result for tests - most tests don't actually need to run an AppHost server - var outputCollector = new OutputCollector(); - return Task.FromResult(new AppHostServerSessionResult( - Success: false, - Session: null, - BuildOutput: outputCollector, - ChannelName: null)); - } -} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 628238e9f0e..bce7e779435 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -152,7 +152,6 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(); services.AddSingleton(options.ScaffoldingServiceFactory); services.AddSingleton(); - services.AddSingleton(options.AppHostServerSessionFactory); services.AddSingleton(); services.AddSingleton(options.LanguageServiceFactory); services.AddSingleton(); @@ -162,7 +161,19 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.LayoutDiscoveryFactory); services.AddSingleton(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + // Mirror Program.cs:414 so consumers (e.g. GuestAppHostProject) that depend on the + // interface receive the same DetachedAppHostShutdownService instance the abstraction + // wraps. Without this, DI returns null and Run-path tests construct the project with + // a missing dependency, masking wiring regressions. + services.AddTransient(sp => sp.GetRequiredService()); + // Match Program.Main's parameterless GracefulShutdownService + 5s finalDrainBudget for CCM + // so tests exercise the same shutdown ladder budget as production. RunCommand and + // GuestAppHostProject require these services in production wiring. + services.AddSingleton(); + services.AddSingleton(sp => new ConsoleCancellationManager( + sp.GetRequiredService(), + finalDrainBudget: TimeSpan.FromSeconds(5))); services.AddSingleton(options.BundlePayloadProviderFactory); services.AddSingleton(options.BundleServiceFactory); services.AddSingleton(); @@ -622,11 +633,6 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser return new TestLanguageService { DefaultProject = defaultProject }; }; - public Func AppHostServerSessionFactory { get; set; } = (IServiceProvider serviceProvider) => - { - return new TestAppHostServerSessionFactory(); - }; - // Layout discovery - returns null by default (no bundle layout), causing SDK mode fallback public Func LayoutDiscoveryFactory { get; set; } = _ => new NullLayoutDiscovery(); From e9508ea34b005f1225e74e6861412647ce59f593 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 1 Jun 2026 18:22:18 -0700 Subject: [PATCH 02/56] Extend graceful shutdown ladder to .NET direct-launch AppHost path The original PR fixed graceful shutdown for the TypeScript guest path. Mac smoke testing surfaced that pure .NET AppHosts (the direct-launch path through DotNetAppHostProject.RunAsync -> ProcessExecutionFactory -> ProcessExecution) bypassed the new ladder entirely because their ProcessInvocationOptions don't opt into it. On Unix this collapsed graceful shutdown to microseconds (the OCE catch in ProcessExecution.WaitForExitAsync passed the already-cancelled token as the graceful budget to ProcessTerminator); on Windows graceful was skipped outright. Changes: - Extract the four-phase ladder into a shared ProcessGracefulShutdownLadder so AppHostServerSession, ProcessGuestLauncher, and the new IsolatedProcessExecution share the same escalation shape. - Extend ProcessInvocationOptions with four opt-in fields (IsolateConsole, ConsoleProcessJob, GracefulShutdownSignaler, ShutdownService). All default null/false so the 11 non-Run callers (build, restore, package add, layout, etc.) preserve their existing ProcessTerminator force-terminate behavior. - Add IsolatedProcessExecution to host an IsolatedProcess inside the existing IProcessExecution shape so the direct-launch path can opt into console isolation on Windows. - Branch ProcessExecutionFactory on IsolateConsole. The Windows-only console isolation requires WindowsConsoleProcessJob to be wired through; fail fast if it isn't (defense-in-depth against the silent-fallthrough footgun the isolated path is meant to prevent). - Make DotNetAppHostProject's ctor require GracefulShutdownService and IProcessTreeGracefulShutdownSignaler. ConsoleProcessJob stays optional (Windows-only DI registration). Only the run-path runOptions populate the four new fields; build/restore/publish callsites leave them unset. - Forward the four new fields through DotNetCliRunner.CreateInstrumentedProcessOptions. Without this, the wiring at DotNetAppHostProject.RunAsync line 318 silently reverts when piped through CLI instrumentation -- the smoke run on Mac initially appeared green only because DCP did the work the CLI ladder thought it was doing. Verified on macOS via a throwaway smoke harness: - Single SIGINT against a graceful-respecting AppHost: clean exit in <1s. - Single SIGINT against an AppHost stalling 15s in ApplicationStopping: process-tree escalation fires at exactly T+5s (matches the configured graceful budget), all descendants gone, exit 0. - Double SIGINT (1s apart) against the same stalled AppHost: graceful window collapses to T+1s, AppHost forcibly terminated, exit 0. Documented tradeoff: an abrupt termination leaves DCP unable to SIGKILL its TERM-ignoring children, so the slow-script orphan in this scenario matches existing semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 47 +++++ .../DotNet/IsolatedProcessExecution.cs | 168 ++++++++++++++++++ src/Aspire.Cli/DotNet/ProcessExecution.cs | 36 +++- .../DotNet/ProcessExecutionFactory.cs | 90 ++++++++++ .../ProcessGracefulShutdownLadder.cs | 147 +++++++++++++++ .../Projects/AppHostServerSession.cs | 119 ++----------- .../Projects/DotNetAppHostProject.cs | 23 ++- .../Projects/ProcessGuestLauncher.cs | 103 +---------- 8 files changed, 527 insertions(+), 206 deletions(-) create mode 100644 src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs create mode 100644 src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 3d19d32ae5e..10ca970de06 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -64,6 +64,43 @@ internal sealed class ProcessInvocationOptions /// Controls whether cancellation should terminate the whole process tree or only the root process. /// public bool KillEntireProcessTreeOnCancel { get; set; } = true; + + /// + /// When true, the spawned process is given its own hidden console group (Windows) + /// and, on Windows, is assigned to the CLI's kill-on-close job object. Required so the + /// shared can target the child with + /// DCP's stop-process-tree CTRL+C dance. + /// + /// + /// Pair with (Windows-only; pass the DI-registered + /// singleton), and . + /// Leaving any of those null means cancellation falls back to today's + /// force-kill behavior, preserving back-compat + /// for the many non-Run callers (build, restore, package add, layout, etc.). + /// + public bool IsolateConsole { get; set; } + + /// + /// The CLI-lifetime Windows job object the spawned process should be assigned to when + /// is true and the host OS is Windows. Required in that + /// configuration; ignored on non-Windows. + /// + public Processes.WindowsConsoleProcessJob? ConsoleProcessJob { get; set; } + + /// + /// Issues the graceful shutdown signal during the shared shutdown ladder (DCP + /// stop-process-tree on Windows, SIGTERM on Unix). When null, the cancellation + /// path uses today's force-kill behavior. + /// + public Processes.IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler { get; set; } + + /// + /// The central graceful-shutdown timing service whose + /// bounds the shared ladder. When + /// null, the cancellation path uses today's + /// force-kill behavior. + /// + public GracefulShutdownService? ShutdownService { get; set; } } internal sealed class DotNetCliRunner( @@ -276,6 +313,16 @@ private static ProcessInvocationOptions CreateInstrumentedProcessOptions( Debug = options.Debug, SuppressLogging = options.SuppressLogging, KillEntireProcessTreeOnCancel = options.KillEntireProcessTreeOnCancel, + // Forward the Run-path shutdown ladder opt-ins. Forgetting any of these silently + // demotes the run to the legacy ProcessTerminator path: IsolateConsole=false skips + // console isolation, and the null signaler/service pair causes ProcessExecution's + // OCE catch (DotNet/ProcessExecution.cs) to fall through to ProcessTerminator + // instead of the shared ProcessGracefulShutdownLadder. Build/restore/etc. callers + // leave these unset and intentionally keep the legacy path. + IsolateConsole = options.IsolateConsole, + ConsoleProcessJob = options.ConsoleProcessJob, + GracefulShutdownSignaler = options.GracefulShutdownSignaler, + ShutdownService = options.ShutdownService, StandardOutputCallback = line => { var lineCount = Interlocked.Increment(ref outputCounters.StdoutLineCount); diff --git a/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs b/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs new file mode 100644 index 00000000000..c647ef0f7f3 --- /dev/null +++ b/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Processes; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.DotNet; + +/// +/// backed by — used when +/// is true so the child can +/// receive DCP's stop-process-tree CTRL+C dance on Windows without also signalling the +/// CLI. The cancellation path uses the shared +/// when graceful infra is wired on the options; +/// otherwise it falls back to for full back-compat. +/// +internal sealed class IsolatedProcessExecution : IProcessExecution +{ + private readonly IsolatedProcess _isolated; + private readonly ILogger _logger; + private readonly ProcessInvocationOptions _options; + private readonly string _fileName; + private readonly IReadOnlyList _arguments; + private readonly IReadOnlyDictionary _environment; + private int _disposed; + + internal IsolatedProcessExecution( + IsolatedProcess isolated, + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary environment, + ILogger logger, + ProcessInvocationOptions options) + { + _isolated = isolated; + _fileName = fileName; + _arguments = arguments; + _environment = environment; + _logger = logger; + _options = options; + } + + /// + public string FileName => _fileName; + + /// + public IReadOnlyList Arguments => _arguments; + + /// + public IReadOnlyDictionary EnvironmentVariables => _environment; + + /// + public bool HasExited => _isolated.HasExited; + + /// + public int ExitCode => _isolated.ExitCode; + + /// + public int ProcessId => _isolated.Id; + + /// + public bool Start() + { + // IsolatedProcess.Start (called by the factory) already spawned the child and started + // the pumps. We're a thin wrapper; "starting" is implicit. Returning true keeps the + // factory contract identical to ProcessExecution. + _logger.LogDebug("{FileName}({ProcessId}) started in isolated console group", _fileName, _isolated.Id); + return true; + } + + /// + public async Task WaitForExitAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("{FileName}({ProcessId}) waiting for exit", _fileName, _isolated.Id); + + try + { + await _isolated.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, escalating shutdown", _fileName, _isolated.Id); + + if (_options.GracefulShutdownSignaler is not null && _options.ShutdownService is not null) + { + // Run-path: drive the same shared ladder used by AppHostServerSession and + // ProcessGuestLauncher. The central token is what bounds graceful — by the + // time we observe OCE here CCM has already started its clock. + await ProcessGracefulShutdownLadder.ExecuteAsync( + _isolated.Process, + _options.GracefulShutdownSignaler, + _options.ShutdownService.Token, + _logger, + _fileName).ConfigureAwait(false); + } + else + { + // No central infra wired — preserve today's tactical fallback. This branch + // is reachable only for non-Run callers that opted into IsolateConsole but + // not into the central graceful budget. Today no such caller exists, but the + // fallback keeps the option independent and easy to test. + await ProcessTerminator.ShutdownAsync( + _isolated.Process, + requestGracefulShutdown: !OperatingSystem.IsWindows(), + _options.KillEntireProcessTreeOnCancel, + _logger, + _fileName, + gracefulShutdownCancellationToken: CancellationToken.None).ConfigureAwait(false); + } + + throw; + } + + _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", _fileName, _isolated.Id, _isolated.ExitCode); + + // Wait for the stdout/stderr pumps to finish draining so callbacks see the tail of + // the output. Bounded by a small drain budget — if the pumps somehow stay open + // beyond it (orphaned pipes, hostile callback) we still surface the exit code. + try + { + using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await Task.WhenAll(_isolated.StandardOutputClosed, _isolated.StandardErrorClosed) + .WaitAsync(drainCts.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _logger.LogWarning("{FileName}({ProcessId}) stdout/stderr pumps did not drain within timeout after exit", _fileName, _isolated.Id); + } + catch (Exception ex) + { + // A handler throw surfaces here via the pump's faulted Task. Log but continue — + // ExitCode is still meaningful even if a callback misbehaved. + _logger.LogWarning(ex, "{FileName}({ProcessId}) stdout/stderr pump faulted while draining after exit", _fileName, _isolated.Id); + } + + return _isolated.ExitCode; + } + + /// + public void Kill(bool entireProcessTree) + { + _isolated.Kill(entireProcessTree); + } + + /// + public void Dispose() + { + // IProcessExecution is a sync IDisposable. IsolatedProcess.DisposeAsync blocks + // briefly on pump drain (5 s ceiling). In practice DotNetCliRunner does not + // dispose the execution (StartBackchannelAsync runs fire-and-forget and reads + // HasExited/ExitCode after the await — see DotNetCliRunner.cs:145), so this + // sync-blocking path is reached only by explicit consumers or finalization. + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try + { + _isolated.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "{FileName}({ProcessId}) IsolatedProcess dispose threw", _fileName, _isolated.Id); + } + } +} diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 763b4ed6304..bc4107e7d35 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -94,13 +94,35 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) catch (OperationCanceledException) { _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, stopping it", FileName, _process.Id); - await ProcessTerminator.ShutdownAsync( - _process, - requestGracefulShutdown: !OperatingSystem.IsWindows(), - _options.KillEntireProcessTreeOnCancel, - _logger, - FileName, - gracefulShutdownCancellationToken: cancellationToken).ConfigureAwait(false); + + if (_options.GracefulShutdownSignaler is not null && _options.ShutdownService is not null) + { + // Run-path: drive the shared ladder so a non-isolated direct-launch AppHost + // gets the same graceful-then-tree-kill semantics as the isolated path. Useful + // mostly for Unix where IsolateConsole is a no-op (process groups + SIGTERM + // give us the same teardown shape DCP uses on Windows). + await ProcessGracefulShutdownLadder.ExecuteAsync( + _process, + _options.GracefulShutdownSignaler, + _options.ShutdownService.Token, + _logger, + FileName).ConfigureAwait(false); + } + else + { + // Today's tactical fallback for the many short-lived non-Run callers (build, + // restore, package add, layout, etc.). Pre-existing semantic bug: the + // gracefulShutdownCancellationToken passed in is the already-cancelled token, + // so on Unix graceful collapses in microseconds. Preserved here for back-compat + // because the Run path now opts into the new ladder above. + await ProcessTerminator.ShutdownAsync( + _process, + requestGracefulShutdown: !OperatingSystem.IsWindows(), + _options.KillEntireProcessTreeOnCancel, + _logger, + FileName, + gracefulShutdownCancellationToken: cancellationToken).ConfigureAwait(false); + } throw; } diff --git a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs index 1f0d9d7fcfe..a12ab8317d1 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Cli.Processes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -27,6 +28,11 @@ public IProcessExecution CreateExecution(string fileName, string[] args, IDictio } } + if (options.IsolateConsole) + { + return CreateIsolatedExecution(fileName, args, env, workingDirectory, options, effectiveLogger); + } + var startInfo = new ProcessStartInfo(fileName) { WorkingDirectory = workingDirectory.FullName, @@ -53,4 +59,88 @@ public IProcessExecution CreateExecution(string fileName, string[] args, IDictio var process = new Process { StartInfo = startInfo }; return new ProcessExecution(process, effectiveLogger, options); } + + private static IProcessExecution CreateIsolatedExecution( + string fileName, + string[] args, + IDictionary? env, + DirectoryInfo workingDirectory, + ProcessInvocationOptions options, + ILogger logger) + { + // Fail fast on Windows + IsolateConsole without a job handle. Silently falling through + // would defeat the kill-on-close safety net that isolation is supposed to enable — + // exactly the same defense-in-depth check IsolatedConsoleSpawner already enforces. + if (OperatingSystem.IsWindows() && options.ConsoleProcessJob is null) + { + // Use a string literal instead of nameof(options.ConsoleProcessJob) so the analyzer + // is satisfied (CA2208 rejects a property path; the actual paramName here describes + // the missing option, not a method parameter). + throw new ArgumentNullException( + "options.ConsoleProcessJob", + "ConsoleProcessJob is required on Windows when IsolateConsole is true. Pass the DI-registered WindowsConsoleProcessJob singleton."); + } + + var startInfo = new IsolatedProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory.FullName, + // Only Windows uses the job handle — the Unix partial of IsolatedProcess ignores + // it because Unix process-group semantics + SIGTERM cover the orphan case. + JobHandle = OperatingSystem.IsWindows() ? options.ConsoleProcessJob?.Handle : null, + }; + + foreach (var a in args) + { + startInfo.ArgumentList.Add(a); + } + + if (env is not null) + { + foreach (var envKvp in env) + { + startInfo.Environment[envKvp.Key] = envKvp.Value; + } + } + + // Mutable line buffer the wrapper exposes via EnvironmentVariables. Captured here + // (not lazily from IsolatedProcessStartInfo.Environment) so EnvironmentVariables stays + // valid after the wrapper takes ownership of the IsolatedProcess. + var envSnapshot = startInfo.HasCustomEnvironment + ? new Dictionary(startInfo.Environment, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + var argsSnapshot = startInfo.ArgumentList.ToArray(); + + // Per-line callbacks fan out to the same callback shape ProcessExecution exposes: + // log trace + StandardOutputCallback / StandardErrorCallback. Mirrors + // ProcessExecution.ForwardStreamToLoggerAsync so the two paths produce identical + // log shape and external observers can't tell them apart. + var isolated = IsolatedProcess.Start( + startInfo, + (sender, line) => + { + if (logger.IsEnabled(LogLevel.Trace)) + { + logger.LogTrace("{FileName}({ProcessId}) stdout: {Line}", fileName, sender.Id, line); + } + options.StandardOutputCallback?.Invoke(line); + }, + (sender, line) => + { + if (logger.IsEnabled(LogLevel.Trace)) + { + logger.LogTrace("{FileName}({ProcessId}) stderr: {Line}", fileName, sender.Id, line); + } + options.StandardErrorCallback?.Invoke(line); + }); + + return new IsolatedProcessExecution( + isolated, + fileName, + argsSnapshot, + envSnapshot, + logger, + options); + } } diff --git a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs new file mode 100644 index 00000000000..05fe932b953 --- /dev/null +++ b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs @@ -0,0 +1,147 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Processes; + +/// +/// Shared "graceful signal → bounded wait → force tree-kill → bounded drain" ladder used by +/// every long-running child process the CLI owns during aspire run +/// (AppHost server, guest, direct-launch AppHost executable). Each call site provides a +/// graceful signaler and the central ; this helper +/// runs the same four-phase escalation against them so the user-visible shutdown shape is +/// uniform across spawn sites. +/// +/// +/// Whoever triggers shutdown ( / +/// ) is responsible for starting the +/// central clock. This helper only consumes the resulting token — it never owns timing. +/// +internal static class ProcessGracefulShutdownLadder +{ + /// + /// Runs the four-phase shutdown ladder against . + /// + /// The child process to shut down. + /// Issues the graceful signal (DCP stop-process-tree on Windows, SIGTERM on Unix). + /// The central . + /// Logger for diagnostics. + /// Short human description used in log messages (e.g. "AppHost server"). + public static async Task ExecuteAsync( + Process process, + IProcessTreeGracefulShutdownSignaler signaler, + CancellationToken gracefulToken, + ILogger logger, + string processDescription) + { + ArgumentNullException.ThrowIfNull(process); + ArgumentNullException.ThrowIfNull(signaler); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(processDescription); + + // Phase 1: best-effort graceful signal bounded by the central token. The DCP path on + // Windows shells out (layout discovery + DCP process launch + wait) and could consume + // the entire graceful window before we ever reach WaitForExitAsync. Sharing the central + // token means a 2nd-Ctrl+C Expire() interrupts the slow DCP shell-out exactly the same + // way it interrupts the WaitForExitAsync below. + try + { + DateTimeOffset? startTime = TryGetStartTime(process); + await signaler.RequestProcessTreeGracefulShutdownAsync( + process.Id, + startTime, + includeStartTimeForDcp: false, + gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (gracefulToken.IsCancellationRequested) + { + // Graceful budget expired before the signal could be issued; fall through to kill. + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to issue graceful shutdown to {ProcessDescription} (pid {Pid}); escalating to kill.", processDescription, SafePid(process)); + } + + // Phase 2: wait for exit bounded by the same central token. Whoever initiated shutdown + // (CCM via RequestShutdown/Cancel) already started the clock; we only consume the token. + try + { + await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Graceful budget expired; fall through to kill. + } + + if (process.HasExited) + { + return; + } + + // Phase 3: ALWAYS tree-kill on escalation, regardless of OS. Even when the graceful + // signal returned cleanly, descendants may still be alive — e.g. on Windows tsx wraps + // node and swallows Ctrl+C/Ctrl+Break, leaving the child node and any further + // descendants running after the tsx shell exits. Skipping tree-kill would orphan them. + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // Process exited between HasExited check and Kill — nothing to do. + return; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to kill {ProcessDescription} (pid {Pid}).", processDescription, SafePid(process)); + return; + } + + // Phase 4: brief separately-bounded drain after kill — independent of the central token + // because by now the central budget has already expired. 1 s is enough for the OS to + // reap the process so the subsequent ExitCode read succeeds. + try + { + using var killDrain = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await process.WaitForExitAsync(killDrain.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Best-effort; nothing more we can do. + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error draining killed {ProcessDescription} (pid {Pid}).", processDescription, SafePid(process)); + } + } + + private static DateTimeOffset? TryGetStartTime(Process process) + { + // Process.StartTime can throw InvalidOperationException on a process whose handle was + // closed or that has exited in some states. Treat as unavailable rather than aborting + // the graceful path — the Unix branch ignores StartTime entirely, and the Windows DCP + // branch only uses it when includeStartTimeForDcp is true (which it never is here). + try + { + return process.StartTime; + } + catch (Exception) + { + return null; + } + } + + private static int SafePid(Process process) + { + try + { + return process.Id; + } + catch (Exception) + { + return -1; + } + } +} diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 3c06eb94c3d..3b074db70e9 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -463,117 +463,28 @@ await ProcessTerminator.ShutdownAsync( } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to shut down {ProcessDescription} (pid {Pid}).", ProcessDescription, SafePid(process)); + // Process.Id can throw if the handle was disposed mid-shutdown — surface as -1 + // rather than masking the original failure with a secondary log exception. + var pid = TryGetPid(process); + _logger.LogWarning(ex, "Failed to shut down {ProcessDescription} (pid {Pid}).", ProcessDescription, pid); } return; } - var gracefulToken = _shutdownService.Token; - - // Phase 1: best-effort graceful signal bounded by the central token. The DCP path on - // Windows shells out (layout discovery + DCP process launch + wait) and could consume the - // entire graceful window before we ever reach WaitForExitAsync. Sharing the central token - // means a 2nd-Ctrl+C Expire() interrupts the slow DCP shell-out exactly the same way it - // interrupts the WaitForExitAsync below. - try - { - DateTimeOffset? startTime = TryGetStartTime(process); - await _gracefulShutdownSignaler.RequestProcessTreeGracefulShutdownAsync( - process.Id, - startTime, - includeStartTimeForDcp: false, - gracefulToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (gracefulToken.IsCancellationRequested) - { - // Graceful budget expired before the signal could be issued; fall through to kill. - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to issue graceful shutdown to {ProcessDescription} (pid {Pid}); escalating to kill.", ProcessDescription, SafePid(process)); - } - - // Phase 2: wait for exit bounded by the same central token. Whoever initiated shutdown - // (CCM via RequestShutdown/Cancel) already started the clock; we only consume the token. - try - { - await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Graceful budget expired; fall through to kill. - } - - if (process.HasExited) - { - return; - } - - // Phase 3: ALWAYS tree-kill on escalation. The previous "Windows uses entireProcessTree: - // false because DCP already did the tree work" optimization was wrong: if the graceful - // signal was cancelled mid-flight, never reached DCP, failed layout discovery, or DCP - // itself was killed during cancellation, descendants are still alive and skipping - // tree-kill orphans them. - try - { - process.Kill(entireProcessTree: true); - } - catch (InvalidOperationException) - { - // Process exited between HasExited check and Kill — nothing to do. - return; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to kill {ProcessDescription} (pid {Pid}).", ProcessDescription, SafePid(process)); - return; - } - - // Brief separately-bounded drain after kill — independent of central token because by now - // the central budget has already expired. 1 s is enough for the OS to reap the process. - try - { - using var killDrain = new CancellationTokenSource(TimeSpan.FromSeconds(1)); - await process.WaitForExitAsync(killDrain.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Best-effort; nothing more we can do. - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error draining killed {ProcessDescription} (pid {Pid}).", ProcessDescription, SafePid(process)); - } + await ProcessGracefulShutdownLadder.ExecuteAsync( + process, + _gracefulShutdownSignaler, + _shutdownService.Token, + _logger, + ProcessDescription).ConfigureAwait(false); } - private static DateTimeOffset? TryGetStartTime(Process process) - { - // Process.StartTime can throw InvalidOperationException on a process whose handle was - // closed or that has exited in some states. Treat as unavailable rather than aborting - // the graceful path — the Unix branch ignores StartTime entirely, and the Windows DCP - // branch only uses it when includeStartTimeForDcp is true. - try - { - return process.StartTime; - } - catch (Exception) - { - return null; - } - } + private static InvalidOperationException NotStarted() => + new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); - private static int SafePid(Process process) + private static int TryGetPid(Process process) { - try - { - return process.Id; - } - catch (Exception) - { - return -1; - } + try { return process.Id; } + catch { return -1; } } - - private static InvalidOperationException NotStarted() => - new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); } diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 0e4db4caf15..88e6dc189cf 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -10,6 +10,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; +using Aspire.Cli.Processes; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; @@ -42,6 +43,9 @@ internal sealed class DotNetAppHostProject : IAppHostProject private readonly Program.CliLoggingOptions _loggingOptions; private readonly IAppHostInfoResolver _appHostInfoResolver; private readonly IConfigurationService _configurationService; + private readonly GracefulShutdownService _shutdownService; + private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; + private readonly WindowsConsoleProcessJob? _consoleProcessJob; private static readonly string[] s_detectionPatterns = ["*.csproj", "*.fsproj", "*.vbproj", "apphost.cs"]; private const string DirectLaunchDisabledConfigKey = "dotnetAppHostDirectLaunchDisabled"; @@ -64,6 +68,9 @@ public DotNetAppHostProject( Program.CliLoggingOptions loggingOptions, IAppHostInfoResolver appHostInfoResolver, IConfigurationService configurationService, + GracefulShutdownService shutdownService, + IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, + WindowsConsoleProcessJob? consoleProcessJob = null, TimeProvider? timeProvider = null) { _runner = runner; @@ -80,6 +87,11 @@ public DotNetAppHostProject( _loggingOptions = loggingOptions; _appHostInfoResolver = appHostInfoResolver; _configurationService = configurationService; + _shutdownService = shutdownService; + _gracefulShutdownSignaler = gracefulShutdownSignaler; + // WindowsConsoleProcessJob is DI-registered Windows-only; null on non-Windows hosts. + // ProcessExecutionFactory enforces the Windows-requires-job invariant at spawn time. + _consoleProcessJob = consoleProcessJob; _timeProvider = timeProvider ?? TimeProvider.System; _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); } @@ -321,7 +333,16 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken StandardErrorCallback = runOutputCollector.AppendError, StartDebugSession = context.StartDebugSession, Debug = context.Debug, - KillEntireProcessTreeOnCancel = ShouldKillEntireProcessTreeOnCancel(OperatingSystem.IsWindows()) + KillEntireProcessTreeOnCancel = ShouldKillEntireProcessTreeOnCancel(OperatingSystem.IsWindows()), + // Run path opts into the shared shutdown ladder so pure .NET AppHosts get the + // same graceful-then-tree-kill semantics as TypeScript AppHosts (which already + // route through AppHostServerSession/ProcessGuestLauncher). Build, restore, + // package add, layout, and other short-lived invocations leave these unset so + // they continue to use today's ProcessTerminator force-kill behavior. + IsolateConsole = true, + ConsoleProcessJob = _consoleProcessJob, + GracefulShutdownSignaler = _gracefulShutdownSignaler, + ShutdownService = _shutdownService, }; // The backchannel completion source is the contract with RunCommand diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 6174800a1db..4c5958ade6c 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -291,100 +291,15 @@ await ProcessTerminator.ShutdownAsync( return; } - // Run-path graceful ladder — mirrors AppHostServerSession.ShutdownAsync. Whoever - // initiated shutdown (CCM via RequestShutdown/Cancel) already started the central - // graceful clock; we only consume the token. - var gracefulToken = options.ShutdownService.Token; - - // Phase 1: best-effort graceful signal bounded by the central token. The DCP path on - // Windows shells out (layout discovery + DCP process launch + wait) and could consume - // the entire graceful window before we ever reach WaitForExitAsync. Sharing the central - // token means a 2nd-Ctrl+C Expire() interrupts the slow DCP shell-out exactly the same - // way it interrupts the WaitForExitAsync below. - try - { - DateTimeOffset? startTime = TryGetStartTime(process); - await options.GracefulShutdownSignaler.RequestProcessTreeGracefulShutdownAsync( - process.Id, - startTime, - includeStartTimeForDcp: false, - gracefulToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (gracefulToken.IsCancellationRequested) - { - // Graceful budget expired before the signal could be issued; fall through to kill. - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to issue graceful shutdown to {Language} guest (pid {Pid}); escalating to kill.", _language, SafePid(process)); - } - - // Phase 2: wait for exit bounded by the same central token. - try - { - await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Graceful budget expired; fall through to kill. - } - - if (process.HasExited) - { - return; - } - - // Phase 3: ALWAYS tree-kill on escalation. Even when the graceful signal returned - // cleanly, the tree may still be alive — e.g. on Windows, tsx wraps node and swallows - // Ctrl+C/Ctrl+Break, leaving the child node and any further descendants running after - // the tsx shell exits. Tree-kill is the only guarantee that nothing is orphaned. - try - { - process.Kill(entireProcessTree: true); - } - catch (InvalidOperationException) - { - // Process exited between HasExited check and Kill — nothing to do. - return; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to kill {Language} guest (pid {Pid}).", _language, SafePid(process)); - return; - } - - // Phase 4: brief separately-bounded drain after kill — independent of central token - // because by now the central budget has already expired. 1 s is enough for the OS to - // reap the process so the subsequent ExitCode read succeeds. - try - { - using var killDrain = new CancellationTokenSource(TimeSpan.FromSeconds(1)); - await process.WaitForExitAsync(killDrain.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Best-effort; nothing more we can do. - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error draining killed {Language} guest (pid {Pid}).", _language, SafePid(process)); - } - } - - private static DateTimeOffset? TryGetStartTime(Process process) - { - // Process.StartTime can throw InvalidOperationException on a process whose handle was - // closed or that has exited in some states. Treat as unavailable rather than aborting - // the graceful path — the Unix branch ignores StartTime entirely, and the Windows DCP - // branch only uses it when includeStartTimeForDcp is true (which it isn't here). - try - { - return process.StartTime; - } - catch (Exception) - { - return null; - } + // Run-path graceful ladder shared with AppHostServerSession and ProcessExecution. + // Whoever initiated shutdown (CCM via RequestShutdown/Cancel) already started the + // central graceful clock; the helper just consumes the token. + await ProcessGracefulShutdownLadder.ExecuteAsync( + process, + options.GracefulShutdownSignaler, + options.ShutdownService.Token, + _logger, + $"{_language} guest").ConfigureAwait(false); } private static int SafePid(Process process) From d31fdda8983de56102eb4bdc15af26ca2622164f Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 1 Jun 2026 21:14:07 -0700 Subject: [PATCH 03/56] Fix Process.ExitCode/HasExited on Windows isolated spawn path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isolated Windows spawn path uses CreateProcessW + Process.GetProcessById to obtain a managed Process for the child. Process objects derived from GetProcessById cannot reliably surface ExitCode on Windows — the BCL state machine depends on the Process instance itself having started the child, and throws InvalidOperationException("Process was not started by this object") otherwise (see dotnet/runtime#45003). This silently broke 'aspire run' on Windows on every code path that reads the AppHost server / guest exit code: AppHostServerSession.Exited handler, AppHostServerSession's post-Run HasExited probe, the backchannel SocketException filter in GuestAppHostProject, IsolatedProcessExecution, and ProcessGuestLauncher's telemetry / return value. Fix it by holding the original CreateProcess HANDLE in a SafeProcessHandle and exposing it via overrides on IsolatedProcess.ExitCode / HasExited that call GetExitCodeProcess + WaitForSingleObject(0) directly. The WaitForSingleObject step disambiguates the 'process exited with code 259' case from the 'still running' STILL_ACTIVE sentinel. Plumb the overrides through: - AppHostServerRunResult gains ExitCodeOverride / HasExitedOverride callbacks plus ReadExitCode() / ReadHasExited() accessors. Non-isolated callers leave the overrides null and the accessors fall back to Process.ExitCode / Process.HasExited, which work correctly for processes the BCL itself started. - DotNetBasedAppHostServerProject and PrebuiltAppHostServer's isolated branches wire the overrides to the IsolatedProcess wrapper. - AppHostServerSession captures the readers into private fields and routes every status check through them. New TryGetServerExitCode() / existing HasServerExited accessors are the recommended public API. - ProcessGuestLauncher captures readExitCode / readHasExited locals on both the isolated and inherited branches and uses them for telemetry + return-value reads. - GuestAppHostProject's StartBackchannelConnectionAsync now takes an AppHostServerSession and uses HasServerExited / TryGetServerExitCode in the SocketException filter instead of the raw Process. The failing IsolatedProcessTests.Start_EchoesLine test that originally exposed this on Windows CI now passes via the IsolatedProcess override path; production paths that previously consumed Process.ExitCode directly on the isolated spawn now go through the wrapper-backed readers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Processes/IsolatedProcess.Windows.cs | 94 ++++++++++++++++--- src/Aspire.Cli/Processes/IsolatedProcess.cs | 48 ++++++++-- .../Processes/WindowsProcessInterop.cs | 26 +++++ .../Projects/AppHostServerSession.cs | 86 +++++++++++++---- .../DotNetBasedAppHostServerProject.cs | 8 +- .../Projects/GuestAppHostProject.cs | 18 ++-- .../Projects/IAppHostServerProject.cs | 31 +++++- .../Projects/PrebuiltAppHostServer.cs | 8 +- .../Projects/ProcessGuestLauncher.cs | 22 ++++- 9 files changed, 291 insertions(+), 50 deletions(-) diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs b/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs index 2704942d774..7fe46ae8a99 100644 --- a/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs +++ b/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs @@ -7,6 +7,7 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; +using Microsoft.Win32.SafeHandles; namespace Aspire.Cli.Processes; @@ -87,6 +88,18 @@ private static IsolatedProcess StartWindows( // client write ends so EOF reaches the StreamReader pumps when the child closes // its handle on exit — without this, the pump would never see EOF and disposal // would always have to wait for the drain timeout. + + // Take ownership of pi.hProcess in a SafeProcessHandle FIRST so that any failure + // below (including OOM in the inner try) cannot leak the raw handle — the + // SafeProcessHandle finalizer will close it. We keep this handle for the lifetime + // of the IsolatedProcess so that ExitCode / HasExited can query the child via + // GetExitCodeProcess / WaitForSingleObject directly. Process objects obtained via + // Process.GetProcessById cannot reliably surface ExitCode on Windows — see + // https://github.com/dotnet/runtime/issues/45003. Holding the original + // CreateProcess handle also pins the OS process object so a recycled PID cannot + // redirect GetProcessById to a different process during the brief window between + // CreateProcess returning and GetProcessById running. + SafeProcessHandle? processHandle = new SafeProcessHandle(pi.hProcess, ownsHandle: true); try { stdoutPipe.DisposeLocalCopyOfClientHandle(); @@ -96,16 +109,10 @@ private static IsolatedProcess StartWindows( // Releasing here avoids a fd leak per spawned child. nulStdinHandle.Dispose(); - // Process.GetProcessById can race a sub-millisecond-exit child: pi.hProcess - // is the only thing keeping the OS process object alive at this moment. - // Hold it open until after GetProcessById returns so a recycled PID can't - // redirect us to a different process. var process = Process.GetProcessById(pi.dwProcessId); - // Managed Process now owns its own handle to the child — release the raw - // CreateProcess handles. + // pi.hThread is no longer needed; the SafeProcessHandle owns pi.hProcess. WindowsProcessInterop.CloseHandle(pi.hThread); - WindowsProcessInterop.CloseHandle(pi.hProcess); // UTF-8 with non-throwing fallback — a stray OEM-encoded byte from a tsx // warning shouldn't kill the pump. Mojibake is the documented tradeoff. @@ -121,6 +128,7 @@ private static IsolatedProcess StartWindows( var capturedStderrReader = stderrReader; var capturedStdoutPipe = stdoutPipe; var capturedStderrPipe = stderrPipe; + var capturedProcessHandle = processHandle; ValueTask ExtraDispose() { @@ -128,13 +136,73 @@ ValueTask ExtraDispose() try { capturedStderrReader.Dispose(); } catch { } try { capturedStdoutPipe.Dispose(); } catch { } try { capturedStderrPipe.Dispose(); } catch { } + // Closes the kept CreateProcess handle. Disposed after the wrapped + // Process so any final ExitCode read from inside Process.Dispose + // semantics can still consult our override; not that we expect it to, + // but the ordering keeps the override path live for the whole disposal + // window. + try { capturedProcessHandle.Dispose(); } catch { } return ValueTask.CompletedTask; } - // From here, pipe/reader ownership has transferred to ExtraDispose; clear - // the local variables so the catch{} cleanup below doesn't double-dispose. + // ExitCode/HasExited overrides for IsolatedProcess.cs. The handle reference + // is the captured local — by the time these closures run, ExtraDispose may + // also be queued, but the closures and ExtraDispose share a single + // SafeProcessHandle whose Dispose state both can observe. After the wrapper + // is disposed, calls to ExitCode/HasExited will throw via the + // SafeHandle-closed path, which matches the contract that the wrapper is + // unusable after disposal. + int GetExitCode() + { + if (capturedProcessHandle.IsClosed || capturedProcessHandle.IsInvalid) + { + throw new InvalidOperationException("Cannot read ExitCode after the IsolatedProcess has been disposed."); + } + + // Disambiguate STILL_ACTIVE (259) from a real 259 exit code via a + // zero-timeout wait. The handle was opened by CreateProcess with full + // access including SYNCHRONIZE, so WaitForSingleObject succeeds. + var waitResult = WindowsProcessInterop.WaitForSingleObject(capturedProcessHandle, 0); + if (waitResult == WindowsProcessInterop.WaitTimeout) + { + throw new InvalidOperationException("Process has not exited; cannot read ExitCode."); + } + if (waitResult == WindowsProcessInterop.WaitFailed) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "WaitForSingleObject failed while reading IsolatedProcess.ExitCode"); + } + + if (!WindowsProcessInterop.GetExitCodeProcess(capturedProcessHandle, out var exitCode)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "GetExitCodeProcess failed while reading IsolatedProcess.ExitCode"); + } + return unchecked((int)exitCode); + } + + bool GetHasExited() + { + if (capturedProcessHandle.IsClosed || capturedProcessHandle.IsInvalid) + { + // After dispose, mirror Process.HasExited's behavior of throwing — + // callers shouldn't be querying a disposed wrapper. + throw new InvalidOperationException("Cannot read HasExited after the IsolatedProcess has been disposed."); + } + + var waitResult = WindowsProcessInterop.WaitForSingleObject(capturedProcessHandle, 0); + return waitResult switch + { + WindowsProcessInterop.WaitObject0 => true, + WindowsProcessInterop.WaitTimeout => false, + WindowsProcessInterop.WaitFailed => throw new Win32Exception(Marshal.GetLastWin32Error(), "WaitForSingleObject failed while reading IsolatedProcess.HasExited"), + _ => throw new InvalidOperationException($"Unexpected WaitForSingleObject result: 0x{waitResult:X8}"), + }; + } + + // From here, pipe/reader/handle ownership has transferred to ExtraDispose; + // clear the locals so the catch{} cleanup below doesn't double-dispose. stdoutPipe = null; stderrPipe = null; + processHandle = null; return WrapStartedProcess( startInfo, @@ -143,7 +211,9 @@ ValueTask ExtraDispose() stderrReader, standardOutputHandler, standardErrorHandler, - ExtraDispose); + ExtraDispose, + exitCodeProvider: GetExitCode, + hasExitedProvider: GetHasExited); } catch { @@ -151,7 +221,9 @@ ValueTask ExtraDispose() // failed — terminate the just-started child so we don't orphan it. try { WindowsProcessInterop.TerminateProcess(pi.hProcess, 1); } catch { } try { WindowsProcessInterop.CloseHandle(pi.hThread); } catch { } - try { WindowsProcessInterop.CloseHandle(pi.hProcess); } catch { } + // Dispose the SafeProcessHandle if we still own it; if ownership already + // transferred (processHandle == null) the wrapper's ExtraDispose owns it. + processHandle?.Dispose(); throw; } } diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.cs b/src/Aspire.Cli/Processes/IsolatedProcess.cs index 6e1f24aadfb..cd3024c8f1e 100644 --- a/src/Aspire.Cli/Processes/IsolatedProcess.cs +++ b/src/Aspire.Cli/Processes/IsolatedProcess.cs @@ -105,6 +105,8 @@ internal sealed class IsolatedProcessStartInfo internal sealed partial class IsolatedProcess : IAsyncDisposable { private readonly Func _disposeAsync; + private readonly Func? _exitCodeProvider; + private readonly Func? _hasExitedProvider; private int _disposed; private IsolatedProcess( @@ -113,7 +115,9 @@ private IsolatedProcess( IReadOnlyList arguments, Task standardOutputClosed, Task standardErrorClosed, - Func disposeAsync) + Func disposeAsync, + Func? exitCodeProvider, + Func? hasExitedProvider) { Process = process; Id = process.Id; @@ -122,6 +126,8 @@ private IsolatedProcess( StandardOutputClosed = standardOutputClosed; StandardErrorClosed = standardErrorClosed; _disposeAsync = disposeAsync; + _exitCodeProvider = exitCodeProvider; + _hasExitedProvider = hasExitedProvider; } /// The underlying for escape-hatch scenarios. @@ -143,11 +149,22 @@ private IsolatedProcess( /// The original argument list. Same rationale as . public IReadOnlyList Arguments { get; } - /// Mirrors . - public bool HasExited => Process.HasExited; + /// + /// Mirrors . The Windows spawn path overrides this with a + /// WaitForSingleObject(handle, 0) check against the kept SafeProcessHandle + /// because on a + /// instance is unreliable for processes the managed Process didn't itself start. + /// + public bool HasExited => _hasExitedProvider?.Invoke() ?? Process.HasExited; - /// Mirrors . - public int ExitCode => Process.ExitCode; + /// + /// Mirrors . The Windows spawn path overrides this with a + /// GetExitCodeProcess call against the kept SafeProcessHandle; without the + /// override throws + /// for processes obtained via on Windows. + /// See https://github.com/dotnet/runtime/issues/45003. + /// + public int ExitCode => _exitCodeProvider?.Invoke() ?? Process.ExitCode; /// /// Completes when the stdout pump finishes (pipe EOF — i.e. child closed the stream @@ -241,6 +258,19 @@ public static IsolatedProcess Start( /// The Windows path uses this slot to dispose the anonymous pipes and the NUL stdin /// handle that are owned by the spawn path, not by the Process. /// + /// + /// Optional override for . The Windows spawn path provides one + /// because the managed returned by + /// cannot reliably surface ExitCode for processes it did not itself start; the override + /// reads the exit code via GetExitCodeProcess against the kept CreateProcess handle. + /// Unix passes — its + /// path produces a Process with a working ExitCode getter. + /// + /// + /// Optional override for . Same rationale as + /// — Windows reads via WaitForSingleObject + /// against the kept handle; Unix uses the default . + /// private static IsolatedProcess WrapStartedProcess( IsolatedProcessStartInfo startInfo, Process process, @@ -248,7 +278,9 @@ private static IsolatedProcess WrapStartedProcess( TextReader standardError, Action standardOutputHandler, Action standardErrorHandler, - Func? extraDispose) + Func? extraDispose, + Func? exitCodeProvider = null, + Func? hasExitedProvider = null) { // Snapshot identity off startInfo now — the caller may mutate the startInfo after // we return and we don't want the wrapper to observe those changes. @@ -308,7 +340,9 @@ async ValueTask DisposeAsync(TimeSpan drainTimeout) argumentsSnapshot, standardOutputClosed: outputTcs.Task, standardErrorClosed: errorTcs.Task, - DisposeAsync); + DisposeAsync, + exitCodeProvider, + hasExitedProvider); // The pumps capture 'isolated' as the handler's "sender". The assignment is fully // visible to the pump's Task.Run worker by happens-before semantics. diff --git a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs index 8eaa458be07..e607b5ae69a 100644 --- a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs +++ b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs @@ -175,6 +175,32 @@ public static extern bool CreateProcessW( [LibraryImport("kernel32.dll", SetLastError = true)] public static partial uint ResumeThread(nint hThread); + // GetExitCodeProcess + WaitForSingleObject are used by IsolatedProcess.Windows so that + // IsolatedProcess.ExitCode / HasExited can query the child via the SafeProcessHandle we + // kept open from CreateProcessW. Process objects obtained via Process.GetProcessById + // cannot reliably surface ExitCode on Windows ("Process was not started by this object" + // InvalidOperationException) — see https://github.com/dotnet/runtime/issues/45003. By + // holding the original CreateProcess handle and calling Win32 directly we sidestep the + // managed-Process state machine that depends on Process.Start having been the producer. + // Docs: + // https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-getexitcodeprocess + // https://learn.microsoft.com/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetExitCodeProcess(SafeProcessHandle hProcess, out uint lpExitCode); + + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial uint WaitForSingleObject(SafeProcessHandle hHandle, uint dwMilliseconds); + + // GetExitCodeProcess returns STILL_ACTIVE (259) when the process is still running, but + // a process can also legitimately exit with code 259. Use WaitForSingleObject with a + // zero timeout to disambiguate: WAIT_OBJECT_0 means signaled (truly exited), WAIT_TIMEOUT + // means still running. See the GetExitCodeProcess remarks in the docs linked above. + public const uint StillActive = 259; + public const uint WaitObject0 = 0x00000000; + public const uint WaitTimeout = 0x00000102; + public const uint WaitFailed = 0xFFFFFFFF; + // Job-object APIs — see // https://learn.microsoft.com/windows/win32/procthread/job-objects. We use a job to // guarantee that interactive children (and their grandchildren) are killed when the CLI diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 3b074db70e9..b168806d613 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -54,6 +54,13 @@ internal sealed class AppHostServerSession : IAsyncDisposable private CancellationTokenRegistration _stopRegistration; private IAppHostRpcClient? _rpcClient; private Task? _shutdownTask; + // Captured from AppHostServerRunResult so we read exit-code / has-exited via the + // IsolatedProcess wrapper on the isolated Windows path. Reading directly from + // _serverProcess.ExitCode / .HasExited on that path throws InvalidOperationException + // ("Process was not started by this object") because the managed Process came from + // Process.GetProcessById. See https://github.com/dotnet/runtime/issues/45003. + private Func? _readExitCode; + private Func? _readHasExited; public AppHostServerSession( IAppHostServerProject project, @@ -118,16 +125,49 @@ public AppHostServerSession( public OutputCollector? Output => _output; /// - /// Gets the underlying server process for read-only observation by the backchannel polling - /// loop, or if has not been called (or threw - /// before the process was published). + /// Gets whether the underlying AppHost server process has exited, or + /// if has not been called (or threw before the process was + /// published). Prefer this over reading ServerProcess.HasExited directly — the + /// isolated Windows spawn path uses a -derived + /// whose status getters are unreliable; this accessor routes through + /// the wrapper that holds the original CreateProcess handle. + /// + public bool? HasServerExited => _readHasExited?.Invoke(); + + /// + /// Reads the AppHost server process's exit code if it has exited, or + /// if the server is still running, has not been started, or the exit code cannot be read. + /// Prefer this over reading ServerProcess.ExitCode directly — see + /// for the same Windows-isolated-spawn rationale. + /// + public int? TryGetServerExitCode() + { + if (_readExitCode is null || _readHasExited is null || _readHasExited() != true) + { + return null; + } + + try + { + return _readExitCode(); + } + catch + { + return null; + } + } + + /// + /// Gets the underlying server process for read-only observation. Prefer + /// for has-exited checks and + /// for exit-code reads — see those members' remarks. /// /// - /// This is intentionally narrow: the only legitimate consumer is - /// StartBackchannelConnectionAsync's catch (SocketException) when (process.HasExited) - /// filter, which distinguishes "server died" from "server still starting up". Callers must not - /// invoke , , - /// or other lifecycle APIs on the returned instance — those belong to the session. + /// Callers must not invoke , + /// , + /// , or other lifecycle / status APIs on the returned + /// instance — those belong to the session (or to / + /// ). /// public Process? ServerProcess => _serverProcess; @@ -208,10 +248,18 @@ public Task StartAsync() _serverProcess = result.Process; _socketPath = result.SocketPath; _output = result.OutputCollector; + // Capture the readers from the run result so every status / exit-code check below + // (Exited handler, post-Run HasExited probe, dispose-time activity update, kill + // fallback) routes through the wrapper instead of the GetProcessById-derived + // Process — required for the isolated Windows path. + _readExitCode = result.ReadExitCode; + _readHasExited = result.ReadHasExited; try { var process = result.Process; + var readExitCode = result.ReadExitCode; + var readHasExited = result.ReadHasExited; _activity.SetProcessId(process.Id); @@ -222,16 +270,16 @@ public Task StartAsync() // Hook Exited before we check HasExited to close the race window where the process // could exit between Start returning and our subscription being wired up. Capture the - // process + completion locals in the closure so the handler doesn't need to read - // mutable fields when it fires from the thread pool. + // completion + readers in the closure so the handler doesn't need to read mutable + // fields when it fires from the thread pool. process.EnableRaisingEvents = true; - process.Exited += (_, _) => TrySetExitCode(completion, process); + process.Exited += (_, _) => TrySetExitCode(completion, readExitCode); // If the process exited before we hooked Exited (or before EnableRaisingEvents was set), // the event will not fire. Trip the completion ourselves in that case. - if (process.HasExited) + if (readHasExited()) { - TrySetExitCode(completion, process); + TrySetExitCode(completion, readExitCode); } // Register on the internal linked CTS. If the caller's token already fired (or DisposeAsync @@ -250,7 +298,7 @@ public Task StartAsync() _activity.Dispose(); try { - if (!result.Process.HasExited) + if (!result.ReadHasExited()) { result.Process.Kill(entireProcessTree: true); } @@ -361,11 +409,11 @@ public async ValueTask DisposeAsync() } } - if (_serverProcess is { } process) + if (_serverProcess is not null && _readHasExited is { } hasExited && _readExitCode is { } exitCode) { - if (process.HasExited) + if (hasExited()) { - _activity.SetProcessExitCode(process.ExitCode); + _activity.SetProcessExitCode(exitCode()); } } @@ -393,11 +441,11 @@ public async ValueTask DisposeAsync() _activity.Dispose(); } - private static void TrySetExitCode(TaskCompletionSource completion, Process process) + private static void TrySetExitCode(TaskCompletionSource completion, Func readExitCode) { try { - completion.TrySetResult(process.ExitCode); + completion.TrySetResult(readExitCode()); } catch (InvalidOperationException) { diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 55cfe723d47..79a2442c24a 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -554,7 +554,13 @@ void OnStderr(int pid, string line) outputCollector, startInfo.FileName, arguments, - isolated); + isolated, + // Route exit-code/has-exited reads through IsolatedProcess so the isolated + // Windows path can use its kept CreateProcess handle instead of the managed + // Process.GetProcessById instance (which throws on ExitCode). See + // https://github.com/dotnet/runtime/issues/45003. + ExitCodeOverride: () => isolated.ExitCode, + HasExitedOverride: () => isolated.HasExited); } var process = Process.Start(startInfo)!; diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 317b8ecd0bc..d4e94d169b3 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -612,7 +612,7 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() // Use the AppHost system token so that a guest-side failure (which faults the // backchannel completion source and cancels appHostSystemCts) stops the polling loop // promptly. - _ = StartBackchannelConnectionAsync(serverSession.ServerProcess!, backchannelSocketPath, backchannelCompletionSource, enableHotReload, startProjectContext, appHostSystemToken); + _ = StartBackchannelConnectionAsync(serverSession, backchannelSocketPath, backchannelCompletionSource, enableHotReload, startProjectContext, appHostSystemToken); return Task.CompletedTask; } @@ -1033,7 +1033,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca // as soon as the server is reachable; the post-start work below races alongside it. if (context.BackchannelCompletionSource is not null) { - _ = StartBackchannelConnectionAsync(serverSession.ServerProcess!, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, startProjectContext, cancellationToken); + _ = StartBackchannelConnectionAsync(serverSession, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, startProjectContext, cancellationToken); } try @@ -1201,7 +1201,7 @@ private static void MergeServerOutputIntoContextCollector(AppHostProjectContext /// Starts connecting to the AppHost server's backchannel server. /// private async Task StartBackchannelConnectionAsync( - Process process, + AppHostServerSession serverSession, string socketPath, TaskCompletionSource backchannelCompletionSource, bool enableHotReload, @@ -1233,10 +1233,16 @@ private async Task StartBackchannelConnectionAsync( _logger.LogDebug("Connected to AppHost server backchannel at {SocketPath}", socketPath); return; } - catch (SocketException ex) when (process.HasExited) + // Route HasExited / ExitCode through the session so the isolated Windows spawn path + // (which surfaces Process via Process.GetProcessById, whose status getters are + // unreliable for processes the BCL did not itself start) goes through the + // IsolatedProcess wrapper's GetExitCodeProcess-backed accessors instead. + // See https://github.com/dotnet/runtime/issues/45003. + catch (SocketException ex) when (serverSession.HasServerExited == true) { - _logger.LogError("AppHost server process has exited with code {ExitCode}. Unable to connect to backchannel at {SocketPath}", process.ExitCode, socketPath); - var message = process.ExitCode == CliExitCodes.Success + var exitCode = serverSession.TryGetServerExitCode() ?? -1; + _logger.LogError("AppHost server process has exited with code {ExitCode}. Unable to connect to backchannel at {SocketPath}", exitCode, socketPath); + var message = exitCode == CliExitCodes.Success ? "AppHost server process has exited" : "AppHost server process has exited unexpectedly"; var backchannelException = new FailedToConnectBackchannelConnection(message, ex); diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index fe385cc1310..8e56d0e33a8 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -32,6 +32,11 @@ internal sealed record AppHostServerPrepareResult( /// (, ), /// but must dispose via instead of the /// directly so the isolated-spawn path can release its anonymous pipes and stdin handle. +/// Callers should prefer the / accessors +/// over / for status checks, +/// because the managed Process returned for the isolated Windows path is obtained via +/// and cannot reliably surface ExitCode/HasExited +/// (see https://github.com/dotnet/runtime/issues/45003 and ). /// /// Captured stdout/stderr for failure display. /// @@ -47,13 +52,37 @@ internal sealed record AppHostServerPrepareResult( /// the isolated path it is the wrapper that also drains the /// stdout/stderr pumps and closes the anonymous pipes + NUL stdin handle on Windows. /// +/// +/// Optional override for . The isolated Windows spawn path supplies +/// one because on a +/// instance throws . Non-isolated callers leave this +/// and the accessor reads from directly. +/// +/// Optional override for . Same rationale as . internal sealed record AppHostServerRunResult( string SocketPath, Process Process, OutputCollector OutputCollector, string FileName, IReadOnlyList Arguments, - IAsyncDisposable ProcessLifetime); + IAsyncDisposable ProcessLifetime, + Func? ExitCodeOverride = null, + Func? HasExitedOverride = null) +{ + /// + /// Reads the child's exit code. Use this instead of Process.ExitCode — on the + /// isolated Windows path that property throws because the managed Process came from + /// ; the override consults the kept CreateProcess + /// handle directly. + /// + public int ReadExitCode() => ExitCodeOverride is { } reader ? reader() : Process.ExitCode; + + /// + /// Reads whether the child has exited. Same rationale as — + /// prefer this over Process.HasExited for status checks on the isolated Windows path. + /// + public bool ReadHasExited() => HasExitedOverride is { } reader ? reader() : Process.HasExited; +} /// /// Represents an AppHost server that can be prepared and run. diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 63e555d5813..ef450b35ede 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -894,7 +894,13 @@ void OnStderr(int pid, string line) outputCollector, startInfo.FileName, arguments, - isolated); + isolated, + // Route exit-code/has-exited reads through IsolatedProcess so the isolated + // Windows path can use its kept CreateProcess handle instead of the managed + // Process.GetProcessById instance (which throws on ExitCode). See + // https://github.com/dotnet/runtime/issues/45003. + ExitCodeOverride: () => isolated.ExitCode, + HasExitedOverride: () => isolated.HasExited); } var process = Process.Start(startInfo)!; diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 4c5958ade6c..4495f2bab47 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -98,6 +98,13 @@ void HandleStderrLine(int pid, string line) IAsyncDisposable? lifetime = null; Task stdoutDrain; Task stderrDrain; + // Readers for exit-code / has-exited that route through the IsolatedProcess wrapper on + // the isolated path. Process.ExitCode on a Process.GetProcessById-derived instance + // throws InvalidOperationException on Windows ("Process was not started by this + // object") — see https://github.com/dotnet/runtime/issues/45003. The wrapper sidesteps + // this by querying GetExitCodeProcess directly against the kept CreateProcess handle. + Func readExitCode; + Func readHasExited; AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); @@ -135,6 +142,8 @@ void HandleStderrLine(int pid, string line) stdoutDrain = isolatedChild.StandardOutputClosed; stderrDrain = isolatedChild.StandardErrorClosed; lifetime = isolatedChild; + readExitCode = () => isolatedChild.ExitCode; + readHasExited = () => isolatedChild.HasExited; } else { @@ -195,6 +204,10 @@ void HandleStderrLine(int pid, string line) process = inheritedProcess; stdoutDrain = stdoutCompleted.Task; stderrDrain = stderrCompleted.Task; + // Non-isolated path: Process was created via new Process { StartInfo = ... } + + // Start(), so Process.ExitCode / Process.HasExited work normally on every OS. + readExitCode = () => inheritedProcess.ExitCode; + readHasExited = () => inheritedProcess.HasExited; } _logger.LogDebug("{Language} guest process {ProcessId} started: {Command}", _language, process.Id, resolvedCommandPath); @@ -228,10 +241,11 @@ void HandleStderrLine(int pid, string line) await ShutdownGuestProcessAsync(process, options, cancellationToken).ConfigureAwait(false); } - _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, process.ExitCode); + _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, readExitCode()); - activity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); - AddEvent(activity, ProfilingTelemetry.Events.GuestProcessExited, TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); + var finalExitCode = readExitCode(); + activity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, finalExitCode); + AddEvent(activity, ProfilingTelemetry.Events.GuestProcessExited, TelemetryConstants.Tags.ProcessExitCode, finalExitCode); // Wait for the redirected streams to finish draining so no trailing lines are lost. // Pass a fresh token rather than the outer cancellation token: when WaitForExitAsync @@ -244,7 +258,7 @@ void HandleStderrLine(int pid, string line) _logger.LogWarning("{Language}({ProcessId}): Timed out waiting for output streams to drain after process exit", _language, process.Id); } - return (process.ExitCode, outputCollector); + return (finalExitCode, outputCollector); } finally { From 53808efc257d2c95497a012098727d179521018d Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 1 Jun 2026 21:43:12 -0700 Subject: [PATCH 04/56] Fix aspire start on Windows: dedupe PROC_THREAD_ATTRIBUTE_HANDLE_LIST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DetachedProcessLauncher.StartWindows points both Stdout and Stderr at the same NUL handle (child writes go nowhere). When the unified WindowsProcessInterop.SpawnConsoleIsolatedProcess started building the PROC_THREAD_ATTRIBUTE_HANDLE_LIST from the (Stdin, Stdout, Stderr) slots, that NUL handle appeared twice. PROC_THREAD_ATTRIBUTE_HANDLE_LIST does not allow duplicate handle entries — CreateProcessW returns ERROR_INVALID_PARAMETER (87), which surfaced as 'aspire start' failing immediately on Windows with exit code 2 in the dogfood starter validation job. See https://devblogs.microsoft.com/oldnewthing/20111216-00/?p=8873 for the documented Win32 constraint. The old standalone DetachedProcessLauncher.StartWindows whitelisted only the single NUL handle (one entry), so this regressed when the launcher was switched over to the unified helper. Dedupe by value before populating the attribute list, and add a Windows- only regression test that spawns a detached process via the production DetachedProcessLauncher path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Processes/WindowsProcessInterop.cs | 25 ++++++++------ .../Processes/DetachedProcessLauncherTests.cs | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Processes/DetachedProcessLauncherTests.cs diff --git a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs index e607b5ae69a..89d7c5dbf92 100644 --- a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs +++ b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs @@ -337,19 +337,24 @@ public static PROCESS_INFORMATION SpawnConsoleIsolatedProcess( // the child is supposed to inherit and nothing else — this is the entire point of // PROC_THREAD_ATTRIBUTE_HANDLE_LIST: deny inheritance of every other inheritable handle // open on any parent thread (DCP socket fds, pipe fds, etc.). + // + // The whitelist MUST NOT contain duplicate handle values. PROC_THREAD_ATTRIBUTE_HANDLE_LIST + // is documented to reject duplicates and CreateProcessW returns ERROR_INVALID_PARAMETER + // (87) if any handle appears more than once. DetachedProcessLauncher legitimately points + // both Stdout and Stderr at the same NUL handle (child writes go nowhere), so we + // de-duplicate by handle value before populating the attribute. See: + // https://devblogs.microsoft.com/oldnewthing/20111216-00/?p=8873 var inheritable = new List(3); - if (stdio.Stdin != nint.Zero) + void AddIfUnique(nint handle) { - inheritable.Add(stdio.Stdin); - } - if (stdio.Stdout != nint.Zero) - { - inheritable.Add(stdio.Stdout); - } - if (stdio.Stderr != nint.Zero) - { - inheritable.Add(stdio.Stderr); + if (handle != nint.Zero && !inheritable.Contains(handle)) + { + inheritable.Add(handle); + } } + AddIfUnique(stdio.Stdin); + AddIfUnique(stdio.Stdout); + AddIfUnique(stdio.Stderr); var attrListSize = nint.Zero; InitializeProcThreadAttributeList(nint.Zero, 1, 0, ref attrListSize); diff --git a/tests/Aspire.Cli.Tests/Processes/DetachedProcessLauncherTests.cs b/tests/Aspire.Cli.Tests/Processes/DetachedProcessLauncherTests.cs new file mode 100644 index 00000000000..da66ac95a86 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Processes/DetachedProcessLauncherTests.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Aspire.Cli.Processes; + +namespace Aspire.Cli.Tests.Processes; + +public class DetachedProcessLauncherTests +{ + // Regression test for the duplicate-handle bug that broke `aspire start` on Windows: + // DetachedProcessLauncher.StartWindows points both Stdout and Stderr at the same NUL + // handle, and PROC_THREAD_ATTRIBUTE_HANDLE_LIST rejects duplicate handle values — + // CreateProcessW returns ERROR_INVALID_PARAMETER (87). The unified + // WindowsProcessInterop.SpawnConsoleIsolatedProcess de-duplicates the inheritable + // handle list, so this spawn must succeed. + [Fact] + [SupportedOSPlatform("windows")] + public void Start_OnWindows_WithSharedStdoutStderrHandle_Succeeds() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test."); + + // A short-lived child is sufficient: we only need CreateProcessW to return successfully. + // `cmd.exe /c exit 0` returns immediately and never touches stdout/stderr, so any + // failure mode here is from the spawn primitive, not from the child itself. + using var child = DetachedProcessLauncher.Start( + "cmd.exe", + ["/c", "exit", "0"], + Environment.CurrentDirectory); + + Assert.True(child.Id > 0); + } +} From 65f3cdac7808b47f2d18762ccc0fa38b869d7f96 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 1 Jun 2026 22:21:12 -0700 Subject: [PATCH 05/56] Drop RequestShutdown/RequestedExitCode plumbing in favor of disposable-driven cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal failure paths in GuestAppHostProject.RunAsync (backchannel fault, dependency install failure, guest non-zero exit, normal completion) no longer route through ConsoleCancellationManager. They return their exit code directly and rely on the existing 'await using' disposables on the AppHost session and guest launcher to force-kill children as the run scope unwinds. DCP runs in a separate process tree and handles its own resource cleanup. The dedicated mutator API (CCM.RequestShutdown / CCM.RequestedExitCode) and the ladder-started/user-signal-count split it required are removed. Cancel is now a single 3-stage signal counter (cancel + watcher, expire graceful, force exit) driven exclusively by user signals from PosixSignalRegistration / Console.CancelKeyPress. The backchannel-fault continuation cancels a local CTS linked to the outer cancellation token, surfacing FailedToDotnetRunAppHost via a local 'internalFaultCode' captured by the outer OCE catch. RunCommand and Program.Main OCE catches now unconditionally map cancellation to CliExitCodes.Cancelled / Success — the only thing that reaches them is user-initiated cancellation, since internal failures return their exit code directly without throwing OCE. Net: -193 lines / +88 lines, with one new regression test removed (the four tests covering RequestShutdown semantics are gone; the FirstSignal test drops its RequestedExitCode assertions). Behavior preserved for user Ctrl+C: still gets the full 5s graceful + 5s drain ladder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 40 ++--- src/Aspire.Cli/ConsoleCancellationManager.cs | 138 +++--------------- .../ProcessGracefulShutdownLadder.cs | 8 +- src/Aspire.Cli/Program.cs | 9 +- .../Projects/GuestAppHostProject.cs | 84 ++++++----- .../Projects/ProcessGuestLauncher.cs | 2 +- .../ConsoleCancellationManagerTests.cs | 86 ----------- .../Projects/GuestAppHostProjectTests.cs | 2 - 8 files changed, 88 insertions(+), 281 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index f776676ec32..356364f567f 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -148,8 +148,8 @@ protected override async Task ExecuteAsync(ParseResult parseResul { // Give DCP a cooperative window to drain resources before the central drain budget // arms and shutdown ladders escalate to forceful kill. Without this, GracefulShutdownToken - // fires immediately on the first signal/RequestShutdown and isolation/AttachConsole buys - // nothing because every ladder sees the graceful window already expired. + // fires immediately on the first user signal and isolation/AttachConsole buys nothing + // because every ladder sees the graceful window already expired. _cancellationManager.ConfigureForCommand(s_gracefulShutdownBudget); var passedAppHostProjectFile = parseResult.GetValue(AppHostLauncher.s_appHostOption); @@ -524,10 +524,11 @@ await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTar { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); - // The user cancelled (e.g. Ctrl+C) OR an internal RequestShutdown fired the central - // token — distinguishing them by RequestedExitCode so internal-failure exit codes - // (e.g. guest exited non-zero) surface instead of being masked as success. - return MapCancellationToResult(); + // User Ctrl+C is the normal exit path for `aspire run`; surface as success. + // Internal failures `return X` directly from GuestAppHostProject.RunAsync rather + // than flowing through this catch, so we don't need to distinguish failure codes + // here. + return CommandResult.Cancelled(CliExitCodes.Success); } finally { @@ -549,10 +550,10 @@ await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTar { runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled"); - // Command is designed to be cancellable by the user (e.g. Ctrl+C) at any time, but also - // by internal RequestShutdown(failureCode). Distinguish via CCM.RequestedExitCode so - // internal-failure exit codes are surfaced rather than swallowed as success. - return MapCancellationToResult(); + // User Ctrl+C is the normal exit path for `aspire run`; surface as success. + // Internal failures `return X` directly from GuestAppHostProject.RunAsync rather + // than flowing through this catch. + return CommandResult.Cancelled(CliExitCodes.Success); } catch (ProjectLocatorException ex) { @@ -632,25 +633,6 @@ private static CommandResult CreateRunExitResult(int exitCode, string? errorMess : CommandResult.Failure(exitCode, errorMessage); } - /// - /// Maps a cancellation captured by an OCE catch to a CommandResult, consulting - /// so internal - /// RequestShutdown(failureCode) calls surface as failures rather than being masked as - /// success by the user-cancel translation. Returns success when no exit code was captured or - /// when the captured code matches the conventional Cancelled (130) signal exit. - /// - private CommandResult MapCancellationToResult() - { - var requested = _cancellationManager.RequestedExitCode; - if (requested is null or CliExitCodes.Cancelled) - { - // Ambient cancellation or user Ctrl+C — preserve today's "stopping intentionally is OK" UX. - return CommandResult.Cancelled(CliExitCodes.Success); - } - - return CommandResult.FromExitCode(requested.Value); - } - private static async Task ObserveEarlyDetachedStartupExitAsync(Task pendingRun, CancellationToken cancellationToken) { var completedTask = await Task.WhenAny( diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index c0b7f07afd4..dfbd8770076 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -27,6 +27,11 @@ namespace Aspire.Cli; /// Third (or later) signal — fires; Main exits NOW. /// /// +/// Internal teardown paths (guest failures, normal completion) do NOT drive this counter. They rely on +/// disposable-driven cleanup — await using of the server session and guest launcher — to run each +/// child process's own per-process shutdown ladder when the run scope unwinds. +/// +/// /// The completion source completing is treated as a strict superset of graceful expiration: /// when the source completes for any reason (drain timeout, third signal, future external triggers), /// is invoked synchronously so ladders observing only @@ -46,11 +51,6 @@ internal sealed class ConsoleCancellationManager : IDisposable private const int SigIntExitCode = 130; private const int SigTermExitCode = 143; - // Sentinel for "no exit code captured yet". int.MinValue is unreachable for any real - // process exit code we surface and lets us use a single Interlocked.CompareExchange - // for first-writer-wins semantics without a separate "is set" flag. - private const int NoExitCodeSentinel = int.MinValue; - private readonly CancellationTokenSource _cts = new(); private readonly GracefulShutdownService _gracefulService; private readonly TimeSpan _finalDrainBudget; @@ -60,18 +60,12 @@ internal sealed class ConsoleCancellationManager : IDisposable private readonly CancellationToken _token; private ILogger _logger; private Task? _startedHandler; - // Gate that ensures the shutdown ladder (CTS cancel + graceful watcher) is started exactly once. - // CAS-flipped 0→1 by whichever of RequestShutdown or first-time Cancel arrives first; subsequent - // callers on either path observe the gate already taken and avoid double-starting the watcher. - // Kept separate from _userSignalCount so internal RequestShutdown does NOT consume a user signal — - // otherwise the user's first real Ctrl+C after an internal request would be observed as the second - // signal and collapse the graceful window immediately. - private int _ladderStarted; - // Number of real user-initiated termination signals received (Ctrl+C, SIGINT, SIGTERM, SIGQUIT, - // ProcessExit). Drives the second-signal (Expire) and third-signal (force exit) escalations. - // Only Cancel touches this — RequestShutdown does not. - private int _userSignalCount; - private int _requestedExitCode = NoExitCodeSentinel; + // Number of termination signals (Ctrl+C, SIGINT, SIGTERM, SIGQUIT, ProcessExit) received. + // Drives the three-stage ladder: 1 = start graceful watcher; 2 = collapse graceful; + // 3+ = force-exit. Internal teardown paths (guest failures, normal completion) do NOT + // drive this counter — they rely on disposable-based cleanup (`await using` of the + // server session + guest launcher) to run the per-process shutdown ladders. + private int _signalCount; private TimeSpan _gracefulBudget = TimeSpan.Zero; private readonly TaskCompletionSource _processTerminationCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -161,22 +155,6 @@ public ConsoleCancellationManager(GracefulShutdownService gracefulService, TimeS public bool IsCancellationRequested => _cts.IsCancellationRequested; - /// - /// Exit code captured at the moment cancellation was first requested. First writer wins; set by - /// either an external termination signal (with the OS-conventional signal exit code) or an internal - /// caller via . Reads return until the first - /// caller writes. - /// - public int? RequestedExitCode - { - get - { - var value = Volatile.Read(ref _requestedExitCode); - - return value == NoExitCodeSentinel ? null : value; - } - } - /// /// Sets the graceful-shutdown budget for the currently-executing command. Default is zero, meaning /// ladders that consume fall through to escalation immediately @@ -194,50 +172,6 @@ public void ConfigureForCommand(TimeSpan gracefulBudget) _gracefulBudget = gracefulBudget; } - /// - /// Requests cooperative cancellation with the given exit code. Used by internal teardown callers - /// (guest exited non-zero, backchannel never completed, normal completion) that need to drive the - /// same shutdown ladder a user signal would, without tampering with private token sources. - /// - /// - /// Unlike , this does NOT contribute to the user-signal escalation counter. - /// Multiple concurrent internal callers (e.g. backchannel fault racing with guest non-zero exit) - /// would otherwise be treated as "second Ctrl+C" and collapse the graceful window immediately — - /// defeating the budget. Instead, the first call captures the exit code and fires the primary CT - /// exactly once; subsequent calls are no-ops (first-writer-wins on the exit code via - /// ). A real Ctrl+C from the user still escalates correctly - /// because drives _userSignalCount independently of _ladderStarted. - /// - public void RequestShutdown(int exitCode) - { - // First-writer-wins on the exit code, regardless of whether we end up firing the ladder. - // (A late internal request after Ctrl+C already started the ladder still tries to register, - // but the SIGINT code stays in place.) - TrySetRequestedExitCode(exitCode); - - // CAS the ladder-started gate 0→1. If a signal handler or prior internal call already started - // the ladder, no-op. Critically: we DO NOT touch _userSignalCount here, so a subsequent user - // Ctrl+C is still seen as the first user signal — not the second. - if (Interlocked.CompareExchange(ref _ladderStarted, 1, 0) != 0) - { - return; - } - - _logger.LogInformation("Internal shutdown requested with exit code {ExitCode}.", exitCode); - - try - { - _cts.Cancel(); - } - catch (ObjectDisposedException) - { - // Process is already tearing down; nothing more to do. - return; - } - - _ = ExpireGracefulThenFinalDrainAsync(exitCode); - } - private void OnPosixSignal(PosixSignalContext context) { context.Cancel = true; @@ -260,21 +194,13 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) internal void Cancel(int exitCode) { - // _userSignalCount tracks real user signals only; _ladderStarted is the one-shot gate that - // tells us whether THIS Cancel is also the call that needs to start the ladder. Keeping the - // two counters separate is what prevents internal RequestShutdown from consuming a user - // signal — without that separation, "internal RequestShutdown + first user Ctrl+C" would - // collapse the graceful window immediately. - var userSignalCount = Interlocked.Increment(ref _userSignalCount); - var ladderJustStarted = Interlocked.CompareExchange(ref _ladderStarted, 1, 0) == 0; - - if (ladderJustStarted) + var n = Interlocked.Increment(ref _signalCount); + + if (n == 1) { - // First signal AND ladder not already started by an internal RequestShutdown: capture the - // exit code, request cooperative cancellation, and schedule the graceful-then-drain watcher. - // The signal handler returns immediately so Program.Main's Task.WhenAny observes handler - // completion without being blocked by the handler thread. - TrySetRequestedExitCode(exitCode); + // First signal: request cooperative cancellation and schedule the graceful-then-drain + // watcher. The signal handler returns immediately so Program.Main's Task.WhenAny observes + // handler completion without being blocked by the handler thread. _logger.LogInformation("Termination signal received, requesting cancellation."); try @@ -288,22 +214,10 @@ internal void Cancel(int exitCode) } _ = ExpireGracefulThenFinalDrainAsync(exitCode); - return; } - - if (userSignalCount == 1) - { - // First user signal but the ladder was already started by an internal RequestShutdown - // (e.g. normal guest exit, backchannel fault). The user has only pressed Ctrl+C once, - // so we do NOT escalate — the graceful watcher continues running. Capture the exit - // code for first-writer-wins (the internal value normally still wins). A second user - // Ctrl+C below will escalate as expected. - TrySetRequestedExitCode(exitCode); - _logger.LogInformation("Termination signal received while shutdown ladder already running."); - } - else if (userSignalCount == 2) + else if (n == 2) { - // Second user signal: collapse Phase 1 immediately. Ladders observing the graceful token + // Second signal: collapse Phase 1 immediately. Ladders observing the graceful token // unblock and escalate to forceful termination; the watcher's Task.Delay(graceful) gets // cancelled and moves on to Phase 2 (final drain). _logger.LogWarning("Second termination signal received, expiring graceful shutdown window."); @@ -311,24 +225,12 @@ internal void Cancel(int exitCode) } else { - // Third (or later) user signal: caller wants out NOW. Skip both graceful and drain budgets. + // Third (or later) signal: caller wants out NOW. Skip both graceful and drain budgets. _logger.LogWarning("Third termination signal received, forcing immediate exit."); _processTerminationCompletionSource.TrySetResult(exitCode); } } - private void TrySetRequestedExitCode(int exitCode) - { - // Normalize the pathological caller that happens to pass the sentinel value so we never - // confuse "no exit code captured" with "exit code is int.MinValue". - if (exitCode == NoExitCodeSentinel) - { - exitCode = -1; - } - - Interlocked.CompareExchange(ref _requestedExitCode, exitCode, NoExitCodeSentinel); - } - private async Task ExpireGracefulThenFinalDrainAsync(int forcedTerminationExitCode) { try diff --git a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs index 05fe932b953..b2aa0ae24a9 100644 --- a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs +++ b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs @@ -15,9 +15,9 @@ namespace Aspire.Cli.Processes; /// uniform across spawn sites. /// /// -/// Whoever triggers shutdown ( / -/// ) is responsible for starting the -/// central clock. This helper only consumes the resulting token — it never owns timing. +/// Whoever triggers shutdown () is responsible +/// for starting the central clock. This helper only consumes the resulting token — it never +/// owns timing. /// internal static class ProcessGracefulShutdownLadder { @@ -65,7 +65,7 @@ await signaler.RequestProcessTreeGracefulShutdownAsync( } // Phase 2: wait for exit bounded by the same central token. Whoever initiated shutdown - // (CCM via RequestShutdown/Cancel) already started the clock; we only consume the token. + // (user Ctrl+C via CCM.Cancel) already started the clock; we only consume the token. try { await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 9c675fd3f86..4b7308aac17 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -954,11 +954,10 @@ public static async Task Main(string[] args) } catch (OperationCanceledException) { - // When the command observed cancellation and propagated OCE rather than returning - // a normal exit code, surface the code declared by whichever caller initiated - // shutdown — Ctrl+C (SIGINT/SIGTERM code) or an internal RequestShutdown(failureCode). - // Fall back to the generic Cancelled code only when nothing was declared. - exitCode = cancellationManager.RequestedExitCode ?? CliExitCodes.Cancelled; + // The command observed cancellation and propagated OCE rather than returning a + // normal exit code. Internal failures `return X` directly from the command, so + // anything reaching here is user-initiated cancellation (Ctrl+C / SIGTERM). + exitCode = CliExitCodes.Cancelled; logger.LogInformation("Command cancelled. Exit code: {ExitCode}", exitCode); } catch (Exception ex) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index d4e94d169b3..35a8f326a3d 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -48,7 +48,6 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly TimeProvider _timeProvider; private readonly RunningInstanceManager _runningInstanceManager; private readonly ProfilingTelemetry _profilingTelemetry; - private readonly ConsoleCancellationManager _cancellationManager; private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; private readonly GracefulShutdownService _shutdownService; private readonly WindowsConsoleProcessJob? _consoleProcessJob; @@ -72,7 +71,6 @@ public GuestAppHostProject( ILogger logger, FileLoggerProvider fileLoggerProvider, ProfilingTelemetry profilingTelemetry, - ConsoleCancellationManager cancellationManager, IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, GracefulShutdownService shutdownService, TimeProvider? timeProvider = null, @@ -94,7 +92,6 @@ public GuestAppHostProject( _profilingTelemetry = profilingTelemetry; _timeProvider = timeProvider ?? TimeProvider.System; _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); - _cancellationManager = cancellationManager; _gracefulShutdownSignaler = gracefulShutdownSignaler; _shutdownService = shutdownService; _consoleProcessJob = consoleProcessJob; @@ -364,6 +361,13 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // regardless of where we exit. var runEnded = 0; + // Captures an exit code surfaced by an internal teardown trigger (currently only the + // backchannel-fault continuation). Read by the outer OCE catch when the in-flight await + // throws OCE because we cancelled appHostSystemCts ourselves. -1 = "no internal failure + // recorded; fall back to Cancelled (130)". Declared at method scope so the catch can read + // it. + var internalFaultCode = -1; + try { // Step 1: Ensure certificates are trusted @@ -443,11 +447,10 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken var enableHotReload = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false); // Step 4: Start the AppHost server process. The linked stop CTS is the only termination - // trigger we hand to the session; cancelling it (here, via outer cancellationToken, or - // via CCM.RequestShutdown/Cancel) is how we ask the session to kill its child process. - // Linking to both the outer CT and CCM token ensures internal RequestShutdown - // propagates to the locally-scoped session token even when tests pass a separate token. - using var serverStopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationManager.Token); + // trigger we hand to the session; cancelling it (here or via the outer cancellationToken) + // is how we ask the session to kill its child process. The outer cancellationToken IS + // CCM.Token (see Program.Main), so a user Ctrl+C lands here automatically. + using var serverStopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await using var serverSession = new AppHostServerSession( appHostServerProject, launchSettingsEnvVars, @@ -507,23 +510,23 @@ await GenerateCodeViaRpcAsync( // 60s timeout, the server exits unexpectedly) so the remaining process gets torn down // promptly. Without this, a hung guest can keep pendingRun alive forever after the CLI // has already given up on the backchannel, causing aspire run/start to hang instead of - // surfacing the failure. Linked to the outer cancellationToken AND CCM token so user - // Ctrl+C and internal RequestShutdown both propagate. - using var appHostSystemCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationManager.Token); + // surfacing the failure. Linked to the outer cancellationToken (which IS CCM.Token in + // production wiring), so user Ctrl+C propagates here automatically. + using var appHostSystemCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var appHostSystemToken = appHostSystemCts.Token; // Guard the backchannel continuation from firing after RunAsync has already returned. - // The continuation can fault late and would otherwise mutate the process-wide CCM - // outside the run scope, e.g. cancelling the next command's CT in a long-lived host. - // The continuation uses an atomic CAS on runEnded (rather than a plain read) so it races - // safely with the finally below — exactly one of (continuation, finally) flips the gate - // 0→1, and the loser observes the flag already set and no-ops. + // The continuation can fault late and would otherwise touch the disposed + // appHostSystemCts. The continuation uses an atomic CAS on runEnded (rather than a + // plain read) so it races safely with the finally below — exactly one of (continuation, + // finally) flips the gate 0→1, and the loser observes the flag already set and no-ops. // When the backchannel polling task gives up (timeout, server process exit, or other - // fatal connection error), escalate to tearing down the whole AppHost system via CCM - // so every shutdown trigger drives the same graceful ladder. The - // BackchannelCompletionSource only signals readiness/connectivity - it never causes the - // server or guest to be killed on its own, so we wire that here. + // fatal connection error), escalate to tearing down the whole AppHost system by + // cancelling the local CTS. The disposable cleanup ladder (`await using serverSession` + // / launcher) runs as the in-flight await unwinds, force-killing children if needed. + // The BackchannelCompletionSource only signals readiness/connectivity — it never + // causes the server or guest to be killed on its own, so we wire that here. _ = backchannelCompletionSource.Task.ContinueWith( t => { @@ -532,7 +535,19 @@ await GenerateCodeViaRpcAsync( // shutdown driver for the backchannel-fault path. if (t.IsFaulted && Interlocked.CompareExchange(ref runEnded, 1, 0) == 0) { - _cancellationManager.RequestShutdown(CliExitCodes.FailedToDotnetRunAppHost); + // First-writer-wins on the surfaced exit code. The outer OCE catch reads + // this when the in-flight await throws because we cancelled below. + Interlocked.CompareExchange(ref internalFaultCode, CliExitCodes.FailedToDotnetRunAppHost, -1); + try + { + appHostSystemCts.Cancel(); + } + catch (ObjectDisposedException) + { + // Continuation lost the race against `using var appHostSystemCts` + // disposal in the enclosing scope. The run already exited via another + // path, so nothing more to do here. + } } }, CancellationToken.None, @@ -552,9 +567,7 @@ await GenerateCodeViaRpcAsync( context.BackchannelCompletionSource?.TrySetException( new InvalidOperationException($"Failed to install {DisplayName} dependencies.")); - // Drive shared graceful ladder so server teardown matches user-initiated Ctrl+C. - _cancellationManager.RequestShutdown(installResult); - + // `await using serverSession` runs the per-process shutdown ladder as we unwind. return installResult; } @@ -676,10 +689,8 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() var error = new InvalidOperationException($"The {DisplayName} apphost failed."); context.BackchannelCompletionSource?.TrySetException(error); - // Drive shared graceful ladder; surfaces guestExitCode via CCM.RequestedExitCode - // even if an OCE wins the race in a higher layer. - _cancellationManager.RequestShutdown(guestExitCode); - + // The backchannel exception above causes RunCommand's startup wait to throw, + // tearing down the run via `await using serverSession`/launcher disposal. return guestExitCode; } @@ -690,11 +701,12 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() return await serverCompletion.WaitAsync(cancellationToken); } - // In watch mode, wait for server to exit (Ctrl+C or orphan detection) - // In non-watch mode, ask the session to terminate now that the apphost has exited + // In watch mode, wait for server to exit (Ctrl+C or orphan detection). + // In non-watch mode the guest already finished cleanly; return success and let the + // `await using serverSession` / launcher disposable cleanup tear down the server. if (!enableHotReload) { - _cancellationManager.RequestShutdown(0); + return 0; } return await serverCompletion.WaitAsync(cancellationToken); @@ -703,10 +715,10 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() { // Signal that build/preparation failed so RunCommand doesn't hang waiting context.BuildCompletionSource?.TrySetResult(false); - // If an internal RequestShutdown captured an exit code (or a signal handler set one), - // surface it rather than the generic Cancelled. Falls back to Cancelled (130) for - // ambient cancellation paths that never touched CCM. - return _cancellationManager.RequestedExitCode ?? CliExitCodes.Cancelled; + // If an internal teardown trigger captured an exit code (currently only the + // backchannel-fault continuation), surface it rather than the generic Cancelled. + // Falls back to Cancelled (130) for ambient cancellation paths. + return internalFaultCode != -1 ? internalFaultCode : CliExitCodes.Cancelled; } catch (AppHostCodeGenerationException ex) { @@ -728,7 +740,7 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() { // Mark the run as ended so the backchannel ContinueWith no-ops if it fires late. // Uses CompareExchange to race safely with the continuation: whichever side flips the - // gate first wins; the loser observes the gate already set and skips RequestShutdown. + // gate first wins; the loser observes the gate already set and skips the cancel call. Interlocked.CompareExchange(ref runEnded, 1, 0); } } diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 4495f2bab47..e82b20765c2 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -306,7 +306,7 @@ await ProcessTerminator.ShutdownAsync( } // Run-path graceful ladder shared with AppHostServerSession and ProcessExecution. - // Whoever initiated shutdown (CCM via RequestShutdown/Cancel) already started the + // Whoever initiated shutdown (user Ctrl+C via CCM.Cancel) already started the // central graceful clock; the helper just consumes the token. await ProcessGracefulShutdownLadder.ExecuteAsync( process, diff --git a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs index 50eaf4fac76..dcb7d591890 100644 --- a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs +++ b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs @@ -29,12 +29,10 @@ public void FirstSignal_RequestsCancellation() using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); Assert.False(manager.IsCancellationRequested); - Assert.Null(manager.RequestedExitCode); manager.Cancel(130); Assert.True(manager.IsCancellationRequested); - Assert.Equal(130, manager.RequestedExitCode); } [Fact] @@ -214,90 +212,6 @@ public void Cancel_IsNonBlocking() Assert.True(manager.IsCancellationRequested); } - [Fact] - public void RequestShutdown_RequestsCancellation_AndCapturesExitCode() - { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); - - manager.RequestShutdown(42); - - Assert.True(manager.IsCancellationRequested); - Assert.Equal(42, manager.RequestedExitCode); - } - - [Fact] - public async Task RequestShutdown_RepeatedInternalCalls_DoNotCollapseGracefulWindow() - { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); - manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); - manager.SetStartedHandler(new TaskCompletionSource().Task); - - // Two concurrent internal teardown callers (e.g. backchannel-fail + guest-non-zero) must - // NOT be treated as "second Ctrl+C" — that would collapse the graceful window and defeat - // the budget. Only a real user signal via Cancel() should escalate. - manager.RequestShutdown(42); - manager.RequestShutdown(99); - - Assert.True(manager.IsCancellationRequested); - Assert.Equal(42, manager.RequestedExitCode); - - // Graceful token should NOT be expired by repeated internal requests — the watcher's - // Task.Delay(graceful budget) is still arming. Give the scheduler a tick to prove it. - await Task.Delay(50); - Assert.False(graceful.Token.IsCancellationRequested); - } - - [Fact] - public async Task RequestShutdown_ThenSingleUserCancel_DoesNotCollapseGracefulWindow() - { - // Regression: previously RequestShutdown and Cancel shared a single counter, so the user's - // first Ctrl+C after any internal RequestShutdown was observed as signalCount == 2 and - // immediately expired the graceful window — defeating the entire ladder during the normal - // "guest exited cleanly, drive teardown" flow in GuestAppHostProject.RunAsync. - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); - manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); - manager.SetStartedHandler(new TaskCompletionSource().Task); - - // Internal request starts the ladder. - manager.RequestShutdown(0); - Assert.True(manager.IsCancellationRequested); - - // The user then hits Ctrl+C ONCE. This is the user's first signal; they must press a second - // time to expire the graceful window. - manager.Cancel(130); - - await Task.Delay(50); - Assert.False( - graceful.Token.IsCancellationRequested, - "First user signal after an internal RequestShutdown must not expire the graceful window."); - - // A second user signal SHOULD escalate. - manager.Cancel(130); - await Task.Delay(50); - Assert.True( - graceful.Token.IsCancellationRequested, - "Second user signal must expire the graceful window even if an internal RequestShutdown ran first."); - } - - [Fact] - public void RequestedExitCode_FirstCallerWins() - { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); - manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); - - manager.SetStartedHandler(new TaskCompletionSource().Task); - - manager.Cancel(42); - manager.RequestShutdown(99); - manager.Cancel(7); - - Assert.Equal(42, manager.RequestedExitCode); - } - [Fact] public async Task ProcessTermination_FiresGracefulExpiration_LaddersUnblock() { diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 2a927a07364..3999af83752 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -928,7 +928,6 @@ private GuestAppHostProject CreateGuestAppHostProject( // for DetachedAppHostShutdownService because none of the tests in this fixture drive the // launcher or AppHostServerSession code paths that would actually invoke it. var gracefulShutdownService = new GracefulShutdownService(); - var cancellationManager = new ConsoleCancellationManager(gracefulShutdownService, finalDrainBudget: TimeSpan.FromSeconds(5)); return new GuestAppHostProject( language: language, @@ -945,7 +944,6 @@ private GuestAppHostProject CreateGuestAppHostProject( logger: NullLogger.Instance, fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()), profilingTelemetry: _profilingTelemetry, - cancellationManager: cancellationManager, gracefulShutdownSignaler: new NoOpGracefulSignaler(), shutdownService: gracefulShutdownService); } From 0e8de7e63a65dada8f3d4c50257714710fa5176e Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 10 Jun 2026 16:40:28 -0700 Subject: [PATCH 06/56] Fix publish-path backchannel wiring missed in merge The merge commit captured an unstaged version of GuestAppHostProject.cs that still referenced main's old 'appHostServerProcess' variable and kept a duplicate eager backchannel start. Adopt main's deferred backchannel pattern for the publish path: remove the eager start and use serverSession in the after-launch callback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/GuestAppHostProject.cs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index c22a6259930..63341bee40f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -1054,13 +1054,6 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca { serverCompletion = serverSession.StartAsync(); - // Start connecting to the backchannel (fire-and-forget) so the caller is unblocked - // as soon as the server is reachable; the post-start work below races alongside it. - if (context.BackchannelCompletionSource is not null) - { - _ = StartBackchannelConnectionAsync(serverSession, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, startProjectContext, cancellationToken); - } - try { // Give the server a moment to start @@ -1107,9 +1100,10 @@ await GenerateCodeViaRpcAsync( } catch (Exception ex) { - // The backchannel connection task was started before code generation - // (see StartBackchannelConnectionAsync above); fault it eagerly so the - // caller doesn't wait out the connection timeout when generateCode fails. + // The backchannel connection is deferred until the guest AppHost launches + // (see StartBackchannelConnectionAfterGuestAppHostLaunchesAsync below), so on a + // setup failure here it was never started. Fault the completion source so the + // publish pipeline waiter doesn't burn the full connection timeout. // The `await using` declaration above disposes the session on the rethrow. context.BackchannelCompletionSource?.TrySetException(ex); throw; @@ -1155,7 +1149,7 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() { if (context.BackchannelCompletionSource is not null) { - _ = StartBackchannelConnectionAsync(appHostServerProcess, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, startProjectContext, cancellationToken); + _ = StartBackchannelConnectionAsync(serverSession, backchannelSocketPath, context.BackchannelCompletionSource, enableHotReload: false, startProjectContext, cancellationToken); } return Task.CompletedTask; From 28b27c07692bed548bfdfd0a7956fed3add1b586 Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:49:12 -0700 Subject: [PATCH 07/56] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index b8b9f124315..a31abe7f737 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -162,7 +162,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(); services.AddTransient(); services.AddTransient(); - // Mirror Program.cs:414 so consumers (e.g. GuestAppHostProject) that depend on the + // Mirror Program.cs so consumers (e.g. GuestAppHostProject) that depend on the // interface receive the same DetachedAppHostShutdownService instance the abstraction // wraps. Without this, DI returns null and Run-path tests construct the project with // a missing dependency, masking wiring regressions. From 9c14441adcf9daaffaf26c65108e30b965cdb022 Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:49:21 -0700 Subject: [PATCH 08/56] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index bd00e6c9721..2a2be01c42c 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -78,9 +78,8 @@ internal sealed class RunCommand : BaseCommand private static readonly TimeSpan s_appHostStartupCancellationTimeout = TimeSpan.FromSeconds(5); - // Graceful shutdown budget for `aspire run`. DCP gets a cooperative window to drain DCP + // Graceful shutdown budget for `aspire run`. DCP gets a cooperative window to drain // resources before the central drain budget arms and ladders escalate to forceful kill. - // See plan §10 for the budget rationale. internal static readonly TimeSpan s_gracefulShutdownBudget = TimeSpan.FromSeconds(5); // Guest AppHosts can bring up the temporary server/backchannel and then fail immediately From 4d3e8bc56f888096dda9d0b70d96068663872f21 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 10 Jun 2026 18:01:08 -0700 Subject: [PATCH 09/56] Address PR review findings: env removal, gate disposal, dead code - IsolatedConsoleSpawner: replace overlay-without-clear with full Clear()+ copy so callers that did startInfo.Environment.Remove(key) see the removal honored on the isolated path. Without this, a caller like PrebuiltAppHostServer.CreateStartInfo that defensively removes IntegrationLibsPath/IntegrationProbeManifestPath would silently re- inherit those vars from the parent CLI's env block. Matches the Unix partial's Environment.Clear()+add semantics. - AppHostServerSession.StartAsync: move the failed-lifetime DisposeAsync out of the lock(_startGate) block. The catch path was running a sync- over-async disposal while holding the start gate, which pinned the gate for whatever IsolatedProcess.DisposeAsync took (pipe pump drain etc.) and queued any concurrent DisposeAsync behind that wait. Capture the failed lifetime and dispose after lock release; rethrow via ExceptionDispatchInfo. - ProcessGracefulShutdownLadder: drop the TryGetStartTime() helper. The sole call site hardcodes includeStartTimeForDcp: false, so the value was always computed and discarded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Processes/IsolatedConsoleSpawner.cs | 25 +++++++--- .../ProcessGracefulShutdownLadder.cs | 24 +++------ .../Projects/AppHostServerSession.cs | 49 +++++++++++++++---- 3 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs b/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs index b51876403e6..bcb463efdf9 100644 --- a/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs +++ b/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs @@ -54,15 +54,24 @@ public static IsolatedProcess StartIsolated( isolatedStartInfo.ArgumentList.Add(arg); } - // Mirror the ProcessStartInfo.Environment overlays. Touching IsolatedProcessStartInfo's - // Environment property snapshots the current process env (same semantics as ProcessStartInfo), - // and we then overlay every key set on the source. We can't just copy over startInfo.Environment - // wholesale because that would re-overlay the inherited block onto itself; only iterate the - // entries the caller actually mutated. ProcessStartInfo doesn't expose a "touched" set, so we - // overlay everything — extra writes of identical values are harmless. - foreach (var key in startInfo.Environment.Keys) + // Replace (not overlay) the env block so callers that did startInfo.Environment.Remove(key) + // see that removal honored — e.g. PrebuiltAppHostServer.CreateStartInfo explicitly removes + // KnownConfigNames.IntegrationLibsPath / IntegrationProbeManifestPath when they aren't + // configured, to suppress any value the parent CLI happens to have set in its own env. + // ProcessStartInfo.Environment is eagerly snapshotted from the parent, so iterating its + // Keys gives us the authoritative "what the child should see" view; a missing key here + // really does mean "do not pass this to the child" — including for vars the parent inherits. + // Overlay-without-clear (a prior approach) silently re-inherited any removed key on the + // isolated path, which the Unix partial also avoids via Environment.Clear() + add. + isolatedStartInfo.Environment.Clear(); + foreach (var (key, value) in startInfo.Environment) { - isolatedStartInfo.Environment[key] = startInfo.Environment[key]; + // Match ProcessStartInfo.Environment semantics: a null value means "do not set this + // variable in the child" — we get there by simply not adding it. + if (value is not null) + { + isolatedStartInfo.Environment[key] = value; + } } return IsolatedProcess.Start( diff --git a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs index b2aa0ae24a9..87c87c6d5fd 100644 --- a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs +++ b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs @@ -48,10 +48,14 @@ public static async Task ExecuteAsync( // way it interrupts the WaitForExitAsync below. try { - DateTimeOffset? startTime = TryGetStartTime(process); + // startTime is intentionally null: includeStartTimeForDcp is always false at this + // call site (the Unix branch ignores StartTime entirely; the Windows DCP branch + // only consults it when includeStartTimeForDcp is true). Querying Process.StartTime + // here would just risk an InvalidOperationException on a process whose handle has + // been closed or is in a state that disallows the read. await signaler.RequestProcessTreeGracefulShutdownAsync( process.Id, - startTime, + startTime: null, includeStartTimeForDcp: false, gracefulToken).ConfigureAwait(false); } @@ -117,22 +121,6 @@ await signaler.RequestProcessTreeGracefulShutdownAsync( } } - private static DateTimeOffset? TryGetStartTime(Process process) - { - // Process.StartTime can throw InvalidOperationException on a process whose handle was - // closed or that has exited in some states. Treat as unavailable rather than aborting - // the graceful path — the Unix branch ignores StartTime entirely, and the Windows DCP - // branch only uses it when includeStartTimeForDcp is true (which it never is here). - try - { - return process.StartTime; - } - catch (Exception) - { - return null; - } - } - private static int SafePid(Process process) { try diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index ef23040ab34..f7b5669dd8a 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -180,6 +180,13 @@ public AppHostServerSession( /// Thrown if the session has been disposed. public Task StartAsync() { + // Captures the lifetime of a failed start so we can dispose it AFTER releasing + // _startGate (DisposeAsync of an IsolatedProcess can synchronously drain pipe pumps + // and we don't want to pin the gate for that duration — see catch block below). + IAsyncDisposable? failedLifetime = null; + Exception? failureToRethrow = null; + TaskCompletionSource? completionToReturn = null; + // Hold _startGate across the entire startup body — env build, _project.Run, field // publication, Exited wiring, and stop registration. DisposeAsync's top-of-method lock // then either runs before us (and StartAsync sees _disposed and throws) or after us @@ -307,26 +314,48 @@ public Task StartAsync() { // Best effort — the lifetime disposal below handles the handles either way. } + // Defer the lifetime disposal until after we exit _startGate. Disposal of + // IsolatedProcess can drain pipe pumps synchronously, and we don't want to pin + // the start gate for an unbounded duration (a concurrent DisposeAsync would + // queue behind us). The Kill above unblocks the pipes so the disposal should + // be quick, but the gate-release-then-dispose ordering is the safe shape. + failedLifetime = result.ProcessLifetime; + _processLifetime = null; + _serverProcess = null; + (_project as IDisposable)?.Dispose(); + completion.TrySetException(ex); + failureToRethrow = ex; + } + + if (failureToRethrow is null) + { + completionToReturn = completion; + } + } + + if (failureToRethrow is not null) + { + if (failedLifetime is not null) + { try { - // Synchronously wait for the disposal: StartAsync's contract is synchronous - // until the returned Task is observed, and the cleanup window for the freshly - // spawned child should be milliseconds (the kill above unblocks the pipes). - result.ProcessLifetime.DisposeAsync().AsTask().GetAwaiter().GetResult(); + // Synchronously wait for disposal: StartAsync's contract is synchronous + // until the returned Task is observed, and the cleanup window for the + // freshly spawned child should be milliseconds now that the kill above + // has unblocked the pipes. We are no longer holding _startGate, so a + // concurrent DisposeAsync waiting on the gate is unblocked while we drain. + failedLifetime.DisposeAsync().AsTask().GetAwaiter().GetResult(); } catch { // Best effort. } - _processLifetime = null; - _serverProcess = null; - (_project as IDisposable)?.Dispose(); - completion.TrySetException(ex); - throw; } - return completion.Task; + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failureToRethrow).Throw(); } + + return completionToReturn!.Task; } /// From b9403bb1fbb1cc482730643fa4b312709c90cf38 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 17 Jun 2026 15:43:13 -0700 Subject: [PATCH 10/56] Fix graceful shutdown: parallel signal + 10s budget Root cause: DCP's `stop-process-tree` blocks until the target apphost actually exits. Awaiting the signaler in Phase 1 of the shutdown ladder sequentially burned the entire 5s graceful window inside DCP's wait, leaving zero budget for Phase 2's WaitForExitAsync. The apphost was escalated to tree-kill at the budget boundary even when its hosted services were milliseconds away from exiting cleanly. Symptom: zombie graceful shutdown that looked successful but never delivered SIGINT to user-installed PosixSignal handlers because the kill raced the in-progress drain. Fix: * ProcessGracefulShutdownLadder runs the signaler invocation in a fire-and-forget Task so its DCP-internal wait runs in parallel with Phase 2's WaitForExitAsync. The full graceful budget is now allocated to actual apphost exit-wait. The signaler is invoked unconditionally (not gated on the graceful token via Task.Run) so callers like `aspire stop` that Expire() the budget intentionally still get the signal dispatched best-effort. * RunCommand.s_gracefulShutdownBudget bumped from 5s to 10s. Default starter templates (apphost + 2 services + dashboard) consistently take ~6s to drain hosted services and exit cleanly; 10s gives comfortable margin for slower hardware and richer apphosts. Verified: * Single Ctrl+Break: clean graceful exit at ~6.3s (well under 10s budget) * Double Ctrl+Break: immediate force-kill at ~1.3s on second signal * All existing CLI ladder tests still pass (10/10 in Processes namespace, including SignalerThrows_StillEscalatesToKill / TreeKill regression tests for the unconditional-invocation contract). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 8 +- .../ProcessGracefulShutdownLadder.cs | 94 +++++++++++++------ 2 files changed, 73 insertions(+), 29 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 2a2be01c42c..73c9f08f574 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -80,7 +80,13 @@ internal sealed class RunCommand : BaseCommand // Graceful shutdown budget for `aspire run`. DCP gets a cooperative window to drain // resources before the central drain budget arms and ladders escalate to forceful kill. - internal static readonly TimeSpan s_gracefulShutdownBudget = TimeSpan.FromSeconds(5); + // 10s gives the AppHost process enough time to actually shut down cleanly: a default + // starter template (apphost + 2 services + dashboard) consistently takes ~6s to drain + // all hosted services and exit, with built-in margin for slower hardware and AppHosts + // that own additional resources. Pre-bump (5s) was too tight — the budget consistently + // expired ~1s before clean exit, forcing a tree-kill in the final stretch of graceful + // shutdown and producing zombie-shaped exits with no SIGINT handler observation. + internal static readonly TimeSpan s_gracefulShutdownBudget = TimeSpan.FromSeconds(10); // Guest AppHosts can bring up the temporary server/backchannel and then fail immediately // afterward when the guest startup process hits a syntax, pre-execute, or model validation diff --git a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs index 87c87c6d5fd..510acdf64d7 100644 --- a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs +++ b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs @@ -41,35 +41,23 @@ public static async Task ExecuteAsync( ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(processDescription); - // Phase 1: best-effort graceful signal bounded by the central token. The DCP path on - // Windows shells out (layout discovery + DCP process launch + wait) and could consume - // the entire graceful window before we ever reach WaitForExitAsync. Sharing the central - // token means a 2nd-Ctrl+C Expire() interrupts the slow DCP shell-out exactly the same - // way it interrupts the WaitForExitAsync below. - try - { - // startTime is intentionally null: includeStartTimeForDcp is always false at this - // call site (the Unix branch ignores StartTime entirely; the Windows DCP branch - // only consults it when includeStartTimeForDcp is true). Querying Process.StartTime - // here would just risk an InvalidOperationException on a process whose handle has - // been closed or is in a state that disallows the read. - await signaler.RequestProcessTreeGracefulShutdownAsync( - process.Id, - startTime: null, - includeStartTimeForDcp: false, - gracefulToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (gracefulToken.IsCancellationRequested) - { - // Graceful budget expired before the signal could be issued; fall through to kill. - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to issue graceful shutdown to {ProcessDescription} (pid {Pid}); escalating to kill.", processDescription, SafePid(process)); - } + // Phase 1: fire-and-forget the graceful signal so its wait does not consume the + // graceful budget. On Windows, DCP's `stop-process-tree` delivers the Ctrl+C signal + // synchronously (in milliseconds) and then BLOCKS until the target process actually + // exits. Awaiting it sequentially would burn the entire graceful window inside DCP's + // wait, leaving zero time for Phase 2's WaitForExitAsync — which then forces a + // tree-kill at the budget boundary even when the AppHost was milliseconds away from + // exiting cleanly. By running the signaler in parallel, the apphost receives the + // signal immediately AND the full graceful budget is allocated to actual exit-wait. + // Important: the signaler is invoked unconditionally (not gated on the graceful + // token) so that when the token is already cancelled at ladder-entry the signal + // still goes out — callers like `aspire stop` Expire() the budget intentionally + // and rely on the signal still being dispatched best-effort. + var signalTask = InvokeSignalerAsync(signaler, SafePid(process), gracefulToken, processDescription, logger); - // Phase 2: wait for exit bounded by the same central token. Whoever initiated shutdown - // (user Ctrl+C via CCM.Cancel) already started the clock; we only consume the token. + // Phase 2: wait for exit with the FULL graceful budget. When the apphost exits, + // the signaler task observes the same exit and completes shortly after. Whoever + // triggered shutdown (CCM.Cancel) owns the timing of `gracefulToken`. try { await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); @@ -81,6 +69,18 @@ await signaler.RequestProcessTreeGracefulShutdownAsync( if (process.HasExited) { + // Best-effort: drain the signaler so its dcp shell-out doesn't outlive us as + // an orphan; safe because the process has already exited so dcp will return + // promptly. Bounded so a stuck dcp can't keep us pinned. + try + { + using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await signalTask.WaitAsync(drainCts.Token).ConfigureAwait(false); + } + catch + { + // Best-effort. + } return; } @@ -121,6 +121,44 @@ await signaler.RequestProcessTreeGracefulShutdownAsync( } } + private static async Task InvokeSignalerAsync( + IProcessTreeGracefulShutdownSignaler signaler, + int pid, + CancellationToken gracefulToken, + string processDescription, + ILogger logger) + { + try + { + // startTime is intentionally null: includeStartTimeForDcp is always false at this + // call site (the Unix branch ignores StartTime entirely; the Windows DCP branch + // only consults it when includeStartTimeForDcp is true). Querying Process.StartTime + // here would just risk an InvalidOperationException on a process whose handle has + // been closed or is in a state that disallows the read. + // + // Yield onto the thread pool so we don't block the caller while the signaler + // performs its (sometimes slow) work — DCP's stop-process-tree blocks until the + // target process actually exits, which is exactly the wait we want to avoid + // serializing in front of Phase 2's WaitForExitAsync. + await Task.Yield(); + + await signaler.RequestProcessTreeGracefulShutdownAsync( + pid, + startTime: null, + includeStartTimeForDcp: false, + gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (gracefulToken.IsCancellationRequested) + { + // Graceful budget expired before the signal could be issued; the kill path + // is responsible for terminating the process. + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to issue graceful shutdown to {ProcessDescription} (pid {Pid}); escalating to kill.", processDescription, pid); + } + } + private static int SafePid(Process process) { try From 369b6b145baaad64aae64f6f43995ea28668b9cc Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 17 Jun 2026 20:28:33 -0700 Subject: [PATCH 11/56] Re-enable inherited CTRL+C handling in CLI startup on Windows The Windows `ignore CTRL+C` attribute is process-level state that is inherited across CreateProcess. If the CLI is launched as a descendant of a NEW_PROCESS_GROUP root (or under any process that called SetConsoleCtrlHandler(NULL, TRUE)), the kernel silently drops CTRL_C_EVENT for the CLI and every process it subsequently spawns -- including the AppHost and DCP-launched services. That breaks the graceful shutdown ladder: DCP's CTRL+C is generated successfully but never delivered, and the AppHost is force-killed by DCP's 6s SIGKILL fallback. Calling SetConsoleCtrlHandler(NULL, FALSE) once at the top of Main clears that inherited state before we set up signal handling or launch any child process, so the rest of the shutdown ladder works regardless of how the CLI itself was started. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Processes/WindowsProcessInterop.cs | 13 +++++++++++++ src/Aspire.Cli/Program.cs | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs index 89d7c5dbf92..8f6ea90bd1e 100644 --- a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs +++ b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs @@ -223,6 +223,19 @@ public static partial bool SetInformationJobObject( [return: MarshalAs(UnmanagedType.Bool)] public static partial bool AssignProcessToJobObject(SafeFileHandle hJob, nint hProcess); + // SetConsoleCtrlHandler — see + // https://learn.microsoft.com/windows/console/setconsolectrlhandler + // Passing NULL as the handler routine controls the inherited "ignore CTRL+C" attribute that + // the kernel propagates across CreateProcess: SetConsoleCtrlHandler(NULL, TRUE) disables + // CTRL+C for the calling process (this is exactly what CREATE_NEW_PROCESS_GROUP does to a + // new root process), and SetConsoleCtrlHandler(NULL, FALSE) re-enables it. Subsequently + // spawned children inherit the new state, so calling FALSE early in CLI startup ensures + // the AppHost and any DCP-launched services see CTRL+C even when the CLI itself was + // launched as a descendant of a NEW_PROCESS_GROUP root. + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool SetConsoleCtrlHandler(nint handlerRoutine, [MarshalAs(UnmanagedType.Bool)] bool add); + // === Job-object constants === public const uint CreateSuspended = 0x00000004; diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index ff178c8637a..a96464dba82 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -931,6 +931,21 @@ private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider, T public static async Task Main(string[] args) { + // Re-enable CTRL+C delivery for ourselves and any process we subsequently spawn. + // Per https://learn.microsoft.com/windows/console/setconsolectrlhandler, the "ignore + // CTRL+C" state is process-level and inherited across CreateProcess. If our parent was + // started with CREATE_NEW_PROCESS_GROUP (or otherwise called SetConsoleCtrlHandler(NULL, + // TRUE)), we inherit "CTRL+C disabled" and the kernel will silently drop CTRL_C_EVENT + // for both us and our descendants — including the AppHost and DCP-launched services. + // Calling SetConsoleCtrlHandler(NULL, FALSE) once at startup clears that inherited + // state so the rest of the PR's signal ladder (CCM → AppHost SIGINT → DCP stop-process-tree) + // can actually deliver. The runtime/Spectre still own the actual CTRL+C handler chain; + // we only flip the inherited "ignored" attribute. + if (OperatingSystem.IsWindows()) + { + WindowsProcessInterop.SetConsoleCtrlHandler(nint.Zero, false); + } + // Setup handling of CTRL-C and SIGTERM as early as possible so that if // we get a signal anywhere that is not handled by Spectre Console // already that we know to trigger cancellation. The graceful service is From b5e287f1ddfebe1b9f2333167c627b3387cf9668 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 17 Jun 2026 20:38:12 -0700 Subject: [PATCH 12/56] Revert graceful shutdown budget back to 5s The earlier bump to 10s was working around the inherited 'ignore CTRL+C' attribute that caused DCP to fall back to its internal 6s SIGKILL deadline (the kernel was silently dropping CTRL_C_EVENT for the AppHost). With that root cause fixed by re-enabling CTRL+C in CLI startup, the AppHost gracefully exits in ~700ms end-to-end on a default starter template, so 5s is a comfortable budget again. Verified locally: * NEW_CONSOLE harness (real terminal): 653ms graceful, all sentinels * NPG-grandparent harness (worst case): 654ms graceful, all sentinels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/RunCommand.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 73c9f08f574..4ff7e80ed3a 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -80,13 +80,13 @@ internal sealed class RunCommand : BaseCommand // Graceful shutdown budget for `aspire run`. DCP gets a cooperative window to drain // resources before the central drain budget arms and ladders escalate to forceful kill. - // 10s gives the AppHost process enough time to actually shut down cleanly: a default - // starter template (apphost + 2 services + dashboard) consistently takes ~6s to drain - // all hosted services and exit, with built-in margin for slower hardware and AppHosts - // that own additional resources. Pre-bump (5s) was too tight — the budget consistently - // expired ~1s before clean exit, forcing a tree-kill in the final stretch of graceful - // shutdown and producing zombie-shaped exits with no SIGINT handler observation. - internal static readonly TimeSpan s_gracefulShutdownBudget = TimeSpan.FromSeconds(10); + // 5s is comfortably enough for a default starter template (apphost + 2 services + + // dashboard) once CTRL+C actually reaches the AppHost — measured graceful exits run + // ~700ms end-to-end. Earlier observations of ~6s drains were an artifact of the + // inherited "ignore CTRL+C" attribute (cleared in Program.cs Main on Windows) which + // caused DCP to fall back to its internal 6s SIGKILL deadline because the kernel was + // silently dropping the CTRL_C_EVENT. + internal static readonly TimeSpan s_gracefulShutdownBudget = TimeSpan.FromSeconds(5); // Guest AppHosts can bring up the temporary server/backchannel and then fail immediately // afterward when the guest startup process hits a syntax, pre-execute, or model validation From 1d6743c758c4caf66693c18edd43a7e689eaa1ff Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 17 Jun 2026 21:18:54 -0700 Subject: [PATCH 13/56] Strengthen CLI graceful shutdown regression tests Remove a flaky negative wall-clock assertion from ConsoleCancellationManagerTests. Add coverage proving the process shutdown ladder does not wait sequentially on a blocking DCP-style signaler, verifies tree-kill escalation removes descendants, and exercises the direct .NET ProcessExecution graceful branch for both isolated and non-isolated process execution paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConsoleCancellationManagerTests.cs | 4 - .../DotNet/ProcessExecutionTests.cs | 251 ++++++++++++++++++ .../Projects/ProcessGuestLauncherTests.cs | 203 ++++++++++++-- 3 files changed, 438 insertions(+), 20 deletions(-) diff --git a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs index dcb7d591890..aaf13e0b9ea 100644 --- a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs +++ b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs @@ -79,10 +79,6 @@ public async Task FirstSignal_NonZeroBudget_DelaysExpireUntilBudgetElapses() var sw = System.Diagnostics.Stopwatch.StartNew(); manager.Cancel(130); - // Right after Cancel, the graceful token should NOT have fired yet (we're in Phase 1). - await Task.Delay(50); - Assert.False(graceful.Token.IsCancellationRequested, "Graceful token fired before budget elapsed."); - // Wait for the budget to elapse. await graceful.Token.WaitForCancellationAsync().DefaultTimeout(); sw.Stop(); diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs index 934a8fc5851..9949b39ffb9 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using Aspire.Cli.DotNet; +using Aspire.Cli.Processes; using Aspire.Cli.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging.Abstractions; @@ -176,6 +177,92 @@ public async Task WaitForExitAsync_KillsProcessWhenCanceled() Assert.True(process.HasExited); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WaitForExitAsync_WithGracefulServices_InvokesSignalerAndThrowsOnCancellation(bool isolateConsole) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); + using var shutdownService = new GracefulShutdownService(); + var signaler = new RecordingGracefulSignaler(onSignal: pid => + { + TryKillProcess(pid); + return Task.FromResult(true); + }); + + using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService); + + Assert.True(execution.Start()); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(() => execution.WaitForExitAsync(cts.Token)); + + Assert.Single(signaler.Pids); + Assert.False(shutdownService.Token.IsCancellationRequested); + Assert.True(WaitForProcessExit(execution.ProcessId, TimeSpan.FromSeconds(10)), $"Expected process {execution.ProcessId} to exit after graceful signal."); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WaitForExitAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEscalatesToKill(bool isolateConsole) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); + using var shutdownService = new GracefulShutdownService(); + var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var signaler = new RecordingGracefulSignaler(onSignal: _ => + { + signaled.TrySetResult(); + return Task.FromResult(true); + }); + + using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService); + + Assert.True(execution.Start()); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var waitTask = Assert.ThrowsAsync(() => execution.WaitForExitAsync(cts.Token)); + await signaled.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + shutdownService.Expire(); + + await waitTask.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Single(signaler.Pids); + Assert.True(WaitForProcessExit(execution.ProcessId, TimeSpan.FromSeconds(10)), $"Expected process {execution.ProcessId} to be killed after graceful expiration."); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WaitForExitAsync_WithGracefulServices_SignalerThrows_StillEscalatesToKill(bool isolateConsole) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); + using var shutdownService = new GracefulShutdownService(); + var signaler = new RecordingGracefulSignaler(onSignal: _ => + throw new InvalidOperationException("simulated DCP failure")); + + using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService); + + Assert.True(execution.Start()); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + shutdownService.Expire(); + + await Assert.ThrowsAsync(() => execution.WaitForExitAsync(cts.Token)); + + Assert.Single(signaler.Pids); + Assert.True(WaitForProcessExit(execution.ProcessId, TimeSpan.FromSeconds(10)), $"Expected process {execution.ProcessId} to be killed after signaler failure."); + } + private static string CreateJsonPayload(int lineCount) { var builder = new StringBuilder(); @@ -313,4 +400,168 @@ private static ProcessStartInfo CreateStartInfo(FileInfo scriptFile) ArgumentList = { scriptFile.FullName } }; } + + private static IProcessExecution CreateExecution( + FileInfo scriptFile, + bool isolateConsole, + IProcessTreeGracefulShutdownSignaler signaler, + GracefulShutdownService shutdownService) + { + var factory = new ProcessExecutionFactory(NullLogger.Instance); + WindowsConsoleProcessJob? consoleProcessJob = null; + + if (OperatingSystem.IsWindows() && isolateConsole) + { + consoleProcessJob = new WindowsConsoleProcessJob(); + } + + try + { + var startInfo = CreateStartInfo(scriptFile); + return new ProcessExecutionWithJob( + factory.CreateExecution( + startInfo.FileName, + startInfo.ArgumentList.ToArray(), + env: null, + new DirectoryInfo(startInfo.WorkingDirectory), + new ProcessInvocationOptions + { + IsolateConsole = isolateConsole, + ConsoleProcessJob = consoleProcessJob, + GracefulShutdownSignaler = signaler, + ShutdownService = shutdownService + }), + consoleProcessJob); + } + catch + { + DisposeConsoleProcessJob(consoleProcessJob); + throw; + } + } + + private static void DisposeConsoleProcessJob(WindowsConsoleProcessJob? consoleProcessJob) + { + if (OperatingSystem.IsWindows()) + { + consoleProcessJob?.Dispose(); + } + } + + private static bool WaitForProcessExit(int pid, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (IsProcessExited(pid)) + { + return true; + } + + Thread.Sleep(25); + } + + return false; + } + + private static bool IsProcessExited(int pid) + { + try + { + using var probe = Process.GetProcessById(pid); + return probe.HasExited; + } + catch (ArgumentException) + { + return true; + } + catch (InvalidOperationException) + { + return true; + } + } + + private static void TryKillProcess(int pid) + { + if (IsProcessExited(pid)) + { + return; + } + + try + { + using var process = Process.GetProcessById(pid); + process.Kill(entireProcessTree: true); + } + catch (ArgumentException) + { + } + catch (InvalidOperationException) + { + } + } + + private sealed class ProcessExecutionWithJob(IProcessExecution inner, WindowsConsoleProcessJob? consoleProcessJob) : IProcessExecution + { + public string FileName => inner.FileName; + + public IReadOnlyList Arguments => inner.Arguments; + + public IReadOnlyDictionary EnvironmentVariables => inner.EnvironmentVariables; + + public bool HasExited => inner.HasExited; + + public int ExitCode => inner.ExitCode; + + public int ProcessId => inner.ProcessId; + + public bool Start() => inner.Start(); + + public Task WaitForExitAsync(CancellationToken cancellationToken) => inner.WaitForExitAsync(cancellationToken); + + public void Kill(bool entireProcessTree) => inner.Kill(entireProcessTree); + + public void Dispose() + { + inner.Dispose(); + DisposeConsoleProcessJob(consoleProcessJob); + } + } + + private sealed class RecordingGracefulSignaler : IProcessTreeGracefulShutdownSignaler + { + private readonly object _lock = new(); + private readonly Func>? _onSignal; + private readonly List _pids = new(); + + public RecordingGracefulSignaler(Func>? onSignal = null) + { + _onSignal = onSignal; + } + + public IReadOnlyList Pids + { + get + { + lock (_lock) + { + return _pids.ToArray(); + } + } + } + + public Task RequestProcessTreeGracefulShutdownAsync( + int pid, + DateTimeOffset? startTime, + bool includeStartTimeForDcp, + CancellationToken cancellationToken) + { + lock (_lock) + { + _pids.Add(pid); + } + + return _onSignal?.Invoke(pid) ?? Task.FromResult(true); + } + } } diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs index 4da922a503b..b1b1b3a993f 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -4,11 +4,12 @@ using System.Diagnostics; using Aspire.Cli.Processes; using Aspire.Cli.Projects; +using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Projects; -public class ProcessGuestLauncherTests +public class ProcessGuestLauncherTests(ITestOutputHelper outputHelper) { [Fact] public async Task LaunchAsync_NoOptions_OnCancellation_ForceKillsProcessTreeAndReturns() @@ -97,6 +98,61 @@ public async Task LaunchAsync_WithGracefulServices_GracefulSucceeds_NoTreeKillEs Assert.False(shutdownService.Token.IsCancellationRequested); } + [Fact] + public async Task LaunchAsync_WithGracefulServices_BlockingSignalerDoesNotConsumeGracefulBudget() + { + // Regression coverage for DCP's stop-process-tree behavior: the graceful signaler can + // deliver the signal quickly and then block until the target exits. The ladder must wait + // for process exit in parallel with that signaler instead of awaiting the signaler first. + var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + var (command, args) = GetLongRunningCommand(); + + using var cts = new CancellationTokenSource(); + using var shutdownService = new GracefulShutdownService(); + var signalerNeverCompletes = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var signaler = new RecordingGracefulSignaler(onSignal: pid => + { + try + { + using var proc = Process.GetProcessById(pid); + proc.Kill(entireProcessTree: true); + } + catch (ArgumentException) + { + // Already exited; treat as graceful success. + } + + return signalerNeverCompletes.Task; + }); + + var options = new GuestLaunchOptions( + IsolateConsoleForGracefulShutdown: false, + GracefulShutdownSignaler: signaler, + ShutdownService: shutdownService); + + var launchTask = launcher.LaunchAsync( + command, + args, + new DirectoryInfo(Path.GetTempPath()), + new Dictionary(), + cts.Token, + afterLaunchAsync: null, + options: options); + + await Task.Delay(500); + cts.Cancel(); + + var stopwatch = Stopwatch.StartNew(); + var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(10)); + stopwatch.Stop(); + + Assert.NotEqual(0, exitCode); + Assert.Single(signaler.Pids); + Assert.False(shutdownService.Token.IsCancellationRequested); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), + $"Expected process exit to win over the still-blocked signaler but it took {stopwatch.Elapsed}."); + } + [Fact] public async Task LaunchAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEscalatesToTreeKill() { @@ -104,8 +160,10 @@ public async Task LaunchAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEs // it (the canonical tsx-swallows-Ctrl+Break scenario on Windows). Expiring the central token // must break the ladder out of WaitForExitAsync and escalate to Kill(entireProcessTree: true) // so the tree dies even when the cooperative path fails. + using var workspace = TemporaryWorkspace.Create(outputHelper); var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); - var (command, args) = GetLongRunningCommand(); + var descendantPidFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "descendant.pid")); + var (command, args) = await GetProcessTreeCommandAsync(workspace.WorkspaceRoot, descendantPidFile); using var cts = new CancellationTokenSource(); using var shutdownService = new GracefulShutdownService(); @@ -126,29 +184,38 @@ public async Task LaunchAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEs var launchTask = launcher.LaunchAsync( command, args, - new DirectoryInfo(Path.GetTempPath()), + workspace.WorkspaceRoot, new Dictionary(), cts.Token, afterLaunchAsync: null, options: options); - await Task.Delay(500); - cts.Cancel(); + var descendantPid = await WaitForPidFileAsync(descendantPidFile); - // Wait until the ladder has actually called the signaler before expiring. Otherwise Expire() - // could fire before the ladder reaches WaitForExitAsync and we'd be testing cancellation - // ordering rather than the escalation path. - await signaled.Task.WaitAsync(TimeSpan.FromSeconds(10)); + try + { + cts.Cancel(); - shutdownService.Expire(); + // Wait until the ladder has actually called the signaler before expiring. Otherwise Expire() + // could fire before the ladder reaches WaitForExitAsync and we'd be testing cancellation + // ordering rather than the escalation path. + await signaled.Task.WaitAsync(TimeSpan.FromSeconds(10)); - var stopwatch = Stopwatch.StartNew(); - var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30)); - stopwatch.Stop(); + shutdownService.Expire(); - Assert.NotEqual(0, exitCode); - Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15), - $"Expected escalation to tree-kill within 15s of Expire() but it took {stopwatch.Elapsed}."); + var stopwatch = Stopwatch.StartNew(); + var (exitCode, _) = await launchTask.WaitAsync(TimeSpan.FromSeconds(30)); + stopwatch.Stop(); + + Assert.NotEqual(0, exitCode); + Assert.True(WaitForProcessExit(descendantPid, TimeSpan.FromSeconds(10)), $"Expected descendant process {descendantPid} to be killed with the root process tree."); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15), + $"Expected escalation to tree-kill within 15s of Expire() but it took {stopwatch.Elapsed}."); + } + finally + { + TryKillProcess(descendantPid); + } } [Fact] @@ -210,6 +277,110 @@ private static (string Command, string[] Args) GetLongRunningCommand() return ("sleep", ["60"]); } + private static async Task<(string Command, string[] Args)> GetProcessTreeCommandAsync(DirectoryInfo workspaceRoot, FileInfo descendantPidFile) + { + if (OperatingSystem.IsWindows()) + { + var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "spawn-descendant.ps1")); + var content = + "$child = Start-Process -FilePath powershell.exe -ArgumentList '-NoProfile', '-Command', 'Start-Sleep -Seconds 60' -PassThru" + Environment.NewLine + + $"Set-Content -Path '{descendantPidFile.FullName}' -Value $child.Id" + Environment.NewLine + + "Wait-Process -Id $child.Id" + Environment.NewLine; + await File.WriteAllTextAsync(scriptFile.FullName, content); + + return ("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptFile.FullName]); + } + + var shellFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "spawn-descendant.sh")); + var shellContent = + "#!/usr/bin/env bash" + Environment.NewLine + + "sleep 60 &" + Environment.NewLine + + $"echo $! > \"{descendantPidFile.FullName}\"" + Environment.NewLine + + "wait $!" + Environment.NewLine; + await File.WriteAllTextAsync(shellFile.FullName, shellContent); + File.SetUnixFileMode( + shellFile.FullName, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + + return ("/bin/bash", [shellFile.FullName]); + } + + private static async Task WaitForPidFileAsync(FileInfo pidFile) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (DateTime.UtcNow < deadline) + { + if (pidFile.Exists) + { + var text = await File.ReadAllTextAsync(pidFile.FullName); + if (int.TryParse(text.Trim(), out var pid)) + { + return pid; + } + } + + await Task.Delay(50); + pidFile.Refresh(); + } + + throw new TimeoutException($"Timed out waiting for descendant pid file '{pidFile.FullName}'."); + } + + private static bool WaitForProcessExit(int pid, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (IsProcessExited(pid)) + { + return true; + } + + Thread.Sleep(25); + } + + return false; + } + + private static bool IsProcessExited(int pid) + { + try + { + using var probe = Process.GetProcessById(pid); + return probe.HasExited; + } + catch (ArgumentException) + { + return true; + } + catch (InvalidOperationException) + { + return true; + } + } + + private static void TryKillProcess(int pid) + { + if (IsProcessExited(pid)) + { + return; + } + + try + { + using var process = Process.GetProcessById(pid); + process.Kill(entireProcessTree: true); + } + catch (ArgumentException) + { + } + catch (InvalidOperationException) + { + } + } + private sealed class RecordingGracefulSignaler : IProcessTreeGracefulShutdownSignaler { private readonly object _lock = new(); From eaf0ef30aec6365a36a9ef971168f4a187145d92 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 17 Jun 2026 21:46:55 -0700 Subject: [PATCH 14/56] Address shutdown PR review comments Update the Windows CTRL+C inheritance comment so it describes the current CLI behavior rather than the PR evolution, and extract the duplicated RecordingGracefulSignaler test fake into TestServices for the shutdown tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Program.cs | 2 +- .../DotNet/ProcessExecutionTests.cs | 37 +--------------- .../Projects/AppHostServerSessionTests.cs | 40 ----------------- .../Projects/ProcessGuestLauncherTests.cs | 41 +----------------- .../TestServices/RecordingGracefulSignaler.cs | 43 +++++++++++++++++++ 5 files changed, 46 insertions(+), 117 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/TestServices/RecordingGracefulSignaler.cs diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 936d026e03a..f0cd65f4fb4 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -940,7 +940,7 @@ public static async Task Main(string[] args) // TRUE)), we inherit "CTRL+C disabled" and the kernel will silently drop CTRL_C_EVENT // for both us and our descendants — including the AppHost and DCP-launched services. // Calling SetConsoleCtrlHandler(NULL, FALSE) once at startup clears that inherited - // state so the rest of the PR's signal ladder (CCM → AppHost SIGINT → DCP stop-process-tree) + // state so the CLI's signal ladder (CCM → AppHost SIGINT → DCP stop-process-tree) // can actually deliver. The runtime/Spectre still own the actual CTRL+C handler chain; // we only flip the inherited "ignored" attribute. if (OperatingSystem.IsWindows()) diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs index 9949b39ffb9..94a523cb2ed 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -7,6 +7,7 @@ using System.Text.Json; using Aspire.Cli.DotNet; using Aspire.Cli.Processes; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging.Abstractions; @@ -528,40 +529,4 @@ public void Dispose() } } - private sealed class RecordingGracefulSignaler : IProcessTreeGracefulShutdownSignaler - { - private readonly object _lock = new(); - private readonly Func>? _onSignal; - private readonly List _pids = new(); - - public RecordingGracefulSignaler(Func>? onSignal = null) - { - _onSignal = onSignal; - } - - public IReadOnlyList Pids - { - get - { - lock (_lock) - { - return _pids.ToArray(); - } - } - } - - public Task RequestProcessTreeGracefulShutdownAsync( - int pid, - DateTimeOffset? startTime, - bool includeStartTimeForDcp, - CancellationToken cancellationToken) - { - lock (_lock) - { - _pids.Add(pid); - } - - return _onSignal?.Invoke(pid) ?? Task.FromResult(true); - } - } } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 6b303f09e34..d4bf8d55742 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -562,46 +562,6 @@ private static AppHostServerProjectFactory CreateAppHostServerProjectFactory() NullLoggerFactory.Instance); } - private sealed class RecordingGracefulSignaler : IProcessTreeGracefulShutdownSignaler - { - private readonly object _lock = new(); - private readonly Func>? _onSignal; - private readonly List _pids = new(); - - public RecordingGracefulSignaler(Func>? onSignal = null) - { - _onSignal = onSignal; - } - - public IReadOnlyList Pids - { - get - { - // The session invokes the signaler from a CT registration callback which can - // race with the test thread reading Pids; snapshot under lock to satisfy - // happens-before for the test assertions. - lock (_lock) - { - return _pids.ToArray(); - } - } - } - - public Task RequestProcessTreeGracefulShutdownAsync( - int pid, - DateTimeOffset? startTime, - bool includeStartTimeForDcp, - CancellationToken cancellationToken) - { - lock (_lock) - { - _pids.Add(pid); - } - - return _onSignal?.Invoke(pid) ?? Task.FromResult(true); - } - } - private sealed class RecordingAppHostServerProject : IAppHostServerProject { public string AppDirectoryPath => Directory.GetCurrentDirectory(); diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs index b1b1b3a993f..0415808dcb3 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -2,8 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using Aspire.Cli.Processes; using Aspire.Cli.Projects; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Logging.Abstractions; @@ -381,43 +381,4 @@ private static void TryKillProcess(int pid) } } - private sealed class RecordingGracefulSignaler : IProcessTreeGracefulShutdownSignaler - { - private readonly object _lock = new(); - private readonly Func>? _onSignal; - private readonly List _pids = new(); - - public RecordingGracefulSignaler(Func>? onSignal = null) - { - _onSignal = onSignal; - } - - public IReadOnlyList Pids - { - get - { - // The launcher invokes the signaler from a background continuation that can race - // with the test thread reading Pids; snapshot under lock to satisfy happens-before - // for the test assertions. - lock (_lock) - { - return _pids.ToArray(); - } - } - } - - public Task RequestProcessTreeGracefulShutdownAsync( - int pid, - DateTimeOffset? startTime, - bool includeStartTimeForDcp, - CancellationToken cancellationToken) - { - lock (_lock) - { - _pids.Add(pid); - } - - return _onSignal?.Invoke(pid) ?? Task.FromResult(true); - } - } } diff --git a/tests/Aspire.Cli.Tests/TestServices/RecordingGracefulSignaler.cs b/tests/Aspire.Cli.Tests/TestServices/RecordingGracefulSignaler.cs new file mode 100644 index 00000000000..efb37ea15f5 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/RecordingGracefulSignaler.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Processes; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class RecordingGracefulSignaler : IProcessTreeGracefulShutdownSignaler +{ + private readonly object _lock = new(); + private readonly Func>? _onSignal; + private readonly List _pids = new(); + + public RecordingGracefulSignaler(Func>? onSignal = null) + { + _onSignal = onSignal; + } + + public IReadOnlyList Pids + { + get + { + lock (_lock) + { + return _pids.ToArray(); + } + } + } + + public Task RequestProcessTreeGracefulShutdownAsync( + int pid, + DateTimeOffset? startTime, + bool includeStartTimeForDcp, + CancellationToken cancellationToken) + { + lock (_lock) + { + _pids.Add(pid); + } + + return _onSignal?.Invoke(pid) ?? Task.FromResult(true); + } +} From ed42de60e4028e3a240ecd46901bf9746f10ebf7 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Wed, 17 Jun 2026 22:59:18 -0700 Subject: [PATCH 15/56] Deduplicate CLI test helpers Extract the identical WaitForProcessExit/IsProcessExited/TryKillProcess helpers from ProcessGuestLauncherTests and ProcessExecutionTests into a shared TestServices/ProcessTestHelpers class. Move the local cancellation-wait extension into the shared AsyncTestHelpers class, renamed to WaitUntilCancelledAsync to disambiguate it from the existing WaitForCancellationAsync, which has different semantics (completes as cancelled vs. completes successfully on cancellation). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConsoleCancellationManagerTests.cs | 20 +----- .../DotNet/ProcessExecutionTests.cs | 54 +--------------- .../Projects/ProcessGuestLauncherTests.cs | 55 +--------------- .../TestServices/ProcessTestHelpers.cs | 62 +++++++++++++++++++ tests/Shared/AsyncTestHelpers.cs | 19 ++++++ 5 files changed, 85 insertions(+), 125 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/TestServices/ProcessTestHelpers.cs diff --git a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs index aaf13e0b9ea..ce9d5884fa2 100644 --- a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs +++ b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs @@ -62,7 +62,7 @@ public async Task FirstSignal_DefaultZeroBudget_ExpiresGracefulImmediately() manager.Cancel(130); // The graceful token must fire essentially immediately (no Phase 1 delay to wait through). - await graceful.Token.WaitForCancellationAsync().DefaultTimeout(); + await graceful.Token.WaitUntilCancelledAsync().DefaultTimeout(); Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); } @@ -80,7 +80,7 @@ public async Task FirstSignal_NonZeroBudget_DelaysExpireUntilBudgetElapses() manager.Cancel(130); // Wait for the budget to elapse. - await graceful.Token.WaitForCancellationAsync().DefaultTimeout(); + await graceful.Token.WaitUntilCancelledAsync().DefaultTimeout(); sw.Stop(); // We allowed 200ms of grace; allow generous slack for CI scheduling but assert we waited at least most of it. @@ -260,19 +260,3 @@ public void Token_RemainsAccessibleAfterDispose() Assert.False(token.IsCancellationRequested); } } - -internal static class CancellationTokenTestExtensions -{ - public static Task WaitForCancellationAsync(this CancellationToken token) - { - if (token.IsCancellationRequested) - { - return Task.CompletedTask; - } - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var registration = token.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), tcs); - tcs.Task.ContinueWith(static (_, state) => ((CancellationTokenRegistration)state!).Dispose(), registration, TaskScheduler.Default); - return tcs.Task; - } -} diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs index 94a523cb2ed..6272c96ed7a 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging.Abstractions; +using static Aspire.Cli.Tests.TestServices.ProcessTestHelpers; namespace Aspire.Cli.Tests.DotNet; @@ -449,59 +450,6 @@ private static void DisposeConsoleProcessJob(WindowsConsoleProcessJob? consolePr } } - private static bool WaitForProcessExit(int pid, TimeSpan timeout) - { - var deadline = DateTime.UtcNow + timeout; - while (DateTime.UtcNow < deadline) - { - if (IsProcessExited(pid)) - { - return true; - } - - Thread.Sleep(25); - } - - return false; - } - - private static bool IsProcessExited(int pid) - { - try - { - using var probe = Process.GetProcessById(pid); - return probe.HasExited; - } - catch (ArgumentException) - { - return true; - } - catch (InvalidOperationException) - { - return true; - } - } - - private static void TryKillProcess(int pid) - { - if (IsProcessExited(pid)) - { - return; - } - - try - { - using var process = Process.GetProcessById(pid); - process.Kill(entireProcessTree: true); - } - catch (ArgumentException) - { - } - catch (InvalidOperationException) - { - } - } - private sealed class ProcessExecutionWithJob(IProcessExecution inner, WindowsConsoleProcessJob? consoleProcessJob) : IProcessExecution { public string FileName => inner.FileName; diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs index 0415808dcb3..dafadf9d04a 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Logging.Abstractions; +using static Aspire.Cli.Tests.TestServices.ProcessTestHelpers; namespace Aspire.Cli.Tests.Projects; @@ -327,58 +328,4 @@ private static async Task WaitForPidFileAsync(FileInfo pidFile) throw new TimeoutException($"Timed out waiting for descendant pid file '{pidFile.FullName}'."); } - - private static bool WaitForProcessExit(int pid, TimeSpan timeout) - { - var deadline = DateTime.UtcNow + timeout; - while (DateTime.UtcNow < deadline) - { - if (IsProcessExited(pid)) - { - return true; - } - - Thread.Sleep(25); - } - - return false; - } - - private static bool IsProcessExited(int pid) - { - try - { - using var probe = Process.GetProcessById(pid); - return probe.HasExited; - } - catch (ArgumentException) - { - return true; - } - catch (InvalidOperationException) - { - return true; - } - } - - private static void TryKillProcess(int pid) - { - if (IsProcessExited(pid)) - { - return; - } - - try - { - using var process = Process.GetProcessById(pid); - process.Kill(entireProcessTree: true); - } - catch (ArgumentException) - { - } - catch (InvalidOperationException) - { - } - } - } diff --git a/tests/Aspire.Cli.Tests/TestServices/ProcessTestHelpers.cs b/tests/Aspire.Cli.Tests/TestServices/ProcessTestHelpers.cs new file mode 100644 index 00000000000..15210200c8d --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/ProcessTestHelpers.cs @@ -0,0 +1,62 @@ +// 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; + +namespace Aspire.Cli.Tests.TestServices; + +internal static class ProcessTestHelpers +{ + public static bool WaitForProcessExit(int pid, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (IsProcessExited(pid)) + { + return true; + } + + Thread.Sleep(25); + } + + return false; + } + + public static bool IsProcessExited(int pid) + { + try + { + using var probe = Process.GetProcessById(pid); + return probe.HasExited; + } + catch (ArgumentException) + { + return true; + } + catch (InvalidOperationException) + { + return true; + } + } + + public static void TryKillProcess(int pid) + { + if (IsProcessExited(pid)) + { + return; + } + + try + { + using var process = Process.GetProcessById(pid); + process.Kill(entireProcessTree: true); + } + catch (ArgumentException) + { + } + catch (InvalidOperationException) + { + } + } +} diff --git a/tests/Shared/AsyncTestHelpers.cs b/tests/Shared/AsyncTestHelpers.cs index 754e6f84560..f37c666d213 100644 --- a/tests/Shared/AsyncTestHelpers.cs +++ b/tests/Shared/AsyncTestHelpers.cs @@ -246,4 +246,23 @@ public static Task WaitForCancellationAsync(CancellationToken token) return tcs.Task; } + + /// + /// Returns a task that completes successfully when is cancelled. + /// Unlike , which completes as cancelled + /// (throwing when awaited), this is intended for tests that + /// want to await the cancellation edge as a normal signal without an exception. + /// + public static Task WaitUntilCancelledAsync(this CancellationToken token) + { + if (token.IsCancellationRequested) + { + return Task.CompletedTask; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = token.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), tcs); + tcs.Task.ContinueWith(static (_, state) => ((CancellationTokenRegistration)state!).Dispose(), registration, TaskScheduler.Default); + return tcs.Task; + } } From e7e3315f81f9b5caf498df143d8d115c6ffa4d3a Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 18 Jun 2026 11:33:13 -0700 Subject: [PATCH 16/56] Resolve Windows console job on-demand and rename shutdown service Two related cleanups in the aspire run shutdown path: * Replace the threaded WindowsConsoleProcessJob optional parameter with a process-wide WindowsConsoleProcessJob.Shared lazy. The job is the same KILL_ON_JOB_CLOSE crash-time safety net everywhere, so callers no longer need to know how to construct or thread it. The two real consumers (ProcessExecutionFactory, IsolatedConsoleSpawner) resolve Shared on demand on Windows; the DI registration, the intermediate plumbing through the project/session/options types, and the now-obsolete fail-fast null checks are removed. * Rename DetachedAppHostShutdownService to ProcessTreeGracefulShutdownService. This PR generalized the service into the shared graceful-shutdown engine for both the detached `aspire stop` path and the in-process `aspire run` ladders (the latter via IProcessTreeGracefulShutdownSignaler), so the old "Detached" name no longer described what it does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AppHostLauncher.cs | 2 +- src/Aspire.Cli/Commands/StopCommand.cs | 4 +- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 15 +--- .../DotNet/ProcessExecutionFactory.cs | 19 +---- .../IProcessTreeGracefulShutdownSignaler.cs | 2 +- .../Processes/IsolatedConsoleSpawner.cs | 21 ++--- src/Aspire.Cli/Processes/IsolatedProcess.cs | 6 +- ... => ProcessTreeGracefulShutdownService.cs} | 8 +- .../Processes/WindowsConsoleProcessJob.cs | 19 ++++- src/Aspire.Cli/Program.cs | 18 ++--- .../Projects/AppHostServerSession.cs | 19 +---- .../Projects/DotNetAppHostProject.cs | 6 -- .../DotNetBasedAppHostServerProject.cs | 5 +- .../Projects/GuestAppHostProject.cs | 9 +-- .../Projects/IAppHostServerProject.cs | 11 +-- .../Projects/IGuestProcessLauncher.cs | 15 +--- .../Projects/PrebuiltAppHostServer.cs | 5 +- .../Projects/ProcessGuestLauncher.cs | 1 - .../Commands/AppHostLauncherTests.cs | 4 +- .../DotNet/ProcessExecutionTests.cs | 78 ++++--------------- ...rocessTreeGracefulShutdownServiceTests.cs} | 10 +-- .../Projects/AppHostServerSessionTests.cs | 9 +-- .../Projects/GuestAppHostProjectTests.cs | 2 +- .../Scaffolding/ChannelReseedTests.cs | 4 +- .../FakeFailingAppHostServerProject.cs | 4 +- .../FakeSucceedingAppHostServerProject.cs | 4 +- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 6 +- 27 files changed, 96 insertions(+), 210 deletions(-) rename src/Aspire.Cli/Processes/{DetachedAppHostShutdownService.cs => ProcessTreeGracefulShutdownService.cs} (97%) rename tests/Aspire.Cli.Tests/Processes/{DetachedAppHostShutdownServiceTests.cs => ProcessTreeGracefulShutdownServiceTests.cs} (95%) diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 759509d2de6..c6cecee38c8 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -33,7 +33,7 @@ internal sealed class AppHostLauncher( AspireCliTelemetry telemetry, ProfilingTelemetry profilingTelemetry, FileLoggerProvider fileLoggerProvider, - DetachedAppHostShutdownService processShutdownService, + ProcessTreeGracefulShutdownService processShutdownService, IDetachedProcessLauncher detachedProcessLauncher, ILogger logger, TimeProvider timeProvider) diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index c408cdca63f..4ec38b6223e 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -23,7 +23,7 @@ internal sealed class StopCommand : BaseCommand private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; private readonly ICliHostEnvironment _hostEnvironment; - private readonly DetachedAppHostShutdownService _processShutdownService; + private readonly ProcessTreeGracefulShutdownService _processShutdownService; private readonly ProfilingTelemetry _profilingTelemetry; private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", StopCommandStrings.ProjectArgumentDescription); @@ -38,7 +38,7 @@ public StopCommand( IAuxiliaryBackchannelMonitor backchannelMonitor, IProjectLocator projectLocator, ICliHostEnvironment hostEnvironment, - DetachedAppHostShutdownService processShutdownService, + ProcessTreeGracefulShutdownService processShutdownService, ILogger logger, ProfilingTelemetry profilingTelemetry, CommonCommandServices services) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index f6ff40df49d..dfddc4b2472 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -72,21 +72,15 @@ internal sealed class ProcessInvocationOptions /// DCP's stop-process-tree CTRL+C dance. /// /// - /// Pair with (Windows-only; pass the DI-registered - /// singleton), and . - /// Leaving any of those null means cancellation falls back to today's + /// Pair with and . + /// On Windows the spawned process is bound to the process-wide + /// kill-on-close job automatically. + /// Leaving the signaler/service unset means cancellation falls back to today's /// force-kill behavior, preserving back-compat /// for the many non-Run callers (build, restore, package add, layout, etc.). /// public bool IsolateConsole { get; set; } - /// - /// The CLI-lifetime Windows job object the spawned process should be assigned to when - /// is true and the host OS is Windows. Required in that - /// configuration; ignored on non-Windows. - /// - public Processes.WindowsConsoleProcessJob? ConsoleProcessJob { get; set; } - /// /// Issues the graceful shutdown signal during the shared shutdown ladder (DCP /// stop-process-tree on Windows, SIGTERM on Unix). When null, the cancellation @@ -320,7 +314,6 @@ private static ProcessInvocationOptions CreateInstrumentedProcessOptions( // instead of the shared ProcessGracefulShutdownLadder. Build/restore/etc. callers // leave these unset and intentionally keep the legacy path. IsolateConsole = options.IsolateConsole, - ConsoleProcessJob = options.ConsoleProcessJob, GracefulShutdownSignaler = options.GracefulShutdownSignaler, ShutdownService = options.ShutdownService, StandardOutputCallback = line => diff --git a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs index b1aa7a949b2..1e53f959a64 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs @@ -86,26 +86,15 @@ private static IProcessExecution CreateIsolatedExecution( ProcessInvocationOptions options, ILogger logger) { - // Fail fast on Windows + IsolateConsole without a job handle. Silently falling through - // would defeat the kill-on-close safety net that isolation is supposed to enable — - // exactly the same defense-in-depth check IsolatedConsoleSpawner already enforces. - if (OperatingSystem.IsWindows() && options.ConsoleProcessJob is null) - { - // Use a string literal instead of nameof(options.ConsoleProcessJob) so the analyzer - // is satisfied (CA2208 rejects a property path; the actual paramName here describes - // the missing option, not a method parameter). - throw new ArgumentNullException( - "options.ConsoleProcessJob", - "ConsoleProcessJob is required on Windows when IsolateConsole is true. Pass the DI-registered WindowsConsoleProcessJob singleton."); - } - var startInfo = new IsolatedProcessStartInfo { FileName = fileName, WorkingDirectory = workingDirectory.FullName, // Only Windows uses the job handle — the Unix partial of IsolatedProcess ignores - // it because Unix process-group semantics + SIGTERM cover the orphan case. - JobHandle = OperatingSystem.IsWindows() ? options.ConsoleProcessJob?.Handle : null, + // it because Unix process-group semantics + SIGTERM cover the orphan case. The job + // is the process-wide kill-on-close singleton, created on demand the first time an + // isolated child needs it. + JobHandle = OperatingSystem.IsWindows() ? WindowsConsoleProcessJob.Shared.Handle : null, }; foreach (var a in args) diff --git a/src/Aspire.Cli/Processes/IProcessTreeGracefulShutdownSignaler.cs b/src/Aspire.Cli/Processes/IProcessTreeGracefulShutdownSignaler.cs index be1bb36c67f..5a0de066e1a 100644 --- a/src/Aspire.Cli/Processes/IProcessTreeGracefulShutdownSignaler.cs +++ b/src/Aspire.Cli/Processes/IProcessTreeGracefulShutdownSignaler.cs @@ -12,7 +12,7 @@ namespace Aspire.Cli.Processes; /// /// /// Existence as an interface (rather than a direct dependency on -/// ) keeps the in-process Run shutdown ladders +/// ) keeps the in-process Run shutdown ladders /// testable: tests can inject a fake that simulates "signal failed," "signal returned false," /// or "signal was issued and observed by a fake process" without needing real DCP layout /// discovery or platform-specific signal plumbing. diff --git a/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs b/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs index bcb463efdf9..295a4387513 100644 --- a/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs +++ b/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs @@ -17,17 +17,17 @@ internal static class IsolatedConsoleSpawner /// /// Spawns the process described by into an isolated console /// group (new hidden console on Windows; effectively a thin - /// wrapper on Unix), optionally bound to the supplied Windows kill-on-close job. + /// wrapper on Unix), bound on Windows to the process-wide kill-on-close job. /// /// - /// On Windows, throws if - /// is . Isolation without the kill-on-close job means the spawned process - /// can survive a CLI crash as an orphan in its new console group, defeating the entire point - /// of the safety net the new-console isolation is supposed to enable. + /// On Windows the spawned child is assigned to + /// (created on first use). Without the kill-on-close job an isolated child could survive a + /// CLI crash as an orphan in its new console group, defeating the entire point of the safety + /// net the new-console isolation is supposed to enable — so the job is resolved here rather + /// than threaded in by callers, who cannot then forget to supply it. /// public static IsolatedProcess StartIsolated( ProcessStartInfo startInfo, - WindowsConsoleProcessJob? consoleProcessJob, Action standardOutputHandler, Action standardErrorHandler) { @@ -35,18 +35,11 @@ public static IsolatedProcess StartIsolated( ArgumentNullException.ThrowIfNull(standardOutputHandler); ArgumentNullException.ThrowIfNull(standardErrorHandler); - if (OperatingSystem.IsWindows() && consoleProcessJob is null) - { - throw new ArgumentNullException( - nameof(consoleProcessJob), - "consoleProcessJob is required when spawning into an isolated console on Windows so the spawned process is bound to the CLI's kill-on-close job."); - } - var isolatedStartInfo = new IsolatedProcessStartInfo { FileName = startInfo.FileName, WorkingDirectory = startInfo.WorkingDirectory, - JobHandle = OperatingSystem.IsWindows() ? consoleProcessJob?.Handle : null, + JobHandle = OperatingSystem.IsWindows() ? WindowsConsoleProcessJob.Shared.Handle : null, }; foreach (var arg in startInfo.ArgumentList) diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.cs b/src/Aspire.Cli/Processes/IsolatedProcess.cs index cd3024c8f1e..8eee43ad31b 100644 --- a/src/Aspire.Cli/Processes/IsolatedProcess.cs +++ b/src/Aspire.Cli/Processes/IsolatedProcess.cs @@ -42,9 +42,9 @@ internal sealed class IsolatedProcessStartInfo /// Windows-only crash-time safety net. When set, the spawned child is atomically /// assigned to this job object via the suspended-create / assign / resume dance in /// . Set to - /// from the DI singleton on Windows - /// hosts; on non-Windows hosts (Unix process-group semantics - /// cover the equivalent case). + /// from + /// on Windows hosts; on non-Windows hosts (Unix process-group + /// semantics cover the equivalent case). /// public SafeFileHandle? JobHandle { get; init; } diff --git a/src/Aspire.Cli/Processes/DetachedAppHostShutdownService.cs b/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs similarity index 97% rename from src/Aspire.Cli/Processes/DetachedAppHostShutdownService.cs rename to src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs index 38f18b250fe..79806f45235 100644 --- a/src/Aspire.Cli/Processes/DetachedAppHostShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs @@ -11,14 +11,16 @@ namespace Aspire.Cli.Processes; /// -/// Coordinates graceful process shutdown requests, termination monitoring, and force-kill fallback. +/// Coordinates graceful process-tree shutdown requests, termination monitoring, and force-kill +/// fallback. Shared by both the detached aspire stop path and the in-process aspire run +/// shutdown ladders, the latter reaching it through . /// -internal sealed class DetachedAppHostShutdownService( +internal sealed class ProcessTreeGracefulShutdownService( ILayoutDiscovery layoutDiscovery, IBundleService bundleService, LayoutProcessRunner layoutProcessRunner, CliExecutionContext executionContext, - ILogger logger, + ILogger logger, TimeProvider timeProvider) : IProcessTreeGracefulShutdownSignaler { private static readonly TimeSpan s_processTerminationTimeout = TimeSpan.FromSeconds(10); diff --git a/src/Aspire.Cli/Processes/WindowsConsoleProcessJob.cs b/src/Aspire.Cli/Processes/WindowsConsoleProcessJob.cs index c6ab4df422a..08224bcb596 100644 --- a/src/Aspire.Cli/Processes/WindowsConsoleProcessJob.cs +++ b/src/Aspire.Cli/Processes/WindowsConsoleProcessJob.cs @@ -11,7 +11,8 @@ namespace Aspire.Cli.Processes; /// /// Owns a Windows job object that is used as the crash-time safety net for interactive /// children spawned by . The job is created -/// once per CLI process, registered as a singleton, and held for the CLI's entire lifetime. +/// once per CLI process — on first isolated spawn via — and held for +/// the CLI's entire lifetime. /// /// /// @@ -41,9 +42,25 @@ namespace Aspire.Cli.Processes; [SupportedOSPlatform("windows")] internal sealed class WindowsConsoleProcessJob : IDisposable { + // The job is a process-wide singleton: its configuration never varies, and the OS closes + // the handle on process exit (firing KILL_ON_JOB_CLOSE on any still-assigned children). + // Created lazily on the first isolated spawn that needs it so non-Run invocations — and + // every non-Windows host — never allocate the kernel object. Threading a job instance + // through the spawn callers was the alternative; an on-demand singleton removes that + // plumbing because there is only ever one correct job to use. + private static readonly Lazy s_shared = new(static () => new WindowsConsoleProcessJob()); + private readonly SafeFileHandle _jobHandle; private int _disposed; + /// + /// The process-wide job, created on first access. Callers on the isolated-console spawn + /// path use this instead of receiving a job instance, so they cannot forget to supply one. + /// Intentionally never disposed in production: the OS closes the handle at process exit, + /// which is exactly the crash-safety net we want. + /// + public static WindowsConsoleProcessJob Shared => s_shared.Value; + public WindowsConsoleProcessJob() { _jobHandle = WindowsProcessInterop.CreateJobObjectW(nint.Zero, null); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index f0cd65f4fb4..8d37fa0d69b 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -446,20 +446,16 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddTransient(); builder.Services.AddSingleton(); // Windows-only crash-time safety net for interactive children spawned by - // IsolatedProcess. Holding this as a CLI-lifetime singleton means the - // OS closes the job handle automatically on process exit, firing KILL_ON_JOB_CLOSE on - // any assigned children that haven't already exited (e.g. orphaned tsx after the CLI - // crashes). On non-Windows, process-group reparenting + ordinary signal delivery cover - // the same case, so no registration is needed. - if (OperatingSystem.IsWindows()) - { - builder.Services.AddSingleton(); - } + // IsolatedProcess is provided by WindowsConsoleProcessJob.Shared — a process-wide + // job created on first isolated spawn. The OS closes the job handle automatically on + // process exit, firing KILL_ON_JOB_CLOSE on any assigned children that haven't already + // exited (e.g. orphaned tsx after the CLI crashes). On non-Windows, process-group + // reparenting + ordinary signal delivery cover the same case, so nothing is needed. builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); // Forward the interface to the existing concrete service so consumers can depend on the // abstraction (used by AppHostServerSession + GuestLaunchOptions in the aspire run path). - builder.Services.AddTransient(sp => sp.GetRequiredService()); + builder.Services.AddTransient(sp => sp.GetRequiredService()); // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed) builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService>())); diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index bfcd765f940..b4300ecdfeb 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -38,7 +38,6 @@ internal sealed class AppHostServerSession : IAppHostServerSession private readonly IProcessTreeGracefulShutdownSignaler? _gracefulShutdownSignaler; private readonly GracefulShutdownService? _shutdownService; private readonly bool _isolateConsole; - private readonly WindowsConsoleProcessJob? _consoleProcessJob; private readonly object _startGate = new(); private bool _startInvoked; @@ -71,8 +70,7 @@ public AppHostServerSession( ProfilingTelemetry? profilingTelemetry = null, IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler = null, GracefulShutdownService? shutdownService = null, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null) + bool isolateConsole = false) { _project = project ?? throw new ArgumentNullException(nameof(project)); _callerEnvironmentVariables = environmentVariables; @@ -84,18 +82,6 @@ public AppHostServerSession( _gracefulShutdownSignaler = gracefulShutdownSignaler; _shutdownService = shutdownService; _isolateConsole = isolateConsole; - _consoleProcessJob = consoleProcessJob; - - // Fail fast on misconfigured isolation: on Windows the kill-on-close job is the safety - // net that ensures the AppHost server doesn't outlive a CLI crash as an orphan in its - // new console group. Without the job the new-console isolation is a downgrade, not a - // safety net. Mirrored in IsolatedConsoleSpawner as defense-in-depth. - if (isolateConsole && OperatingSystem.IsWindows() && consoleProcessJob is null) - { - throw new ArgumentNullException( - nameof(consoleProcessJob), - "consoleProcessJob is required when isolateConsole is true on Windows."); - } // Linked CTS so caller-initiated cancellation AND DisposeAsync both flow through the // same stop trigger. The registered callback on _stopCts.Token (wired in StartAsync) is @@ -234,8 +220,7 @@ public Task StartAsync() Environment.ProcessId, serverEnvironmentVariables, debug: _debug, - isolateConsole: _isolateConsole, - consoleProcessJob: _consoleProcessJob); + isolateConsole: _isolateConsole); } catch (Exception ex) { diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 33143b383c8..e934b1df5e9 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -45,7 +45,6 @@ internal sealed class DotNetAppHostProject : IAppHostProject private readonly IConfigurationService _configurationService; private readonly GracefulShutdownService _shutdownService; private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; - private readonly WindowsConsoleProcessJob? _consoleProcessJob; private readonly CliExecutionContext _executionContext; private static readonly string[] s_detectionPatterns = ["*.csproj", "*.fsproj", "*.vbproj", "apphost.cs"]; @@ -79,7 +78,6 @@ public DotNetAppHostProject( GracefulShutdownService shutdownService, IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, CliExecutionContext executionContext, - WindowsConsoleProcessJob? consoleProcessJob = null, TimeProvider? timeProvider = null) { _runner = runner; @@ -98,9 +96,6 @@ public DotNetAppHostProject( _configurationService = configurationService; _shutdownService = shutdownService; _gracefulShutdownSignaler = gracefulShutdownSignaler; - // WindowsConsoleProcessJob is DI-registered Windows-only; null on non-Windows hosts. - // ProcessExecutionFactory enforces the Windows-requires-job invariant at spawn time. - _consoleProcessJob = consoleProcessJob; _executionContext = executionContext; _timeProvider = timeProvider ?? TimeProvider.System; _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); @@ -355,7 +350,6 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // package add, layout, and other short-lived invocations leave these unset so // they continue to use today's ProcessTerminator force-kill behavior. IsolateConsole = true, - ConsoleProcessJob = _consoleProcessJob, GracefulShutdownSignaler = _gracefulShutdownSignaler, ShutdownService = _shutdownService, }; diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 8788df6fbc2..bb510f7e549 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -469,8 +469,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null) + bool isolateConsole = false) { var assemblyPath = Path.Combine(BuildPath, ProjectDllName); var dotnetExe = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; @@ -560,7 +559,7 @@ void OnStderr(int pid, string line) if (isolateConsole) { - var isolated = IsolatedConsoleSpawner.StartIsolated(startInfo, consoleProcessJob, OnStdout, OnStderr); + var isolated = IsolatedConsoleSpawner.StartIsolated(startInfo, OnStdout, OnStderr); return new AppHostServerRunResult( _socketPath, isolated.Process, diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index efaefa449fb..33ab10775cc 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -50,7 +50,6 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly ProfilingTelemetry _profilingTelemetry; private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; private readonly GracefulShutdownService _shutdownService; - private readonly WindowsConsoleProcessJob? _consoleProcessJob; private readonly AppHostServerCodegenSessionFactory _codegenSessionFactory; // Language is always resolved via constructor @@ -75,7 +74,6 @@ public GuestAppHostProject( IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, GracefulShutdownService shutdownService, TimeProvider? timeProvider = null, - WindowsConsoleProcessJob? consoleProcessJob = null, AppHostServerCodegenSessionFactory? codegenSessionFactory = null) { _resolvedLanguage = language; @@ -96,7 +94,6 @@ public GuestAppHostProject( _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); _gracefulShutdownSignaler = gracefulShutdownSignaler; _shutdownService = shutdownService; - _consoleProcessJob = consoleProcessJob; // Default to constructing a real session for code generation. This uses the simple // construction (no graceful-shutdown parameters) because the codegen session is transient: @@ -496,8 +493,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken _profilingTelemetry, gracefulShutdownSignaler: _gracefulShutdownSignaler, shutdownService: _shutdownService, - isolateConsole: true, - consoleProcessJob: _consoleProcessJob); + isolateConsole: true); Task serverCompletion; IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) @@ -686,8 +682,7 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() var guestLaunchOptions = new GuestLaunchOptions( IsolateConsoleForGracefulShutdown: true, GracefulShutdownSignaler: _gracefulShutdownSignaler, - ShutdownService: _shutdownService, - ConsoleProcessJob: _consoleProcessJob); + ShutdownService: _shutdownService); (guestExitCode, guestOutput) = await ExecuteGuestAppHostAsync( appHostFile, directory, environmentVariables, enableHotReload, context.NoBuild, rpcClient, launcher, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, appHostSystemToken, guestLaunchOptions); } diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index 8e56d0e33a8..7acd0c1f5c4 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -128,12 +128,8 @@ Task PrepareAsync( /// so DCP's stop-process-tree can AttachConsole + post CTRL_C_EVENT /// against the server without also signalling the CLI. On Unix the flag is observed but the /// resulting spawn is effectively the same as today's path (a thin - /// wrapper) because SIGTERM via the process group is enough. When on - /// Windows, must be non-null. - /// - /// - /// Windows-only kill-on-close safety net. Required when is - /// on Windows; ignored on Unix and on the non-isolated path. + /// wrapper) because SIGTERM via the process group is enough. On Windows the server is bound to + /// the process-wide kill-on-close safety net. /// /// The launched server process and its associated cleanup handle. AppHostServerRunResult Run( @@ -141,8 +137,7 @@ AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null); + bool isolateConsole = false); /// /// Gets a unique identifier path for this AppHost, used for running instance detection. diff --git a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs index 7850e7e81e8..0aa87130c7c 100644 --- a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs +++ b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs @@ -18,7 +18,7 @@ namespace Aspire.Cli.Projects; /// When , spawn the guest via /// so it lands in its own hidden console /// group. Required on Windows for the AttachConsole + GenerateConsoleCtrlEvent dance -/// (executed by ) to target the guest +/// (executed by ) to target the guest /// without also signalling the CLI itself. No-op on Unix where SIGTERM is sufficient. /// /// @@ -33,21 +33,10 @@ namespace Aspire.Cli.Projects; /// (which calls via the cancellation manager) /// interrupts both immediately and the ladder escalates to Kill(entireProcessTree: true). /// -/// -/// Windows-only crash-time safety net. When supplied (and -/// is ), the -/// guest process is atomically assigned to this kill-on-close job at spawn time so a CLI -/// crash takes the guest with it instead of leaving an orphaned process in its own console -/// group. on non-Windows hosts (Unix process-group semantics handle -/// the equivalent case via normal signal delivery). DCP and other infrastructure that needs -/// to outlive the CLI for its own cleanup is expected to use CREATE_BREAKAWAY_FROM_JOB -/// to opt out of this kill. -/// internal sealed record GuestLaunchOptions( bool IsolateConsoleForGracefulShutdown = false, IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler = null, - GracefulShutdownService? ShutdownService = null, - WindowsConsoleProcessJob? ConsoleProcessJob = null); + GracefulShutdownService? ShutdownService = null); /// /// Strategy for launching a guest language process. diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 78e3c179552..83e1ef5be8a 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -917,8 +917,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null) + bool isolateConsole = false) { var startInfo = CreateStartInfo(hostPid, environmentVariables, additionalArgs, debug); var outputCollector = new OutputCollector(); @@ -952,7 +951,7 @@ void OnStderr(int pid, string line) if (isolateConsole) { - var isolated = IsolatedConsoleSpawner.StartIsolated(startInfo, consoleProcessJob, OnStdout, OnStderr); + var isolated = IsolatedConsoleSpawner.StartIsolated(startInfo, OnStdout, OnStderr); return new AppHostServerRunResult( _socketPath, isolated.Process, diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index e82b20765c2..f7b4e72959b 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -135,7 +135,6 @@ void HandleStderrLine(int pid, string line) var isolatedChild = IsolatedConsoleSpawner.StartIsolated( startInfo, - options.ConsoleProcessJob, HandleStdoutLine, HandleStderrLine); process = isolatedChild.Process; diff --git a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs index 833b75fe347..ab9bbe844ba 100644 --- a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs @@ -718,12 +718,12 @@ public static AppHostLauncherHarness Create(ITestOutputHelper outputHelper) var monitor = new TestAuxiliaryBackchannelMonitor(); var processLauncher = new TestDetachedProcessLauncher(); var fileLoggerProvider = new FileLoggerProvider(executionContext.LogFilePath, new TestStartupErrorWriter()); - var processShutdownService = new DetachedAppHostShutdownService( + var processShutdownService = new ProcessTreeGracefulShutdownService( new FixedLayoutDiscovery(), new NullBundleService(), new LayoutProcessRunner(new TestProcessExecutionFactory()), executionContext, - NullLogger.Instance, + NullLogger.Instance, TimeProvider.System); var launcher = new AppHostLauncher( new TestProjectLocator(), diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs index 6272c96ed7a..95720026237 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -410,71 +410,21 @@ private static IProcessExecution CreateExecution( GracefulShutdownService shutdownService) { var factory = new ProcessExecutionFactory(NullLogger.Instance); - WindowsConsoleProcessJob? consoleProcessJob = null; - - if (OperatingSystem.IsWindows() && isolateConsole) - { - consoleProcessJob = new WindowsConsoleProcessJob(); - } - - try - { - var startInfo = CreateStartInfo(scriptFile); - return new ProcessExecutionWithJob( - factory.CreateExecution( - startInfo.FileName, - startInfo.ArgumentList.ToArray(), - env: null, - new DirectoryInfo(startInfo.WorkingDirectory), - new ProcessInvocationOptions - { - IsolateConsole = isolateConsole, - ConsoleProcessJob = consoleProcessJob, - GracefulShutdownSignaler = signaler, - ShutdownService = shutdownService - }), - consoleProcessJob); - } - catch - { - DisposeConsoleProcessJob(consoleProcessJob); - throw; - } - } - - private static void DisposeConsoleProcessJob(WindowsConsoleProcessJob? consoleProcessJob) - { - if (OperatingSystem.IsWindows()) - { - consoleProcessJob?.Dispose(); - } - } - - private sealed class ProcessExecutionWithJob(IProcessExecution inner, WindowsConsoleProcessJob? consoleProcessJob) : IProcessExecution - { - public string FileName => inner.FileName; - - public IReadOnlyList Arguments => inner.Arguments; - - public IReadOnlyDictionary EnvironmentVariables => inner.EnvironmentVariables; - - public bool HasExited => inner.HasExited; - - public int ExitCode => inner.ExitCode; - - public int ProcessId => inner.ProcessId; - - public bool Start() => inner.Start(); - - public Task WaitForExitAsync(CancellationToken cancellationToken) => inner.WaitForExitAsync(cancellationToken); - - public void Kill(bool entireProcessTree) => inner.Kill(entireProcessTree); + var startInfo = CreateStartInfo(scriptFile); - public void Dispose() - { - inner.Dispose(); - DisposeConsoleProcessJob(consoleProcessJob); - } + // The Windows kill-on-close job is now resolved on-demand inside the factory via + // WindowsConsoleProcessJob.Shared, so the test no longer creates or disposes one. + return factory.CreateExecution( + startInfo.FileName, + startInfo.ArgumentList.ToArray(), + env: null, + new DirectoryInfo(startInfo.WorkingDirectory), + new ProcessInvocationOptions + { + IsolateConsole = isolateConsole, + GracefulShutdownSignaler = signaler, + ShutdownService = shutdownService + }); } } diff --git a/tests/Aspire.Cli.Tests/Processes/DetachedAppHostShutdownServiceTests.cs b/tests/Aspire.Cli.Tests/Processes/ProcessTreeGracefulShutdownServiceTests.cs similarity index 95% rename from tests/Aspire.Cli.Tests/Processes/DetachedAppHostShutdownServiceTests.cs rename to tests/Aspire.Cli.Tests/Processes/ProcessTreeGracefulShutdownServiceTests.cs index e70bbe93e51..0663ad8e495 100644 --- a/tests/Aspire.Cli.Tests/Processes/DetachedAppHostShutdownServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Processes/ProcessTreeGracefulShutdownServiceTests.cs @@ -15,7 +15,7 @@ namespace Aspire.Cli.Tests.Processes; -public class DetachedAppHostShutdownServiceTests(ITestOutputHelper outputHelper) +public class ProcessTreeGracefulShutdownServiceTests(ITestOutputHelper outputHelper) { [Fact] public async Task TryStopProcessTreeWithDcpAsync_UsesDcpStopProcessTreeArguments() @@ -50,7 +50,7 @@ public async Task TryStopProcessTreeWithDcpAsync_UsesDcpStopProcessTreeArguments "--pid", Environment.ProcessId.ToString(CultureInfo.InvariantCulture), "--process-start-time", - DetachedAppHostShutdownService.FormatDcpProcessStartTime(startTime) + ProcessTreeGracefulShutdownService.FormatDcpProcessStartTime(startTime) ], capturedArguments); } @@ -143,7 +143,7 @@ public async Task StopAppHostAsync_CleansUpCliProcessWithoutWaitingForItAsSucces } } - private static DetachedAppHostShutdownService CreateService( + private static ProcessTreeGracefulShutdownService CreateService( TemporaryWorkspace workspace, string dcpDirectory, TestProcessExecutionFactory executionFactory, @@ -159,12 +159,12 @@ private static DetachedAppHostShutdownService CreateService( Path.Combine(workspace.WorkspaceRoot.FullName, "test.log"), identityChannel: "local"); - return new DetachedAppHostShutdownService( + return new ProcessTreeGracefulShutdownService( new FixedLayoutDiscovery(dcpDirectory), bundleService ?? new NullBundleService(), new LayoutProcessRunner(executionFactory), executionContext, - NullLogger.Instance, + NullLogger.Instance, timeProvider ?? TimeProvider.System); } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index d4bf8d55742..dad9353a360 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -585,8 +585,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null) + bool isolateConsole = false) { ReceivedEnvironmentVariables = environmentVariables is null ? null @@ -630,8 +629,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null) + bool isolateConsole = false) { // Use a cross-platform long-running command so the test exercises the kill path // rather than a quickly-exiting probe like `dotnet --version`. @@ -679,8 +677,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null) => + bool isolateConsole = false) => throw new InvalidOperationException("simulated launch failure"); public void Dispose() => Disposed = true; diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 7a6e146c23c..8303251a0eb 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -1174,7 +1174,7 @@ private GuestAppHostProject CreateGuestAppHostProject( // Construct real graceful-shutdown collaborators so the contract matches production: // GuestAppHostProject requires these services even when a test exits the Run path early // (e.g. via FailedToBuildArtifacts) without exercising them. A no-op signaler stands in - // for DetachedAppHostShutdownService because none of the tests in this fixture drive the + // for ProcessTreeGracefulShutdownService because none of the tests in this fixture drive the // launcher or AppHostServerSession code paths that would actually invoke it. var gracefulShutdownService = new GracefulShutdownService(); diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index 129afc51ea8..34be08a84d4 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Configuration; -using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Scaffolding; using Aspire.Cli.Tests.TestServices; @@ -144,8 +143,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null) => + bool isolateConsole = false) => throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); } } diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index 013430320ee..c1a86470cc4 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Configuration; -using Aspire.Cli.Processes; using Aspire.Cli.Projects; namespace Aspire.Cli.Tests.TestServices; @@ -35,8 +34,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null) => + bool isolateConsole = false) => throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); public void Dispose() diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs index 6bde7cc1637..34263e66473 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Configuration; -using Aspire.Cli.Processes; using Aspire.Cli.Projects; namespace Aspire.Cli.Tests.TestServices; @@ -32,8 +31,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false, - WindowsConsoleProcessJob? consoleProcessJob = null) => + bool isolateConsole = false) => throw new NotSupportedException("Run should not be invoked when using a fake codegen session."); public void Dispose() diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 6bacc8490ef..e51822d6e38 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -161,12 +161,12 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.LayoutDiscoveryFactory); services.AddSingleton(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); // Mirror Program.cs so consumers (e.g. GuestAppHostProject) that depend on the - // interface receive the same DetachedAppHostShutdownService instance the abstraction + // interface receive the same ProcessTreeGracefulShutdownService instance the abstraction // wraps. Without this, DI returns null and Run-path tests construct the project with // a missing dependency, masking wiring regressions. - services.AddTransient(sp => sp.GetRequiredService()); + services.AddTransient(sp => sp.GetRequiredService()); // Match Program.Main's parameterless GracefulShutdownService + 5s finalDrainBudget for CCM // so tests exercise the same shutdown ladder budget as production. RunCommand and // GuestAppHostProject require these services in production wiring. From 190048f6bdd9aa64853687ff151d357bbece3178 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 18 Jun 2026 12:14:04 -0700 Subject: [PATCH 17/56] Add ProcessShutdownCoordinator: single cancel->shutdown decision point Collapse the copy-pasted "cancel -> (graceful ladder | force-kill fallback)" branch that had drifted across four CLI process owners (ProcessExecution, IsolatedProcessExecution, AppHostServerSession, ProcessGuestLauncher) into a single ProcessShutdownCoordinator.ShutdownAsync. Each site now passes its per-site fallback flags as data; the choice and the fallback's exact semantics are defined once. Also fixes a latent hang: IsolatedProcessExecution's force-kill fallback passed CancellationToken.None to ProcessTerminator with requestGracefulShutdown true, which would WaitForExitAsync(None) forever if the child ignored SIGTERM. The coordinator standardizes the fallback's graceful wait on a pre-cancelled token, so the terminator dispatches the best-effort signal then immediately escalates to Kill. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DotNet/IsolatedProcessExecution.cs | 35 ++------ src/Aspire.Cli/DotNet/ProcessExecution.cs | 37 ++------- .../Processes/ProcessShutdownCoordinator.cs | 83 +++++++++++++++++++ .../Projects/AppHostServerSession.cs | 48 +++-------- .../Projects/ProcessGuestLauncher.cs | 63 ++++---------- 5 files changed, 126 insertions(+), 140 deletions(-) create mode 100644 src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs diff --git a/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs b/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs index c647ef0f7f3..d67ea32227c 100644 --- a/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs @@ -81,32 +81,15 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) { _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, escalating shutdown", _fileName, _isolated.Id); - if (_options.GracefulShutdownSignaler is not null && _options.ShutdownService is not null) - { - // Run-path: drive the same shared ladder used by AppHostServerSession and - // ProcessGuestLauncher. The central token is what bounds graceful — by the - // time we observe OCE here CCM has already started its clock. - await ProcessGracefulShutdownLadder.ExecuteAsync( - _isolated.Process, - _options.GracefulShutdownSignaler, - _options.ShutdownService.Token, - _logger, - _fileName).ConfigureAwait(false); - } - else - { - // No central infra wired — preserve today's tactical fallback. This branch - // is reachable only for non-Run callers that opted into IsolateConsole but - // not into the central graceful budget. Today no such caller exists, but the - // fallback keeps the option independent and easy to test. - await ProcessTerminator.ShutdownAsync( - _isolated.Process, - requestGracefulShutdown: !OperatingSystem.IsWindows(), - _options.KillEntireProcessTreeOnCancel, - _logger, - _fileName, - gracefulShutdownCancellationToken: CancellationToken.None).ConfigureAwait(false); - } + await ProcessShutdownCoordinator.ShutdownAsync( + _isolated.Process, + _options.GracefulShutdownSignaler, + _options.ShutdownService, + gracefulBudgetActive: true, + fallbackRequestGracefulShutdown: !OperatingSystem.IsWindows(), + fallbackKillEntireProcessTree: _options.KillEntireProcessTreeOnCancel, + _logger, + _fileName).ConfigureAwait(false); throw; } diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index bc4107e7d35..656af8d6eda 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -95,34 +95,15 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) { _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, stopping it", FileName, _process.Id); - if (_options.GracefulShutdownSignaler is not null && _options.ShutdownService is not null) - { - // Run-path: drive the shared ladder so a non-isolated direct-launch AppHost - // gets the same graceful-then-tree-kill semantics as the isolated path. Useful - // mostly for Unix where IsolateConsole is a no-op (process groups + SIGTERM - // give us the same teardown shape DCP uses on Windows). - await ProcessGracefulShutdownLadder.ExecuteAsync( - _process, - _options.GracefulShutdownSignaler, - _options.ShutdownService.Token, - _logger, - FileName).ConfigureAwait(false); - } - else - { - // Today's tactical fallback for the many short-lived non-Run callers (build, - // restore, package add, layout, etc.). Pre-existing semantic bug: the - // gracefulShutdownCancellationToken passed in is the already-cancelled token, - // so on Unix graceful collapses in microseconds. Preserved here for back-compat - // because the Run path now opts into the new ladder above. - await ProcessTerminator.ShutdownAsync( - _process, - requestGracefulShutdown: !OperatingSystem.IsWindows(), - _options.KillEntireProcessTreeOnCancel, - _logger, - FileName, - gracefulShutdownCancellationToken: cancellationToken).ConfigureAwait(false); - } + await ProcessShutdownCoordinator.ShutdownAsync( + _process, + _options.GracefulShutdownSignaler, + _options.ShutdownService, + gracefulBudgetActive: true, + fallbackRequestGracefulShutdown: !OperatingSystem.IsWindows(), + fallbackKillEntireProcessTree: _options.KillEntireProcessTreeOnCancel, + _logger, + FileName).ConfigureAwait(false); throw; } diff --git a/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs b/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs new file mode 100644 index 00000000000..8fa8ea50f87 --- /dev/null +++ b/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs @@ -0,0 +1,83 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Processes; + +/// +/// The single decision point every CLI process owner routes through when a child must be torn +/// down on cancellation. Picks between the shared +/// (graceful signal → bounded wait → tree-kill, used on the aspire run path) and the +/// best-effort force-kill fallback (non-Run callers, or the +/// dispose-only path where no central graceful budget was ever started). +/// +/// +/// This consolidates the "ladder vs. force-kill" branch that previously lived, copy-pasted and +/// drifted, in ProcessExecution, IsolatedProcessExecution, AppHostServerSession +/// and ProcessGuestLauncher. Having one place means the choice — and the fallback's exact +/// semantics — can only be defined once. +/// +internal static class ProcessShutdownCoordinator +{ + // Pre-cancelled token handed to the force-kill fallback's graceful wait. An already-cancelled + // token makes ProcessTerminator dispatch the best-effort SIGTERM (Unix) and then immediately + // escalate to Kill, rather than waiting for a graceful exit. CancellationToken.None must NOT + // be used here: with requestGracefulShutdown the terminator would WaitForExitAsync(None) and + // block forever if the child ignores SIGTERM. + private static readonly CancellationToken s_immediateEscalation = new(canceled: true); + + /// + /// Tears down on cancellation, running the graceful ladder when the + /// run-path graceful infrastructure is wired and its budget is live, otherwise force-killing. + /// + /// The child process to shut down. + /// Graceful signaler, or for non-Run callers. + /// + /// Owns the central graceful budget token, or for non-Run callers. + /// + /// + /// when the central budget timer was never started (e.g. the dispose-only + /// path), which forces the fallback so the ladder doesn't wait on a clock nobody started. + /// + /// + /// Whether the force-kill fallback should dispatch a best-effort graceful signal (SIGTERM) before + /// killing. Typically !OperatingSystem.IsWindows(). + /// + /// Whether the force-kill fallback kills the whole tree. + /// Logger for diagnostics. + /// Short human description used in log messages. + public static Task ShutdownAsync( + Process process, + IProcessTreeGracefulShutdownSignaler? signaler, + GracefulShutdownService? gracefulShutdownService, + bool gracefulBudgetActive, + bool fallbackRequestGracefulShutdown, + bool fallbackKillEntireProcessTree, + ILogger logger, + string processDescription) + { + ArgumentNullException.ThrowIfNull(process); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(processDescription); + + if (signaler is not null && gracefulShutdownService is not null && gracefulBudgetActive) + { + return ProcessGracefulShutdownLadder.ExecuteAsync( + process, + signaler, + gracefulShutdownService.Token, + logger, + processDescription); + } + + return ProcessTerminator.ShutdownAsync( + process, + requestGracefulShutdown: fallbackRequestGracefulShutdown, + entireProcessTree: fallbackKillEntireProcessTree, + logger, + processDescription, + gracefulShutdownCancellationToken: s_immediateEscalation); + } +} diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index b4300ecdfeb..c48ee016ee5 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -541,41 +541,21 @@ private void OnStopRequested() _shutdownTask = ShutdownAsync(process, externalStopFired: _externalStopToken.IsCancellationRequested); } - private async Task ShutdownAsync(Process process, bool externalStopFired) + private Task ShutdownAsync(Process process, bool externalStopFired) { - // Force-kill path: take this when graceful infra isn't wired (non-Run callers — SDK gen, - // scaffolding, publish, dump) OR when the dispose-only path was taken (external token - // never fired). The dispose-only case is the critical reason for the externalStopFired - // check: if we observed _shutdownService.Token in that case, the ladder would hang - // indefinitely because nothing started the central timer. - if (_gracefulShutdownSignaler is null || _shutdownService is null || !externalStopFired) - { - try - { - await ProcessTerminator.ShutdownAsync( - process, - requestGracefulShutdown: false, - entireProcessTree: !OperatingSystem.IsWindows(), - _logger, - ProcessDescription, - CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - // Process.Id can throw if the handle was disposed mid-shutdown — surface as -1 - // rather than masking the original failure with a secondary log exception. - var pid = TryGetPid(process); - _logger.LogWarning(ex, "Failed to shut down {ProcessDescription} (pid {Pid}).", ProcessDescription, pid); - } - return; - } - - await ProcessGracefulShutdownLadder.ExecuteAsync( + // externalStopFired distinguishes "caller cancelled the external token" (the central + // graceful budget is live, so run the ladder) from "DisposeAsync cancelled the internal + // linked CTS" (the external token never fired, so nothing started the central timer and + // we must force-kill). See OnStopRequested for the full rationale. + return ProcessShutdownCoordinator.ShutdownAsync( process, _gracefulShutdownSignaler, - _shutdownService.Token, + _shutdownService, + gracefulBudgetActive: externalStopFired, + fallbackRequestGracefulShutdown: false, + fallbackKillEntireProcessTree: !OperatingSystem.IsWindows(), _logger, - ProcessDescription).ConfigureAwait(false); + ProcessDescription); } private static void ObserveFaultedTask(Task task) @@ -589,10 +569,4 @@ private static void ObserveFaultedTask(Task task) private static InvalidOperationException NotStarted() => new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); - - private static int TryGetPid(Process process) - { - try { return process.Id; } - catch { return -1; } - } } diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index f7b4e72959b..1d4dcd7ba1f 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -237,7 +237,7 @@ void HandleStderrLine(int pid, string line) // (e.g. surfacing captured output when the guest was killed because the AppHost system // failed). Wait without honoring cancellation so the OS reports the final exit code and // the redirected output streams have time to drain. - await ShutdownGuestProcessAsync(process, options, cancellationToken).ConfigureAwait(false); + await ShutdownGuestProcessAsync(process, options).ConfigureAwait(false); } _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, readExitCode()); @@ -273,58 +273,23 @@ void HandleStderrLine(int pid, string line) } } - private async Task ShutdownGuestProcessAsync( + private Task ShutdownGuestProcessAsync( Process process, - GuestLaunchOptions? options, - CancellationToken cancellationToken) + GuestLaunchOptions? options) { - // Force-kill path: take this when graceful infra isn't wired (non-Run callers — publish, - // extension adapter, anyone not opting into the central shutdown budget). We don't have - // a central token to bound a graceful wait against, so kill the tree and let - // WaitForDrainAsync surface any trailing output. This preserves the previous tactical - // behavior (graceful on Unix, force-kill on Windows) for callers that pass no options. - if (options?.GracefulShutdownSignaler is null || options.ShutdownService is null) - { - _logger.LogInformation("Stopping {Language} guest process tree {ProcessId}", _language, process.Id); - try - { - await ProcessTerminator.ShutdownAsync( - process, - requestGracefulShutdown: !OperatingSystem.IsWindows(), - entireProcessTree: true, - _logger, - $"{_language} guest", - gracefulShutdownCancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to shut down {Language} guest process {ProcessId}.", _language, SafePid(process)); - } - - return; - } - - // Run-path graceful ladder shared with AppHostServerSession and ProcessExecution. - // Whoever initiated shutdown (user Ctrl+C via CCM.Cancel) already started the - // central graceful clock; the helper just consumes the token. - await ProcessGracefulShutdownLadder.ExecuteAsync( + // Run-path graceful ladder shared with AppHostServerSession and ProcessExecution when the + // central budget is wired; otherwise a best-effort force-kill for non-Run callers (publish, + // extension adapter) that didn't opt into the central shutdown budget. Whoever initiated + // shutdown (user Ctrl+C via CCM.Cancel) already started the central graceful clock. + return ProcessShutdownCoordinator.ShutdownAsync( process, - options.GracefulShutdownSignaler, - options.ShutdownService.Token, + options?.GracefulShutdownSignaler, + options?.ShutdownService, + gracefulBudgetActive: true, + fallbackRequestGracefulShutdown: !OperatingSystem.IsWindows(), + fallbackKillEntireProcessTree: true, _logger, - $"{_language} guest").ConfigureAwait(false); - } - - private static int SafePid(Process process) - { - try - { - return process.Id; - } - catch (Exception) - { - return -1; - } + $"{_language} guest"); } private static Activity? GetCurrentProfilingActivity() From 65651271301496dbef76c8dda88a50e4187b0475 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 18 Jun 2026 13:40:02 -0700 Subject: [PATCH 18/56] Unify ProcessExecution + IsolatedProcessExecution into one wrapper (P2) Collapse the two parallel IProcessExecution implementations into a single ProcessExecution that wraps IsolatedProcess for both the isolated-console run path and ordinary non-isolated subprocesses. The only difference is now the IsolateConsole flag the factory sets on IsolatedProcessStartInfo. - IsolatedProcessStartInfo gains IsolateConsole (default true). When false the child is spawned via an ordinary redirected Process.Start on every platform (no new console, no job handle, stdin wired to an empty pipe). - IsolatedProcess.Start branches on IsolateConsole; StartUnix is generalized into a cross-platform StartRedirected(redirectStandardInput) used by the non-isolated path (all platforms) and the isolated-Unix path. Deleted IsolatedProcess.Unix.cs. - ProcessExecution is rewritten as the single wrapper: lazy spawn on Start (so the extension-host path that reads Arguments/EnvironmentVariables before starting doesn't orphan a process), an idle-based output drain over the pump completion tasks (strictly more forgiving than the old fixed-5s isolated drain, preserving the tail-output drain tests), and the shared ProcessShutdownCoordinator on cancellation. - ProcessExecutionFactory builds one IsolatedProcessStartInfo for both paths and now strips ASPIRE_CLI_* identity env vars on BOTH the isolated and non-isolated paths. The isolated path previously failed to strip them, leaking the parent CLI's identity overrides into AppHost children (a latent bug per docs/specs/cli-identity-sidecar.md). - Deleted IsolatedProcessExecution.cs. - Migrated the three direct-ctor ProcessExecution tests to construct via the factory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DotNet/IsolatedProcessExecution.cs | 151 ----------- src/Aspire.Cli/DotNet/ProcessExecution.cs | 246 +++++++++--------- .../DotNet/ProcessExecutionFactory.cs | 127 ++------- .../Processes/IsolatedProcess.Unix.cs | 74 ------ src/Aspire.Cli/Processes/IsolatedProcess.cs | 98 ++++++- .../DotNet/ProcessExecutionTests.cs | 52 ++-- 6 files changed, 270 insertions(+), 478 deletions(-) delete mode 100644 src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs delete mode 100644 src/Aspire.Cli/Processes/IsolatedProcess.Unix.cs diff --git a/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs b/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs deleted file mode 100644 index d67ea32227c..00000000000 --- a/src/Aspire.Cli/DotNet/IsolatedProcessExecution.cs +++ /dev/null @@ -1,151 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.Processes; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.DotNet; - -/// -/// backed by — used when -/// is true so the child can -/// receive DCP's stop-process-tree CTRL+C dance on Windows without also signalling the -/// CLI. The cancellation path uses the shared -/// when graceful infra is wired on the options; -/// otherwise it falls back to for full back-compat. -/// -internal sealed class IsolatedProcessExecution : IProcessExecution -{ - private readonly IsolatedProcess _isolated; - private readonly ILogger _logger; - private readonly ProcessInvocationOptions _options; - private readonly string _fileName; - private readonly IReadOnlyList _arguments; - private readonly IReadOnlyDictionary _environment; - private int _disposed; - - internal IsolatedProcessExecution( - IsolatedProcess isolated, - string fileName, - IReadOnlyList arguments, - IReadOnlyDictionary environment, - ILogger logger, - ProcessInvocationOptions options) - { - _isolated = isolated; - _fileName = fileName; - _arguments = arguments; - _environment = environment; - _logger = logger; - _options = options; - } - - /// - public string FileName => _fileName; - - /// - public IReadOnlyList Arguments => _arguments; - - /// - public IReadOnlyDictionary EnvironmentVariables => _environment; - - /// - public bool HasExited => _isolated.HasExited; - - /// - public int ExitCode => _isolated.ExitCode; - - /// - public int ProcessId => _isolated.Id; - - /// - public bool Start() - { - // IsolatedProcess.Start (called by the factory) already spawned the child and started - // the pumps. We're a thin wrapper; "starting" is implicit. Returning true keeps the - // factory contract identical to ProcessExecution. - _logger.LogDebug("{FileName}({ProcessId}) started in isolated console group", _fileName, _isolated.Id); - return true; - } - - /// - public async Task WaitForExitAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("{FileName}({ProcessId}) waiting for exit", _fileName, _isolated.Id); - - try - { - await _isolated.WaitForExitAsync(cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, escalating shutdown", _fileName, _isolated.Id); - - await ProcessShutdownCoordinator.ShutdownAsync( - _isolated.Process, - _options.GracefulShutdownSignaler, - _options.ShutdownService, - gracefulBudgetActive: true, - fallbackRequestGracefulShutdown: !OperatingSystem.IsWindows(), - fallbackKillEntireProcessTree: _options.KillEntireProcessTreeOnCancel, - _logger, - _fileName).ConfigureAwait(false); - - throw; - } - - _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", _fileName, _isolated.Id, _isolated.ExitCode); - - // Wait for the stdout/stderr pumps to finish draining so callbacks see the tail of - // the output. Bounded by a small drain budget — if the pumps somehow stay open - // beyond it (orphaned pipes, hostile callback) we still surface the exit code. - try - { - using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - await Task.WhenAll(_isolated.StandardOutputClosed, _isolated.StandardErrorClosed) - .WaitAsync(drainCts.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - _logger.LogWarning("{FileName}({ProcessId}) stdout/stderr pumps did not drain within timeout after exit", _fileName, _isolated.Id); - } - catch (Exception ex) - { - // A handler throw surfaces here via the pump's faulted Task. Log but continue — - // ExitCode is still meaningful even if a callback misbehaved. - _logger.LogWarning(ex, "{FileName}({ProcessId}) stdout/stderr pump faulted while draining after exit", _fileName, _isolated.Id); - } - - return _isolated.ExitCode; - } - - /// - public void Kill(bool entireProcessTree) - { - _isolated.Kill(entireProcessTree); - } - - /// - public void Dispose() - { - // IProcessExecution is a sync IDisposable. IsolatedProcess.DisposeAsync blocks - // briefly on pump drain (5 s ceiling). In practice DotNetCliRunner does not - // dispose the execution (StartBackchannelAsync runs fire-and-forget and reads - // HasExited/ExitCode after the await — see DotNetCliRunner.cs:145), so this - // sync-blocking path is reached only by explicit consumers or finalization. - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - try - { - _isolated.DisposeAsync().AsTask().GetAwaiter().GetResult(); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "{FileName}({ProcessId}) IsolatedProcess dispose threw", _fileName, _isolated.Id); - } - } -} diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 656af8d6eda..f2f42bb4912 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -8,212 +8,216 @@ namespace Aspire.Cli.DotNet; /// -/// Represents a configured process execution backed by a real OS process. +/// The single implementation. Wraps an +/// for both the isolated-console run path (Windows AppHost graceful shutdown) and ordinary +/// non-isolated subprocesses — the only difference is the +/// flag the factory sets. The child is +/// spawned lazily on so callers that build an execution but never start it +/// (e.g. the extension-host launch path, which reads / +/// and returns before starting) don't orphan a process. /// internal sealed class ProcessExecution : IProcessExecution { - private static readonly TimeSpan s_forwarderIdleTimeout = TimeSpan.FromSeconds(5); - private static readonly TimeSpan s_forwarderPollInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan s_drainIdleTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_drainPollInterval = TimeSpan.FromMilliseconds(100); - private readonly Process _process; + private readonly IsolatedProcessStartInfo _startInfo; + private readonly string _fileName; + private readonly IReadOnlyList _arguments; + private readonly IReadOnlyDictionary _environment; private readonly ILogger _logger; private readonly ProcessInvocationOptions _options; - private Task? _stdoutForwarder; - private Task? _stderrForwarder; - private long _lastForwarderActivityTimestamp = Stopwatch.GetTimestamp(); - - internal ProcessExecution(Process process, ILogger logger, ProcessInvocationOptions options) + private IsolatedProcess? _process; + private long _lastActivityTimestamp = Stopwatch.GetTimestamp(); + private int _disposed; + + internal ProcessExecution( + IsolatedProcessStartInfo startInfo, + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary environment, + ILogger logger, + ProcessInvocationOptions options) { - _process = process; + _startInfo = startInfo; + _fileName = fileName; + _arguments = arguments; + _environment = environment; _logger = logger; _options = options; } /// - public string FileName => _process.StartInfo.FileName; + public string FileName => _fileName; /// - public IReadOnlyList Arguments => _process.StartInfo.ArgumentList.ToArray(); + public IReadOnlyList Arguments => _arguments; /// - public IReadOnlyDictionary EnvironmentVariables => - _process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + public IReadOnlyDictionary EnvironmentVariables => _environment; /// - public bool HasExited => _process.HasExited; + public int ProcessId => Process.Id; /// - public int ExitCode => _process.ExitCode; + public bool HasExited => Process.HasExited; /// - public int ProcessId => _process.Id; + public int ExitCode => Process.ExitCode; + + private IsolatedProcess Process => + _process ?? throw new InvalidOperationException($"{nameof(ProcessExecution)} has not been started. Call {nameof(Start)} first."); /// public bool Start() { - var started = _process.Start(); - - if (!started) - { - _logger.LogDebug("{FileName} failed to start with args: {Args}", FileName, string.Join(" ", Arguments)); - return false; - } - - _logger.LogDebug("{FileName}({ProcessId}) started in {WorkingDirectory}", FileName, _process.Id, _process.StartInfo.WorkingDirectory); - RecordForwarderActivity(); - - // Start stream forwarders - _stdoutForwarder = Task.Run(async () => - { - await ForwardStreamToLoggerAsync( - _process.StandardOutput, - "stdout", - _options.StandardOutputCallback); - }); - - _stderrForwarder = Task.Run(async () => - { - await ForwardStreamToLoggerAsync( - _process.StandardError, - "stderr", - _options.StandardErrorCallback); - }); - + // IsolatedProcess.Start spawns the child and starts the stdout/stderr pumps. It throws on + // spawn failure (matching the old ProcessExecution, whose Process.Start could also throw), + // so a successful return always means the child is running — there is no false-on-failure + // case to model. The old Process.Start() == false path was dead for UseShellExecute=false. + _process = IsolatedProcess.Start(_startInfo, OnOutputLine, OnErrorLine); + _logger.LogDebug("{FileName}({ProcessId}) started in {WorkingDirectory}", _fileName, _process.Id, _startInfo.WorkingDirectory); return true; } /// public async Task WaitForExitAsync(CancellationToken cancellationToken) { - _logger.LogDebug("{FileName}({ProcessId}) waiting for exit", FileName, _process.Id); + var process = Process; + _logger.LogDebug("{FileName}({ProcessId}) waiting for exit", _fileName, process.Id); try { - await _process.WaitForExitAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { - _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, stopping it", FileName, _process.Id); + _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, stopping it", _fileName, process.Id); await ProcessShutdownCoordinator.ShutdownAsync( - _process, + process.Process, _options.GracefulShutdownSignaler, _options.ShutdownService, gracefulBudgetActive: true, fallbackRequestGracefulShutdown: !OperatingSystem.IsWindows(), fallbackKillEntireProcessTree: _options.KillEntireProcessTreeOnCancel, _logger, - FileName).ConfigureAwait(false); + _fileName).ConfigureAwait(false); throw; } - _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", FileName, _process.Id, _process.ExitCode); - - // Give the forwarders a fresh idle window to consume any buffered tail output produced right before exit. - RecordForwarderActivity(); + _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", _fileName, process.Id, process.ExitCode); - // Wait for the stream forwarders to drain naturally first so we don't cut off the - // tail of the process output. In some environments the stream handles can stay open - // after the process exits, so we fall back to closing them only if the forwarders - // stop making progress for the idle timeout. - if (_stdoutForwarder is not null && _stderrForwarder is not null) - { - var forwardersCompleted = Task.WhenAll([_stdoutForwarder, _stderrForwarder]); - if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false)) - { - _logger.LogDebug("{FileName}({ProcessId}) closing stdout/stderr streams after forwarder idle timeout", FileName, _process.Id); - _process.StandardOutput.Close(); - _process.StandardError.Close(); - - if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false)) - { - _logger.LogWarning("{FileName}({ProcessId}) stream forwarders did not complete within idle timeout after stream close", FileName, _process.Id); - } - } - } + // Reset the idle window at exit so the drain budget is measured from "process gone", not + // from the last line read. A consumer can block in a callback right up to exit and still + // get the full tail — see + // ProcessExecutionTests.WaitForExitAsync_AllowsBufferedTailOutputAfterLongIdlePeriod. + RecordActivity(); + await DrainOutputAsync(process, cancellationToken).ConfigureAwait(false); - return _process.ExitCode; + return process.ExitCode; } /// - public void Kill(bool entireProcessTree) - { - _process.Kill(entireProcessTree); - } + public void Kill(bool entireProcessTree) => Process.Kill(entireProcessTree); /// public void Dispose() { - _process.Dispose(); - } + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } - private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identifier, Action? lineCallback) - { - _logger.LogDebug( - "{FileName}({ProcessId}) starting to forward {Identifier} stream", - FileName, - _process.Id, - identifier - ); + // IProcessExecution is a sync IDisposable but IsolatedProcess only exposes DisposeAsync + // (it drains the pumps then tears the pipes/handles down). DotNetCliRunner does not dispose + // the execution (StartBackchannelAsync runs fire-and-forget and reads HasExited/ExitCode + // after the await — see DotNetCliRunner.cs), so this sync-blocking path is reached only by + // explicit `using` consumers and tests. + var process = _process; + if (process is null) + { + return; + } try { - string? line; - while ((line = await reader.ReadLineAsync()) is not null) - { - RecordForwarderActivity(); + process.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "{FileName} IsolatedProcess dispose threw", _fileName); + } + } - if (_logger.IsEnabled(LogLevel.Trace)) - { - _logger.LogTrace( - "{FileName}({ProcessId}) {Identifier}: {Line}", - FileName, - _process.Id, - identifier, - line - ); - } - lineCallback?.Invoke(line); - RecordForwarderActivity(); - } + private void OnOutputLine(IsolatedProcess sender, string line) + { + // RecordActivity brackets the callback (matching the old forwarder) so a slow consumer + // keeps the drain budget alive both while we hand it the line and while it processes it. + RecordActivity(); + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("{FileName}({ProcessId}) stdout: {Line}", _fileName, sender.Id, line); } - catch (ObjectDisposedException) + _options.StandardOutputCallback?.Invoke(line); + RecordActivity(); + } + + private void OnErrorLine(IsolatedProcess sender, string line) + { + RecordActivity(); + if (_logger.IsEnabled(LogLevel.Trace)) { - // Stream was closed externally (e.g., after process exit). This is expected. - _logger.LogDebug("{FileName}({ProcessId}) {Identifier} stream forwarder completed - stream was closed", FileName, _process.Id, identifier); + _logger.LogTrace("{FileName}({ProcessId}) stderr: {Line}", _fileName, sender.Id, line); } + _options.StandardErrorCallback?.Invoke(line); + RecordActivity(); } - private async Task WaitForForwardersAsync(Task forwardersCompleted, CancellationToken cancellationToken) + private async Task DrainOutputAsync(IsolatedProcess process, CancellationToken cancellationToken) { + var drained = Task.WhenAll(process.StandardOutputClosed, process.StandardErrorClosed); + while (true) { - if (forwardersCompleted.IsCompleted) + if (drained.IsCompleted) { - await forwardersCompleted.ConfigureAwait(false); - _logger.LogDebug("{FileName}({ProcessId}) forwarders completed", FileName, _process.Id); - return true; + try + { + await drained.ConfigureAwait(false); + } + catch (Exception ex) + { + // A throwing callback faults the pump task and surfaces here. The pumps still + // drained to EOF so output isn't lost; log and move on — the exit code is valid. + _logger.LogWarning(ex, "{FileName}({ProcessId}) stdout/stderr pump faulted while draining after exit", _fileName, process.Id); + } + + _logger.LogDebug("{FileName}({ProcessId}) output drained", _fileName, process.Id); + return; } - if (Stopwatch.GetElapsedTime(Interlocked.Read(ref _lastForwarderActivityTimestamp)) >= s_forwarderIdleTimeout) + // Idle-based budget: a slow-but-progressing consumer keeps resetting the timer via + // RecordActivity, so only a genuinely stalled pump (no output for the whole window) + // gives up. The pumps keep running in the background and are reaped by DisposeAsync — + // we never force the streams closed (that's the isolated path's already-accepted shape). + if (Stopwatch.GetElapsedTime(Interlocked.Read(ref _lastActivityTimestamp)) >= s_drainIdleTimeout) { - return false; + _logger.LogWarning("{FileName}({ProcessId}) stdout/stderr pumps did not drain within idle timeout after exit", _fileName, process.Id); + return; } try { - await Task.Delay(s_forwarderPollInterval, cancellationToken).ConfigureAwait(false); + await Task.Delay(s_drainPollInterval, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - return false; + return; } } } - private void RecordForwarderActivity() - { - Interlocked.Exchange(ref _lastForwarderActivityTimestamp, Stopwatch.GetTimestamp()); - } + private void RecordActivity() => Interlocked.Exchange(ref _lastActivityTimestamp, Stopwatch.GetTimestamp()); } diff --git a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs index 1e53f959a64..55930dc6a72 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs @@ -1,7 +1,6 @@ // 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.Cli.Processes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -28,78 +27,38 @@ public IProcessExecution CreateExecution(string fileName, string[] args, IDictio } } - if (options.IsolateConsole) - { - return CreateIsolatedExecution(fileName, args, env, workingDirectory, options, effectiveLogger); - } - - var startInfo = new ProcessStartInfo(fileName) + var startInfo = new IsolatedProcessStartInfo { + FileName = fileName, WorkingDirectory = workingDirectory.FullName, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, + IsolateConsole = options.IsolateConsole, + // Only the isolated path on Windows uses the kill-on-close job; the non-isolated path + // and every Unix path leave it null. The job is the process-wide singleton, created on + // demand the first time an isolated child needs it. + JobHandle = options.IsolateConsole && OperatingSystem.IsWindows() ? WindowsConsoleProcessJob.Shared.Handle : null, }; - // Strip ASPIRE_CLI_* identity overrides from every spawned process. - // These env vars are an in-process, parent-only test affordance — a - // developer or test bench uses them to coerce the *current* CLI into - // pretending it is a different channel/version/commit or to retarget - // its emitted nuget.config at a local proxy. Letting them leak into - // child processes (apphost, dotnet, restore, peer probes) means any - // nested `aspire` invocation inherits the parent's lie about its - // identity, which silently corrupts `aspire doctor`, breaks peer - // probing, and undermines the "what is this binary actually" answer - // we want callers to see on disk. We strip before the explicit `env` - // dictionary is merged so a caller can still re-add an ASPIRE_CLI_* - // var deliberately if a future test needs to. - // See docs/specs/cli-identity-sidecar.md. - foreach (var envVarName in Acquisition.IdentityResolver.IdentityEnvVarNames) - { - startInfo.EnvironmentVariables.Remove(envVarName); - } - - if (env is not null) - { - foreach (var envKvp in env) - { - startInfo.EnvironmentVariables[envKvp.Key] = envKvp.Value; - } - } - foreach (var a in args) { startInfo.ArgumentList.Add(a); } - var process = new Process { StartInfo = startInfo }; - return new ProcessExecution(process, effectiveLogger, options); - } - - private static IProcessExecution CreateIsolatedExecution( - string fileName, - string[] args, - IDictionary? env, - DirectoryInfo workingDirectory, - ProcessInvocationOptions options, - ILogger logger) - { - var startInfo = new IsolatedProcessStartInfo - { - FileName = fileName, - WorkingDirectory = workingDirectory.FullName, - // Only Windows uses the job handle — the Unix partial of IsolatedProcess ignores - // it because Unix process-group semantics + SIGTERM cover the orphan case. The job - // is the process-wide kill-on-close singleton, created on demand the first time an - // isolated child needs it. - JobHandle = OperatingSystem.IsWindows() ? WindowsConsoleProcessJob.Shared.Handle : null, - }; - - foreach (var a in args) + // Strip ASPIRE_CLI_* identity overrides from every spawned process — both the isolated + // AppHost run path and every non-isolated subprocess. These env vars are an in-process, + // parent-only test affordance: a developer or test bench uses them to coerce the *current* + // CLI into pretending it is a different channel/version/commit or to retarget its emitted + // nuget.config at a local proxy. Letting them leak into child processes (apphost, dotnet, + // restore, peer probes) means any nested `aspire` invocation inherits the parent's lie + // about its identity, which silently corrupts `aspire doctor`, breaks peer probing, and + // undermines the "what is this binary actually" answer we want callers to see on disk. + // Touching Environment here (which snapshots the parent env on first access) also forces + // the spawn to build a custom env block, so the strip applies even when the caller passes + // no explicit `env`. We strip before merging `env` so a caller can still re-add an + // ASPIRE_CLI_* var deliberately if a future test needs to. + // See docs/specs/cli-identity-sidecar.md. + foreach (var envVarName in Acquisition.IdentityResolver.IdentityEnvVarNames) { - startInfo.ArgumentList.Add(a); + startInfo.Environment.Remove(envVarName); } if (env is not null) @@ -110,44 +69,12 @@ private static IProcessExecution CreateIsolatedExecution( } } - // Mutable line buffer the wrapper exposes via EnvironmentVariables. Captured here - // (not lazily from IsolatedProcessStartInfo.Environment) so EnvironmentVariables stays - // valid after the wrapper takes ownership of the IsolatedProcess. - var envSnapshot = startInfo.HasCustomEnvironment - ? new Dictionary(startInfo.Environment, StringComparer.OrdinalIgnoreCase) - : new Dictionary(StringComparer.OrdinalIgnoreCase); - + // Snapshot args + env now so the IProcessExecution surfaces them before Start() spawns the + // child. The extension-host launch path reads Arguments / EnvironmentVariables and returns + // without ever calling Start (DotNetCliRunner), so these must be valid pre-spawn. var argsSnapshot = startInfo.ArgumentList.ToArray(); + var envSnapshot = new Dictionary(startInfo.Environment, StringComparer.OrdinalIgnoreCase); - // Per-line callbacks fan out to the same callback shape ProcessExecution exposes: - // log trace + StandardOutputCallback / StandardErrorCallback. Mirrors - // ProcessExecution.ForwardStreamToLoggerAsync so the two paths produce identical - // log shape and external observers can't tell them apart. - var isolated = IsolatedProcess.Start( - startInfo, - (sender, line) => - { - if (logger.IsEnabled(LogLevel.Trace)) - { - logger.LogTrace("{FileName}({ProcessId}) stdout: {Line}", fileName, sender.Id, line); - } - options.StandardOutputCallback?.Invoke(line); - }, - (sender, line) => - { - if (logger.IsEnabled(LogLevel.Trace)) - { - logger.LogTrace("{FileName}({ProcessId}) stderr: {Line}", fileName, sender.Id, line); - } - options.StandardErrorCallback?.Invoke(line); - }); - - return new IsolatedProcessExecution( - isolated, - fileName, - argsSnapshot, - envSnapshot, - logger, - options); + return new ProcessExecution(startInfo, fileName, argsSnapshot, envSnapshot, effectiveLogger, options); } } diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.Unix.cs b/src/Aspire.Cli/Processes/IsolatedProcess.Unix.cs deleted file mode 100644 index 47e700b9ceb..00000000000 --- a/src/Aspire.Cli/Processes/IsolatedProcess.Unix.cs +++ /dev/null @@ -1,74 +0,0 @@ -// 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.Text; - -namespace Aspire.Cli.Processes; - -internal sealed partial class IsolatedProcess -{ - /// - /// Unix implementation — a thin wrapper. - /// SIGTERM / process groups handle cooperative shutdown on Unix, so we do not need - /// the new-console gymnastics the Windows partial uses. - /// is ignored on Unix (Unix process-group reparenting + signal delivery cover the - /// crash-time case that JobHandle exists to address on Windows). - /// - private static IsolatedProcess StartUnix( - IsolatedProcessStartInfo startInfo, - Action standardOutputHandler, - Action standardErrorHandler) - { - var psi = new ProcessStartInfo - { - FileName = startInfo.FileName, - WorkingDirectory = startInfo.WorkingDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = false, - // Pin encodings so the new pump matches the existing ProcessGuestLauncher behavior - // regardless of the ambient Console.OutputEncoding (e.g. on container hosts that - // leave it set to ASCII). - StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false), - StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false), - }; - - foreach (var arg in startInfo.ArgumentList) - { - psi.ArgumentList.Add(arg); - } - - // Only mutate the ProcessStartInfo env block when the caller actually touched - // IsolatedProcessStartInfo.Environment. Otherwise leave ProcessStartInfo to inherit - // the parent's env verbatim — saves a snapshot-and-copy round trip for the common - // case where nothing was customized. - if (startInfo.HasCustomEnvironment) - { - psi.Environment.Clear(); - foreach (var (key, value) in startInfo.Environment) - { - // Match ProcessStartInfo.Environment semantics: a null value means "do not - // set this variable in the child" — we get there by simply not adding it. - if (value is not null) - { - psi.Environment[key] = value; - } - } - } - - var process = Process.Start(psi) - ?? throw new InvalidOperationException($"Failed to start interactive child process: {startInfo.FileName}"); - - return WrapStartedProcess( - startInfo, - process, - process.StandardOutput, - process.StandardError, - standardOutputHandler, - standardErrorHandler, - extraDispose: null); - } -} diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.cs b/src/Aspire.Cli/Processes/IsolatedProcess.cs index 8eee43ad31b..fc9feb5e3b1 100644 --- a/src/Aspire.Cli/Processes/IsolatedProcess.cs +++ b/src/Aspire.Cli/Processes/IsolatedProcess.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; +using System.Text; using Microsoft.Win32.SafeHandles; namespace Aspire.Cli.Processes; @@ -48,6 +49,18 @@ internal sealed class IsolatedProcessStartInfo /// public SafeFileHandle? JobHandle { get; init; } + /// + /// When (the default) the child is spawned in its own hidden console + /// group on Windows (CREATE_NEW_CONSOLE | SW_HIDE) so DCP's stop-process-tree CTRL+C + /// dance can target it without also signalling the CLI. When the child + /// is spawned via an ordinary redirected — no new + /// console, no , and stdin wired to an empty pipe — which is the shape + /// every non-isolated CLI subprocess (build, restore, package add, …) uses. On Unix both modes + /// are identical because SIGTERM via the process group covers teardown, so only Windows branches + /// on this flag. + /// + public bool IsolateConsole { get; init; } = true; + /// /// Returns true when the caller has read or modified . The /// spawn paths use this to decide whether to inherit the parent env verbatim @@ -223,12 +236,95 @@ public static IsolatedProcess Start( ArgumentNullException.ThrowIfNull(standardOutputHandler); ArgumentNullException.ThrowIfNull(standardErrorHandler); + // Non-isolated mode is an ordinary redirected Process.Start on every platform: no new + // console, no JobHandle, stdin wired to an empty pipe. On Unix the isolated mode is the + // same redirected spawn (SIGTERM via the process group is enough), so only the isolated + // Windows path needs the dedicated new-console launcher. + if (!startInfo.IsolateConsole) + { + return StartRedirected(startInfo, standardOutputHandler, standardErrorHandler, redirectStandardInput: true); + } + if (OperatingSystem.IsWindows()) { return StartWindows(startInfo, standardOutputHandler, standardErrorHandler); } - return StartUnix(startInfo, standardOutputHandler, standardErrorHandler); + return StartRedirected(startInfo, standardOutputHandler, standardErrorHandler, redirectStandardInput: false); + } + + /// + /// Cross-platform redirected spawn — a thin + /// wrapper. Used for every non-isolated child (all platforms) and for isolated children on + /// Unix, where SIGTERM / process groups handle cooperative shutdown so the new-console + /// gymnastics the Windows partial uses are unnecessary. + /// is ignored here (Unix process-group reparenting + signal delivery cover the crash-time case + /// that JobHandle exists to address on Windows). + /// + /// Process launch parameters. + /// Per-line callback for stdout; receives the wrapper as sender. + /// Per-line callback for stderr; receives the wrapper as sender. + /// + /// wires stdin to an empty redirected pipe (the non-isolated shape every + /// other CLI subprocess uses). lets the child inherit the CLI's stdin + /// (the isolated-Unix shape). + /// + private static IsolatedProcess StartRedirected( + IsolatedProcessStartInfo startInfo, + Action standardOutputHandler, + Action standardErrorHandler, + bool redirectStandardInput) + { + var psi = new ProcessStartInfo + { + FileName = startInfo.FileName, + WorkingDirectory = startInfo.WorkingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = redirectStandardInput, + // Pin encodings so the new pump matches the existing ProcessGuestLauncher behavior + // regardless of the ambient Console.OutputEncoding (e.g. on container hosts that + // leave it set to ASCII). + StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false), + StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false), + }; + + foreach (var arg in startInfo.ArgumentList) + { + psi.ArgumentList.Add(arg); + } + + // Only mutate the ProcessStartInfo env block when the caller actually touched + // IsolatedProcessStartInfo.Environment. Otherwise leave ProcessStartInfo to inherit + // the parent's env verbatim — saves a snapshot-and-copy round trip for the common + // case where nothing was customized. + if (startInfo.HasCustomEnvironment) + { + psi.Environment.Clear(); + foreach (var (key, value) in startInfo.Environment) + { + // Match ProcessStartInfo.Environment semantics: a null value means "do not + // set this variable in the child" — we get there by simply not adding it. + if (value is not null) + { + psi.Environment[key] = value; + } + } + } + + var process = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start child process: {startInfo.FileName}"); + + return WrapStartedProcess( + startInfo, + process, + process.StandardOutput, + process.StandardError, + standardOutputHandler, + standardErrorHandler, + extraDispose: null); } /// diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs index 95720026237..a827afaeae0 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -26,11 +26,6 @@ public async Task WaitForExitAsync_AllowsForwardersToDrainBeforeClosingStreams() await File.WriteAllTextAsync(outputFile.FullName, CreateJsonPayload(lineCount: 400)); var scriptFile = await CreateOutputScriptAsync(workspace.WorkspaceRoot, outputFile); - var startInfo = CreateStartInfo(scriptFile); - var process = new Process - { - StartInfo = startInfo - }; var stdoutBuilder = new StringBuilder(); var stderrBuilder = new StringBuilder(); @@ -46,9 +41,8 @@ public async Task WaitForExitAsync_AllowsForwardersToDrainBeforeClosingStreams() }); var isFirstLine = true; - using var execution = new ProcessExecution( - process, - NullLogger.Instance, + using var execution = CreateExecution( + scriptFile, new ProcessInvocationOptions { StandardOutputCallback = line => @@ -92,11 +86,6 @@ public async Task WaitForExitAsync_AllowsBufferedTailOutputAfterLongIdlePeriod() await File.WriteAllTextAsync(outputFile.FullName, CreateJsonPayload(lineCount: 400)); var scriptFile = await CreateDelayedOutputScriptAsync(workspace.WorkspaceRoot, outputFile); - var startInfo = CreateStartInfo(scriptFile); - var process = new Process - { - StartInfo = startInfo - }; var stdoutBuilder = new StringBuilder(); var stderrBuilder = new StringBuilder(); @@ -111,9 +100,8 @@ public async Task WaitForExitAsync_AllowsBufferedTailOutputAfterLongIdlePeriod() releaseCallback.SetResult(); }); - using var execution = new ProcessExecution( - process, - NullLogger.Instance, + using var execution = CreateExecution( + scriptFile, new ProcessInvocationOptions { StandardOutputCallback = line => @@ -157,15 +145,9 @@ public async Task WaitForExitAsync_KillsProcessWhenCanceled() using var workspace = TemporaryWorkspace.Create(outputHelper); var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); - var startInfo = CreateStartInfo(scriptFile); - var process = new Process - { - StartInfo = startInfo - }; - using var execution = new ProcessExecution( - process, - NullLogger.Instance, + using var execution = CreateExecution( + scriptFile, new ProcessInvocationOptions()); Assert.True(execution.Start()); @@ -174,9 +156,8 @@ public async Task WaitForExitAsync_KillsProcessWhenCanceled() await cts.CancelAsync(); await Assert.ThrowsAsync(() => execution.WaitForExitAsync(cts.Token)); - await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); - Assert.True(process.HasExited); + Assert.True(WaitForProcessExit(execution.ProcessId, TimeSpan.FromSeconds(10)), $"Expected process {execution.ProcessId} to exit after cancellation."); } [Theory] @@ -405,20 +386,29 @@ private static ProcessStartInfo CreateStartInfo(FileInfo scriptFile) private static IProcessExecution CreateExecution( FileInfo scriptFile, - bool isolateConsole, - IProcessTreeGracefulShutdownSignaler signaler, - GracefulShutdownService shutdownService) + ProcessInvocationOptions options) { var factory = new ProcessExecutionFactory(NullLogger.Instance); var startInfo = CreateStartInfo(scriptFile); - // The Windows kill-on-close job is now resolved on-demand inside the factory via - // WindowsConsoleProcessJob.Shared, so the test no longer creates or disposes one. return factory.CreateExecution( startInfo.FileName, startInfo.ArgumentList.ToArray(), env: null, new DirectoryInfo(startInfo.WorkingDirectory), + options); + } + + private static IProcessExecution CreateExecution( + FileInfo scriptFile, + bool isolateConsole, + IProcessTreeGracefulShutdownSignaler signaler, + GracefulShutdownService shutdownService) + { + // The Windows kill-on-close job is now resolved on-demand inside the factory via + // WindowsConsoleProcessJob.Shared, so the test no longer creates or disposes one. + return CreateExecution( + scriptFile, new ProcessInvocationOptions { IsolateConsole = isolateConsole, From 30ec2ecab53689d2565cdea8230021d2c7d8f13e Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 18 Jun 2026 14:13:19 -0700 Subject: [PATCH 19/56] Make graceful shutdown a command-level policy owned by GracefulShutdownService GracefulShutdownService now owns the graceful budget and self-bounds the window via BeginGracefulWindow (CancelAfter), exposing IsEnabled as the single graceful-vs-force decision. ProcessShutdownCoordinator keys off IsEnabled and starts the central clock when it selects the ladder, so the wait is always bounded regardless of whether teardown was triggered by a user signal or by disposal. This removes the per-call gracefulBudgetActive flag and the session's externalStopFired distinction, eliminating the dispose-only hang risk those guards previously covered. ConsoleCancellationManager delegates graceful timing to the service. Tests model the run path by configuring a budget; the dispose-only force-kill case now reflects the unconfigured (non-run) service. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/ConsoleCancellationManager.cs | 59 ++++------ src/Aspire.Cli/DotNet/ProcessExecution.cs | 1 - src/Aspire.Cli/GracefulShutdownService.cs | 108 +++++++++++++++--- .../Processes/ProcessShutdownCoordinator.cs | 26 +++-- .../Projects/AppHostServerSession.cs | 28 ++--- .../Projects/ProcessGuestLauncher.cs | 7 +- .../DotNet/ProcessExecutionTests.cs | 8 ++ .../GracefulShutdownServiceTests.cs | 91 +++++++++++++++ .../Projects/AppHostServerSessionTests.cs | 23 +++- .../Projects/ProcessGuestLauncherTests.cs | 11 ++ 10 files changed, 272 insertions(+), 90 deletions(-) diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index 9af6dc17267..6be392302a5 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -1,7 +1,6 @@ // 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.Runtime.InteropServices; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -66,7 +65,6 @@ internal sealed class ConsoleCancellationManager : IDisposable // drive this counter — they rely on disposable-based cleanup (`await using` of the // server session + guest launcher) to run the per-process shutdown ladders. private int _signalCount; - private TimeSpan _gracefulBudget = TimeSpan.Zero; private readonly TaskCompletionSource _processTerminationCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -156,20 +154,15 @@ public ConsoleCancellationManager(GracefulShutdownService gracefulService, TimeS public bool IsCancellationRequested => _cts.IsCancellationRequested; /// - /// Sets the graceful-shutdown budget for the currently-executing command. Default is zero, meaning - /// ladders that consume fall through to escalation immediately - /// (preserving today's behavior for every command that doesn't opt in). The aspire run handler - /// calls this with five seconds so DCP and the AppHost get a real cooperative-shutdown window before - /// escalation. + /// Sets the graceful-shutdown budget for the currently-executing command by forwarding it to + /// . Default is zero, meaning ladders that consume + /// fall through to escalation immediately (preserving today's + /// behavior for every command that doesn't opt in). The aspire run handler calls this with + /// five seconds so DCP and the AppHost get a real cooperative-shutdown window before escalation. /// public void ConfigureForCommand(TimeSpan gracefulBudget) { - if (gracefulBudget < TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(gracefulBudget), "Graceful budget cannot be negative."); - } - - _gracefulBudget = gracefulBudget; + _gracefulService.Configure(gracefulBudget); } private void OnPosixSignal(PosixSignalContext context) @@ -235,36 +228,26 @@ private async Task ExpireGracefulThenFinalDrainAsync(int forcedTerminationExitCo { try { - // When a debugger is attached, don't escalate or force-terminate — the developer needs - // unlimited time to step through cancellation/cleanup logic. The graceful token therefore - // never fires under the debugger via this watcher; ladders that observe it sit indefinitely - // (the right behavior for stepping). A manual second Ctrl+C still works because it calls - // _gracefulService.Expire() synchronously via the signal counter, bypassing this method. - if (Debugger.IsAttached) + // Phase 1: graceful window. Start the central clock on the service, then wait for the + // graceful token to fire. BeginGracefulWindow arms a CancelAfter(budget) (or, for a + // zero-budget command, expires immediately), so the token is guaranteed to fire without us + // owning a timer here. A 2nd Ctrl+C calls _gracefulService.Expire() from the signal counter, + // which fires the token early and drops us straight into Phase 2. + // + // Under a debugger BeginGracefulWindow is a no-op (the developer needs unlimited time to + // step), so the token never auto-fires and this await sits indefinitely — the right behavior + // for stepping. A manual second Ctrl+C still escalates via Expire(). + _gracefulService.BeginGracefulWindow(); + + try { - return; + await Task.Delay(Timeout.InfiniteTimeSpan, _gracefulService.Token).ConfigureAwait(false); } - - // Phase 1: graceful window. Delay is cancellable via the graceful token so a 2nd Ctrl+C - // (which calls _gracefulService.Expire from the signal counter) drops us straight into - // Phase 2 without waiting out the remaining budget. Zero-budget commands skip the delay - // entirely and fall through to the unconditional Expire() below. - if (_gracefulBudget > TimeSpan.Zero) + catch (OperationCanceledException) { - try - { - await Task.Delay(_gracefulBudget, _gracefulService.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // 2nd Ctrl+C arrived; fall through to Phase 2. - } + // Graceful window expired (budget elapsed or 2nd Ctrl+C); fall through to Phase 2. } - // Guarantees the graceful token fires before Phase 2 starts, even when the budget was zero - // (no Phase 1 delay) or when the delay elapsed without a 2nd Ctrl+C. Idempotent. - _gracefulService.Expire(); - // Phase 2: final drain. Give the handler a chance to finish gracefully within the configured // drain budget. Task.WhenAny completes when either the handler or the delay finishes first, // without propagating exceptions from the losing task. It's ok that this delay isn't diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index f2f42bb4912..9862d4a93bb 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -98,7 +98,6 @@ await ProcessShutdownCoordinator.ShutdownAsync( process.Process, _options.GracefulShutdownSignaler, _options.ShutdownService, - gracefulBudgetActive: true, fallbackRequestGracefulShutdown: !OperatingSystem.IsWindows(), fallbackKillEntireProcessTree: _options.KillEntireProcessTreeOnCancel, _logger, diff --git a/src/Aspire.Cli/GracefulShutdownService.cs b/src/Aspire.Cli/GracefulShutdownService.cs index d88d1c5365a..12d3cfb058a 100644 --- a/src/Aspire.Cli/GracefulShutdownService.cs +++ b/src/Aspire.Cli/GracefulShutdownService.cs @@ -1,31 +1,49 @@ // 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; + namespace Aspire.Cli; /// -/// Thin facade over a single that signals when the -/// CLI's graceful-shutdown window has expired. Owned and timed exclusively by -/// ; consumed by long-running ladders -/// (e.g. AppHostServerSession, ProcessGuestLauncher) that need to know -/// when to stop waiting for a cooperative shutdown and escalate to forceful termination. +/// The single authority for the CLI's command-level graceful-shutdown policy. Owns the +/// graceful budget (the time limit configured for the running command) and the +/// that fires when that window expires. Consumed by every +/// long-running shutdown ladder (e.g. AppHostServerSession, ProcessGuestLauncher, +/// ProcessExecution) so the decision to wait for a cooperative shutdown vs. +/// escalate to forceful termination is made once, for the whole command, rather than per child or +/// per process execution. /// /// /// -/// The service intentionally exposes no timing or budget configuration of its own — CCM -/// is the sole timing authority. Multiple calls are safe and -/// idempotent; the token transitions from un-cancelled to cancelled exactly once. +/// Graceful shutdown is all-or-nothing per command: reflects whether a +/// positive time limit was configured via . aspire run configures a +/// budget; every other command leaves it at zero so its children force-kill immediately +/// (preserving today's behavior). +/// +/// +/// The service also owns the timer. idempotently arms a +/// CancelAfter(budget) so the token is guaranteed to fire within the budget once shutdown +/// begins — regardless of whether shutdown was initiated by a user signal () +/// or by disposal of a child owner. This is what lets ladders consume the token without risking a +/// hang: there is no path where a ladder waits on a clock that nobody started. +/// +/// +/// Multiple / calls are safe and idempotent; +/// the token transitions from un-cancelled to cancelled exactly once. /// /// -/// Registered as a DI singleton via services.AddSingleton(instance) so the -/// container does not take disposal ownership; the bootstrap path (Program.Main) -/// owns the instance lifetime alongside CCM. +/// Registered as a DI singleton via services.AddSingleton(instance) so the container does not +/// take disposal ownership; the bootstrap path (Program.Main) owns the instance lifetime +/// alongside CCM. /// /// internal sealed class GracefulShutdownService : IDisposable { private readonly CancellationTokenSource _cts = new(); private readonly CancellationToken _token; + private TimeSpan _budget = TimeSpan.Zero; + private int _windowStarted; public GracefulShutdownService() { @@ -41,8 +59,72 @@ public GracefulShutdownService() public CancellationToken Token => _token; /// - /// Signals that the graceful-shutdown window is over. Safe to call multiple times - /// from any thread; the underlying token transitions to cancelled at most once. + /// Whether graceful shutdown is enabled for the running command — i.e. a positive budget was + /// configured via . When , shutdown ladders + /// escalate straight to forceful termination. + /// + public bool IsEnabled => _budget > TimeSpan.Zero; + + /// + /// Sets the graceful-shutdown budget for the running command. Default is zero + /// ( is ). The aspire run handler configures + /// a positive budget so DCP and the AppHost get a real cooperative-shutdown window before + /// escalation. + /// + public void Configure(TimeSpan budget) + { + if (budget < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(budget), "Graceful budget cannot be negative."); + } + + _budget = budget; + } + + /// + /// Starts the graceful-shutdown clock. Idempotent — the first caller arms a + /// CancelAfter(budget) so is guaranteed to fire within the budget; + /// subsequent calls are no-ops. Called by whoever initiates teardown (a user signal via CCM, or a + /// child owner's disposal-driven ladder) so the token is always bounded. + /// + public void BeginGracefulWindow() + { + // When a debugger is attached, never arm the clock — the developer needs unlimited time to + // step through cancellation/cleanup logic. The token therefore never auto-fires; ladders that + // observe it sit indefinitely (the right behavior for stepping). A manual second Ctrl+C still + // escalates because it calls Expire() directly, bypassing this method. + if (Debugger.IsAttached) + { + return; + } + + // A non-positive budget means graceful shutdown isn't configured for this command; the window + // is "over" the moment it begins, so escalate immediately. + if (_budget <= TimeSpan.Zero) + { + Expire(); + return; + } + + if (Interlocked.Exchange(ref _windowStarted, 1) != 0) + { + return; + } + + try + { + _cts.CancelAfter(_budget); + } + catch (ObjectDisposedException) + { + // Racing process shutdown after dispose; the token's final state is already observable. + } + } + + /// + /// Signals that the graceful-shutdown window is over immediately, regardless of the remaining + /// budget. Safe to call multiple times from any thread; the underlying token transitions to + /// cancelled at most once. /// public void Expire() { diff --git a/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs b/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs index 8fa8ea50f87..37ec47e1191 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs +++ b/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs @@ -10,14 +10,20 @@ namespace Aspire.Cli.Processes; /// The single decision point every CLI process owner routes through when a child must be torn /// down on cancellation. Picks between the shared /// (graceful signal → bounded wait → tree-kill, used on the aspire run path) and the -/// best-effort force-kill fallback (non-Run callers, or the -/// dispose-only path where no central graceful budget was ever started). +/// best-effort force-kill fallback (non-Run callers). /// /// /// This consolidates the "ladder vs. force-kill" branch that previously lived, copy-pasted and /// drifted, in ProcessExecution, IsolatedProcessExecution, AppHostServerSession /// and ProcessGuestLauncher. Having one place means the choice — and the fallback's exact /// semantics — can only be defined once. +/// +/// The graceful-vs-force decision is command-level and all-or-nothing: it keys off +/// (true when the running command configured a +/// positive budget). No per-child or per-call flag. When the ladder is selected this also starts +/// the central clock via , so the ladder's +/// wait is always bounded regardless of whether teardown was initiated by a user signal or by +/// disposal of the child owner. /// internal static class ProcessShutdownCoordinator { @@ -30,16 +36,12 @@ internal static class ProcessShutdownCoordinator /// /// Tears down on cancellation, running the graceful ladder when the - /// run-path graceful infrastructure is wired and its budget is live, otherwise force-killing. + /// run-path graceful infrastructure is wired and enabled for the command, otherwise force-killing. /// /// The child process to shut down. /// Graceful signaler, or for non-Run callers. /// - /// Owns the central graceful budget token, or for non-Run callers. - /// - /// - /// when the central budget timer was never started (e.g. the dispose-only - /// path), which forces the fallback so the ladder doesn't wait on a clock nobody started. + /// Owns the central graceful budget + token, or for non-Run callers. /// /// /// Whether the force-kill fallback should dispatch a best-effort graceful signal (SIGTERM) before @@ -52,7 +54,6 @@ public static Task ShutdownAsync( Process process, IProcessTreeGracefulShutdownSignaler? signaler, GracefulShutdownService? gracefulShutdownService, - bool gracefulBudgetActive, bool fallbackRequestGracefulShutdown, bool fallbackKillEntireProcessTree, ILogger logger, @@ -62,8 +63,13 @@ public static Task ShutdownAsync( ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(processDescription); - if (signaler is not null && gracefulShutdownService is not null && gracefulBudgetActive) + if (signaler is not null && gracefulShutdownService is { IsEnabled: true }) { + // Start the central clock so the ladder's wait is bounded even when teardown was triggered + // by disposal (e.g. normal aspire run completion) rather than a user signal. Idempotent — + // if a user Ctrl+C already armed the window this is a no-op. + gracefulShutdownService.BeginGracefulWindow(); + return ProcessGracefulShutdownLadder.ExecuteAsync( process, signaler, diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index c48ee016ee5..5d230ee132d 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -34,7 +34,6 @@ internal sealed class AppHostServerSession : IAppHostServerSession private readonly ProfilingTelemetry? _profilingTelemetry; private readonly string _authenticationToken; private readonly CancellationTokenSource _stopCts; - private readonly CancellationToken _externalStopToken; private readonly IProcessTreeGracefulShutdownSignaler? _gracefulShutdownSignaler; private readonly GracefulShutdownService? _shutdownService; private readonly bool _isolateConsole; @@ -78,17 +77,15 @@ public AppHostServerSession( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _profilingTelemetry = profilingTelemetry; _authenticationToken = TokenGenerator.GenerateToken(); - _externalStopToken = stopRequested; _gracefulShutdownSignaler = gracefulShutdownSignaler; _shutdownService = shutdownService; _isolateConsole = isolateConsole; // Linked CTS so caller-initiated cancellation AND DisposeAsync both flow through the // same stop trigger. The registered callback on _stopCts.Token (wired in StartAsync) is - // the single kill site for the process. OnStopRequested reads _externalStopToken to - // distinguish "caller-initiated stop" (run the graceful ladder) from "dispose-only stop" - // (force-kill immediately — graceful ladder would hang because nothing started the - // central GracefulShutdownService timer). + // the single kill site for the process. The graceful-vs-force decision is made centrally by + // the coordinator off GracefulShutdownService.IsEnabled — there is no per-stop distinction + // here, and the coordinator bounds the graceful wait by starting the central clock itself. _stopCts = CancellationTokenSource.CreateLinkedTokenSource(stopRequested); } @@ -533,25 +530,20 @@ private void OnStopRequested() return; } - // _externalStopToken.IsCancellationRequested distinguishes "caller cancelled the external - // token" (we're on the propagation path and the token reads as cancelled) from - // "DisposeAsync cancelled the internal linked CTS" (the external token never fired). - // Only the former should run the graceful ladder — the latter must force-kill because - // nothing started the central GracefulShutdownService timer. - _shutdownTask = ShutdownAsync(process, externalStopFired: _externalStopToken.IsCancellationRequested); + _shutdownTask = ShutdownAsync(process); } - private Task ShutdownAsync(Process process, bool externalStopFired) + private Task ShutdownAsync(Process process) { - // externalStopFired distinguishes "caller cancelled the external token" (the central - // graceful budget is live, so run the ladder) from "DisposeAsync cancelled the internal - // linked CTS" (the external token never fired, so nothing started the central timer and - // we must force-kill). See OnStopRequested for the full rationale. + // The graceful-vs-force decision is owned centrally by the coordinator, keyed off + // GracefulShutdownService.IsEnabled. When graceful is enabled for the command (aspire run), + // the coordinator starts the central clock and runs the bounded ladder regardless of whether + // this stop came from a user signal or from DisposeAsync. When graceful is not wired/enabled + // (non-Run callers: SDK gen, scaffolding, publish, dump), it force-kills. return ProcessShutdownCoordinator.ShutdownAsync( process, _gracefulShutdownSignaler, _shutdownService, - gracefulBudgetActive: externalStopFired, fallbackRequestGracefulShutdown: false, fallbackKillEntireProcessTree: !OperatingSystem.IsWindows(), _logger, diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 1d4dcd7ba1f..a14f37eb73b 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -278,14 +278,13 @@ private Task ShutdownGuestProcessAsync( GuestLaunchOptions? options) { // Run-path graceful ladder shared with AppHostServerSession and ProcessExecution when the - // central budget is wired; otherwise a best-effort force-kill for non-Run callers (publish, - // extension adapter) that didn't opt into the central shutdown budget. Whoever initiated - // shutdown (user Ctrl+C via CCM.Cancel) already started the central graceful clock. + // central budget is wired and enabled; otherwise a best-effort force-kill for non-Run callers + // (publish, extension adapter) that didn't opt into the central shutdown budget. The coordinator + // starts the central graceful clock when it selects the ladder, so the wait is always bounded. return ProcessShutdownCoordinator.ShutdownAsync( process, options?.GracefulShutdownSignaler, options?.ShutdownService, - gracefulBudgetActive: true, fallbackRequestGracefulShutdown: !OperatingSystem.IsWindows(), fallbackKillEntireProcessTree: true, _logger, diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs index a827afaeae0..61c04630047 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -168,6 +168,9 @@ public async Task WaitForExitAsync_WithGracefulServices_InvokesSignalerAndThrows using var workspace = TemporaryWorkspace.Create(outputHelper); var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled (positive budget) so the coordinator runs + // the ladder. Escalation in this test is driven explicitly (signaler kill), not by the budget. + shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaler = new RecordingGracefulSignaler(onSignal: pid => { TryKillProcess(pid); @@ -196,6 +199,9 @@ public async Task WaitForExitAsync_WithGracefulServices_ProcessIgnoresSignal_Exp using var workspace = TemporaryWorkspace.Create(outputHelper); var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled so the coordinator runs the ladder. + // Escalation is driven by the explicit Expire() below, not by the budget elapsing. + shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var signaler = new RecordingGracefulSignaler(onSignal: _ => { @@ -229,6 +235,8 @@ public async Task WaitForExitAsync_WithGracefulServices_SignalerThrows_StillEsca using var workspace = TemporaryWorkspace.Create(outputHelper); var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled so the coordinator runs the ladder. + shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaler = new RecordingGracefulSignaler(onSignal: _ => throw new InvalidOperationException("simulated DCP failure")); diff --git a/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs b/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs index 474042c6f97..113cef8f467 100644 --- a/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs +++ b/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs @@ -1,6 +1,9 @@ // 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 Microsoft.AspNetCore.InternalTesting; + namespace Aspire.Cli.Tests; public class GracefulShutdownServiceTests @@ -56,4 +59,92 @@ public void Token_RemainsAccessibleAfterDispose() // Token was captured up front; reading state after dispose must not throw. Assert.False(token.IsCancellationRequested); } + + [Fact] + public void IsEnabled_DefaultsToFalse() + { + using var service = new GracefulShutdownService(); + + Assert.False(service.IsEnabled); + } + + [Fact] + public void IsEnabled_TrueAfterPositiveConfigure() + { + using var service = new GracefulShutdownService(); + + service.Configure(TimeSpan.FromSeconds(5)); + + Assert.True(service.IsEnabled); + } + + [Fact] + public void IsEnabled_FalseAfterZeroConfigure() + { + using var service = new GracefulShutdownService(); + + service.Configure(TimeSpan.Zero); + + Assert.False(service.IsEnabled); + } + + [Fact] + public void Configure_NegativeBudget_Throws() + { + using var service = new GracefulShutdownService(); + + Assert.Throws(() => service.Configure(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void BeginGracefulWindow_ZeroBudget_ExpiresImmediately() + { + using var service = new GracefulShutdownService(); + + // No Configure call → zero budget → window is "over" the moment it begins. + service.BeginGracefulWindow(); + + Assert.True(service.Token.IsCancellationRequested); + } + + [Fact] + public async Task BeginGracefulWindow_PositiveBudget_FiresTokenAfterBudget() + { + // BeginGracefulWindow arms CancelAfter, which is a no-op while a debugger is attached + // (developers need unlimited stepping time). Skip the timing assertion in that case. + if (Debugger.IsAttached) + { + return; + } + + using var service = new GracefulShutdownService(); + service.Configure(TimeSpan.FromMilliseconds(50)); + + service.BeginGracefulWindow(); + Assert.False(service.Token.IsCancellationRequested); + + await service.Token.WaitUntilCancelledAsync().DefaultTimeout(); + + Assert.True(service.Token.IsCancellationRequested); + } + + [Fact] + public async Task BeginGracefulWindow_SecondCall_DoesNotResetTimer() + { + if (Debugger.IsAttached) + { + return; + } + + using var service = new GracefulShutdownService(); + service.Configure(TimeSpan.FromMilliseconds(50)); + + service.BeginGracefulWindow(); + // A second call must be idempotent and must not re-arm (which would extend the window). + service.BeginGracefulWindow(); + + await service.Token.WaitUntilCancelledAsync().DefaultTimeout(); + + Assert.True(service.Token.IsCancellationRequested); + } } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index dad9353a360..82708202d3e 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -232,6 +232,9 @@ public async Task StartAsync_StopRequested_WithGracefulServices_InvokesGracefulS using var stopCts = new CancellationTokenSource(); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled so the session routes through the ladder. + shutdownService.Configure(TimeSpan.FromSeconds(30)); + var signaler = new RecordingGracefulSignaler(onSignal: pid => { try @@ -282,6 +285,10 @@ public async Task StartAsync_StopRequested_GracefulIgnored_ExpireEscalatesToTree using var stopCts = new CancellationTokenSource(); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled so the session routes through the ladder. + // Escalation is driven by the explicit Expire() below, not by the budget elapsing. + shutdownService.Configure(TimeSpan.FromSeconds(30)); + var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var signaler = new RecordingGracefulSignaler(onSignal: _ => { @@ -328,6 +335,9 @@ public async Task StartAsync_StopRequested_GracefulSignalerThrows_StillEscalates using var stopCts = new CancellationTokenSource(); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled so the session routes through the ladder. + shutdownService.Configure(TimeSpan.FromSeconds(30)); + var signaler = new RecordingGracefulSignaler(onSignal: _ => throw new InvalidOperationException("simulated DCP failure")); @@ -354,13 +364,14 @@ public async Task StartAsync_StopRequested_GracefulSignalerThrows_StillEscalates } [Fact] - public async Task DisposeAsync_WithGracefulServicesButNoExternalStop_ForceKillsWithoutSignaling() + public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithoutSignaling() { - // Dispose-only teardown must NOT take the graceful path: the central GracefulShutdownService - // is timed by ConsoleCancellationManager, and a dispose without an external stop means CCM - // never started that timer. Running the graceful ladder would hang on WaitForExitAsync - // until the central token fires (which it never will). The session detects this by reading - // the external token's IsCancellationRequested state inside OnStopRequested. + // A graceful service that was never Configure()'d models a non-run command (IsEnabled is + // false). The coordinator's graceful-vs-force decision is all-or-nothing per command and keys + // off IsEnabled, so dispose-only teardown here must take the force-kill path and never invoke + // the signaler. (On the run path the service IS configured, so completion routes through the + // bounded ladder instead — that bound comes from GracefulShutdownService self-arming its timer, + // not from a per-session external-stop flag.) var project = new LongRunningAppHostServerProject(); using var stopCts = new CancellationTokenSource(); using var shutdownService = new GracefulShutdownService(); diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs index dafadf9d04a..8886d9ed641 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -60,6 +60,8 @@ public async Task LaunchAsync_WithGracefulServices_GracefulSucceeds_NoTreeKillEs using var cts = new CancellationTokenSource(); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. + shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaler = new RecordingGracefulSignaler(onSignal: pid => { try @@ -110,6 +112,8 @@ public async Task LaunchAsync_WithGracefulServices_BlockingSignalerDoesNotConsum using var cts = new CancellationTokenSource(); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. + shutdownService.Configure(TimeSpan.FromSeconds(30)); var signalerNeverCompletes = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var signaler = new RecordingGracefulSignaler(onSignal: pid => { @@ -169,6 +173,10 @@ public async Task LaunchAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEs using var cts = new CancellationTokenSource(); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. + // Escalation is driven by the explicit Expire() below, not by the budget elapsing. + shutdownService.Configure(TimeSpan.FromSeconds(30)); + var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var signaler = new RecordingGracefulSignaler(onSignal: _ => { @@ -232,6 +240,9 @@ public async Task LaunchAsync_WithGracefulServices_SignalerThrows_StillEscalates using var cts = new CancellationTokenSource(); using var shutdownService = new GracefulShutdownService(); + // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. + shutdownService.Configure(TimeSpan.FromSeconds(30)); + var signaler = new RecordingGracefulSignaler(onSignal: _ => throw new InvalidOperationException("simulated DCP failure")); From cd75220167f4dfade757d89cdede0529327cf643 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 18 Jun 2026 14:58:45 -0700 Subject: [PATCH 20/56] Merge GracefulShutdownService into ConsoleCancellationManager; make IProcessExecution async-disposable Fold the GracefulShutdownService token/budget into ConsoleCancellationManager behind an IGracefulShutdownWindow seam so a single service owns the graceful shutdown clock and policy. Switch IProcessExecution to IAsyncDisposable and drain trailing output on the cancel path so the unified WaitForExitAsync captures the process tail before rethrowing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/DashboardRunCommand.cs | 2 +- src/Aspire.Cli/ConsoleCancellationManager.cs | 208 +++++++++++++---- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 6 +- src/Aspire.Cli/DotNet/IProcessExecution.cs | 7 +- src/Aspire.Cli/DotNet/ProcessExecution.cs | 22 +- src/Aspire.Cli/GracefulShutdownService.cs | 144 ------------ src/Aspire.Cli/Layout/LayoutProcessRunner.cs | 7 +- .../ProcessGracefulShutdownLadder.cs | 4 +- .../Processes/ProcessShutdownCoordinator.cs | 16 +- .../Profiling/ProfileCaptureService.cs | 2 +- src/Aspire.Cli/Program.cs | 33 ++- .../Projects/AppHostServerSession.cs | 10 +- .../Projects/DotNetAppHostProject.cs | 4 +- .../Projects/GuestAppHostProject.cs | 4 +- .../Projects/IGuestProcessLauncher.cs | 6 +- .../DcpConnectionChecker.cs | 4 +- tests/Aspire.Cli.Tests/CliBootstrapTests.cs | 2 +- .../ConsoleCancellationManagerTests.cs | 213 ++++++++++++++---- .../DotNet/DotNetCliRunnerTests.cs | 4 +- .../DotNet/ProcessExecutionTests.cs | 25 +- .../GracefulShutdownServiceTests.cs | 150 ------------ .../Projects/AppHostServerSessionTests.cs | 24 +- .../Projects/GuestAppHostProjectTests.cs | 14 +- .../Projects/ProcessGuestLauncherTests.cs | 16 +- .../Telemetry/TelemetryConfigurationTests.cs | 2 +- .../TestGracefulShutdownWindow.cs | 44 ++++ .../TestProcessExecutionFactory.cs | 3 +- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 10 +- 28 files changed, 490 insertions(+), 496 deletions(-) delete mode 100644 src/Aspire.Cli/GracefulShutdownService.cs delete mode 100644 tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestGracefulShutdownWindow.cs diff --git a/src/Aspire.Cli/Commands/DashboardRunCommand.cs b/src/Aspire.Cli/Commands/DashboardRunCommand.cs index 2e8b791f5d2..8508391775f 100644 --- a/src/Aspire.Cli/Commands/DashboardRunCommand.cs +++ b/src/Aspire.Cli/Commands/DashboardRunCommand.cs @@ -380,7 +380,7 @@ private async Task ExecuteForegroundAsync(string managedPath, Lis return CommandResult.Failure(CliExitCodes.DashboardFailure); } - using var _ = process; + await using var _ = process; // Wait for the dashboard to become ready, the process to exit, or a timeout. var processExitTask = process.WaitForExitAsync(cancellationToken); diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index 6be392302a5..33028e497d1 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -1,6 +1,7 @@ // 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.Runtime.InteropServices; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -8,41 +9,53 @@ namespace Aspire.Cli; /// -/// Manages Ctrl+C, SIGINT, and SIGTERM signal handling with a shared . -/// On the first termination signal it requests cooperative cancellation; after an optional graceful -/// window elapses it expires so long-running ladders escalate to -/// forceful termination; after a final drain budget it signals -/// so Program.Main abandons the handler task and -/// returns the captured exit code. +/// The CLI's single shutdown service. Manages Ctrl+C, SIGINT, and SIGTERM signal handling with a +/// shared , and owns the command-level graceful-shutdown policy: +/// the graceful budget (), the clock that bounds it, and the +/// the per-child shutdown ladders consume via +/// . /// /// /// +/// On the first termination signal it requests cooperative cancellation; after the graceful window +/// elapses it expires so long-running ladders escalate to forceful +/// termination; after a final drain budget it signals +/// so Program.Main abandons the handler task and returns the captured exit code. +/// +/// /// The three-stage signal counter mirrors the same ladder: /// /// /// First signal — primary cancels and the graceful watcher starts. -/// Second signal — graceful window is collapsed via ; -/// ladders see the graceful token fire immediately and escalate. +/// Second signal — the graceful window is collapsed via ; ladders see +/// fire immediately and escalate. /// Third (or later) signal — fires; Main exits NOW. /// /// -/// Internal teardown paths (guest failures, normal completion) do NOT drive this counter. They rely on -/// disposable-driven cleanup — await using of the server session and guest launcher — to run each +/// Graceful shutdown is all-or-nothing per command: reflects whether a positive +/// budget was configured via . aspire run configures a budget; +/// every other command leaves it at zero so its children force-kill immediately (preserving today's +/// behavior). The service self-bounds the window: arms a +/// CancelAfter(budget) so the token is guaranteed to fire once shutdown begins — regardless of +/// whether shutdown was initiated by a user signal or by disposal of a child owner. This is what lets +/// ladders consume the token without risking a hang. +/// +/// +/// Internal teardown paths (guest failures, normal completion) do NOT drive the signal counter. They rely +/// on disposable-driven cleanup — await using of the server session and guest launcher — to run each /// child process's own per-process shutdown ladder when the run scope unwinds. /// /// -/// The completion source completing is treated as a strict superset of graceful expiration: -/// when the source completes for any reason (drain timeout, third signal, future external triggers), -/// is invoked synchronously so ladders observing only -/// the graceful token unblock in time to issue a kill before Main abandons them. +/// The completion source completing is treated as a strict superset of graceful expiration: when the source +/// completes for any reason (drain timeout, third signal, future external triggers), is +/// invoked synchronously so ladders observing only the graceful token unblock in time to issue a kill before +/// Main abandons them. /// /// -/// Disposing this instance unregisters all signal handlers and disposes the internal token source. -/// The is owned by the caller (typically Program.Main) -/// and is not disposed here. +/// Disposing this instance unregisters all signal handlers and disposes the internal token sources. /// /// -internal sealed class ConsoleCancellationManager : IDisposable +internal sealed class ConsoleCancellationManager : IDisposable, IGracefulShutdownWindow { // Standard Unix exit codes: 128 + signal number (SIGINT=2, SIGTERM=15). // SigIntExitCode (130): used when the user presses Ctrl+C (SIGINT) or Ctrl+Break/SIGQUIT. @@ -51,12 +64,18 @@ internal sealed class ConsoleCancellationManager : IDisposable private const int SigTermExitCode = 143; private readonly CancellationTokenSource _cts = new(); - private readonly GracefulShutdownService _gracefulService; + private readonly CancellationTokenSource _gracefulCts = new(); private readonly TimeSpan _finalDrainBudget; private readonly PosixSignalRegistration? _sigIntRegistration; private readonly PosixSignalRegistration? _sigTermRegistration; private readonly PosixSignalRegistration? _sigQuitRegistration; private readonly CancellationToken _token; + private readonly CancellationToken _gracefulToken; + // Graceful-shutdown budget for the running command. Zero (the default) means graceful shutdown is + // disabled, so per-child ladders escalate to forceful termination immediately. + private TimeSpan _gracefulBudget = TimeSpan.Zero; + // Idempotency guard so the graceful clock (CancelAfter) is armed at most once. + private int _gracefulWindowStarted; private ILogger _logger; private Task? _startedHandler; // Number of termination signals (Ctrl+C, SIGINT, SIGTERM, SIGQUIT, ProcessExit) received. @@ -87,26 +106,24 @@ internal sealed class ConsoleCancellationManager : IDisposable /// internal void SetLogger(ILogger logger) => Volatile.Write(ref _logger, logger); - public ConsoleCancellationManager(GracefulShutdownService gracefulService, TimeSpan finalDrainBudget) + public ConsoleCancellationManager(TimeSpan finalDrainBudget) { - ArgumentNullException.ThrowIfNull(gracefulService); - - _gracefulService = gracefulService; _finalDrainBudget = finalDrainBudget; _logger = NullLogger.Instance; - // Set to a field so getting the token doesn't error after dispose. + // Capture tokens to fields so getting them doesn't error after dispose. _token = _cts.Token; + _gracefulToken = _gracefulCts.Token; // Phase 3 → Phase 2 fallthrough. When the termination completion source completes for any reason // (drain timeout, third Ctrl+C, future external triggers), any ladder still observing only the - // graceful token would otherwise sit on a Task.Delay(budget, gracefulService.Token) and miss its + // graceful token would otherwise sit on a Task.Delay(budget, GracefulShutdownToken) and miss its // last chance to issue a kill before Main abandons it. Cancel synchronously so this fires before // continuations of the completion source observe completion. Expire() is idempotent — multiple // calls across the watcher (Phase 1 end), the 2nd-signal branch, and this continuation are safe. _processTerminationCompletionSource.Task.ContinueWith( - static (_, state) => ((GracefulShutdownService)state!).Expire(), - _gracefulService, + static (_, state) => ((ConsoleCancellationManager)state!).Expire(), + this, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); @@ -145,24 +162,91 @@ public ConsoleCancellationManager(GracefulShutdownService gracefulService, TimeS /// /// Token that fires when the graceful-shutdown window has been exhausted (graceful budget elapsed, - /// second termination signal, or process-termination completion). Convenience accessor — callers - /// that already have a reference to can read its - /// directly. + /// second termination signal, or process-termination completion). Consumed by the per-child shutdown + /// ladders through . + /// + public CancellationToken GracefulShutdownToken => _gracefulToken; + + /// + /// Whether graceful shutdown is enabled for the running command — i.e. a positive budget was + /// configured via . When , shutdown ladders + /// escalate straight to forceful termination. /// - public CancellationToken GracefulShutdownToken => _gracefulService.Token; + public bool IsEnabled => _gracefulBudget > TimeSpan.Zero; public bool IsCancellationRequested => _cts.IsCancellationRequested; /// - /// Sets the graceful-shutdown budget for the currently-executing command by forwarding it to - /// . Default is zero, meaning ladders that consume - /// fall through to escalation immediately (preserving today's - /// behavior for every command that doesn't opt in). The aspire run handler calls this with - /// five seconds so DCP and the AppHost get a real cooperative-shutdown window before escalation. + /// Sets the graceful-shutdown budget for the currently-executing command. Default is zero, meaning + /// ladders that consume fall through to escalation immediately + /// (preserving today's behavior for every command that doesn't opt in). The aspire run handler + /// calls this so DCP and the AppHost get a real cooperative-shutdown window before escalation. /// public void ConfigureForCommand(TimeSpan gracefulBudget) { - _gracefulService.Configure(gracefulBudget); + if (gracefulBudget < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(gracefulBudget), "Graceful budget cannot be negative."); + } + + _gracefulBudget = gracefulBudget; + } + + /// + /// Starts the graceful-shutdown clock. Idempotent — the first caller arms a CancelAfter(budget) + /// so is guaranteed to fire within the budget; subsequent calls are + /// no-ops. Called by whoever initiates teardown (a user signal via , or a child + /// owner's disposal-driven ladder) so the token is always bounded. + /// + public void BeginGracefulWindow() + { + // When a debugger is attached, never arm the clock — the developer needs unlimited time to step + // through cancellation/cleanup logic. The token therefore never auto-fires; ladders that observe it + // sit indefinitely (the right behavior for stepping). A manual second Ctrl+C still escalates because + // it calls Expire() directly, bypassing this method. + if (Debugger.IsAttached) + { + return; + } + + // A non-positive budget means graceful shutdown isn't configured for this command; the window is + // "over" the moment it begins, so escalate immediately. + if (_gracefulBudget <= TimeSpan.Zero) + { + Expire(); + return; + } + + if (Interlocked.Exchange(ref _gracefulWindowStarted, 1) != 0) + { + return; + } + + try + { + _gracefulCts.CancelAfter(_gracefulBudget); + } + catch (ObjectDisposedException) + { + // Racing process shutdown after dispose; the token's final state is already observable. + } + } + + /// + /// Collapses the graceful-shutdown window immediately, regardless of the remaining budget. Safe to call + /// multiple times from any thread; transitions to cancelled at most once. + /// + public void Expire() + { + try + { + _gracefulCts.Cancel(); + } + catch (ObjectDisposedException) + { + // Expire can race with process shutdown after dispose; swallow rather than propagating so + // callers (signal handlers, watcher continuations) never have to guard against it. + } } private void OnPosixSignal(PosixSignalContext context) @@ -214,7 +298,7 @@ internal void Cancel(int exitCode) // unblock and escalate to forceful termination; the watcher's Task.Delay(graceful) gets // cancelled and moves on to Phase 2 (final drain). _logger.LogWarning("Second termination signal received, expiring graceful shutdown window."); - _gracefulService.Expire(); + Expire(); } else { @@ -228,20 +312,20 @@ private async Task ExpireGracefulThenFinalDrainAsync(int forcedTerminationExitCo { try { - // Phase 1: graceful window. Start the central clock on the service, then wait for the - // graceful token to fire. BeginGracefulWindow arms a CancelAfter(budget) (or, for a - // zero-budget command, expires immediately), so the token is guaranteed to fire without us - // owning a timer here. A 2nd Ctrl+C calls _gracefulService.Expire() from the signal counter, - // which fires the token early and drops us straight into Phase 2. + // Phase 1: graceful window. Start the central clock, then wait for the graceful token to + // fire. BeginGracefulWindow arms a CancelAfter(budget) (or, for a zero-budget command, + // expires immediately), so the token is guaranteed to fire without us owning a timer here. + // A 2nd Ctrl+C calls Expire() from the signal counter, which fires the token early and drops + // us straight into Phase 2. // // Under a debugger BeginGracefulWindow is a no-op (the developer needs unlimited time to // step), so the token never auto-fires and this await sits indefinitely — the right behavior // for stepping. A manual second Ctrl+C still escalates via Expire(). - _gracefulService.BeginGracefulWindow(); + BeginGracefulWindow(); try { - await Task.Delay(Timeout.InfiniteTimeSpan, _gracefulService.Token).ConfigureAwait(false); + await Task.Delay(Timeout.InfiniteTimeSpan, _gracefulToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -284,6 +368,40 @@ public void Dispose() AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; _cts.Dispose(); + _gracefulCts.Dispose(); } } +/// +/// The command-level graceful-shutdown window consumed by every per-child shutdown path +/// ( and the ladders it drives). Implemented by +/// , which owns the budget, the clock, and the token as part +/// of the single CLI shutdown service. This narrow contract is what the process-spawn sites depend on +/// so they don't take a dependency on the console signal manager in full. It lives in this file rather +/// than its own because it exists only to subdivide when +/// referenced by the spawn sites. +/// +internal interface IGracefulShutdownWindow +{ + /// + /// Whether graceful shutdown is enabled for the running command — i.e. a positive budget was + /// configured. When , shutdown ladders escalate straight to forceful + /// termination. + /// + bool IsEnabled { get; } + + /// + /// Fires when the graceful-shutdown window has been exhausted (graceful budget elapsed, a second + /// termination signal, or process-termination completion). + /// + CancellationToken GracefulShutdownToken { get; } + + /// + /// Starts the graceful-shutdown clock. Idempotent — the first caller arms the budget so + /// is guaranteed to fire within it; later calls are no-ops. + /// Called by whoever initiates teardown (a user signal, or a child owner's disposal-driven ladder) + /// so the token is always bounded. + /// + void BeginGracefulWindow(); +} + diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index dfddc4b2472..434e608bdcd 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -89,12 +89,12 @@ internal sealed class ProcessInvocationOptions public Processes.IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler { get; set; } /// - /// The central graceful-shutdown timing service whose - /// bounds the shared ladder. When + /// The central graceful-shutdown window whose + /// bounds the shared ladder. When /// null, the cancellation path uses today's /// force-kill behavior. /// - public GracefulShutdownService? ShutdownService { get; set; } + public IGracefulShutdownWindow? ShutdownService { get; set; } } internal sealed class DotNetCliRunner( diff --git a/src/Aspire.Cli/DotNet/IProcessExecution.cs b/src/Aspire.Cli/DotNet/IProcessExecution.cs index d8ff1cce5cf..5c34b53d56c 100644 --- a/src/Aspire.Cli/DotNet/IProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/IProcessExecution.cs @@ -4,9 +4,12 @@ namespace Aspire.Cli.DotNet; /// -/// Represents a configured process execution that can be started and awaited. +/// Represents a configured process execution that can be started and awaited. Disposal is +/// asynchronous because the underlying primitive drains its stdout/stderr pumps and releases the +/// anonymous pipes + console handles it owns (the isolated-console path on Windows), which cannot +/// be done from a synchronous without blocking. /// -internal interface IProcessExecution : IDisposable +internal interface IProcessExecution : IAsyncDisposable { /// /// Gets the file name of the executable to run. diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 9862d4a93bb..3d58ce26b21 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -103,6 +103,14 @@ await ProcessShutdownCoordinator.ShutdownAsync( _logger, _fileName).ConfigureAwait(false); + // The child has now been signalled/killed by the coordinator. Drain trailing stdout/stderr + // before propagating the cancellation so callers that observe output — or that swallow the + // OCE and read ExitCode (e.g. the guest launcher distinguishing user-cancel from internal + // teardown) — still get the full tail. Use a detached token + reset idle window so the drain + // gets its whole budget even though the caller's token is already cancelled. + RecordActivity(); + await DrainOutputAsync(process, CancellationToken.None).ConfigureAwait(false); + throw; } @@ -122,18 +130,18 @@ await ProcessShutdownCoordinator.ShutdownAsync( public void Kill(bool entireProcessTree) => Process.Kill(entireProcessTree); /// - public void Dispose() + public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } - // IProcessExecution is a sync IDisposable but IsolatedProcess only exposes DisposeAsync - // (it drains the pumps then tears the pipes/handles down). DotNetCliRunner does not dispose - // the execution (StartBackchannelAsync runs fire-and-forget and reads HasExited/ExitCode - // after the await — see DotNetCliRunner.cs), so this sync-blocking path is reached only by - // explicit `using` consumers and tests. + // IsolatedProcess exposes only DisposeAsync — it drains the pumps then tears the + // pipes/handles down. DotNetCliRunner does not dispose the execution (StartBackchannelAsync + // runs fire-and-forget and reads HasExited/ExitCode after the await — see DotNetCliRunner.cs), + // so this path is reached only by explicit `await using` consumers (the session, guest + // launcher) and tests. var process = _process; if (process is null) { @@ -142,7 +150,7 @@ public void Dispose() try { - process.DisposeAsync().AsTask().GetAwaiter().GetResult(); + await process.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/Aspire.Cli/GracefulShutdownService.cs b/src/Aspire.Cli/GracefulShutdownService.cs deleted file mode 100644 index 12d3cfb058a..00000000000 --- a/src/Aspire.Cli/GracefulShutdownService.cs +++ /dev/null @@ -1,144 +0,0 @@ -// 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; - -namespace Aspire.Cli; - -/// -/// The single authority for the CLI's command-level graceful-shutdown policy. Owns the -/// graceful budget (the time limit configured for the running command) and the -/// that fires when that window expires. Consumed by every -/// long-running shutdown ladder (e.g. AppHostServerSession, ProcessGuestLauncher, -/// ProcessExecution) so the decision to wait for a cooperative shutdown vs. -/// escalate to forceful termination is made once, for the whole command, rather than per child or -/// per process execution. -/// -/// -/// -/// Graceful shutdown is all-or-nothing per command: reflects whether a -/// positive time limit was configured via . aspire run configures a -/// budget; every other command leaves it at zero so its children force-kill immediately -/// (preserving today's behavior). -/// -/// -/// The service also owns the timer. idempotently arms a -/// CancelAfter(budget) so the token is guaranteed to fire within the budget once shutdown -/// begins — regardless of whether shutdown was initiated by a user signal () -/// or by disposal of a child owner. This is what lets ladders consume the token without risking a -/// hang: there is no path where a ladder waits on a clock that nobody started. -/// -/// -/// Multiple / calls are safe and idempotent; -/// the token transitions from un-cancelled to cancelled exactly once. -/// -/// -/// Registered as a DI singleton via services.AddSingleton(instance) so the container does not -/// take disposal ownership; the bootstrap path (Program.Main) owns the instance lifetime -/// alongside CCM. -/// -/// -internal sealed class GracefulShutdownService : IDisposable -{ - private readonly CancellationTokenSource _cts = new(); - private readonly CancellationToken _token; - private TimeSpan _budget = TimeSpan.Zero; - private int _windowStarted; - - public GracefulShutdownService() - { - // Capture the token up front so callers can still observe its (final) state - // after Dispose, matching the pattern used by ConsoleCancellationManager. - _token = _cts.Token; - } - - /// - /// Fires when the CLI's graceful-shutdown budget has been exhausted (or when an - /// external signal has determined that further waiting is no longer useful). - /// - public CancellationToken Token => _token; - - /// - /// Whether graceful shutdown is enabled for the running command — i.e. a positive budget was - /// configured via . When , shutdown ladders - /// escalate straight to forceful termination. - /// - public bool IsEnabled => _budget > TimeSpan.Zero; - - /// - /// Sets the graceful-shutdown budget for the running command. Default is zero - /// ( is ). The aspire run handler configures - /// a positive budget so DCP and the AppHost get a real cooperative-shutdown window before - /// escalation. - /// - public void Configure(TimeSpan budget) - { - if (budget < TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(budget), "Graceful budget cannot be negative."); - } - - _budget = budget; - } - - /// - /// Starts the graceful-shutdown clock. Idempotent — the first caller arms a - /// CancelAfter(budget) so is guaranteed to fire within the budget; - /// subsequent calls are no-ops. Called by whoever initiates teardown (a user signal via CCM, or a - /// child owner's disposal-driven ladder) so the token is always bounded. - /// - public void BeginGracefulWindow() - { - // When a debugger is attached, never arm the clock — the developer needs unlimited time to - // step through cancellation/cleanup logic. The token therefore never auto-fires; ladders that - // observe it sit indefinitely (the right behavior for stepping). A manual second Ctrl+C still - // escalates because it calls Expire() directly, bypassing this method. - if (Debugger.IsAttached) - { - return; - } - - // A non-positive budget means graceful shutdown isn't configured for this command; the window - // is "over" the moment it begins, so escalate immediately. - if (_budget <= TimeSpan.Zero) - { - Expire(); - return; - } - - if (Interlocked.Exchange(ref _windowStarted, 1) != 0) - { - return; - } - - try - { - _cts.CancelAfter(_budget); - } - catch (ObjectDisposedException) - { - // Racing process shutdown after dispose; the token's final state is already observable. - } - } - - /// - /// Signals that the graceful-shutdown window is over immediately, regardless of the remaining - /// budget. Safe to call multiple times from any thread; the underlying token transitions to - /// cancelled at most once. - /// - public void Expire() - { - try - { - _cts.Cancel(); - } - catch (ObjectDisposedException) - { - // Expire can race with process shutdown after dispose; swallow rather - // than propagating so callers (signal handlers, watcher continuations) - // never have to guard against it. - } - } - - public void Dispose() => _cts.Dispose(); -} diff --git a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs index c3ba3d4fcd3..8039e9d3835 100644 --- a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs +++ b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs @@ -32,7 +32,7 @@ internal sealed class LayoutProcessRunner(IProcessExecutionFactory executionFact var args = arguments.ToArray(); var workDir = new DirectoryInfo(workingDirectory ?? Directory.GetCurrentDirectory()); - using var execution = executionFactory.CreateExecution(toolPath, args, environmentVariables, workDir, options); + await using var execution = executionFactory.CreateExecution(toolPath, args, environmentVariables, workDir, options); if (!execution.Start()) { @@ -59,7 +59,10 @@ public IProcessExecution Start( if (!execution.Start()) { - execution.Dispose(); + // Start() returning false means nothing was spawned, so disposal is a synchronous + // no-op (ProcessExecution has no child to tear down). Drive it to completion here so + // the sync Start contract is preserved without a sync-over-async hazard on the real path. + execution.DisposeAsync().AsTask().GetAwaiter().GetResult(); throw new InvalidOperationException($"Failed to start process: {toolPath}"); } diff --git a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs index 510acdf64d7..3cd506e9eb1 100644 --- a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs +++ b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs @@ -10,7 +10,7 @@ namespace Aspire.Cli.Processes; /// Shared "graceful signal → bounded wait → force tree-kill → bounded drain" ladder used by /// every long-running child process the CLI owns during aspire run /// (AppHost server, guest, direct-launch AppHost executable). Each call site provides a -/// graceful signaler and the central ; this helper +/// graceful signaler and the central ; this helper /// runs the same four-phase escalation against them so the user-visible shutdown shape is /// uniform across spawn sites. /// @@ -26,7 +26,7 @@ internal static class ProcessGracefulShutdownLadder /// /// The child process to shut down. /// Issues the graceful signal (DCP stop-process-tree on Windows, SIGTERM on Unix). - /// The central . + /// The central . /// Logger for diagnostics. /// Short human description used in log messages (e.g. "AppHost server"). public static async Task ExecuteAsync( diff --git a/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs b/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs index 37ec47e1191..230ea7a557b 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs +++ b/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs @@ -19,9 +19,9 @@ namespace Aspire.Cli.Processes; /// semantics — can only be defined once. /// /// The graceful-vs-force decision is command-level and all-or-nothing: it keys off -/// (true when the running command configured a +/// (true when the running command configured a /// positive budget). No per-child or per-call flag. When the ladder is selected this also starts -/// the central clock via , so the ladder's +/// the central clock via , so the ladder's /// wait is always bounded regardless of whether teardown was initiated by a user signal or by /// disposal of the child owner. /// @@ -40,8 +40,8 @@ internal static class ProcessShutdownCoordinator /// /// The child process to shut down. /// Graceful signaler, or for non-Run callers. - /// - /// Owns the central graceful budget + token, or for non-Run callers. + /// + /// The command-level graceful window (budget + token), or for non-Run callers. /// /// /// Whether the force-kill fallback should dispatch a best-effort graceful signal (SIGTERM) before @@ -53,7 +53,7 @@ internal static class ProcessShutdownCoordinator public static Task ShutdownAsync( Process process, IProcessTreeGracefulShutdownSignaler? signaler, - GracefulShutdownService? gracefulShutdownService, + IGracefulShutdownWindow? gracefulShutdownWindow, bool fallbackRequestGracefulShutdown, bool fallbackKillEntireProcessTree, ILogger logger, @@ -63,17 +63,17 @@ public static Task ShutdownAsync( ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(processDescription); - if (signaler is not null && gracefulShutdownService is { IsEnabled: true }) + if (signaler is not null && gracefulShutdownWindow is { IsEnabled: true }) { // Start the central clock so the ladder's wait is bounded even when teardown was triggered // by disposal (e.g. normal aspire run completion) rather than a user signal. Idempotent — // if a user Ctrl+C already armed the window this is a no-op. - gracefulShutdownService.BeginGracefulWindow(); + gracefulShutdownWindow.BeginGracefulWindow(); return ProcessGracefulShutdownLadder.ExecuteAsync( process, signaler, - gracefulShutdownService.Token, + gracefulShutdownWindow.GracefulShutdownToken, logger, processDescription); } diff --git a/src/Aspire.Cli/Profiling/ProfileCaptureService.cs b/src/Aspire.Cli/Profiling/ProfileCaptureService.cs index c07505a214c..8687dae73ae 100644 --- a/src/Aspire.Cli/Profiling/ProfileCaptureService.cs +++ b/src/Aspire.Cli/Profiling/ProfileCaptureService.cs @@ -377,7 +377,7 @@ public async ValueTask DisposeAsync() } finally { - _dashboardProcess.Dispose(); + await _dashboardProcess.DisposeAsync().ConfigureAwait(false); _layoutLease?.Dispose(); } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 8d37fa0d69b..c3c1ac314cc 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -279,7 +279,7 @@ internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProv return (factory, fileLoggerProvider); } - internal static async Task BuildApplicationAsync(string[] args, CliStartupContext startupContext, Dictionary? configurationValues = null, ConsoleCancellationManager? cancellationManager = null, GracefulShutdownService? gracefulShutdownService = null) + internal static async Task BuildApplicationAsync(string[] args, CliStartupContext startupContext, Dictionary? configurationValues = null, ConsoleCancellationManager? cancellationManager = null) { // Check for --non-interactive flag early var nonInteractive = args?.Any(a => a == CommonOptionNames.NonInteractive) ?? false; @@ -336,19 +336,20 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu // Register logging options so components can read the user's chosen log level builder.Services.AddSingleton(startupContext.LoggingOptions); - // Register CCM and the graceful shutdown service as instance singletons so the DI container - // does not take disposal ownership — Program.Main owns the lifetime via `using` statements. - // Tests that drive BuildApplicationAsync directly without passing them in still work because - // the registrations are gated on non-null arguments. + // Register CCM as an instance singleton so the DI container does not take disposal ownership — + // Program.Main owns the lifetime via a `using` statement. Tests that drive BuildApplicationAsync + // directly without passing it in still work because startupContext.CancellationManager is + // registered unconditionally above. if (cancellationManager is not null) { builder.Services.AddSingleton(cancellationManager); } - if (gracefulShutdownService is not null) - { - builder.Services.AddSingleton(gracefulShutdownService); - } + // The graceful-shutdown window is the same object as the CCM (CCM owns the OS-signal + // registration AND the graceful budget/clock/token). Map the consumer-facing interface to + // whichever CCM singleton was registered so per-child shutdown ladders depend on the narrow + // contract rather than the whole signal manager. + builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Configure OpenTelemetry tracing. TelemetryManager reads configuration and creates // separate TracerProvider instances: @@ -946,13 +947,11 @@ public static async Task Main(string[] args) // Setup handling of CTRL-C and SIGTERM as early as possible so that if // we get a signal anywhere that is not handled by Spectre Console - // already that we know to trigger cancellation. The graceful service is - // constructed first and handed to CCM so the two share the same lifetime - // and CCM can drive timing from the signal handler. Both are registered - // as DI singletons below via AddSingleton(instance) so the container does - // not take disposal ownership. - using var gracefulShutdownService = new GracefulShutdownService(); - using var cancellationManager = new ConsoleCancellationManager(gracefulShutdownService, finalDrainBudget: TimeSpan.FromSeconds(5)); + // already that we know to trigger cancellation. The cancellation manager + // owns both the OS-signal registration and the graceful-shutdown budget, + // clock, and token. It is registered as a DI singleton below via + // AddSingleton(instance) so the container does not take disposal ownership. + using var cancellationManager = new ConsoleCancellationManager(finalDrainBudget: TimeSpan.FromSeconds(5)); Console.OutputEncoding = Encoding.UTF8; @@ -993,7 +992,7 @@ public static async Task Main(string[] args) IHost? app = null; try { - app = await BuildApplicationAsync(args, startupContext, cancellationManager: cancellationManager, gracefulShutdownService: gracefulShutdownService); + app = await BuildApplicationAsync(args, startupContext, cancellationManager: cancellationManager); await app.StartAsync().ConfigureAwait(false); } catch (Exception ex) diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 5d230ee132d..f7e7aa2544b 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -35,7 +35,7 @@ internal sealed class AppHostServerSession : IAppHostServerSession private readonly string _authenticationToken; private readonly CancellationTokenSource _stopCts; private readonly IProcessTreeGracefulShutdownSignaler? _gracefulShutdownSignaler; - private readonly GracefulShutdownService? _shutdownService; + private readonly IGracefulShutdownWindow? _shutdownService; private readonly bool _isolateConsole; private readonly object _startGate = new(); @@ -68,7 +68,7 @@ public AppHostServerSession( CancellationToken stopRequested, ProfilingTelemetry? profilingTelemetry = null, IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler = null, - GracefulShutdownService? shutdownService = null, + IGracefulShutdownWindow? shutdownService = null, bool isolateConsole = false) { _project = project ?? throw new ArgumentNullException(nameof(project)); @@ -84,7 +84,7 @@ public AppHostServerSession( // Linked CTS so caller-initiated cancellation AND DisposeAsync both flow through the // same stop trigger. The registered callback on _stopCts.Token (wired in StartAsync) is // the single kill site for the process. The graceful-vs-force decision is made centrally by - // the coordinator off GracefulShutdownService.IsEnabled — there is no per-stop distinction + // the coordinator off IGracefulShutdownWindow.IsEnabled — there is no per-stop distinction // here, and the coordinator bounds the graceful wait by starting the central clock itself. _stopCts = CancellationTokenSource.CreateLinkedTokenSource(stopRequested); } @@ -509,7 +509,7 @@ private void OnStopRequested() // CT registration callbacks must be synchronous. We stash the started ShutdownAsync task // in _shutdownTask so DisposeAsync can await it instead of leaving it dangling. The kill // path inside ShutdownAsync is bounded (either an immediate force-kill on the no-graceful - // path, or by the central GracefulShutdownService.Token on the graceful path), so the + // path, or by the central graceful-shutdown token on the graceful path), so the // task cannot hang past the central budget. // // Intentionally no `_disposed` check: DisposeAsync sets `_disposed = true` before calling @@ -536,7 +536,7 @@ private void OnStopRequested() private Task ShutdownAsync(Process process) { // The graceful-vs-force decision is owned centrally by the coordinator, keyed off - // GracefulShutdownService.IsEnabled. When graceful is enabled for the command (aspire run), + // IGracefulShutdownWindow.IsEnabled. When graceful is enabled for the command (aspire run), // the coordinator starts the central clock and runs the bounded ladder regardless of whether // this stop came from a user signal or from DisposeAsync. When graceful is not wired/enabled // (non-Run callers: SDK gen, scaffolding, publish, dump), it force-kills. diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index e934b1df5e9..5b6679dac94 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -43,7 +43,7 @@ internal sealed class DotNetAppHostProject : IAppHostProject private readonly Program.CliLoggingOptions _loggingOptions; private readonly IAppHostInfoResolver _appHostInfoResolver; private readonly IConfigurationService _configurationService; - private readonly GracefulShutdownService _shutdownService; + private readonly IGracefulShutdownWindow _shutdownService; private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; private readonly CliExecutionContext _executionContext; @@ -75,7 +75,7 @@ public DotNetAppHostProject( Program.CliLoggingOptions loggingOptions, IAppHostInfoResolver appHostInfoResolver, IConfigurationService configurationService, - GracefulShutdownService shutdownService, + IGracefulShutdownWindow shutdownService, IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, CliExecutionContext executionContext, TimeProvider? timeProvider = null) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 33ab10775cc..24ff6d326af 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -49,7 +49,7 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly RunningInstanceManager _runningInstanceManager; private readonly ProfilingTelemetry _profilingTelemetry; private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; - private readonly GracefulShutdownService _shutdownService; + private readonly IGracefulShutdownWindow _shutdownService; private readonly AppHostServerCodegenSessionFactory _codegenSessionFactory; // Language is always resolved via constructor @@ -72,7 +72,7 @@ public GuestAppHostProject( FileLoggerProvider fileLoggerProvider, ProfilingTelemetry profilingTelemetry, IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, - GracefulShutdownService shutdownService, + IGracefulShutdownWindow shutdownService, TimeProvider? timeProvider = null, AppHostServerCodegenSessionFactory? codegenSessionFactory = null) { diff --git a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs index 0aa87130c7c..2ae91127531 100644 --- a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs +++ b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs @@ -28,15 +28,15 @@ namespace Aspire.Cli.Projects; /// cancellation path issues this signal before escalating to Process.Kill. /// /// -/// The central graceful-shutdown budget. Its +/// The central graceful-shutdown window. Its /// bounds both the graceful-signal call and the post-signal wait-for-exit, so a 2nd Ctrl+C -/// (which calls via the cancellation manager) +/// (which calls ) /// interrupts both immediately and the ladder escalates to Kill(entireProcessTree: true). /// internal sealed record GuestLaunchOptions( bool IsolateConsoleForGracefulShutdown = false, IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler = null, - GracefulShutdownService? ShutdownService = null); + IGracefulShutdownWindow? ShutdownService = null); /// /// Strategy for launching a guest language process. diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionChecker.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionChecker.cs index afe6ccd4768..f40afa7a5d0 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionChecker.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionChecker.cs @@ -291,7 +291,7 @@ public static async Task StartAsync( } finally { - process.Dispose(); + await process.DisposeAsync().ConfigureAwait(false); } } @@ -360,7 +360,7 @@ public async ValueTask DisposeAsync() } finally { - _process.Dispose(); + await _process.DisposeAsync().ConfigureAwait(false); try { diff --git a/tests/Aspire.Cli.Tests/CliBootstrapTests.cs b/tests/Aspire.Cli.Tests/CliBootstrapTests.cs index 53054f85c65..040ef4788c7 100644 --- a/tests/Aspire.Cli.Tests/CliBootstrapTests.cs +++ b/tests/Aspire.Cli.Tests/CliBootstrapTests.cs @@ -29,7 +29,7 @@ private static async Task BuildHostAsync() var logBufferContext = new ConsoleLogBufferContext(); var (loggerFactory, fileLoggerProvider) = Program.CreateLoggerFactory([], loggingOptions, errorWriter, logBufferContext); var identityChannelReader = new IdentityChannelReader(typeof(Program).Assembly); - var startupContext = new Program.CliStartupContext(loggingOptions, errorWriter, loggerFactory, fileLoggerProvider, logBufferContext, loggerFactory.CreateLogger(Program.RootLoggerName), new ConsoleCancellationManager(new GracefulShutdownService(), finalDrainBudget: Timeout.InfiniteTimeSpan), identityChannelReader); + var startupContext = new Program.CliStartupContext(loggingOptions, errorWriter, loggerFactory, fileLoggerProvider, logBufferContext, loggerFactory.CreateLogger(Program.RootLoggerName), new ConsoleCancellationManager(finalDrainBudget: Timeout.InfiniteTimeSpan), identityChannelReader); return await Program.BuildApplicationAsync([], startupContext); } diff --git a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs index ce9d5884fa2..f432803968d 100644 --- a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs +++ b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs @@ -1,23 +1,17 @@ // 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 Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests; public class ConsoleCancellationManagerTests { - [Fact] - public void Constructor_NullGracefulService_Throws() - { - Assert.Throws(() => new ConsoleCancellationManager(null!, TimeSpan.FromSeconds(5))); - } - [Fact] public void ConfigureForCommand_NegativeBudget_Throws() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); Assert.Throws(() => manager.ConfigureForCommand(TimeSpan.FromMilliseconds(-1))); } @@ -25,8 +19,7 @@ public void ConfigureForCommand_NegativeBudget_Throws() [Fact] public void FirstSignal_RequestsCancellation() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); Assert.False(manager.IsCancellationRequested); @@ -38,8 +31,7 @@ public void FirstSignal_RequestsCancellation() [Fact] public void FirstSignal_TokenIsCancelled() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); var token = manager.Token; Assert.False(token.IsCancellationRequested); @@ -52,8 +44,7 @@ public void FirstSignal_TokenIsCancelled() [Fact] public async Task FirstSignal_DefaultZeroBudget_ExpiresGracefulImmediately() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); // No ConfigureForCommand — graceful budget defaults to zero. // Set a handler that never completes so the drain budget governs forced termination. @@ -62,25 +53,31 @@ public async Task FirstSignal_DefaultZeroBudget_ExpiresGracefulImmediately() manager.Cancel(130); // The graceful token must fire essentially immediately (no Phase 1 delay to wait through). - await graceful.Token.WaitUntilCancelledAsync().DefaultTimeout(); + await manager.GracefulShutdownToken.WaitUntilCancelledAsync().DefaultTimeout(); Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); } [Fact] public async Task FirstSignal_NonZeroBudget_DelaysExpireUntilBudgetElapses() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + // The graceful clock is armed via CancelAfter, which is a no-op while a debugger is + // attached (developers need unlimited stepping time). + if (Debugger.IsAttached) + { + return; + } + + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); manager.ConfigureForCommand(TimeSpan.FromMilliseconds(200)); // Set a handler that never completes so we observe the graceful → drain timing. manager.SetStartedHandler(new TaskCompletionSource().Task); - var sw = System.Diagnostics.Stopwatch.StartNew(); + var sw = Stopwatch.StartNew(); manager.Cancel(130); // Wait for the budget to elapse. - await graceful.Token.WaitUntilCancelledAsync().DefaultTimeout(); + await manager.GracefulShutdownToken.WaitUntilCancelledAsync().DefaultTimeout(); sw.Stop(); // We allowed 200ms of grace; allow generous slack for CI scheduling but assert we waited at least most of it. @@ -90,8 +87,7 @@ public async Task FirstSignal_NonZeroBudget_DelaysExpireUntilBudgetElapses() [Fact] public async Task SecondSignal_ExpiresGracefulImmediately() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); // Large graceful budget — without a 2nd signal the token would not fire for 30s. manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); @@ -99,12 +95,17 @@ public async Task SecondSignal_ExpiresGracefulImmediately() manager.SetStartedHandler(new TaskCompletionSource().Task); manager.Cancel(130); - Assert.False(graceful.Token.IsCancellationRequested); + + // Under a debugger the window is never armed, so the token stays unfired until the 2nd signal. + if (!Debugger.IsAttached) + { + Assert.False(manager.GracefulShutdownToken.IsCancellationRequested); + } manager.Cancel(130); // 2nd signal expires graceful synchronously. - Assert.True(graceful.Token.IsCancellationRequested); + Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); // But the completion source should NOT have fired — that's Phase 3, requires a 3rd signal or drain timeout. await Task.Delay(100); @@ -114,8 +115,7 @@ public async Task SecondSignal_ExpiresGracefulImmediately() [Fact] public async Task ThirdSignal_FiresProcessTerminationImmediately() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); // Long graceful budget so the watcher would not naturally complete in the test window. manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); @@ -133,8 +133,7 @@ public async Task ThirdSignal_FiresProcessTerminationImmediately() [Fact] public async Task FirstSignal_HandlerCompletesWithinDrainBudget_DoesNotForceTermination() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); // Set a handler that completes immediately. manager.SetStartedHandler(Task.FromResult(0)); @@ -151,8 +150,7 @@ public async Task FirstSignal_HandlerCompletesWithinDrainBudget_DoesNotForceTerm [Fact] public async Task FirstSignal_HandlerExceedsDrainBudget_ForcesTermination() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromMilliseconds(50)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromMilliseconds(50)); // Set a handler that never completes. manager.SetStartedHandler(new TaskCompletionSource().Task); @@ -166,8 +164,7 @@ public async Task FirstSignal_HandlerExceedsDrainBudget_ForcesTermination() [Fact] public async Task FirstSignal_WithNoHandler_ForcesTerminationAfterDrainBudget() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromMilliseconds(50)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromMilliseconds(50)); // No handler set, watcher still has to wait out the drain budget before forcing termination. manager.Cancel(143); @@ -179,8 +176,7 @@ public async Task FirstSignal_WithNoHandler_ForcesTerminationAfterDrainBudget() [Fact] public async Task GracefulBudgetElapses_ThenDrainBudgetElapses_FiresProcessTermination() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromMilliseconds(50)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromMilliseconds(50)); manager.ConfigureForCommand(TimeSpan.FromMilliseconds(50)); manager.SetStartedHandler(new TaskCompletionSource().Task); @@ -188,19 +184,18 @@ public async Task GracefulBudgetElapses_ThenDrainBudgetElapses_FiresProcessTermi var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); Assert.Equal(130, exitCode); - Assert.True(graceful.Token.IsCancellationRequested); + Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); } [Fact] public void Cancel_IsNonBlocking() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); manager.SetStartedHandler(new TaskCompletionSource().Task); - var sw = System.Diagnostics.Stopwatch.StartNew(); + var sw = Stopwatch.StartNew(); manager.Cancel(130); sw.Stop(); @@ -211,15 +206,15 @@ public void Cancel_IsNonBlocking() [Fact] public async Task ProcessTermination_FiresGracefulExpiration_LaddersUnblock() { - using var graceful = new GracefulShutdownService(); - using var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(30)); + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); // A ladder observing only the graceful token, awaiting a long delay. + var gracefulToken = manager.GracefulShutdownToken; var ladderTask = Task.Run(async () => { try { - await Task.Delay(TimeSpan.FromMinutes(5), graceful.Token); + await Task.Delay(TimeSpan.FromMinutes(5), gracefulToken); return false; } catch (OperationCanceledException) @@ -240,8 +235,7 @@ public async Task ProcessTermination_FiresGracefulExpiration_LaddersUnblock() [Fact] public void Dispose_AllowsSubsequentCancelWithoutException() { - using var graceful = new GracefulShutdownService(); - var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); + var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); manager.Dispose(); // Cancel after dispose should not throw (signal can race with shutdown). @@ -251,12 +245,143 @@ public void Dispose_AllowsSubsequentCancelWithoutException() [Fact] public void Token_RemainsAccessibleAfterDispose() { - using var graceful = new GracefulShutdownService(); - var manager = new ConsoleCancellationManager(graceful, TimeSpan.FromSeconds(5)); + var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); var token = manager.Token; manager.Dispose(); // Token should still be accessible (stored in field before dispose). Assert.False(token.IsCancellationRequested); } + + [Fact] + public void GracefulShutdownToken_BeforeExpire_NotCancelled() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + Assert.False(manager.GracefulShutdownToken.IsCancellationRequested); + } + + [Fact] + public void Expire_FiresGracefulToken() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + manager.Expire(); + + Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); + } + + [Fact] + public void Expire_Idempotent() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + manager.Expire(); + manager.Expire(); + manager.Expire(); + + Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); + } + + [Fact] + public void Expire_AfterDispose_DoesNotThrow() + { + var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + manager.Dispose(); + + // Expire racing with dispose must not propagate to callers (signal handler / + // watcher continuation contexts have nowhere meaningful to surface this). + manager.Expire(); + } + + [Fact] + public void GracefulShutdownToken_RemainsAccessibleAfterDispose() + { + var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + var token = manager.GracefulShutdownToken; + manager.Dispose(); + + // Token was captured up front; reading state after dispose must not throw. + Assert.False(token.IsCancellationRequested); + } + + [Fact] + public void IsEnabled_DefaultsToFalse() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + Assert.False(manager.IsEnabled); + } + + [Fact] + public void IsEnabled_TrueAfterPositiveConfigure() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + manager.ConfigureForCommand(TimeSpan.FromSeconds(5)); + + Assert.True(manager.IsEnabled); + } + + [Fact] + public void IsEnabled_FalseAfterZeroConfigure() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + manager.ConfigureForCommand(TimeSpan.Zero); + + Assert.False(manager.IsEnabled); + } + + [Fact] + public void BeginGracefulWindow_ZeroBudget_ExpiresImmediately() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + // No ConfigureForCommand → zero budget → window is "over" the moment it begins. + manager.BeginGracefulWindow(); + + Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); + } + + [Fact] + public async Task BeginGracefulWindow_PositiveBudget_FiresTokenAfterBudget() + { + // BeginGracefulWindow arms CancelAfter, which is a no-op while a debugger is attached + // (developers need unlimited stepping time). Skip the timing assertion in that case. + if (Debugger.IsAttached) + { + return; + } + + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + manager.ConfigureForCommand(TimeSpan.FromMilliseconds(50)); + + manager.BeginGracefulWindow(); + Assert.False(manager.GracefulShutdownToken.IsCancellationRequested); + + await manager.GracefulShutdownToken.WaitUntilCancelledAsync().DefaultTimeout(); + + Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); + } + + [Fact] + public async Task BeginGracefulWindow_SecondCall_DoesNotResetTimer() + { + if (Debugger.IsAttached) + { + return; + } + + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + manager.ConfigureForCommand(TimeSpan.FromMilliseconds(50)); + + manager.BeginGracefulWindow(); + // A second call must be idempotent and must not re-arm (which would extend the window). + manager.BeginGracefulWindow(); + + await manager.GracefulShutdownToken.WaitUntilCancelledAsync().DefaultTimeout(); + + Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); + } } diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index ba317b3a808..a5ee4bf74ac 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -2480,8 +2480,6 @@ public void Kill(bool entireProcessTree) { } - public void Dispose() - { - } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs index 61c04630047..6f3f31ee44f 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -41,7 +41,7 @@ public async Task WaitForExitAsync_AllowsForwardersToDrainBeforeClosingStreams() }); var isFirstLine = true; - using var execution = CreateExecution( + await using var execution = CreateExecution( scriptFile, new ProcessInvocationOptions { @@ -100,7 +100,7 @@ public async Task WaitForExitAsync_AllowsBufferedTailOutputAfterLongIdlePeriod() releaseCallback.SetResult(); }); - using var execution = CreateExecution( + await using var execution = CreateExecution( scriptFile, new ProcessInvocationOptions { @@ -146,7 +146,7 @@ public async Task WaitForExitAsync_KillsProcessWhenCanceled() var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); - using var execution = CreateExecution( + await using var execution = CreateExecution( scriptFile, new ProcessInvocationOptions()); @@ -167,17 +167,16 @@ public async Task WaitForExitAsync_WithGracefulServices_InvokesSignalerAndThrows { using var workspace = TemporaryWorkspace.Create(outputHelper); var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled (positive budget) so the coordinator runs // the ladder. Escalation in this test is driven explicitly (signaler kill), not by the budget. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaler = new RecordingGracefulSignaler(onSignal: pid => { TryKillProcess(pid); return Task.FromResult(true); }); - using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService); + await using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService); Assert.True(execution.Start()); @@ -187,7 +186,7 @@ public async Task WaitForExitAsync_WithGracefulServices_InvokesSignalerAndThrows await Assert.ThrowsAsync(() => execution.WaitForExitAsync(cts.Token)); Assert.Single(signaler.Pids); - Assert.False(shutdownService.Token.IsCancellationRequested); + Assert.False(shutdownService.GracefulShutdownToken.IsCancellationRequested); Assert.True(WaitForProcessExit(execution.ProcessId, TimeSpan.FromSeconds(10)), $"Expected process {execution.ProcessId} to exit after graceful signal."); } @@ -198,10 +197,9 @@ public async Task WaitForExitAsync_WithGracefulServices_ProcessIgnoresSignal_Exp { using var workspace = TemporaryWorkspace.Create(outputHelper); var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled so the coordinator runs the ladder. // Escalation is driven by the explicit Expire() below, not by the budget elapsing. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var signaler = new RecordingGracefulSignaler(onSignal: _ => { @@ -209,7 +207,7 @@ public async Task WaitForExitAsync_WithGracefulServices_ProcessIgnoresSignal_Exp return Task.FromResult(true); }); - using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService); + await using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService); Assert.True(execution.Start()); @@ -234,13 +232,12 @@ public async Task WaitForExitAsync_WithGracefulServices_SignalerThrows_StillEsca { using var workspace = TemporaryWorkspace.Create(outputHelper); var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled so the coordinator runs the ladder. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaler = new RecordingGracefulSignaler(onSignal: _ => throw new InvalidOperationException("simulated DCP failure")); - using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService); + await using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService); Assert.True(execution.Start()); @@ -411,7 +408,7 @@ private static IProcessExecution CreateExecution( FileInfo scriptFile, bool isolateConsole, IProcessTreeGracefulShutdownSignaler signaler, - GracefulShutdownService shutdownService) + IGracefulShutdownWindow shutdownService) { // The Windows kill-on-close job is now resolved on-demand inside the factory via // WindowsConsoleProcessJob.Shared, so the test no longer creates or disposes one. diff --git a/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs b/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs deleted file mode 100644 index 113cef8f467..00000000000 --- a/tests/Aspire.Cli.Tests/GracefulShutdownServiceTests.cs +++ /dev/null @@ -1,150 +0,0 @@ -// 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 Microsoft.AspNetCore.InternalTesting; - -namespace Aspire.Cli.Tests; - -public class GracefulShutdownServiceTests -{ - [Fact] - public void Token_BeforeExpire_NotCancelled() - { - using var service = new GracefulShutdownService(); - - Assert.False(service.Token.IsCancellationRequested); - } - - [Fact] - public void Expire_FiresToken() - { - using var service = new GracefulShutdownService(); - - service.Expire(); - - Assert.True(service.Token.IsCancellationRequested); - } - - [Fact] - public void Expire_Idempotent() - { - using var service = new GracefulShutdownService(); - - service.Expire(); - service.Expire(); - service.Expire(); - - Assert.True(service.Token.IsCancellationRequested); - } - - [Fact] - public void Expire_AfterDispose_DoesNotThrow() - { - var service = new GracefulShutdownService(); - service.Dispose(); - - // Expire racing with dispose must not propagate to callers (signal handler / - // watcher continuation contexts have nowhere meaningful to surface this). - service.Expire(); - } - - [Fact] - public void Token_RemainsAccessibleAfterDispose() - { - var service = new GracefulShutdownService(); - var token = service.Token; - service.Dispose(); - - // Token was captured up front; reading state after dispose must not throw. - Assert.False(token.IsCancellationRequested); - } - - [Fact] - public void IsEnabled_DefaultsToFalse() - { - using var service = new GracefulShutdownService(); - - Assert.False(service.IsEnabled); - } - - [Fact] - public void IsEnabled_TrueAfterPositiveConfigure() - { - using var service = new GracefulShutdownService(); - - service.Configure(TimeSpan.FromSeconds(5)); - - Assert.True(service.IsEnabled); - } - - [Fact] - public void IsEnabled_FalseAfterZeroConfigure() - { - using var service = new GracefulShutdownService(); - - service.Configure(TimeSpan.Zero); - - Assert.False(service.IsEnabled); - } - - [Fact] - public void Configure_NegativeBudget_Throws() - { - using var service = new GracefulShutdownService(); - - Assert.Throws(() => service.Configure(TimeSpan.FromSeconds(-1))); - } - - [Fact] - public void BeginGracefulWindow_ZeroBudget_ExpiresImmediately() - { - using var service = new GracefulShutdownService(); - - // No Configure call → zero budget → window is "over" the moment it begins. - service.BeginGracefulWindow(); - - Assert.True(service.Token.IsCancellationRequested); - } - - [Fact] - public async Task BeginGracefulWindow_PositiveBudget_FiresTokenAfterBudget() - { - // BeginGracefulWindow arms CancelAfter, which is a no-op while a debugger is attached - // (developers need unlimited stepping time). Skip the timing assertion in that case. - if (Debugger.IsAttached) - { - return; - } - - using var service = new GracefulShutdownService(); - service.Configure(TimeSpan.FromMilliseconds(50)); - - service.BeginGracefulWindow(); - Assert.False(service.Token.IsCancellationRequested); - - await service.Token.WaitUntilCancelledAsync().DefaultTimeout(); - - Assert.True(service.Token.IsCancellationRequested); - } - - [Fact] - public async Task BeginGracefulWindow_SecondCall_DoesNotResetTimer() - { - if (Debugger.IsAttached) - { - return; - } - - using var service = new GracefulShutdownService(); - service.Configure(TimeSpan.FromMilliseconds(50)); - - service.BeginGracefulWindow(); - // A second call must be idempotent and must not re-arm (which would extend the window). - service.BeginGracefulWindow(); - - await service.Token.WaitUntilCancelledAsync().DefaultTimeout(); - - Assert.True(service.Token.IsCancellationRequested); - } -} diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 82708202d3e..5d84cfb36e5 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -230,10 +230,9 @@ public async Task StartAsync_StopRequested_WithGracefulServices_InvokesGracefulS // escalation branch and verifies the happy path end-to-end. var project = new LongRunningAppHostServerProject(); using var stopCts = new CancellationTokenSource(); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled so the session routes through the ladder. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaler = new RecordingGracefulSignaler(onSignal: pid => { @@ -271,7 +270,7 @@ public async Task StartAsync_StopRequested_WithGracefulServices_InvokesGracefulS Assert.Contains(serverPid, signaler.Pids); // Graceful budget was never exhausted in this scenario — the signaler simulated success // and WaitForExitAsync observed the exit before anyone called Expire(). - Assert.False(shutdownService.Token.IsCancellationRequested); + Assert.False(shutdownService.GracefulShutdownToken.IsCancellationRequested); } [Fact] @@ -283,11 +282,10 @@ public async Task StartAsync_StopRequested_GracefulIgnored_ExpireEscalatesToTree // is guaranteed to die — covering the "DCP signal didn't take" failure mode. var project = new LongRunningAppHostServerProject(); using var stopCts = new CancellationTokenSource(); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled so the session routes through the ladder. // Escalation is driven by the explicit Expire() below, not by the budget elapsing. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var signaler = new RecordingGracefulSignaler(onSignal: _ => @@ -333,10 +331,9 @@ public async Task StartAsync_StopRequested_GracefulSignalerThrows_StillEscalates // "signal succeeded but process ignored it" case above. var project = new LongRunningAppHostServerProject(); using var stopCts = new CancellationTokenSource(); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled so the session routes through the ladder. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaler = new RecordingGracefulSignaler(onSignal: _ => throw new InvalidOperationException("simulated DCP failure")); @@ -366,15 +363,14 @@ public async Task StartAsync_StopRequested_GracefulSignalerThrows_StillEscalates [Fact] public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithoutSignaling() { - // A graceful service that was never Configure()'d models a non-run command (IsEnabled is - // false). The coordinator's graceful-vs-force decision is all-or-nothing per command and keys - // off IsEnabled, so dispose-only teardown here must take the force-kill path and never invoke - // the signaler. (On the run path the service IS configured, so completion routes through the - // bounded ladder instead — that bound comes from GracefulShutdownService self-arming its timer, - // not from a per-session external-stop flag.) + // A graceful window with IsEnabled == false models a non-run command. The coordinator's + // graceful-vs-force decision is all-or-nothing per command and keys off IsEnabled, so + // dispose-only teardown here must take the force-kill path and never invoke the signaler. + // (On the run path IsEnabled is true, so completion routes through the bounded ladder instead — + // that bound comes from the central graceful clock, not from a per-session external-stop flag.) var project = new LongRunningAppHostServerProject(); using var stopCts = new CancellationTokenSource(); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow { IsEnabled = false }; var signaler = new RecordingGracefulSignaler(); var session = new AppHostServerSession( diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 8303251a0eb..7af6f37769e 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -1171,12 +1171,12 @@ private GuestAppHostProject CreateGuestAppHostProject( logFilePath: logFilePath, identityOverridden: identityOverridden); - // Construct real graceful-shutdown collaborators so the contract matches production: - // GuestAppHostProject requires these services even when a test exits the Run path early - // (e.g. via FailedToBuildArtifacts) without exercising them. A no-op signaler stands in - // for ProcessTreeGracefulShutdownService because none of the tests in this fixture drive the - // launcher or AppHostServerSession code paths that would actually invoke it. - var gracefulShutdownService = new GracefulShutdownService(); + // Construct a real graceful-shutdown window so the contract matches production: + // GuestAppHostProject requires it even when a test exits the Run path early + // (e.g. via FailedToBuildArtifacts) without exercising shutdown. The test fake stands in for + // ConsoleCancellationManager so the fixture doesn't register process-global OS signal handlers; + // none of the tests here drive the launcher or AppHostServerSession paths that would fire it. + var shutdownWindow = new TestGracefulShutdownWindow(); return new GuestAppHostProject( language: language, @@ -1194,7 +1194,7 @@ private GuestAppHostProject CreateGuestAppHostProject( fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()), profilingTelemetry: _profilingTelemetry, gracefulShutdownSignaler: new NoOpGracefulSignaler(), - shutdownService: gracefulShutdownService, + shutdownService: shutdownWindow, codegenSessionFactory: codegenSessionFactory); } diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs index 8886d9ed641..375feb7b234 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -59,9 +59,8 @@ public async Task LaunchAsync_WithGracefulServices_GracefulSucceeds_NoTreeKillEs var (command, args) = GetLongRunningCommand(); using var cts = new CancellationTokenSource(); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaler = new RecordingGracefulSignaler(onSignal: pid => { try @@ -98,7 +97,7 @@ public async Task LaunchAsync_WithGracefulServices_GracefulSucceeds_NoTreeKillEs Assert.NotEqual(0, exitCode); Assert.Single(signaler.Pids); - Assert.False(shutdownService.Token.IsCancellationRequested); + Assert.False(shutdownService.GracefulShutdownToken.IsCancellationRequested); } [Fact] @@ -111,9 +110,8 @@ public async Task LaunchAsync_WithGracefulServices_BlockingSignalerDoesNotConsum var (command, args) = GetLongRunningCommand(); using var cts = new CancellationTokenSource(); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signalerNeverCompletes = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var signaler = new RecordingGracefulSignaler(onSignal: pid => { @@ -153,7 +151,7 @@ public async Task LaunchAsync_WithGracefulServices_BlockingSignalerDoesNotConsum Assert.NotEqual(0, exitCode); Assert.Single(signaler.Pids); - Assert.False(shutdownService.Token.IsCancellationRequested); + Assert.False(shutdownService.GracefulShutdownToken.IsCancellationRequested); Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Expected process exit to win over the still-blocked signaler but it took {stopwatch.Elapsed}."); } @@ -171,11 +169,10 @@ public async Task LaunchAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEs var (command, args) = await GetProcessTreeCommandAsync(workspace.WorkspaceRoot, descendantPidFile); using var cts = new CancellationTokenSource(); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. // Escalation is driven by the explicit Expire() below, not by the budget elapsing. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var signaler = new RecordingGracefulSignaler(onSignal: _ => @@ -238,10 +235,9 @@ public async Task LaunchAsync_WithGracefulServices_SignalerThrows_StillEscalates var (command, args) = GetLongRunningCommand(); using var cts = new CancellationTokenSource(); - using var shutdownService = new GracefulShutdownService(); + using var shutdownService = new TestGracefulShutdownWindow(); // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. - shutdownService.Configure(TimeSpan.FromSeconds(30)); var signaler = new RecordingGracefulSignaler(onSignal: _ => throw new InvalidOperationException("simulated DCP failure")); diff --git a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs index e7d270a0ceb..b659543a244 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs @@ -42,7 +42,7 @@ private static async Task BuildHostAsync(Dictionary? con var logBufferContext = new ConsoleLogBufferContext(); var (loggerFactory, fileLoggerProvider) = Program.CreateLoggerFactory([], loggingOptions, errorWriter, logBufferContext); var identityChannelReader = new IdentityChannelReader(typeof(Program).Assembly); - var startupContext = new Program.CliStartupContext(loggingOptions, errorWriter, loggerFactory, fileLoggerProvider, logBufferContext, loggerFactory.CreateLogger(Program.RootLoggerName), new ConsoleCancellationManager(new GracefulShutdownService(), finalDrainBudget: Timeout.InfiniteTimeSpan), identityChannelReader); + var startupContext = new Program.CliStartupContext(loggingOptions, errorWriter, loggerFactory, fileLoggerProvider, logBufferContext, loggerFactory.CreateLogger(Program.RootLoggerName), new ConsoleCancellationManager(finalDrainBudget: Timeout.InfiniteTimeSpan), identityChannelReader); return await Program.BuildApplicationAsync([], startupContext, config); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestGracefulShutdownWindow.cs b/tests/Aspire.Cli.Tests/TestServices/TestGracefulShutdownWindow.cs new file mode 100644 index 00000000000..05aa51286e2 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestGracefulShutdownWindow.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// Test double for . Unlike the real +/// , it registers no process-global OS signal handlers and +/// arms no real timer, so it is safe to use from unit tests running in parallel. The graceful token +/// only fires when a test explicitly calls ; +/// is a no-op so happy-path tests (process exits before any escalation) observe an unfired token +/// deterministically. +/// +internal sealed class TestGracefulShutdownWindow : IGracefulShutdownWindow, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + + /// + /// Whether the coordinator should run the graceful ladder. Defaults to + /// (models a run command with a configured budget); set to to model a + /// non-run command that force-kills. + /// + public bool IsEnabled { get; set; } = true; + + public CancellationToken GracefulShutdownToken => _cts.Token; + + public void BeginGracefulWindow() + { + // Intentionally no-op: tests drive escalation explicitly via Expire(). + } + + public void Expire() + { + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + } + } + + public void Dispose() => _cts.Dispose(); +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs index e1664c0d03e..8be0b006615 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs @@ -210,10 +210,11 @@ public void Kill(bool entireProcessTree) KillCallback?.Invoke(entireProcessTree); } - public void Dispose() + public ValueTask DisposeAsync() { DisposeCount++; DisposeCallback?.Invoke(); + return ValueTask.CompletedTask; } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index e51822d6e38..25873670667 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -167,13 +167,13 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work // wraps. Without this, DI returns null and Run-path tests construct the project with // a missing dependency, masking wiring regressions. services.AddTransient(sp => sp.GetRequiredService()); - // Match Program.Main's parameterless GracefulShutdownService + 5s finalDrainBudget for CCM - // so tests exercise the same shutdown ladder budget as production. RunCommand and - // GuestAppHostProject require these services in production wiring. - services.AddSingleton(); + // Match Program.Main's ConsoleCancellationManager (5s finalDrainBudget) so tests exercise the + // same shutdown ladder budget as production. RunCommand and GuestAppHostProject require these + // services in production wiring. IGracefulShutdownWindow resolves to the same CCM instance, + // mirroring Program.cs. services.AddSingleton(sp => new ConsoleCancellationManager( - sp.GetRequiredService(), finalDrainBudget: TimeSpan.FromSeconds(5))); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(options.BundlePayloadProviderFactory); services.AddSingleton(options.BundleServiceFactory); services.AddSingleton(); From 65ef50ad0d352cb38b09f7ba6ddfb1fe35f306ad Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 18 Jun 2026 15:39:34 -0700 Subject: [PATCH 21/56] Unify process execution + shutdown ladder onto IProcessExecution Collapse the parallel process-execution + graceful-shutdown machinery onto a single IProcessExecution abstraction: - Merge IsolatedProcessExecution into ProcessExecution; the factory returns one wrapper over the IsolatedProcess primitive for both isolated and non-isolated spawns. Delete IsolatedConsoleSpawner and ProcessLifetimeAdapter. - AppHostServerRunResult now carries an IProcessExecution. AppHostServerSession and ProcessGuestLauncher consume IProcessExecution and cancel a token instead of hand-rolling their own spawn + shutdown drivers. - Move kill-if-alive into ProcessExecution.DisposeAsync so the execution owns terminating a still-running child on dispose. This lets AppHostServerSession .StartAsync shed its explicit Kill + deferred-dispose ceremony: the drive loop is started immediately after publishing the execution so completion is always wired to the process and DisposeAsync can never hang on a post-spawn fault. - Strip ASPIRE_CLI_* on both isolated and non-isolated factory paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DotNet/IProcessExecutionFactory.cs | 15 + src/Aspire.Cli/DotNet/ProcessExecution.cs | 20 + .../DotNet/ProcessExecutionFactory.cs | 90 ++++- .../Processes/IsolatedConsoleSpawner.cs | 75 ---- .../Processes/ProcessLifetimeAdapter.cs | 38 -- .../Projects/AppHostServerProject.cs | 3 + .../Projects/AppHostServerSession.cs | 358 +++++------------- .../DotNetBasedAppHostServerProject.cs | 86 ++--- .../Projects/IAppHostServerProject.cs | 107 +++--- .../Projects/PrebuiltAppHostServer.cs | 83 ++-- .../Projects/ProcessGuestLauncher.cs | 270 ++++--------- ...asedAppHostServerChannelResolutionTests.cs | 1 + ...uiltAppHostServerChannelResolutionTests.cs | 1 + .../Projects/AppHostServerProjectTests.cs | 6 +- .../Projects/AppHostServerSessionTests.cs | 90 +++-- .../Projects/PrebuiltAppHostServerTests.cs | 13 + .../Scaffolding/ChannelReseedTests.cs | 2 +- .../FakeFailingAppHostServerProject.cs | 2 +- .../FakeSucceedingAppHostServerProject.cs | 2 +- .../TestProcessExecutionFactory.cs | 29 +- 20 files changed, 506 insertions(+), 785 deletions(-) delete mode 100644 src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs delete mode 100644 src/Aspire.Cli/Processes/ProcessLifetimeAdapter.cs diff --git a/src/Aspire.Cli/DotNet/IProcessExecutionFactory.cs b/src/Aspire.Cli/DotNet/IProcessExecutionFactory.cs index bd3e68c12e8..661222b951f 100644 --- a/src/Aspire.Cli/DotNet/IProcessExecutionFactory.cs +++ b/src/Aspire.Cli/DotNet/IProcessExecutionFactory.cs @@ -23,4 +23,19 @@ IProcessExecution CreateExecution( IDictionary? env, DirectoryInfo workingDirectory, ProcessInvocationOptions options); + + /// + /// Creates a configured process execution from a fully-populated . + /// Unlike the (fileName, args, env, ...) overload — which overlays env on the parent + /// environment — this overload treats 's environment as the authoritative, + /// complete view the child should see, so caller removals (startInfo.Environment.Remove(key)) are + /// honored. Used by the AppHost server and guest spawn paths, which build a complete + /// (including explicit env suppression). + /// + /// The fully-populated start info describing the child to launch. + /// Invocation options for the command. + /// A configured ready to be started. + IProcessExecution CreateExecution( + System.Diagnostics.ProcessStartInfo startInfo, + ProcessInvocationOptions options); } diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 3d58ce26b21..1e6905543db 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -148,6 +148,26 @@ public async ValueTask DisposeAsync() return; } + // Terminate the child if it is still running. On the normal teardown paths the caller drives + // WaitForExitAsync(token) first, so the shutdown ladder has already exited or killed the + // process by the time we get here and this is a no-op. It matters for the path where an + // execution was started but never driven (e.g. a fault between Start and the caller wiring up + // its wait loop): IsolatedProcess.DisposeAsync only drains pumps and releases handles — it + // does NOT terminate the process — so without this kill the child would be orphaned. Owning + // "kill if still alive on dispose" here keeps that responsibility off every consumer. + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + // Best effort: the process may have exited between the check and the kill, or be + // unkillable. The drain/handle release below still runs. + } + try { await process.DisposeAsync().ConfigureAwait(false); diff --git a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs index 55930dc6a72..47e3ad4198e 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs @@ -43,23 +43,9 @@ public IProcessExecution CreateExecution(string fileName, string[] args, IDictio startInfo.ArgumentList.Add(a); } - // Strip ASPIRE_CLI_* identity overrides from every spawned process — both the isolated - // AppHost run path and every non-isolated subprocess. These env vars are an in-process, - // parent-only test affordance: a developer or test bench uses them to coerce the *current* - // CLI into pretending it is a different channel/version/commit or to retarget its emitted - // nuget.config at a local proxy. Letting them leak into child processes (apphost, dotnet, - // restore, peer probes) means any nested `aspire` invocation inherits the parent's lie - // about its identity, which silently corrupts `aspire doctor`, breaks peer probing, and - // undermines the "what is this binary actually" answer we want callers to see on disk. - // Touching Environment here (which snapshots the parent env on first access) also forces - // the spawn to build a custom env block, so the strip applies even when the caller passes - // no explicit `env`. We strip before merging `env` so a caller can still re-add an - // ASPIRE_CLI_* var deliberately if a future test needs to. - // See docs/specs/cli-identity-sidecar.md. - foreach (var envVarName in Acquisition.IdentityResolver.IdentityEnvVarNames) - { - startInfo.Environment.Remove(envVarName); - } + // Touching Environment here snapshots the parent env on first access, so the strip below + // applies even when the caller passes no explicit env. Then overlay the caller's env deltas. + StripIdentityEnvVars(startInfo); if (env is not null) { @@ -69,12 +55,80 @@ public IProcessExecution CreateExecution(string fileName, string[] args, IDictio } } + return Build(startInfo, fileName, effectiveLogger, options); + } + + public IProcessExecution CreateExecution(System.Diagnostics.ProcessStartInfo startInfo, ProcessInvocationOptions options) + { + var effectiveLogger = options.SuppressLogging ? (ILogger)NullLogger.Instance : logger; + + effectiveLogger.LogDebug("Running {FileName} in {WorkingDirectory} with args: {Args}", startInfo.FileName, startInfo.WorkingDirectory, string.Join(" ", startInfo.ArgumentList)); + + var isolatedStartInfo = new IsolatedProcessStartInfo + { + FileName = startInfo.FileName, + WorkingDirectory = startInfo.WorkingDirectory, + IsolateConsole = options.IsolateConsole, + JobHandle = options.IsolateConsole && OperatingSystem.IsWindows() ? WindowsConsoleProcessJob.Shared.Handle : null, + }; + + foreach (var arg in startInfo.ArgumentList) + { + isolatedStartInfo.ArgumentList.Add(arg); + } + + // Replace (not overlay) the env block so callers that did startInfo.Environment.Remove(key) + // see that removal honored — e.g. PrebuiltAppHostServer.CreateStartInfo explicitly removes + // KnownConfigNames.IntegrationLibsPath / IntegrationProbeManifestPath when they aren't + // configured, to suppress any value the parent CLI happens to have set in its own env. + // ProcessStartInfo.Environment is eagerly snapshotted from the parent, so iterating it gives + // the authoritative "what the child should see" view; a missing key really means "do not pass + // this to the child". (Clear() first seeds the parent env then empties it, so HasCustomEnvironment + // is set and the spawn uses our explicit block rather than re-inheriting the parent.) + isolatedStartInfo.Environment.Clear(); + foreach (var (key, value) in startInfo.Environment) + { + // Match ProcessStartInfo.Environment semantics: a null value means "do not set this + // variable in the child" — we get there by simply not adding it. + if (value is not null) + { + isolatedStartInfo.Environment[key] = value; + } + } + + // Strip after the copy so an ASPIRE_CLI_* var the parent happens to hold is not re-introduced + // into the child via startInfo.Environment (which is parent-seeded). Same rationale as the + // other overload — see StripIdentityEnvVars. + StripIdentityEnvVars(isolatedStartInfo); + + return Build(isolatedStartInfo, startInfo.FileName, effectiveLogger, options); + } + + // Strip ASPIRE_CLI_* identity overrides from every spawned process — both the isolated AppHost + // run path and every non-isolated subprocess. These env vars are an in-process, parent-only test + // affordance: a developer or test bench uses them to coerce the *current* CLI into pretending it + // is a different channel/version/commit or to retarget its emitted nuget.config at a local proxy. + // Letting them leak into child processes (apphost, dotnet, restore, peer probes) means any nested + // `aspire` invocation inherits the parent's lie about its identity, which silently corrupts + // `aspire doctor`, breaks peer probing, and undermines the "what is this binary actually" answer + // we want callers to see on disk. We strip before merging caller env so a caller can still re-add + // an ASPIRE_CLI_* var deliberately if a future test needs to. See docs/specs/cli-identity-sidecar.md. + private static void StripIdentityEnvVars(IsolatedProcessStartInfo startInfo) + { + foreach (var envVarName in Acquisition.IdentityResolver.IdentityEnvVarNames) + { + startInfo.Environment.Remove(envVarName); + } + } + + private static ProcessExecution Build(IsolatedProcessStartInfo startInfo, string fileName, ILogger logger, ProcessInvocationOptions options) + { // Snapshot args + env now so the IProcessExecution surfaces them before Start() spawns the // child. The extension-host launch path reads Arguments / EnvironmentVariables and returns // without ever calling Start (DotNetCliRunner), so these must be valid pre-spawn. var argsSnapshot = startInfo.ArgumentList.ToArray(); var envSnapshot = new Dictionary(startInfo.Environment, StringComparer.OrdinalIgnoreCase); - return new ProcessExecution(startInfo, fileName, argsSnapshot, envSnapshot, effectiveLogger, options); + return new ProcessExecution(startInfo, fileName, argsSnapshot, envSnapshot, logger, options); } } diff --git a/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs b/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs deleted file mode 100644 index 295a4387513..00000000000 --- a/src/Aspire.Cli/Processes/IsolatedConsoleSpawner.cs +++ /dev/null @@ -1,75 +0,0 @@ -// 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; - -namespace Aspire.Cli.Processes; - -/// -/// Shared helper for the isolated-console spawn path: translates a fully-populated -/// into the shape -/// expects, then spawns the child. Centralizes the -/// translation so every caller (AppHost server, guest apphost, future spawners) sees the -/// same env/arg shape and the same fail-fast contract on Windows. -/// -internal static class IsolatedConsoleSpawner -{ - /// - /// Spawns the process described by into an isolated console - /// group (new hidden console on Windows; effectively a thin - /// wrapper on Unix), bound on Windows to the process-wide kill-on-close job. - /// - /// - /// On Windows the spawned child is assigned to - /// (created on first use). Without the kill-on-close job an isolated child could survive a - /// CLI crash as an orphan in its new console group, defeating the entire point of the safety - /// net the new-console isolation is supposed to enable — so the job is resolved here rather - /// than threaded in by callers, who cannot then forget to supply it. - /// - public static IsolatedProcess StartIsolated( - ProcessStartInfo startInfo, - Action standardOutputHandler, - Action standardErrorHandler) - { - ArgumentNullException.ThrowIfNull(startInfo); - ArgumentNullException.ThrowIfNull(standardOutputHandler); - ArgumentNullException.ThrowIfNull(standardErrorHandler); - - var isolatedStartInfo = new IsolatedProcessStartInfo - { - FileName = startInfo.FileName, - WorkingDirectory = startInfo.WorkingDirectory, - JobHandle = OperatingSystem.IsWindows() ? WindowsConsoleProcessJob.Shared.Handle : null, - }; - - foreach (var arg in startInfo.ArgumentList) - { - isolatedStartInfo.ArgumentList.Add(arg); - } - - // Replace (not overlay) the env block so callers that did startInfo.Environment.Remove(key) - // see that removal honored — e.g. PrebuiltAppHostServer.CreateStartInfo explicitly removes - // KnownConfigNames.IntegrationLibsPath / IntegrationProbeManifestPath when they aren't - // configured, to suppress any value the parent CLI happens to have set in its own env. - // ProcessStartInfo.Environment is eagerly snapshotted from the parent, so iterating its - // Keys gives us the authoritative "what the child should see" view; a missing key here - // really does mean "do not pass this to the child" — including for vars the parent inherits. - // Overlay-without-clear (a prior approach) silently re-inherited any removed key on the - // isolated path, which the Unix partial also avoids via Environment.Clear() + add. - isolatedStartInfo.Environment.Clear(); - foreach (var (key, value) in startInfo.Environment) - { - // Match ProcessStartInfo.Environment semantics: a null value means "do not set this - // variable in the child" — we get there by simply not adding it. - if (value is not null) - { - isolatedStartInfo.Environment[key] = value; - } - } - - return IsolatedProcess.Start( - isolatedStartInfo, - (sender, line) => standardOutputHandler(sender.Id, line), - (sender, line) => standardErrorHandler(sender.Id, line)); - } -} diff --git a/src/Aspire.Cli/Processes/ProcessLifetimeAdapter.cs b/src/Aspire.Cli/Processes/ProcessLifetimeAdapter.cs deleted file mode 100644 index 846e30dc590..00000000000 --- a/src/Aspire.Cli/Processes/ProcessLifetimeAdapter.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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; - -namespace Aspire.Cli.Processes; - -/// -/// Tiny adapter that wraps a in an so callers -/// of both isolated and non-isolated spawn paths can hold a uniformly non-null disposable -/// lifetime handle and have a single disposal site. Isolated spawns return the -/// wrapper directly (which already implements -/// and owns extra resources like anonymous pipes); non-isolated -/// spawns wrap the bare through this adapter. -/// -internal static class ProcessLifetimeAdapter -{ - public static IAsyncDisposable ForProcess(Process process) => new ProcessOnlyLifetime(process); - - private sealed class ProcessOnlyLifetime(Process process) : IAsyncDisposable - { - public ValueTask DisposeAsync() - { - try - { - process.Dispose(); - } - catch - { - // Best-effort: disposal may race a concurrent kill / handle close. The caller - // already awaited the shutdown ladder, so by here the process is either exited - // or being killed; throwing from a disposal path is never useful. - } - - return ValueTask.CompletedTask; - } - } -} diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index 5dbe57629d9..78514855bf9 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -30,6 +30,7 @@ internal sealed class AppHostServerProjectFactory( BundleNuGetService bundleNuGetService, IDotNetSdkInstaller sdkInstaller, CliExecutionContext executionContext, + IProcessExecutionFactory processExecutionFactory, ILoggerFactory loggerFactory) : IAppHostServerProjectFactory { public async Task CreateAsync(string appPath, CancellationToken cancellationToken = default) @@ -46,6 +47,7 @@ public async Task CreateAsync(string appPath, Cancellatio repoRoot, dotNetCliRunner, packagingService, + processExecutionFactory, loggerFactory.CreateLogger(), logFilePath: executionContext.LogFilePath); } @@ -83,6 +85,7 @@ internal PrebuiltAppHostServer CreatePrebuiltAppHostServer( sdkInstaller, packagingService, executionContext, + processExecutionFactory, loggerFactory.CreateLogger(), layoutLease); } diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index f7e7aa2544b..f18856f85a4 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -1,7 +1,7 @@ // 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.Cli.DotNet; using Aspire.Cli.Processes; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; @@ -17,16 +17,17 @@ namespace Aspire.Cli.Projects; /// code. /// /// -/// The session is the only class that calls on its child. -/// Termination is requested either by cancelling the stopRequested token passed to the -/// constructor, or by calling . Both routes flow through the same -/// internal linked CTS, so there is exactly one kill site (the registered callback). Callers -/// should not reach into to drive lifecycle. +/// The session drives the child entirely through the returned by +/// . Termination is requested either by cancelling the +/// stopRequested token passed to the constructor, or by calling . +/// Both routes cancel the same internal linked CTS, which the drive loop passes to +/// ; the execution runs the +/// shared shutdown ladder (graceful signal → bounded wait → tree-kill, or force-kill fallback) +/// from inside that call. The session itself never spawns or kills — there is exactly one shutdown +/// driver, and it lives in . /// internal sealed class AppHostServerSession : IAppHostServerSession { - private const string ProcessDescription = "AppHost server"; - private readonly IAppHostServerProject _project; private readonly Dictionary? _callerEnvironmentVariables; private readonly bool _debug; @@ -41,24 +42,14 @@ internal sealed class AppHostServerSession : IAppHostServerSession private readonly object _startGate = new(); private bool _startInvoked; private bool _disposed; - private int _stopRequested; - private Process? _serverProcess; - private IAsyncDisposable? _processLifetime; + private IProcessExecution? _execution; + private Task? _runTask; private string? _socketPath; private OutputCollector? _output; private TaskCompletionSource? _completion; private ProfilingTelemetry.ActivityScope _activity; - private CancellationTokenRegistration _stopRegistration; private IAppHostRpcClient? _rpcClient; - private Task? _shutdownTask; - // Captured from AppHostServerRunResult so we read exit-code / has-exited via the - // IsolatedProcess wrapper on the isolated Windows path. Reading directly from - // _serverProcess.ExitCode / .HasExited on that path throws InvalidOperationException - // ("Process was not started by this object") because the managed Process came from - // Process.GetProcessById. See https://github.com/dotnet/runtime/issues/45003. - private Func? _readExitCode; - private Func? _readHasExited; public AppHostServerSession( IAppHostServerProject project, @@ -81,11 +72,12 @@ public AppHostServerSession( _shutdownService = shutdownService; _isolateConsole = isolateConsole; - // Linked CTS so caller-initiated cancellation AND DisposeAsync both flow through the - // same stop trigger. The registered callback on _stopCts.Token (wired in StartAsync) is - // the single kill site for the process. The graceful-vs-force decision is made centrally by - // the coordinator off IGracefulShutdownWindow.IsEnabled — there is no per-stop distinction - // here, and the coordinator bounds the graceful wait by starting the central clock itself. + // Linked CTS so caller-initiated cancellation AND DisposeAsync both flow through the same + // stop trigger. The drive loop (DriveAsync) passes _stopCts.Token to WaitForExitAsync, and + // the execution runs the shared shutdown ladder when that token fires. The graceful-vs-force + // decision is made centrally inside the ladder off IGracefulShutdownWindow.IsEnabled — there + // is no per-stop distinction here, and the ladder bounds the graceful wait by starting the + // central clock itself, so even a dispose-only teardown cannot hang. _stopCts = CancellationTokenSource.CreateLinkedTokenSource(stopRequested); } @@ -110,29 +102,27 @@ public AppHostServerSession( /// /// Gets whether the underlying AppHost server process has exited, or /// if has not been called (or threw before the process was - /// published). Prefer this over reading ServerProcess.HasExited directly — the - /// isolated Windows spawn path uses a -derived - /// whose status getters are unreliable; this accessor routes through - /// the wrapper that holds the original CreateProcess handle. + /// published). Routes through the , which encapsulates the + /// isolated Windows spawn quirk (the underlying Process is obtained via + /// ); see + /// https://github.com/dotnet/runtime/issues/45003. /// - public bool? HasServerExited => _readHasExited?.Invoke(); + public bool? HasServerExited => _execution?.HasExited; /// /// Reads the AppHost server process's exit code if it has exited, or /// if the server is still running, has not been started, or the exit code cannot be read. - /// Prefer this over reading ServerProcess.ExitCode directly — see - /// for the same Windows-isolated-spawn rationale. /// public int? TryGetServerExitCode() { - if (_readExitCode is null || _readHasExited is null || _readHasExited() != true) + if (_execution is not { HasExited: true } execution) { return null; } try { - return _readExitCode(); + return execution.ExitCode; } catch { @@ -141,42 +131,29 @@ public AppHostServerSession( } /// - /// Gets the underlying server process for read-only observation. Prefer - /// for has-exited checks and - /// for exit-code reads — see those members' remarks. + /// Gets the underlying server process id for read-only observation, or + /// if has not been called (or threw before the process was published). + /// Prefer for has-exited checks and + /// for exit-code reads. /// - /// - /// Callers must not invoke , - /// , - /// , or other lifecycle / status APIs on the returned - /// instance — those belong to the session (or to / - /// ). - /// - public Process? ServerProcess => _serverProcess; + public int? ServerProcessId => _execution?.ProcessId; /// /// Launches the AppHost server process. The returned task completes with the process exit /// code when the process exits (either on its own, or because the stop token supplied to the - /// constructor was cancelled and the session killed it). + /// constructor was cancelled and the session ran the shutdown ladder). /// /// Thrown if has already been called. /// Thrown if the session has been disposed. public Task StartAsync() { - // Captures the lifetime of a failed start so we can dispose it AFTER releasing - // _startGate (DisposeAsync of an IsolatedProcess can synchronously drain pipe pumps - // and we don't want to pin the gate for that duration — see catch block below). - IAsyncDisposable? failedLifetime = null; - Exception? failureToRethrow = null; - TaskCompletionSource? completionToReturn = null; - // Hold _startGate across the entire startup body — env build, _project.Run, field - // publication, Exited wiring, and stop registration. DisposeAsync's top-of-method lock - // then either runs before us (and StartAsync sees _disposed and throws) or after us - // (and Dispose sees a fully-published process + registration). Without this widening - // there is a window between _project.Run returning and the stop registration completing - // where a concurrent Dispose would orphan the just-launched process. Every operation - // below is synchronous, so a Monitor lock is safe (no await inside). + // publication, and drive-loop start. DisposeAsync's top-of-method lock then either runs + // before us (and StartAsync sees _disposed and throws) or after us (and Dispose sees a + // fully-published execution + run task). Without this widening there is a window between + // _project.Run returning and the run task starting where a concurrent Dispose would orphan + // the just-launched process. Every operation below is synchronous, so a Monitor lock is + // safe (no await inside). lock (_startGate) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -217,7 +194,7 @@ public Task StartAsync() Environment.ProcessId, serverEnvironmentVariables, debug: _debug, - isolateConsole: _isolateConsole); + runControl: new AppHostServerRunControl(_isolateConsole, _gracefulShutdownSignaler, _shutdownService)); } catch (Exception ex) { @@ -228,116 +205,53 @@ public Task StartAsync() throw; } - // Publish the lifetime BEFORE any further work so a fault in the wiring below still - // routes the spawned process through normal DisposeAsync cleanup (which awaits the - // shutdown ladder and then disposes the lifetime). Anything between here and the - // stop registration that throws will fall into the catch below and tear the just- - // launched process down explicitly. - _processLifetime = result.ProcessLifetime; - _serverProcess = result.Process; + // Publish the execution + socket, then immediately start the drive loop so the completion + // is wired to the process before anything else. From here on a fault routes through normal + // DisposeAsync cleanup: the session is held in `await using`, and DisposeAsync cancels the + // stop CTS (driving the drive loop's WaitForExitAsync into the shutdown ladder, which trips + // the completion) then disposes the execution — which now kills the still-running child if + // the ladder never ran. The telemetry calls below cannot throw, so there is no post-spawn + // fault path to hand-roll a kill for. + _execution = result.Execution; _socketPath = result.SocketPath; _output = result.OutputCollector; - // Capture the readers from the run result so every status / exit-code check below - // (Exited handler, post-Run HasExited probe, dispose-time activity update, kill - // fallback) routes through the wrapper instead of the GetProcessById-derived - // Process — required for the isolated Windows path. - _readExitCode = result.ReadExitCode; - _readHasExited = result.ReadHasExited; - try - { - var process = result.Process; - var readExitCode = result.ReadExitCode; - var readHasExited = result.ReadHasExited; - - _activity.SetProcessId(process.Id); - - // Read identity from the result, not process.StartInfo: on the isolated Windows - // path the Process is obtained via Process.GetProcessById and its StartInfo is - // empty, so reading from there would lose the telemetry signal. - _activity.SetProcessInvocation(result.FileName, result.Arguments); - - // Hook Exited before we check HasExited to close the race window where the process - // could exit between Start returning and our subscription being wired up. Capture the - // completion + readers in the closure so the handler doesn't need to read mutable - // fields when it fires from the thread pool. - process.EnableRaisingEvents = true; - process.Exited += (_, _) => TrySetExitCode(completion, readExitCode); - - // If the process exited before we hooked Exited (or before EnableRaisingEvents was set), - // the event will not fire. Trip the completion ourselves in that case. - if (readHasExited()) - { - TrySetExitCode(completion, readExitCode); - } - - // Register on the internal linked CTS. If the caller's token already fired (or DisposeAsync - // raced us to Cancel) the registration's callback fires synchronously here, killing the - // just-launched process. CT.Register handles already-cancelled tokens via inline invocation. - _stopRegistration = _stopCts.Token.Register(static state => ((AppHostServerSession)state!).OnStopRequested(), this); - } - catch (Exception ex) - { - // Post-spawn wiring failed (Exited hook, HasExited probe, stop registration). - // The lifetime is published, so DisposeAsync would normally clean it up — but the - // caller is about to receive an exception from StartAsync and may not call - // DisposeAsync. Tear the process down here so we don't leak the just-launched - // child + (on the isolated Windows path) its anonymous pipes and NUL stdin handle. - _activity.SetError(ex.Message); - _activity.Dispose(); - try - { - if (!result.ReadHasExited()) - { - result.Process.Kill(entireProcessTree: true); - } - } - catch - { - // Best effort — the lifetime disposal below handles the handles either way. - } - // Defer the lifetime disposal until after we exit _startGate. Disposal of - // IsolatedProcess can drain pipe pumps synchronously, and we don't want to pin - // the start gate for an unbounded duration (a concurrent DisposeAsync would - // queue behind us). The Kill above unblocks the pipes so the disposal should - // be quick, but the gate-release-then-dispose ordering is the safe shape. - failedLifetime = result.ProcessLifetime; - _processLifetime = null; - _serverProcess = null; - (_project as IDisposable)?.Dispose(); - completion.TrySetException(ex); - failureToRethrow = ex; - } + // Start the drive loop. It owns the single WaitForExitAsync call, trips the completion + // when the process exits (or is torn down), and never throws — so a fire-and-forget caller + // cannot orphan a faulted task, and DisposeAsync can always observe completion. + _runTask = DriveAsync(result.Execution, completion, _stopCts.Token); - if (failureToRethrow is null) - { - completionToReturn = completion; - } - } + _activity.SetProcessId(result.Execution.ProcessId); - if (failureToRethrow is not null) - { - if (failedLifetime is not null) - { - try - { - // Synchronously wait for disposal: StartAsync's contract is synchronous - // until the returned Task is observed, and the cleanup window for the - // freshly spawned child should be milliseconds now that the kill above - // has unblocked the pipes. We are no longer holding _startGate, so a - // concurrent DisposeAsync waiting on the gate is unblocked while we drain. - failedLifetime.DisposeAsync().AsTask().GetAwaiter().GetResult(); - } - catch - { - // Best effort. - } - } + // Read identity from the execution, not process.StartInfo: on the isolated Windows path the + // Process is obtained via Process.GetProcessById and its StartInfo is empty, so the + // execution captured these at spawn time instead. + _activity.SetProcessInvocation(result.Execution.FileName, result.Execution.Arguments); - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failureToRethrow).Throw(); + return completion.Task; } + } - return completionToReturn!.Task; + // Owns the single WaitForExitAsync call for the child. On normal exit it trips the completion + // with the exit code. On cancellation (external stop OR DisposeAsync), the execution runs the + // shared shutdown ladder from inside WaitForExitAsync and ALWAYS rethrows OCE even when the + // ladder cleanly exited the process — so we read the (now-exited or killed) exit code and trip + // the completion with it. This preserves the "StartAsync always completes with an int, never + // surfaces OCE" contract that GuestAppHostProject and the codegen path depend on. + private static async Task DriveAsync(IProcessExecution execution, TaskCompletionSource completion, CancellationToken stopToken) + { + try + { + completion.TrySetResult(await execution.WaitForExitAsync(stopToken).ConfigureAwait(false)); + } + catch (OperationCanceledException) + { + completion.TrySetResult(execution.HasExited ? execution.ExitCode : -1); + } + catch (Exception ex) + { + completion.TrySetException(ex); + } } /// @@ -399,39 +313,33 @@ public async ValueTask DisposeAsync() _disposed = true; } - // Trigger the registered stop callback (idempotent — if the caller's token already fired - // earlier this is a no-op). The callback runs ProcessTerminator.ShutdownAsync, which is - // synchronous when requestGracefulShutdown:false, so by the time Cancel returns the kill - // signal has been sent. + // Cancel the stop trigger. This drives the run loop's WaitForExitAsync(_stopCts.Token) + // into the shared shutdown ladder (graceful or force-kill, decided centrally), which then + // rethrows OCE so DriveAsync trips the completion. Awaiting _runTask below therefore cannot + // hang past the central budget. try { _stopCts.Cancel(); } catch (ObjectDisposedException) { - // CTS was already disposed by a prior partial teardown; the stop callback (if any) - // would have fired then. + // CTS was already disposed by a prior partial teardown. } - // Detach the registration before disposing the CTS so no late callback fires while the - // rest of the teardown progresses. - await _stopRegistration.DisposeAsync().ConfigureAwait(false); - - // Await the shutdown ladder before observing completion: when external stop fired, the - // ladder is bounded by the central token (so it cannot hang past the central budget); - // when dispose-only fired, the ladder force-killed near-instantly. Either way, awaiting - // here keeps the kill sequence ordered ahead of completion + RPC teardown so the process - // is dead (or definitely going to die) before we touch its handles. - if (_shutdownTask is { } shutdownTask) + // Await the drive loop before observing completion: it owns the single WaitForExitAsync, + // so once it returns the process is exited (graceful) or killed (escalation) and the + // completion has been tripped. Keeping this ordered ahead of RPC teardown ensures the + // process is dead (or definitely dying) before we touch its handles. + if (_runTask is { } runTask) { try { - await shutdownTask.ConfigureAwait(false); + await runTask.ConfigureAwait(false); } catch { - // The ladder swallows expected errors internally; defensively swallow anything - // else so disposal stays best-effort. + // DriveAsync never throws (it funnels faults into the completion), but swallow + // defensively so disposal stays best-effort. } } @@ -443,8 +351,7 @@ public async ValueTask DisposeAsync() // Observe the completion task unconditionally to prevent UnobservedTaskException if // StartAsync's _project.Run threw synchronously and faulted the completion before the - // process handle was assigned. When the process did start, this also waits for the - // Exited handler (or post-Run HasExited check) to flow the exit code through. + // execution was published. When the process did start, DriveAsync has already tripped this. if (_completion is { } completion) { try @@ -457,31 +364,33 @@ public async ValueTask DisposeAsync() } } - if (_serverProcess is not null && _readHasExited is { } hasExited && _readExitCode is { } exitCode) + if (_execution is { HasExited: true } execution) { - if (hasExited()) + try { - _activity.SetProcessExitCode(exitCode()); + _activity.SetProcessExitCode(execution.ExitCode); + } + catch + { + // Exit code unreadable (handle closed concurrently) — telemetry is best-effort. } } - // Dispose via the lifetime, not the process. On the isolated Windows path the lifetime is - // the IsolatedProcess wrapper which drains stdout/stderr pumps (bounded by an internal 5s - // timeout) and releases the anonymous pipes + NUL stdin handle that the Process doesn't - // own. On the non-isolated path the lifetime is a thin adapter that just disposes the - // Process. Either way, this is the single disposal site for the spawned child. - if (_processLifetime is { } lifetime) + // Single disposal site for the spawned child. On the isolated Windows path the execution + // drains stdout/stderr pumps (bounded internally) and releases the anonymous pipes + NUL + // stdin handle the Process doesn't own. On the non-isolated path it disposes the Process. + if (_execution is { } executionToDispose) { try { - await lifetime.DisposeAsync().ConfigureAwait(false); + await executionToDispose.DisposeAsync().ConfigureAwait(false); } catch { // Best-effort: the shutdown ladder has already run, so the process is exited or // being killed. Throwing from a disposal path is never useful. } - _processLifetime = null; + _execution = null; } _stopCts.Dispose(); @@ -489,67 +398,6 @@ public async ValueTask DisposeAsync() _activity.Dispose(); } - private static void TrySetExitCode(TaskCompletionSource completion, Func readExitCode) - { - try - { - completion.TrySetResult(readExitCode()); - } - catch (InvalidOperationException) - { - // Process handle was closed concurrently (e.g., during DisposeAsync teardown). The - // completion task is the contract callers observe; without an exit code there is - // nothing meaningful to surface, so just leave the completion open — DisposeAsync - // swallows the awaited exception. - } - } - - private void OnStopRequested() - { - // CT registration callbacks must be synchronous. We stash the started ShutdownAsync task - // in _shutdownTask so DisposeAsync can await it instead of leaving it dangling. The kill - // path inside ShutdownAsync is bounded (either an immediate force-kill on the no-graceful - // path, or by the central graceful-shutdown token on the graceful path), so the - // task cannot hang past the central budget. - // - // Intentionally no `_disposed` check: DisposeAsync sets `_disposed = true` before calling - // `_stopCts.Cancel()`, so an early return here would suppress the kill that disposal is - // trying to trigger. - // - // Defense in depth against the helper being called more than once — a CT registration only - // fires once today, but the kill path should never depend on that contract holding through - // future refactors (e.g. if disposal grows additional cancel paths). - if (Interlocked.Exchange(ref _stopRequested, 1) != 0) - { - return; - } - - var process = _serverProcess; - if (process is null) - { - return; - } - - _shutdownTask = ShutdownAsync(process); - } - - private Task ShutdownAsync(Process process) - { - // The graceful-vs-force decision is owned centrally by the coordinator, keyed off - // IGracefulShutdownWindow.IsEnabled. When graceful is enabled for the command (aspire run), - // the coordinator starts the central clock and runs the bounded ladder regardless of whether - // this stop came from a user signal or from DisposeAsync. When graceful is not wired/enabled - // (non-Run callers: SDK gen, scaffolding, publish, dump), it force-kills. - return ProcessShutdownCoordinator.ShutdownAsync( - process, - _gracefulShutdownSignaler, - _shutdownService, - fallbackRequestGracefulShutdown: false, - fallbackKillEntireProcessTree: !OperatingSystem.IsWindows(), - _logger, - ProcessDescription); - } - private static void ObserveFaultedTask(Task task) { _ = task.ContinueWith( diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index bb510f7e549..227c655e446 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -9,7 +9,6 @@ using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Packaging; -using Aspire.Cli.Processes; using Aspire.Cli.Utils; using Aspire.Hosting; using Aspire.Shared; @@ -38,6 +37,7 @@ internal sealed class DotNetBasedAppHostServerProject : IAppHostServerProject private readonly string _repoRoot; private readonly IDotNetCliRunner _dotNetCliRunner; private readonly IPackagingService _packagingService; + private readonly IProcessExecutionFactory _processExecutionFactory; private readonly ILogger _logger; private readonly string? _logFilePath; @@ -47,6 +47,7 @@ public DotNetBasedAppHostServerProject( string repoRoot, IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, + IProcessExecutionFactory processExecutionFactory, ILogger logger, string? projectModelPath = null, string? logFilePath = null) @@ -58,6 +59,7 @@ public DotNetBasedAppHostServerProject( _repoRoot = Path.GetFullPath(repoRoot) + Path.DirectorySeparatorChar; _dotNetCliRunner = dotNetCliRunner; _packagingService = packagingService; + _processExecutionFactory = processExecutionFactory; _logger = logger; _logFilePath = logFilePath; @@ -469,7 +471,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false) + AppHostServerRunControl? runControl = null) { var assemblyPath = Path.Combine(BuildPath, ProjectDllName); var dotnetExe = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; @@ -540,67 +542,51 @@ public AppHostServerRunResult Run( startInfo.RedirectStandardError = true; var outputCollector = new OutputCollector(); - var arguments = startInfo.ArgumentList.ToArray(); - // Pulled out so the inherited-console (event-based) and isolated (handler-based) paths - // produce identical log/collector output. Keeps the per-line behavior in one place even - // if we end up with a third spawn variant later. - void OnStdout(int pid, string line) + // The execution local is forward-referenced by the log callbacks so they can read the + // child's pid per line. ProcessInvocationOptions.StandardOutputCallback is Action + // (line only), but the AppHost wants the pid in each trace line (#16729). The callbacks + // only fire after Start(), by which point `execution` is assigned and ProcessId is valid. + ProcessExecution execution = null!; + + void OnStdout(string line) { - _logger.LogTrace("AppHostServer({ProcessId}) stdout: {Line}", pid, line); + _logger.LogTrace("AppHostServer({ProcessId}) stdout: {Line}", execution.ProcessId, line); outputCollector.AppendOutput(line); } - void OnStderr(int pid, string line) + void OnStderr(string line) { - _logger.LogTrace("AppHostServer({ProcessId}) stderr: {Line}", pid, line); + _logger.LogTrace("AppHostServer({ProcessId}) stderr: {Line}", execution.ProcessId, line); outputCollector.AppendError(line); } - if (isolateConsole) - { - var isolated = IsolatedConsoleSpawner.StartIsolated(startInfo, OnStdout, OnStderr); - return new AppHostServerRunResult( - _socketPath, - isolated.Process, - outputCollector, - startInfo.FileName, - arguments, - isolated, - // Route exit-code/has-exited reads through IsolatedProcess so the isolated - // Windows path can use its kept CreateProcess handle instead of the managed - // Process.GetProcessById instance (which throws on ExitCode). See - // https://github.com/dotnet/runtime/issues/45003. - ExitCodeOverride: () => isolated.ExitCode, - HasExitedOverride: () => isolated.HasExited); - } + var options = new ProcessInvocationOptions + { + StandardOutputCallback = OnStdout, + StandardErrorCallback = OnStderr, + IsolateConsole = runControl?.IsolateConsole ?? false, + GracefulShutdownSignaler = runControl?.GracefulShutdownSignaler, + ShutdownService = runControl?.ShutdownService, + // The graceful ladder always tree-kills on escalation; this fallback only matters when + // graceful services were not wired (non-Run callers), where it preserves the old session + // behavior of force-killing the tree on Unix but only the root on Windows. + KillEntireProcessTreeOnCancel = !OperatingSystem.IsWindows(), + }; - var process = Process.Start(startInfo)!; + execution = (ProcessExecution)_processExecutionFactory.CreateExecution(startInfo, options); - process.OutputDataReceived += (sender, e) => + try { - if (e.Data is not null) - { - OnStdout(process.Id, e.Data); - } - }; - process.ErrorDataReceived += (sender, e) => + execution.Start(); + } + catch { - if (e.Data is not null) - { - OnStderr(process.Id, e.Data); - } - }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - return new AppHostServerRunResult( - _socketPath, - process, - outputCollector, - startInfo.FileName, - arguments, - ProcessLifetimeAdapter.ForProcess(process)); + execution.DisposeAsync().AsTask().GetAwaiter().GetResult(); + throw; + } + + return new AppHostServerRunResult(_socketPath, outputCollector, execution); } private static string? FindNuGetConfig(string workingDirectory) diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index 7acd0c1f5c4..693aaa74fc0 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -1,8 +1,8 @@ // 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.Cli.Configuration; +using Aspire.Cli.DotNet; using Aspire.Cli.Processes; using Aspire.Cli.Utils; @@ -22,67 +22,50 @@ internal sealed record AppHostServerPrepareResult( bool NeedsCodeGeneration = false); /// -/// Result of — a launched AppHost server process plus -/// the cleanup handle that owns it. +/// Result of — a launched AppHost server process plus the +/// captured output. /// /// RPC socket the server is publishing on. -/// -/// The underlying . Callers may observe state -/// (, , etc.) and drive lifecycle APIs -/// (, ), -/// but must dispose via instead of the -/// directly so the isolated-spawn path can release its anonymous pipes and stdin handle. -/// Callers should prefer the / accessors -/// over / for status checks, -/// because the managed Process returned for the isolated Windows path is obtained via -/// and cannot reliably surface ExitCode/HasExited -/// (see https://github.com/dotnet/runtime/issues/45003 and ). -/// /// Captured stdout/stderr for failure display. -/// -/// The launched executable, captured at spawn time. Reading -/// off is unreliable on the isolated Windows path (the Process is obtained -/// via , which returns an empty ), -/// so telemetry should read identity from this field instead. -/// -/// The argument list, captured at spawn time. Same rationale as . -/// -/// Disposes the process and any associated isolated-spawn resources. Always non-null; for the -/// non-isolated path this is a thin adapter that just disposes , for -/// the isolated path it is the wrapper that also drains the -/// stdout/stderr pumps and closes the anonymous pipes + NUL stdin handle on Windows. +/// +/// The started that owns the server child. Callers observe state +/// (, , +/// ), drive its lifetime via +/// (which runs the shared +/// shutdown ladder on cancellation), and dispose it via +/// . The execution encapsulates the isolated +/// Windows spawn quirk (the underlying Process is obtained via ), +/// so its status getters are reliable on every path — see https://github.com/dotnet/runtime/issues/45003. /// -/// -/// Optional override for . The isolated Windows spawn path supplies -/// one because on a -/// instance throws . Non-isolated callers leave this -/// and the accessor reads from directly. -/// -/// Optional override for . Same rationale as . internal sealed record AppHostServerRunResult( string SocketPath, - Process Process, OutputCollector OutputCollector, - string FileName, - IReadOnlyList Arguments, - IAsyncDisposable ProcessLifetime, - Func? ExitCodeOverride = null, - Func? HasExitedOverride = null) -{ - /// - /// Reads the child's exit code. Use this instead of Process.ExitCode — on the - /// isolated Windows path that property throws because the managed Process came from - /// ; the override consults the kept CreateProcess - /// handle directly. - /// - public int ReadExitCode() => ExitCodeOverride is { } reader ? reader() : Process.ExitCode; + IProcessExecution Execution); - /// - /// Reads whether the child has exited. Same rationale as — - /// prefer this over Process.HasExited for status checks on the isolated Windows path. - /// - public bool ReadHasExited() => HasExitedOverride is { } reader ? reader() : Process.HasExited; -} +/// +/// Controls how spawns and tears down the server child. +/// The default (all-null / false) preserves today's force-kill-on-cancel behavior for the non-Run +/// callers (SDK gen, scaffolding, publish, dump). The run path supplies the graceful infrastructure. +/// +/// +/// When , on Windows the server is spawned via +/// into its own hidden console (CREATE_NEW_CONSOLE | SW_HIDE) so DCP's stop-process-tree can +/// AttachConsole + post CTRL_C_EVENT against the server without also signalling the CLI, +/// and the child is bound to the process-wide kill-on-close +/// safety net. On Unix the spawn is effectively the same as today's path. +/// +/// +/// Issues the graceful shutdown signal during the shared ladder, or to fall +/// back to force-kill on cancellation. +/// +/// +/// The command-level graceful window bounding the ladder, or to fall back to +/// force-kill on cancellation. +/// +internal sealed record AppHostServerRunControl( + bool IsolateConsole = false, + IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler = null, + IGracefulShutdownWindow? ShutdownService = null); /// /// Represents an AppHost server that can be prepared and run. @@ -122,22 +105,18 @@ Task PrepareAsync( /// Environment variables to pass to the server. /// Additional command-line arguments. /// Whether to enable debug logging. - /// - /// When , on Windows the server is spawned via - /// into its own hidden console (CREATE_NEW_CONSOLE | SW_HIDE) - /// so DCP's stop-process-tree can AttachConsole + post CTRL_C_EVENT - /// against the server without also signalling the CLI. On Unix the flag is observed but the - /// resulting spawn is effectively the same as today's path (a thin - /// wrapper) because SIGTERM via the process group is enough. On Windows the server is bound to - /// the process-wide kill-on-close safety net. + /// + /// Console-isolation + graceful-shutdown wiring for the spawn. (the + /// default) preserves force-kill-on-cancel semantics for non-Run callers (SDK gen, scaffolding, + /// publish, dump). The run path passes a populated . /// - /// The launched server process and its associated cleanup handle. + /// The launched server process execution and its captured output. AppHostServerRunResult Run( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false); + AppHostServerRunControl? runControl = null); /// /// Gets a unique identifier path for this AppHost, used for running instance detection. diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 83e1ef5be8a..bd94b8284a8 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -13,7 +13,6 @@ using Aspire.Cli.Layout; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; -using Aspire.Cli.Processes; using Aspire.Cli.Utils; using Aspire.Hosting; using Aspire.Shared; @@ -45,6 +44,7 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject, IDisposable private readonly IDotNetSdkInstaller _sdkInstaller; private readonly IPackagingService _packagingService; private readonly CliExecutionContext _executionContext; + private readonly IProcessExecutionFactory _processExecutionFactory; private readonly ILogger _logger; private readonly BundleLayoutLease? _layoutLease; private readonly string _workingDirectory; @@ -67,6 +67,7 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject, IDisposable /// The SDK installer for checking .NET SDK availability. /// The packaging service for channel resolution. /// The CLI execution context providing identity channel information. + /// The factory used to spawn and manage the AppHost server child process. /// The logger for diagnostic output. /// The active bundle layout lease, if this server is running from a versioned bundle. public PrebuiltAppHostServer( @@ -78,6 +79,7 @@ public PrebuiltAppHostServer( IDotNetSdkInstaller sdkInstaller, IPackagingService packagingService, CliExecutionContext executionContext, + IProcessExecutionFactory processExecutionFactory, ILogger logger, BundleLayoutLease? layoutLease = null) { @@ -89,6 +91,7 @@ public PrebuiltAppHostServer( _sdkInstaller = sdkInstaller; _packagingService = packagingService; _executionContext = executionContext; + _processExecutionFactory = processExecutionFactory; _logger = logger; _layoutLease = layoutLease; @@ -917,17 +920,19 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false) + AppHostServerRunControl? runControl = null) { var startInfo = CreateStartInfo(hostPid, environmentVariables, additionalArgs, debug); var outputCollector = new OutputCollector(); - var arguments = startInfo.ArgumentList.ToArray(); - // Pulled out so the inherited-console (event-based) and isolated (handler-based) paths - // produce identical log/collector output. The log level + prefix differ from the dotnet-based - // server (see DotNetBasedAppHostServerProject.Run) — keeping them here keeps both spawn - // variants on the same per-line behavior for this server. - void OnStdout(int pid, string line) + // The execution local is forward-referenced by the log callbacks so they can read the + // child's pid per line (ProcessInvocationOptions.StandardOutputCallback is line-only). The + // log level + prefix differ from the dotnet-based server (#16729); keeping them here keeps + // this server's per-line behavior in one place. Callbacks only fire after Start(), so + // `execution` is assigned and ProcessId is valid by then. + ProcessExecution execution = null!; + + void OnStdout(string line) { // Promoted from LogTrace to LogDebug so that apphost-server stdout reaches the // CLI's on-disk log under the default file-logger filter (Debug). Previously @@ -935,65 +940,43 @@ void OnStdout(int pid, string line) // (for example, "LoaderExceptions" from the type-discovery path) invisible to // anyone diagnosing a "no code generator found" / "no language support found" // error. See https://github.com/microsoft/aspire/issues/16729. - _logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", pid, line); + _logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", execution.ProcessId, line); outputCollector.AppendOutput(line); } - void OnStderr(int pid, string line) + void OnStderr(string line) { // Promoted from LogTrace to LogInformation so that apphost-server stderr is // visible at the default console log level (Information). Stderr is reserved // for genuine problems in well-behaved server processes, so surfacing it // by default is appropriate. See https://github.com/microsoft/aspire/issues/16729. - _logger.LogInformation("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", pid, line); + _logger.LogInformation("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", execution.ProcessId, line); outputCollector.AppendError(line); } - if (isolateConsole) + var options = new ProcessInvocationOptions { - var isolated = IsolatedConsoleSpawner.StartIsolated(startInfo, OnStdout, OnStderr); - return new AppHostServerRunResult( - _socketPath, - isolated.Process, - outputCollector, - startInfo.FileName, - arguments, - isolated, - // Route exit-code/has-exited reads through IsolatedProcess so the isolated - // Windows path can use its kept CreateProcess handle instead of the managed - // Process.GetProcessById instance (which throws on ExitCode). See - // https://github.com/dotnet/runtime/issues/45003. - ExitCodeOverride: () => isolated.ExitCode, - HasExitedOverride: () => isolated.HasExited); - } + StandardOutputCallback = OnStdout, + StandardErrorCallback = OnStderr, + IsolateConsole = runControl?.IsolateConsole ?? false, + GracefulShutdownSignaler = runControl?.GracefulShutdownSignaler, + ShutdownService = runControl?.ShutdownService, + KillEntireProcessTreeOnCancel = !OperatingSystem.IsWindows(), + }; - var process = Process.Start(startInfo)!; + execution = (ProcessExecution)_processExecutionFactory.CreateExecution(startInfo, options); - process.OutputDataReceived += (_, e) => + try { - if (e.Data is not null) - { - OnStdout(process.Id, e.Data); - } - }; - process.ErrorDataReceived += (_, e) => + execution.Start(); + } + catch { - if (e.Data is not null) - { - OnStderr(process.Id, e.Data); - } - }; - - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + execution.DisposeAsync().AsTask().GetAwaiter().GetResult(); + throw; + } - return new AppHostServerRunResult( - _socketPath, - process, - outputCollector, - startInfo.FileName, - arguments, - ProcessLifetimeAdapter.ForProcess(process)); + return new AppHostServerRunResult(_socketPath, outputCollector, execution); } internal ProcessStartInfo CreateStartInfo( diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index a14f37eb73b..a52a10ca578 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -3,10 +3,11 @@ using System.Diagnostics; using Aspire.Cli.Diagnostics; -using Aspire.Cli.Processes; +using Aspire.Cli.DotNet; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Projects; @@ -19,17 +20,24 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher private readonly ILogger _logger; private readonly FileLoggerProvider? _fileLoggerProvider; private readonly Func _commandResolver; + private readonly IProcessExecutionFactory _processExecutionFactory; public ProcessGuestLauncher( string language, ILogger logger, FileLoggerProvider? fileLoggerProvider = null, - Func? commandResolver = null) + Func? commandResolver = null, + IProcessExecutionFactory? processExecutionFactory = null) { _language = language; _logger = logger; _fileLoggerProvider = fileLoggerProvider; _commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath; + // The guest launcher does its own per-line trace logging via the per-line callbacks below, + // so the execution's logger is suppressed to avoid double-logging each stdout/stderr line. + // Defaulting here (rather than requiring DI threading through GuestRuntime/ScaffoldingService) + // keeps the construction sites unchanged; the factory is stateless. + _processExecutionFactory = processExecutionFactory ?? new ProcessExecutionFactory(NullLogger.Instance); } public async Task<(int ExitCode, OutputCollector? Output)> LaunchAsync( @@ -63,17 +71,18 @@ public ProcessGuestLauncher( ProfilingTelemetry.AddActivityContextToEnvironment(activity, effectiveEnvironmentVariables); var outputCollector = new OutputCollector(_fileLoggerProvider, CliLogFormat.Categories.AppHost); - var stdoutCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var stderrCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var firstStdoutSeen = 0; var firstStderrSeen = 0; - // Per-line handlers are shared between the two spawn paths. The pid arrives via parameter - // rather than being closed over because the isolated path uses an Action - // at construction time, and we want both paths to fire telemetry/log lines against the same - // pid the line actually came from (no race against the Process variable assignment). - void HandleStdoutLine(int pid, string line) + // The execution local is forward-referenced by the per-line callbacks so they can read the + // child's pid per line. ProcessInvocationOptions.StandardOutputCallback is Action + // (line only), but the guest wants the pid in each trace line. The callbacks only fire after + // Start(), by which point `execution` is assigned and ProcessId is valid. + IProcessExecution execution = null!; + + void HandleStdoutLine(string line) { + var pid = execution.ProcessId; if (Interlocked.Exchange(ref firstStdoutSeen, 1) == 0) { AddEvent(activity, ProfilingTelemetry.Events.GuestFirstStdout, TelemetryConstants.Tags.ProcessPid, pid); @@ -83,8 +92,9 @@ void HandleStdoutLine(int pid, string line) outputCollector.AppendOutput(line); } - void HandleStderrLine(int pid, string line) + void HandleStderrLine(string line) { + var pid = execution.ProcessId; if (Interlocked.Exchange(ref firstStderrSeen, 1) == 0) { AddEvent(activity, ProfilingTelemetry.Events.GuestFirstStderr, TelemetryConstants.Tags.ProcessPid, pid); @@ -94,203 +104,94 @@ void HandleStderrLine(int pid, string line) outputCollector.AppendError(line); } - Process process; - IAsyncDisposable? lifetime = null; - Task stdoutDrain; - Task stderrDrain; - // Readers for exit-code / has-exited that route through the IsolatedProcess wrapper on - // the isolated path. Process.ExitCode on a Process.GetProcessById-derived instance - // throws InvalidOperationException on Windows ("Process was not started by this - // object") — see https://github.com/dotnet/runtime/issues/45003. The wrapper sidesteps - // this by querying GetExitCodeProcess directly against the kept CreateProcess handle. - Func readExitCode; - Func readHasExited; - - AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); - - try + // Canonical ProcessStartInfo — the factory translates it into the right spawn mode + // (isolated console group on the run path, ordinary redirected process elsewhere) and + // strips ASPIRE_CLI_* identity overrides from the child env. The environment is overlaid + // onto the inherited parent block, matching the previous inherited-console behavior. + var startInfo = new ProcessStartInfo { - if (options?.IsolateConsoleForGracefulShutdown == true) - { - // Run-path spawn: isolated console group + anonymous-pipe stdio so DCP's - // AttachConsole + GenerateConsoleCtrlEvent dance can target the guest without - // also signalling the CLI itself. Build the canonical ProcessStartInfo first so - // env/arg shape stays identical to the inherited branch; IsolatedConsoleSpawner - // translates to IsolatedProcessStartInfo and fail-fasts on Windows + null job. - var startInfo = new ProcessStartInfo - { - FileName = resolvedCommandPath, - WorkingDirectory = workingDirectory.FullName, - }; + FileName = resolvedCommandPath, + WorkingDirectory = workingDirectory.FullName, + }; - foreach (var arg in args) - { - startInfo.ArgumentList.Add(arg); - } + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } - foreach (var (key, value) in effectiveEnvironmentVariables) - { - startInfo.Environment[key] = value; - } + foreach (var (key, value) in effectiveEnvironmentVariables) + { + startInfo.Environment[key] = value; + } - var isolatedChild = IsolatedConsoleSpawner.StartIsolated( - startInfo, - HandleStdoutLine, - HandleStderrLine); - process = isolatedChild.Process; - stdoutDrain = isolatedChild.StandardOutputClosed; - stderrDrain = isolatedChild.StandardErrorClosed; - lifetime = isolatedChild; - readExitCode = () => isolatedChild.ExitCode; - readHasExited = () => isolatedChild.HasExited; - } - else - { - // Inherited-console spawn — today's behavior, retained for non-Run callers - // (publish, scaffolding) where the new-console dance is unnecessary. - var startInfo = new ProcessStartInfo - { - FileName = resolvedCommandPath, - WorkingDirectory = workingDirectory.FullName, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; + var invocationOptions = new ProcessInvocationOptions + { + StandardOutputCallback = HandleStdoutLine, + StandardErrorCallback = HandleStderrLine, + // Run-path spawn: isolated console group + anonymous-pipe stdio so DCP's AttachConsole + + // GenerateConsoleCtrlEvent dance can target the guest without also signalling the CLI. + IsolateConsole = options?.IsolateConsoleForGracefulShutdown == true, + GracefulShutdownSignaler = options?.GracefulShutdownSignaler, + ShutdownService = options?.ShutdownService, + // The guest is the AppHost's primary process; always tree-kill on escalation so no + // descendants (tsx/node) are orphaned. This fallback only governs the no-graceful path + // (non-Run callers); the graceful ladder always tree-kills regardless. + KillEntireProcessTreeOnCancel = true, + }; - foreach (var arg in args) - { - startInfo.ArgumentList.Add(arg); - } + AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); - foreach (var (key, value) in effectiveEnvironmentVariables) - { - startInfo.EnvironmentVariables[key] = value; - } + execution = _processExecutionFactory.CreateExecution(startInfo, invocationOptions); - var inheritedProcess = new Process { StartInfo = startInfo }; - // Publish the lifetime immediately so a fault between here and the end of the - // wiring block (Start, BeginOutputReadLine, BeginErrorReadLine) still runs disposal - // through the finally — Process owns an OS handle even before Start. - lifetime = ProcessLifetimeAdapter.ForProcess(inheritedProcess); - inheritedProcess.OutputDataReceived += (sender, e) => - { - if (e.Data is null) - { - // ProcessDataReceivedEventArgs.Data is null when the redirected stdout stream closes. - stdoutCompleted.TrySetResult(); - } - else - { - HandleStdoutLine(inheritedProcess.Id, e.Data); - } - }; - inheritedProcess.ErrorDataReceived += (sender, e) => - { - if (e.Data is null) - { - // ProcessDataReceivedEventArgs.Data is null when the redirected stderr stream closes. - stderrCompleted.TrySetResult(); - } - else - { - HandleStderrLine(inheritedProcess.Id, e.Data); - } - }; - inheritedProcess.Start(); - inheritedProcess.BeginOutputReadLine(); - inheritedProcess.BeginErrorReadLine(); - process = inheritedProcess; - stdoutDrain = stdoutCompleted.Task; - stderrDrain = stderrCompleted.Task; - // Non-isolated path: Process was created via new Process { StartInfo = ... } + - // Start(), so Process.ExitCode / Process.HasExited work normally on every OS. - readExitCode = () => inheritedProcess.ExitCode; - readHasExited = () => inheritedProcess.HasExited; - } + try + { + execution.Start(); - _logger.LogDebug("{Language} guest process {ProcessId} started: {Command}", _language, process.Id, resolvedCommandPath); - activity?.SetTag(TelemetryConstants.Tags.ProcessPid, process.Id); - AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStarted, TelemetryConstants.Tags.ProcessPid, process.Id); + _logger.LogDebug("{Language} guest process {ProcessId} started: {Command}", _language, execution.ProcessId, resolvedCommandPath); + activity?.SetTag(TelemetryConstants.Tags.ProcessPid, execution.ProcessId); + AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStarted, TelemetryConstants.Tags.ProcessPid, execution.ProcessId); if (afterLaunchAsync is not null) { await afterLaunchAsync().ConfigureAwait(false); } + int finalExitCode; try { using var _ = cancellationToken.Register(() => - _logger.LogInformation("Cancellation requested while waiting for {Language} guest process {ProcessId} to exit", _language, process.Id)); + _logger.LogInformation("Cancellation requested while waiting for {Language} guest process {ProcessId} to exit", _language, execution.ProcessId)); - await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + // WaitForExitAsync owns the shutdown ladder: on cancellation it runs the shared + // graceful-then-tree-kill (or force-kill fallback) decision and drains the output + // streams before rethrowing OCE. There is no separate shutdown driver here. + finalExitCode = await execution.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { - // The guest process is the AppHost's primary process for this language. When the caller - // cancels - either because the user pressed Ctrl+C or because a fatal startup condition - // (e.g. the AppHost server backchannel timed out) escalated into a teardown - we must kill - // the process tree, otherwise the AppHost stays alive after the CLI returns and the run - // appears to hang from the user's perspective. - // - // We don't rethrow the OperationCanceledException because the caller in GuestAppHostProject - // uses the returned exit code to distinguish user cancellation from internal teardown - // (e.g. surfacing captured output when the guest was killed because the AppHost system - // failed). Wait without honoring cancellation so the OS reports the final exit code and - // the redirected output streams have time to drain. - await ShutdownGuestProcessAsync(process, options).ConfigureAwait(false); + // The guest process is the AppHost's primary process for this language. The execution + // has already killed the tree and drained output by the time the OCE surfaces. We don't + // rethrow because the caller in GuestAppHostProject uses the returned exit code to + // distinguish user cancellation from internal teardown (surfacing captured output when + // the guest was killed because the AppHost system failed). Read the final code from the + // now-exited process; -1 only if the kill somehow left it observably alive. + finalExitCode = execution.HasExited ? execution.ExitCode : -1; } - _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, readExitCode()); - - var finalExitCode = readExitCode(); + _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, execution.ProcessId, finalExitCode); activity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, finalExitCode); AddEvent(activity, ProfilingTelemetry.Events.GuestProcessExited, TelemetryConstants.Tags.ProcessExitCode, finalExitCode); - // Wait for the redirected streams to finish draining so no trailing lines are lost. - // Pass a fresh token rather than the outer cancellation token: when WaitForExitAsync - // above was canceled we deliberately killed the process and want to give the streams - // their full 5s grace period to flush trailing lines, otherwise drain would short-circuit - // immediately and we'd both drop output and log a misleading "drain timeout" warning. - if (!await WaitForDrainAsync(Task.WhenAll(stdoutDrain, stderrDrain))) - { - AddEvent(activity, ProfilingTelemetry.Events.GuestOutputDrainTimeout, TelemetryConstants.Tags.ProcessPid, process.Id); - _logger.LogWarning("{Language}({ProcessId}): Timed out waiting for output streams to drain after process exit", _language, process.Id); - } - return (finalExitCode, outputCollector); } finally { - // Single disposal site for both spawn paths. The lifetime is either an IsolatedProcess - // (which drains pumps + closes the anonymous pipes + NUL stdin handle on top of - // disposing the Process) or a ProcessLifetimeAdapter that just disposes the Process. - // Null when the spawn itself threw (e.g. IsolatedConsoleSpawner fail-fast) — nothing - // to dispose in that case. - if (lifetime is not null) - { - await lifetime.DisposeAsync().ConfigureAwait(false); - } + // Single disposal site. The execution drains its stdout/stderr pumps (bounded internally) + // and, on the isolated path, releases the anonymous pipes + NUL stdin handle on top of + // disposing the underlying process. + await execution.DisposeAsync().ConfigureAwait(false); } } - private Task ShutdownGuestProcessAsync( - Process process, - GuestLaunchOptions? options) - { - // Run-path graceful ladder shared with AppHostServerSession and ProcessExecution when the - // central budget is wired and enabled; otherwise a best-effort force-kill for non-Run callers - // (publish, extension adapter) that didn't opt into the central shutdown budget. The coordinator - // starts the central graceful clock when it selects the ladder, so the wait is always bounded. - return ProcessShutdownCoordinator.ShutdownAsync( - process, - options?.GracefulShutdownSignaler, - options?.ShutdownService, - fallbackRequestGracefulShutdown: !OperatingSystem.IsWindows(), - fallbackKillEntireProcessTree: true, - _logger, - $"{_language} guest"); - } - private static Activity? GetCurrentProfilingActivity() { var activity = Activity.Current; @@ -315,23 +216,4 @@ private static void AddEvent(Activity? activity, string eventName, string? tagNa [tagName] = tagValue })); } - - private static async Task WaitForDrainAsync(Task drainTask) - { - // Bounded grace period for stdout/stderr to flush after the process exits. Intentionally - // does not honor any outer cancellation token: callers reach here after killing the - // process on cancellation and we want to give the streams their full budget to surface - // trailing output regardless of why we got here. - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - try - { - await drainTask.WaitAsync(timeoutCts.Token); - return true; - } - catch (OperationCanceledException) - { - return false; - } - - } } diff --git a/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs index f9365c651b8..0e3d3938332 100644 --- a/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs @@ -114,6 +114,7 @@ private static DotNetBasedAppHostServerProject CreateProject(string appPath, Tes repoRoot: appPath, new TestDotNetCliRunner(), packagingService, + new TestProcessExecutionFactory(), NullLogger.Instance, projectModelPath); } diff --git a/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs index a2445716768..ce15ca2276a 100644 --- a/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs @@ -107,6 +107,7 @@ private static PrebuiltAppHostServer CreateServer(string appPath) new TestDotNetSdkInstaller(), MockPackagingServiceFactory.Create(), TestExecutionContextFactory.CreateTestContext(), + new TestProcessExecutionFactory(), NullLogger.Instance); } } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index ac1d16f1a85..230f7cca736 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -40,7 +40,7 @@ private DotNetBasedAppHostServerProject CreateProject(string? appPath = null) // Use workspace root as repo root for testing var repoRoot = _workspace.WorkspaceRoot.FullName; - return new DotNetBasedAppHostServerProject(appPath, socketPath, repoRoot, runner, packagingService, logger); + return new DotNetBasedAppHostServerProject(appPath, socketPath, repoRoot, runner, packagingService, new TestProcessExecutionFactory(), logger); } [Fact] @@ -312,7 +312,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ // Use a workspace-local ProjectModelPath for test isolation var projectModelPath = Path.Combine(appPath, ".aspire_server"); - var project = new DotNetBasedAppHostServerProject(appPath, "test.sock", appPath, runner, packagingService, logger, projectModelPath); + var project = new DotNetBasedAppHostServerProject(appPath, "test.sock", appPath, runner, packagingService, new TestProcessExecutionFactory(), logger, projectModelPath); var packages = new List { @@ -392,6 +392,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ appPath, new TestDotNetCliRunner(), packagingService, + new TestProcessExecutionFactory(), NullLogger.Instance, projectModelPath); @@ -444,6 +445,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ appPath, new TestDotNetCliRunner(), packagingService, + new TestProcessExecutionFactory(), NullLogger.Instance, projectModelPath); diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 5d84cfb36e5..cfd23fca398 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -4,9 +4,9 @@ using System.Diagnostics; using Aspire.Cli.Bundles; using Aspire.Cli.Configuration; +using Aspire.Cli.DotNet; using Aspire.Cli.Layout; using Aspire.Cli.NuGet; -using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.Mcp; @@ -150,7 +150,11 @@ public async Task GetRpcClientAsync_WhenServerExitsBeforeSocketIsAvailable_Fails // Wait for the process to exit so the stopwatch measures only the early-exit detection // latency, not the variable execution time of "dotnet --version" on loaded CI machines. - await project.StartedProcess!.WaitForExitAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + // Poll the OS by pid rather than awaiting the execution's WaitForExitAsync, which the + // session's own drive loop is already awaiting on the same execution instance. + Assert.True( + WaitForProcessExit(project.StartedExecution!.ProcessId, TimeSpan.FromSeconds(30)), + "Expected the server probe process to exit."); var stopwatch = Stopwatch.StartNew(); var exception = await Assert.ThrowsAsync( @@ -175,7 +179,7 @@ public void SessionState_BeforeStart_IsNull() Assert.Null(session.SocketPath); Assert.Null(session.Output); - Assert.Null(session.ServerProcess); + Assert.Null(session.ServerProcessId); } [Fact] @@ -210,15 +214,15 @@ public async Task StartAsync_StopRequested_KillsProcessAndCompletesTask() // Process should be running before we ask the session to stop. Assert.False(completion.IsCompleted); - Assert.False(session.ServerProcess!.HasExited); + Assert.False(session.HasServerExited); stopCts.Cancel(); // The session's stop registration fires Kill synchronously inline. The Exited // event fires asynchronously, so allow the completion task to observe the result. var exitCode = await completion.WaitAsync(TimeSpan.FromSeconds(30)); - Assert.True(session.ServerProcess!.HasExited); - Assert.Equal(session.ServerProcess.ExitCode, exitCode); + Assert.True(session.HasServerExited); + Assert.Equal(session.TryGetServerExitCode(), exitCode); } [Fact] @@ -260,13 +264,13 @@ public async Task StartAsync_StopRequested_WithGracefulServices_InvokesGracefulS var completion = session.StartAsync(); Assert.False(completion.IsCompleted); - var serverPid = session.ServerProcess!.Id; + var serverPid = session.ServerProcessId!.Value; stopCts.Cancel(); await completion.WaitAsync(TimeSpan.FromSeconds(30)); - Assert.True(session.ServerProcess.HasExited); + Assert.True(session.HasServerExited); Assert.Contains(serverPid, signaler.Pids); // Graceful budget was never exhausted in this scenario — the signaler simulated success // and WaitForExitAsync observed the exit before anyone called Expire(). @@ -319,7 +323,7 @@ public async Task StartAsync_StopRequested_GracefulIgnored_ExpireEscalatesToTree await completion.WaitAsync(TimeSpan.FromSeconds(30)); - Assert.True(session.ServerProcess!.HasExited); + Assert.True(session.HasServerExited); } [Fact] @@ -356,7 +360,7 @@ public async Task StartAsync_StopRequested_GracefulSignalerThrows_StillEscalates await completion.WaitAsync(TimeSpan.FromSeconds(30)); - Assert.True(session.ServerProcess!.HasExited); + Assert.True(session.HasServerExited); Assert.Single(signaler.Pids); } @@ -385,9 +389,8 @@ public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithout var completion = session.StartAsync(); Assert.False(completion.IsCompleted); - var serverProcess = session.ServerProcess!; - var pid = serverProcess.Id; - Assert.False(serverProcess.HasExited); + var pid = session.ServerProcessId!.Value; + Assert.False(session.HasServerExited); // DisposeAsync must return promptly even though the graceful token will never fire and // the process would otherwise run for a minute. The 30 s timeout is the regression check: @@ -508,6 +511,24 @@ public void CreatePrebuiltAppHostServer_DisposesLayoutLeaseWhenConstructorFails( } } + private static IProcessExecution CreateServerExecution(ProcessStartInfo startInfo, AppHostServerRunControl? runControl) + { + // Build a real execution through the production factory so the test exercises the unified + // IProcessExecution shutdown ladder rather than a bespoke fake. The options mirror what + // DotNetBasedAppHostServerProject.Run wires from the run control. + var options = new ProcessInvocationOptions + { + GracefulShutdownSignaler = runControl?.GracefulShutdownSignaler, + ShutdownService = runControl?.ShutdownService, + // Matches production: the graceful ladder always tree-kills on escalation; this fallback + // only governs the no-graceful-services path, where Unix force-kills the tree. + KillEntireProcessTreeOnCancel = !OperatingSystem.IsWindows(), + }; + + return new ProcessExecutionFactory(NullLogger.Instance) + .CreateExecution(startInfo, options); + } + private static bool WaitForProcessExit(int pid, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; @@ -566,6 +587,7 @@ private static AppHostServerProjectFactory CreateAppHostServerProjectFactory() nugetService, new TestDotNetSdkInstaller(), executionContext, + new TestProcessExecutionFactory(), NullLoggerFactory.Instance); } @@ -575,7 +597,7 @@ private sealed class RecordingAppHostServerProject : IAppHostServerProject public Dictionary? ReceivedEnvironmentVariables { get; private set; } - public Process? StartedProcess { get; private set; } + public IProcessExecution? StartedExecution { get; private set; } public string GetInstanceIdentifier() => AppDirectoryPath; @@ -592,28 +614,28 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false) + AppHostServerRunControl? runControl = null) { ReceivedEnvironmentVariables = environmentVariables is null ? null : new Dictionary(environmentVariables); - var startInfo = new ProcessStartInfo("dotnet", "--version") + var startInfo = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; - var process = Process.Start(startInfo)!; + startInfo.ArgumentList.Add("--version"); + + var execution = CreateServerExecution(startInfo, runControl); + execution.Start(); - StartedProcess = process; + StartedExecution = execution; return new AppHostServerRunResult( SocketPath: "test.sock", - Process: process, OutputCollector: new OutputCollector(), - FileName: startInfo.FileName, - Arguments: new[] { "--version" }, - ProcessLifetime: ProcessLifetimeAdapter.ForProcess(process)); + Execution: execution); } } @@ -636,30 +658,32 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false) + AppHostServerRunControl? runControl = null) { // Use a cross-platform long-running command so the test exercises the kill path // rather than a quickly-exiting probe like `dotnet --version`. var (fileName, arguments) = OperatingSystem.IsWindows() - ? ("cmd.exe", "/c pause") - : ("sleep", "60"); + ? ("cmd.exe", new[] { "/c", "pause" }) + : ("sleep", new[] { "60" }); - var startInfo = new ProcessStartInfo(fileName, arguments) + var startInfo = new ProcessStartInfo(fileName) { - RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; - var process = Process.Start(startInfo)!; + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg); + } + + var execution = CreateServerExecution(startInfo, runControl); + execution.Start(); return new AppHostServerRunResult( SocketPath: "test.sock", - Process: process, OutputCollector: new OutputCollector(), - FileName: fileName, - Arguments: new[] { arguments }, - ProcessLifetime: ProcessLifetimeAdapter.ForProcess(process)); + Execution: execution); } } @@ -684,7 +708,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false) => + AppHostServerRunControl? runControl = null) => throw new InvalidOperationException("simulated launch failure"); public void Dispose() => Disposed = true; diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 34ad8ecbc9a..2229a614369 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -242,6 +242,7 @@ public void Constructor_UsesWorkspaceAspireDirectoryForWorkingDirectory() new TestDotNetSdkInstaller(), Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), + new TestProcessExecutionFactory(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var workingDirectory = Assert.IsType( @@ -292,6 +293,7 @@ public void Constructor_UsesDistinctWorkingDirectoriesForMultipleAppHostsInSameW new TestDotNetSdkInstaller(), Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), + new TestProcessExecutionFactory(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var firstServer = CreateServer(firstAppHost.FullName); @@ -923,6 +925,7 @@ public async Task GetNuGetSources_NonStagingRequest_NotAffectedByStagingUnavaila new TestDotNetSdkInstaller(), packagingService, executionContext, + new TestProcessExecutionFactory(), NullLogger.Instance); var sources = await server.GetNuGetSourcesAsync("daily", packageSourceOverride: null, CancellationToken.None); @@ -969,6 +972,7 @@ private static PrebuiltAppHostServer CreateServerWithUnavailableStagingChannel( new TestDotNetSdkInstaller(), packagingService, executionContext, + new TestProcessExecutionFactory(), NullLogger.Instance); } @@ -1032,6 +1036,7 @@ private static PrebuiltAppHostServer CreateServerWithPackagingService( new TestDotNetSdkInstaller(), packagingService, executionContext, + new TestProcessExecutionFactory(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } @@ -1086,6 +1091,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ new TestDotNetSdkInstaller(), Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), + new TestProcessExecutionFactory(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var channel = server.ResolveRequestedChannel(); @@ -1108,6 +1114,7 @@ public async Task PrepareAsync_WithNoIntegrations_WritesDefaultAppSettings() new TestDotNetSdkInstaller(), MockPackagingServiceFactory.Create(), TestExecutionContextFactory.CreateTestContext(), + new TestProcessExecutionFactory(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var workingDirectory = GetWorkingDirectory(server); @@ -1772,6 +1779,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ new TestDotNetSdkInstaller(), packagingService, TestExecutionContextFactory.CreateTestContext(), + new TestProcessExecutionFactory(), NullLogger.Instance); var workingDirectory = GetWorkingDirectory(server); @@ -1916,6 +1924,7 @@ public async Task PrepareAsync_WithProjectReferencesAndPackageSourceOverride_Use new TestDotNetSdkInstaller(), MockPackagingServiceFactory.Create(), TestExecutionContextFactory.CreateTestContext(), + new TestProcessExecutionFactory(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var workingDirectory = GetWorkingDirectory(server); @@ -2011,6 +2020,7 @@ public async Task PrepareAsync_WithStagingPinnedProjectOutsideLaunchDirectory_Us new TestDotNetSdkInstaller(), packagingService, executionContext, + new TestProcessExecutionFactory(), NullLogger.Instance); var workingDirectory = GetWorkingDirectory(server); @@ -2493,6 +2503,7 @@ private static PrebuiltAppHostServer CreateProjectReferenceServer( new TestDotNetSdkInstaller(), MockPackagingServiceFactory.Create(), TestExecutionContextFactory.CreateTestContext(), + new TestProcessExecutionFactory(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } @@ -2523,6 +2534,7 @@ private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory Execut new TestDotNetSdkInstaller(), packagingService, TestExecutionContextFactory.CreateTestContext(), + new TestProcessExecutionFactory(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); return (server, executionFactory); @@ -2669,6 +2681,7 @@ public void CreateStartInfo_SetsCliLogFilePathEnvironmentVariable() new TestDotNetSdkInstaller(), MockPackagingServiceFactory.Create(), executionContext, + new TestProcessExecutionFactory(), NullLogger.Instance); var startInfo = server.CreateStartInfo(123); diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index 34be08a84d4..bc82a7965e9 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -143,7 +143,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false) => + AppHostServerRunControl? runControl = null) => throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); } } diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index c1a86470cc4..9603fce3e08 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -34,7 +34,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false) => + AppHostServerRunControl? runControl = null) => throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); public void Dispose() diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs index 34263e66473..05b873ce6ba 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs @@ -31,7 +31,7 @@ public AppHostServerRunResult Run( IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, bool debug = false, - bool isolateConsole = false) => + AppHostServerRunControl? runControl = null) => throw new NotSupportedException("Run should not be invoked when using a fake codegen session."); public void Dispose() diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs index 8be0b006615..3b874dc6a94 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs @@ -18,7 +18,7 @@ internal sealed class TestProcessExecutionFactory : IProcessExecutionFactory private int _attemptCount; /// - /// Gets or sets a callback that is invoked when is called. + /// Gets or sets a callback that is invoked when CreateExecution is called. /// If this returns an , that execution is returned directly. /// public Func?, DirectoryInfo, ProcessInvocationOptions, IProcessExecution>? CreateExecutionCallback { get; set; } @@ -26,7 +26,7 @@ internal sealed class TestProcessExecutionFactory : IProcessExecutionFactory public Func?, DirectoryInfo, ProcessInvocationOptions, IProcessExecution>? CreateExecutionWithFileNameCallback { get; set; } /// - /// Gets or sets an action that is invoked when is called, + /// Gets or sets an action that is invoked when CreateExecution is called, /// typically used for assertions on the arguments. /// public Action?, DirectoryInfo, ProcessInvocationOptions>? AssertionCallback { get; set; } @@ -69,7 +69,7 @@ internal sealed class TestProcessExecutionFactory : IProcessExecutionFactory public ProcessInvocationOptions? LastProcessInvocationOptions { get; private set; } /// - /// Gets the number of times has been called. + /// Gets the number of times CreateExecution has been called. /// public int AttemptCount => _attemptCount; @@ -111,6 +111,29 @@ public IProcessExecution CreateExecution(string fileName, string[] args, IDictio CreatedExecutions.Add(testExecution); return testExecution; } + + public IProcessExecution CreateExecution(System.Diagnostics.ProcessStartInfo startInfo, ProcessInvocationOptions options) + { + // Translate the fully-populated ProcessStartInfo into the (fileName, args, env, workingDirectory) + // shape the rest of this fake understands, so the AppHost server / guest spawn paths (which use + // the PSI overload) flow through the same assertion + callback machinery as every other caller. + var args = startInfo.ArgumentList.ToArray(); + + // Only forward an env dictionary when the caller supplied custom values; otherwise leave it null + // so we don't materialize (and assert against) the entire inherited parent environment. + IDictionary? env = null; + if (startInfo.Environment.Count > 0) + { + env = startInfo.Environment + .Where(static kvp => kvp.Value is not null) + .ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value!); + } + + var workingDirectory = new DirectoryInfo( + string.IsNullOrEmpty(startInfo.WorkingDirectory) ? Directory.GetCurrentDirectory() : startInfo.WorkingDirectory); + + return CreateExecution(startInfo.FileName, args, env, workingDirectory, options); + } } internal sealed class TestProcessExecution : IProcessExecution From 231530ce74d1cc643506b01bf6e9933f0fbd26bd Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 18 Jun 2026 15:51:16 -0700 Subject: [PATCH 22/56] Fold ProcessShutdownCoordinator into ProcessExecution ProcessShutdownCoordinator was a static class with a single method whose only caller was ProcessExecution.WaitForExitAsync. Inline its ladder-vs-force-kill decision as a private ShutdownOnCancelAsync method on ProcessExecution (carrying over the s_immediateEscalation pre-cancelled token and its rationale), delete the class, and retarget the IGracefulShutdownWindow doc cref at ProcessExecution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/ConsoleCancellationManager.cs | 2 +- src/Aspire.Cli/DotNet/ProcessExecution.cs | 59 ++++++++++-- .../Processes/ProcessShutdownCoordinator.cs | 89 ------------------- 3 files changed, 52 insertions(+), 98 deletions(-) delete mode 100644 src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index 33028e497d1..413fa6227e8 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -374,7 +374,7 @@ public void Dispose() /// /// The command-level graceful-shutdown window consumed by every per-child shutdown path -/// ( and the ladders it drives). Implemented by +/// ( and the ladders it drives). Implemented by /// , which owns the budget, the clock, and the token as part /// of the single CLI shutdown service. This narrow contract is what the process-spawn sites depend on /// so they don't take a dependency on the console signal manager in full. It lives in this file rather diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 1e6905543db..5d302be6ea9 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -21,6 +21,13 @@ internal sealed class ProcessExecution : IProcessExecution private static readonly TimeSpan s_drainIdleTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan s_drainPollInterval = TimeSpan.FromMilliseconds(100); + // Pre-cancelled token handed to the force-kill fallback's graceful wait. An already-cancelled + // token makes ProcessTerminator dispatch the best-effort SIGTERM (Unix) and then immediately + // escalate to Kill, rather than waiting for a graceful exit. CancellationToken.None must NOT + // be used here: with requestGracefulShutdown the terminator would WaitForExitAsync(None) and + // block forever if the child ignores SIGTERM. + private static readonly CancellationToken s_immediateEscalation = new(canceled: true); + private readonly IsolatedProcessStartInfo _startInfo; private readonly string _fileName; private readonly IReadOnlyList _arguments; @@ -94,14 +101,7 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) { _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, stopping it", _fileName, process.Id); - await ProcessShutdownCoordinator.ShutdownAsync( - process.Process, - _options.GracefulShutdownSignaler, - _options.ShutdownService, - fallbackRequestGracefulShutdown: !OperatingSystem.IsWindows(), - fallbackKillEntireProcessTree: _options.KillEntireProcessTreeOnCancel, - _logger, - _fileName).ConfigureAwait(false); + await ShutdownOnCancelAsync(process.Process).ConfigureAwait(false); // The child has now been signalled/killed by the coordinator. Drain trailing stdout/stderr // before propagating the cancellation so callers that observe output — or that swallow the @@ -126,6 +126,49 @@ await ProcessShutdownCoordinator.ShutdownAsync( return process.ExitCode; } + /// + /// The single decision point this execution routes through when its child must be torn down on + /// cancellation. Picks between the shared (graceful + /// signal → bounded wait → tree-kill, used on the aspire run path) and the best-effort + /// force-kill fallback (non-Run callers). + /// + /// + /// The graceful-vs-force decision is command-level and all-or-nothing: it keys off + /// (true when the running command configured a + /// positive budget). There is no per-child or per-call flag. When the ladder is selected this also + /// starts the central clock via , so the + /// ladder's wait is always bounded regardless of whether teardown was initiated by a user signal or + /// by disposal of the child owner. + /// + private Task ShutdownOnCancelAsync(Process process) + { + var signaler = _options.GracefulShutdownSignaler; + var gracefulShutdownWindow = _options.ShutdownService; + + if (signaler is not null && gracefulShutdownWindow is { IsEnabled: true }) + { + // Start the central clock so the ladder's wait is bounded even when teardown was triggered + // by disposal (e.g. normal aspire run completion) rather than a user signal. Idempotent — + // if a user Ctrl+C already armed the window this is a no-op. + gracefulShutdownWindow.BeginGracefulWindow(); + + return ProcessGracefulShutdownLadder.ExecuteAsync( + process, + signaler, + gracefulShutdownWindow.GracefulShutdownToken, + _logger, + _fileName); + } + + return ProcessTerminator.ShutdownAsync( + process, + requestGracefulShutdown: !OperatingSystem.IsWindows(), + entireProcessTree: _options.KillEntireProcessTreeOnCancel, + _logger, + _fileName, + gracefulShutdownCancellationToken: s_immediateEscalation); + } + /// public void Kill(bool entireProcessTree) => Process.Kill(entireProcessTree); diff --git a/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs b/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs deleted file mode 100644 index 230ea7a557b..00000000000 --- a/src/Aspire.Cli/Processes/ProcessShutdownCoordinator.cs +++ /dev/null @@ -1,89 +0,0 @@ -// 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 Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Processes; - -/// -/// The single decision point every CLI process owner routes through when a child must be torn -/// down on cancellation. Picks between the shared -/// (graceful signal → bounded wait → tree-kill, used on the aspire run path) and the -/// best-effort force-kill fallback (non-Run callers). -/// -/// -/// This consolidates the "ladder vs. force-kill" branch that previously lived, copy-pasted and -/// drifted, in ProcessExecution, IsolatedProcessExecution, AppHostServerSession -/// and ProcessGuestLauncher. Having one place means the choice — and the fallback's exact -/// semantics — can only be defined once. -/// -/// The graceful-vs-force decision is command-level and all-or-nothing: it keys off -/// (true when the running command configured a -/// positive budget). No per-child or per-call flag. When the ladder is selected this also starts -/// the central clock via , so the ladder's -/// wait is always bounded regardless of whether teardown was initiated by a user signal or by -/// disposal of the child owner. -/// -internal static class ProcessShutdownCoordinator -{ - // Pre-cancelled token handed to the force-kill fallback's graceful wait. An already-cancelled - // token makes ProcessTerminator dispatch the best-effort SIGTERM (Unix) and then immediately - // escalate to Kill, rather than waiting for a graceful exit. CancellationToken.None must NOT - // be used here: with requestGracefulShutdown the terminator would WaitForExitAsync(None) and - // block forever if the child ignores SIGTERM. - private static readonly CancellationToken s_immediateEscalation = new(canceled: true); - - /// - /// Tears down on cancellation, running the graceful ladder when the - /// run-path graceful infrastructure is wired and enabled for the command, otherwise force-killing. - /// - /// The child process to shut down. - /// Graceful signaler, or for non-Run callers. - /// - /// The command-level graceful window (budget + token), or for non-Run callers. - /// - /// - /// Whether the force-kill fallback should dispatch a best-effort graceful signal (SIGTERM) before - /// killing. Typically !OperatingSystem.IsWindows(). - /// - /// Whether the force-kill fallback kills the whole tree. - /// Logger for diagnostics. - /// Short human description used in log messages. - public static Task ShutdownAsync( - Process process, - IProcessTreeGracefulShutdownSignaler? signaler, - IGracefulShutdownWindow? gracefulShutdownWindow, - bool fallbackRequestGracefulShutdown, - bool fallbackKillEntireProcessTree, - ILogger logger, - string processDescription) - { - ArgumentNullException.ThrowIfNull(process); - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(processDescription); - - if (signaler is not null && gracefulShutdownWindow is { IsEnabled: true }) - { - // Start the central clock so the ladder's wait is bounded even when teardown was triggered - // by disposal (e.g. normal aspire run completion) rather than a user signal. Idempotent — - // if a user Ctrl+C already armed the window this is a no-op. - gracefulShutdownWindow.BeginGracefulWindow(); - - return ProcessGracefulShutdownLadder.ExecuteAsync( - process, - signaler, - gracefulShutdownWindow.GracefulShutdownToken, - logger, - processDescription); - } - - return ProcessTerminator.ShutdownAsync( - process, - requestGracefulShutdown: fallbackRequestGracefulShutdown, - entireProcessTree: fallbackKillEntireProcessTree, - logger, - processDescription, - gracefulShutdownCancellationToken: s_immediateEscalation); - } -} From 048ad2948a06aa555c1243851058a890a0607b1c Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 18 Jun 2026 16:17:57 -0700 Subject: [PATCH 23/56] Merge ProcessTerminator into ProcessGracefulShutdownLadder Collapse the two parallel child-process shutdown helpers into one. The ladder gains a force-kill fallback mode (best-effort SIGTERM on Unix, then hard-kill) selected when no graceful signaler is supplied, so it serves both the aspire run graceful path and every non-Run caller. - ProcessExecution.ShutdownOnCancelAsync routes both branches through the single ProcessGracefulShutdownLadder.ShutdownAsync; drop the now-unused pre-cancelled s_immediateEscalation token. - aspire stop's force-kill tail now calls ProcessSignaler.ForceKill directly (byte-for-byte equivalent to the old ProcessTerminator force path); ForceKillRemainingProcesses is now synchronous. - Delete ProcessTerminator.cs and retarget its doc crefs to the merged ladder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 20 +-- src/Aspire.Cli/DotNet/ProcessExecution.cs | 26 ++-- .../ProcessGracefulShutdownLadder.cs | 111 +++++++++++++++-- src/Aspire.Cli/Processes/ProcessTerminator.cs | 116 ------------------ .../ProcessTreeGracefulShutdownService.cs | 18 ++- .../Projects/DotNetAppHostProject.cs | 2 +- 6 files changed, 127 insertions(+), 166 deletions(-) delete mode 100644 src/Aspire.Cli/Processes/ProcessTerminator.cs diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 434e608bdcd..c2b3d19d9c9 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -75,8 +75,8 @@ internal sealed class ProcessInvocationOptions /// Pair with and . /// On Windows the spawned process is bound to the process-wide /// kill-on-close job automatically. - /// Leaving the signaler/service unset means cancellation falls back to today's - /// force-kill behavior, preserving back-compat + /// Leaving the signaler/service unset means cancellation falls back to the shared + /// force-kill mode, preserving back-compat /// for the many non-Run callers (build, restore, package add, layout, etc.). /// public bool IsolateConsole { get; set; } @@ -84,15 +84,15 @@ internal sealed class ProcessInvocationOptions /// /// Issues the graceful shutdown signal during the shared shutdown ladder (DCP /// stop-process-tree on Windows, SIGTERM on Unix). When null, the cancellation - /// path uses today's force-kill behavior. + /// path uses the shared force-kill mode. /// public Processes.IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler { get; set; } /// /// The central graceful-shutdown window whose /// bounds the shared ladder. When - /// null, the cancellation path uses today's - /// force-kill behavior. + /// null, the cancellation path uses the shared + /// force-kill mode. /// public IGracefulShutdownWindow? ShutdownService { get; set; } } @@ -308,11 +308,11 @@ private static ProcessInvocationOptions CreateInstrumentedProcessOptions( SuppressLogging = options.SuppressLogging, KillEntireProcessTreeOnCancel = options.KillEntireProcessTreeOnCancel, // Forward the Run-path shutdown ladder opt-ins. Forgetting any of these silently - // demotes the run to the legacy ProcessTerminator path: IsolateConsole=false skips - // console isolation, and the null signaler/service pair causes ProcessExecution's - // OCE catch (DotNet/ProcessExecution.cs) to fall through to ProcessTerminator - // instead of the shared ProcessGracefulShutdownLadder. Build/restore/etc. callers - // leave these unset and intentionally keep the legacy path. + // demotes the run to the force-kill fallback: IsolateConsole=false skips console + // isolation, and the null signaler/service pair causes ProcessExecution's OCE catch + // (DotNet/ProcessExecution.cs) to route through ProcessGracefulShutdownLadder's force + // mode (best-effort SIGTERM then kill) instead of its graceful ladder. Build/restore/etc. + // callers leave these unset and intentionally keep the force-kill path. IsolateConsole = options.IsolateConsole, GracefulShutdownSignaler = options.GracefulShutdownSignaler, ShutdownService = options.ShutdownService, diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 5d302be6ea9..df8e1bf8af9 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -21,13 +21,6 @@ internal sealed class ProcessExecution : IProcessExecution private static readonly TimeSpan s_drainIdleTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan s_drainPollInterval = TimeSpan.FromMilliseconds(100); - // Pre-cancelled token handed to the force-kill fallback's graceful wait. An already-cancelled - // token makes ProcessTerminator dispatch the best-effort SIGTERM (Unix) and then immediately - // escalate to Kill, rather than waiting for a graceful exit. CancellationToken.None must NOT - // be used here: with requestGracefulShutdown the terminator would WaitForExitAsync(None) and - // block forever if the child ignores SIGTERM. - private static readonly CancellationToken s_immediateEscalation = new(canceled: true); - private readonly IsolatedProcessStartInfo _startInfo; private readonly string _fileName; private readonly IReadOnlyList _arguments; @@ -128,9 +121,9 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) /// /// The single decision point this execution routes through when its child must be torn down on - /// cancellation. Picks between the shared (graceful - /// signal → bounded wait → tree-kill, used on the aspire run path) and the best-effort - /// force-kill fallback (non-Run callers). + /// cancellation. Both branches call the shared : with a + /// signaler for the graceful ladder (the aspire run path) or without one for the best-effort + /// force-kill fallback (non-Run callers). /// /// /// The graceful-vs-force decision is command-level and all-or-nothing: it keys off @@ -152,21 +145,22 @@ private Task ShutdownOnCancelAsync(Process process) // if a user Ctrl+C already armed the window this is a no-op. gracefulShutdownWindow.BeginGracefulWindow(); - return ProcessGracefulShutdownLadder.ExecuteAsync( + return ProcessGracefulShutdownLadder.ShutdownAsync( process, signaler, gracefulShutdownWindow.GracefulShutdownToken, + entireProcessTreeOnForceKill: _options.KillEntireProcessTreeOnCancel, _logger, _fileName); } - return ProcessTerminator.ShutdownAsync( + return ProcessGracefulShutdownLadder.ShutdownAsync( process, - requestGracefulShutdown: !OperatingSystem.IsWindows(), - entireProcessTree: _options.KillEntireProcessTreeOnCancel, + signaler: null, + gracefulToken: CancellationToken.None, + entireProcessTreeOnForceKill: _options.KillEntireProcessTreeOnCancel, _logger, - _fileName, - gracefulShutdownCancellationToken: s_immediateEscalation); + _fileName); } /// diff --git a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs index 3cd506e9eb1..96b7c48f022 100644 --- a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs +++ b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs @@ -7,12 +7,30 @@ namespace Aspire.Cli.Processes; /// -/// Shared "graceful signal → bounded wait → force tree-kill → bounded drain" ladder used by -/// every long-running child process the CLI owns during aspire run -/// (AppHost server, guest, direct-launch AppHost executable). Each call site provides a -/// graceful signaler and the central ; this helper -/// runs the same four-phase escalation against them so the user-visible shutdown shape is -/// uniform across spawn sites. +/// The single child-process shutdown helper for every long-running process the CLI owns. It has two +/// modes selected by whether a graceful signaler is supplied: +/// +/// +/// +/// Graceful (signaler supplied — the aspire run path): runs the +/// "graceful signal → bounded wait → force tree-kill → bounded drain" four-phase escalation +/// against the central , so the +/// user-visible shutdown shape is uniform across spawn sites (AppHost server, guest, +/// direct-launch AppHost executable). +/// +/// +/// +/// +/// Force (no signaler — every non-Run caller: build, restore, package add, layout, the +/// aspire stop force-kill tail, etc.): best-effort courtesy SIGTERM on Unix (a no-op on +/// Windows, where Ctrl+C delivery needs the signaler-backed DCP console dance) followed by an +/// immediate force-kill. There is no graceful budget on this path. +/// +/// +/// +/// Both modes use the same primitives the previous ProcessTerminator / ladder split used — +/// DCP stop-process-tree on Windows and SIGTERM on Unix — they only differ in whether a +/// graceful budget is honored before the kill. /// /// /// Whoever triggers shutdown () is responsible @@ -22,25 +40,44 @@ namespace Aspire.Cli.Processes; internal static class ProcessGracefulShutdownLadder { /// - /// Runs the four-phase shutdown ladder against . + /// Shuts down , choosing the graceful ladder or the force-kill fallback + /// based on whether is supplied. /// /// The child process to shut down. - /// Issues the graceful signal (DCP stop-process-tree on Windows, SIGTERM on Unix). - /// The central . + /// + /// Issues the graceful signal (DCP stop-process-tree on Windows, SIGTERM on Unix). When + /// null, the force-kill fallback runs instead and is ignored. + /// + /// + /// The central bounding the graceful + /// wait. Only consulted when is non-null. + /// + /// + /// Kill scope for the force-kill fallback (used only when is + /// null). The graceful escalation always tree-kills regardless of this value, because a child + /// like tsx can swallow Ctrl+C and leave descendants running even after a clean graceful signal. + /// /// Logger for diagnostics. /// Short human description used in log messages (e.g. "AppHost server"). - public static async Task ExecuteAsync( + public static async Task ShutdownAsync( Process process, - IProcessTreeGracefulShutdownSignaler signaler, + IProcessTreeGracefulShutdownSignaler? signaler, CancellationToken gracefulToken, + bool entireProcessTreeOnForceKill, ILogger logger, string processDescription) { ArgumentNullException.ThrowIfNull(process); - ArgumentNullException.ThrowIfNull(signaler); ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(processDescription); + if (signaler is null) + { + // Force mode: no graceful budget. Best-effort courtesy SIGTERM (Unix) then hard-kill. + ForceKill(process, entireProcessTreeOnForceKill, logger, processDescription); + return; + } + // Phase 1: fire-and-forget the graceful signal so its wait does not consume the // graceful budget. On Windows, DCP's `stop-process-tree` delivers the Ctrl+C signal // synchronously (in milliseconds) and then BLOCKS until the target process actually @@ -159,6 +196,56 @@ await signaler.RequestProcessTreeGracefulShutdownAsync( } } + private static void ForceKill(Process process, bool entireProcessTree, ILogger logger, string processDescription) + { + // Mirrors the previous ProcessTerminator force path: resolve "already gone?", issue a + // best-effort courtesy SIGTERM on Unix (so a SIGTERM-aware child can flush), then hard-kill. + // On Windows ProcessSignaler.RequestGracefulShutdown is a no-op — Ctrl+C delivery to a child + // requires DCP's stop-process-tree console dance, which only the signaler-backed graceful + // ladder performs — so we skip straight to the kill. + try + { + if (process.HasExited) + { + logger.LogDebug("{ProcessDescription} process {ProcessId} already exited.", processDescription, process.Id); + return; + } + + if (!OperatingSystem.IsWindows()) + { + ProcessSignaler.RequestGracefulShutdown(process.Id, expectedStartTime: null, logger); + + if (process.HasExited) + { + return; + } + } + + logger.LogDebug( + "Sending kill to {ProcessDescription} process {ProcessId} (entireProcessTree={EntireProcessTree}).", + processDescription, + process.Id, + entireProcessTree); + process.Kill(entireProcessTree); + } + catch (InvalidOperationException ex) + { + logger.LogDebug( + ex, + "{ProcessDescription} process exited before termination could complete (entireProcessTree={EntireProcessTree}).", + processDescription, + entireProcessTree); + } + catch (Exception ex) + { + logger.LogDebug( + ex, + "Failed to terminate {ProcessDescription} process (entireProcessTree={EntireProcessTree}).", + processDescription, + entireProcessTree); + } + } + private static int SafePid(Process process) { try diff --git a/src/Aspire.Cli/Processes/ProcessTerminator.cs b/src/Aspire.Cli/Processes/ProcessTerminator.cs deleted file mode 100644 index 165a836fabb..00000000000 --- a/src/Aspire.Cli/Processes/ProcessTerminator.cs +++ /dev/null @@ -1,116 +0,0 @@ -// 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 Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Processes; - -/// -/// Provides child process shutdown primitives shared by CLI process owners. -/// -internal static class ProcessTerminator -{ - public static async Task ShutdownAsync( - Process process, - bool requestGracefulShutdown, - bool entireProcessTree, - ILogger logger, - string processDescription, - CancellationToken gracefulShutdownCancellationToken) - { - try - { - if (process.HasExited) - { - logger.LogDebug("{ProcessDescription} process {ProcessId} already exited.", processDescription, process.Id); - return true; - } - - if (requestGracefulShutdown) - { - logger.LogDebug("Requesting graceful shutdown of {ProcessDescription} process {ProcessId}.", processDescription, process.Id); - ProcessSignaler.RequestGracefulShutdown(process.Id, expectedStartTime: null, logger); - - try - { - await process.WaitForExitAsync(gracefulShutdownCancellationToken).ConfigureAwait(false); - return true; - } - catch (OperationCanceledException) - { - if (process.HasExited) - { - logger.LogDebug("{ProcessDescription} process {ProcessId} exited while graceful shutdown was being cancelled.", processDescription, process.Id); - return true; - } - } - - logger.LogWarning( - "{ProcessDescription} process {ProcessId} did not stop gracefully before cancellation. Forcing process to terminate.", - processDescription, - process.Id); - } - - if (process.HasExited) - { - return true; - } - - logger.LogDebug( - "Sending kill to {ProcessDescription} process {ProcessId} (entireProcessTree={EntireProcessTree}).", - processDescription, - process.Id, - entireProcessTree); - - process.Kill(entireProcessTree); - return false; - } - catch (InvalidOperationException ex) - { - logger.LogDebug( - ex, - "{ProcessDescription} process exited before termination could complete (entireProcessTree={EntireProcessTree}).", - processDescription, - entireProcessTree); - return true; - } - catch (Exception ex) - { - logger.LogDebug( - ex, - "Failed to terminate {ProcessDescription} process (entireProcessTree={EntireProcessTree}).", - processDescription, - entireProcessTree); - return false; - } - } - - public static async Task ShutdownAsync( - int pid, - DateTimeOffset? expectedStartTime, - bool requestGracefulShutdown, - bool entireProcessTree, - ILogger logger, - string processDescription, - CancellationToken gracefulShutdownCancellationToken) - { - // Resolve the pid to a live Process handle here so the inner overload can stay agnostic of - // how it was obtained. The `using` must extend across the await — otherwise the Process is - // disposed while the inner overload still depends on the handle (e.g. for WaitForExitAsync - // on the graceful path). Hence the explicit async + await, not a Task-returning passthrough. - using var process = ProcessSignaler.TryGetRunningProcess(pid, expectedStartTime, logger); - if (process is null) - { - return true; - } - - return await ShutdownAsync( - process, - requestGracefulShutdown, - entireProcessTree, - logger, - processDescription, - gracefulShutdownCancellationToken).ConfigureAwait(false); - } -} diff --git a/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs b/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs index 79806f45235..3b4fc6bb2d5 100644 --- a/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs @@ -85,16 +85,16 @@ private async Task StopProcessesAsync( var gracefulShutdownRequested = await TryRequestGracefulShutdownAsync(requestGracefulShutdownAsync, cancellationToken).ConfigureAwait(false); if (gracefulShutdownRequested && await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false)) { - await ForceKillRemainingProcessesAsync(processesToForceKill.Except(processesToMonitor), afterTimeout: false).ConfigureAwait(false); + ForceKillRemainingProcesses(processesToForceKill.Except(processesToMonitor), afterTimeout: false); return true; } - await ForceKillRemainingProcessesAsync(processesToForceKill, afterTimeout: true).ConfigureAwait(false); + ForceKillRemainingProcesses(processesToForceKill, afterTimeout: true); return await MonitorProcessesForTerminationAsync(processesToMonitor, cancellationToken).ConfigureAwait(false); } - private async Task ForceKillRemainingProcessesAsync(IEnumerable processes, bool afterTimeout) + private void ForceKillRemainingProcesses(IEnumerable processes, bool afterTimeout) { var killEntireProcessTree = !OperatingSystem.IsWindows(); @@ -109,14 +109,10 @@ private async Task ForceKillRemainingProcessesAsync(IEnumerable p logger.LogDebug("Forcing remaining shutdown handle process {Pid} to terminate.", process.Pid); } - await ProcessTerminator.ShutdownAsync( - process.Pid, - process.StartTime, - requestGracefulShutdown: false, - killEntireProcessTree, - logger, - "shutdown target", - gracefulShutdownCancellationToken: CancellationToken.None).ConfigureAwait(false); + // Resolve the pid against its expected start time and hard-kill. This path never requests + // graceful shutdown — the graceful attempt already happened (or was intentionally skipped), + // so we go straight to the kill that the shared shutdown helper's force mode also performs. + ProcessSignaler.ForceKill(process.Pid, process.StartTime, logger, killEntireProcessTree); } } diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 5b6679dac94..fde13f2fdba 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -348,7 +348,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // same graceful-then-tree-kill semantics as TypeScript AppHosts (which already // route through AppHostServerSession/ProcessGuestLauncher). Build, restore, // package add, layout, and other short-lived invocations leave these unset so - // they continue to use today's ProcessTerminator force-kill behavior. + // they continue to use the shared ladder's force-kill mode. IsolateConsole = true, GracefulShutdownSignaler = _gracefulShutdownSignaler, ShutdownService = _shutdownService, From cf3763dd665bd05174061f1e7f29ec6d9d92fc48 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 18 Jun 2026 19:59:24 -0700 Subject: [PATCH 24/56] Fold ProcessGracefulShutdownLadder into ProcessExecution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessExecution was the only caller of the ladder, so inline it as private methods (ShutdownLadderAsync, ForceKillChild, InvokeSignalerAsync, SafePid) rather than keeping a standalone static helper. This leaves a single shutdown coordinator class — ProcessTreeGracefulShutdownService (the graceful-signal primitive plus the detached aspire stop orchestrator) — with no ambiguity about which type owns the run-path escalation. Retarget the doc crefs in DotNetCliRunner to ProcessExecution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 20 +- src/Aspire.Cli/DotNet/ProcessExecution.cs | 220 +++++++++++++-- .../ProcessGracefulShutdownLadder.cs | 260 ------------------ 3 files changed, 213 insertions(+), 287 deletions(-) delete mode 100644 src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index c2b3d19d9c9..9e7bdf03539 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -68,30 +68,30 @@ internal sealed class ProcessInvocationOptions /// /// When true, the spawned process is given its own hidden console group (Windows) /// and, on Windows, is assigned to the CLI's kill-on-close job object. Required so the - /// shared can target the child with + /// shutdown ladder in can target the child with /// DCP's stop-process-tree CTRL+C dance. /// /// /// Pair with and . /// On Windows the spawned process is bound to the process-wide /// kill-on-close job automatically. - /// Leaving the signaler/service unset means cancellation falls back to the shared - /// force-kill mode, preserving back-compat + /// Leaving the signaler/service unset means cancellation falls back to + /// 's force-kill mode, preserving back-compat /// for the many non-Run callers (build, restore, package add, layout, etc.). /// public bool IsolateConsole { get; set; } /// - /// Issues the graceful shutdown signal during the shared shutdown ladder (DCP + /// Issues the graceful shutdown signal during the shutdown ladder (DCP /// stop-process-tree on Windows, SIGTERM on Unix). When null, the cancellation - /// path uses the shared force-kill mode. + /// path uses 's force-kill mode. /// public Processes.IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler { get; set; } /// /// The central graceful-shutdown window whose - /// bounds the shared ladder. When - /// null, the cancellation path uses the shared + /// bounds the shutdown ladder. When + /// null, the cancellation path uses 's /// force-kill mode. /// public IGracefulShutdownWindow? ShutdownService { get; set; } @@ -310,9 +310,9 @@ private static ProcessInvocationOptions CreateInstrumentedProcessOptions( // Forward the Run-path shutdown ladder opt-ins. Forgetting any of these silently // demotes the run to the force-kill fallback: IsolateConsole=false skips console // isolation, and the null signaler/service pair causes ProcessExecution's OCE catch - // (DotNet/ProcessExecution.cs) to route through ProcessGracefulShutdownLadder's force - // mode (best-effort SIGTERM then kill) instead of its graceful ladder. Build/restore/etc. - // callers leave these unset and intentionally keep the force-kill path. + // (DotNet/ProcessExecution.cs) to route through its force-kill mode (best-effort SIGTERM + // then kill) instead of its graceful ladder. Build/restore/etc. callers leave these unset + // and intentionally keep the force-kill path. IsolateConsole = options.IsolateConsole, GracefulShutdownSignaler = options.GracefulShutdownSignaler, ShutdownService = options.ShutdownService, diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index df8e1bf8af9..1bbada1ecc6 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -121,9 +121,9 @@ public async Task WaitForExitAsync(CancellationToken cancellationToken) /// /// The single decision point this execution routes through when its child must be torn down on - /// cancellation. Both branches call the shared : with a - /// signaler for the graceful ladder (the aspire run path) or without one for the best-effort - /// force-kill fallback (non-Run callers). + /// cancellation. Both branches run the same : with a signaler for + /// the graceful ladder (the aspire run path) or without one for the best-effort force-kill + /// fallback (non-Run callers). /// /// /// The graceful-vs-force decision is command-level and all-or-nothing: it keys off @@ -145,22 +145,208 @@ private Task ShutdownOnCancelAsync(Process process) // if a user Ctrl+C already armed the window this is a no-op. gracefulShutdownWindow.BeginGracefulWindow(); - return ProcessGracefulShutdownLadder.ShutdownAsync( - process, - signaler, - gracefulShutdownWindow.GracefulShutdownToken, - entireProcessTreeOnForceKill: _options.KillEntireProcessTreeOnCancel, - _logger, - _fileName); + return ShutdownLadderAsync(process, signaler, gracefulShutdownWindow.GracefulShutdownToken); } - return ProcessGracefulShutdownLadder.ShutdownAsync( - process, - signaler: null, - gracefulToken: CancellationToken.None, - entireProcessTreeOnForceKill: _options.KillEntireProcessTreeOnCancel, - _logger, - _fileName); + return ShutdownLadderAsync(process, signaler: null, gracefulToken: CancellationToken.None); + } + + /// + /// Shuts down the child, choosing the graceful ladder or the force-kill fallback based on whether + /// is supplied. Graceful mode (signaler present — aspire run) + /// runs the four-phase "graceful signal → bounded wait → force tree-kill → bounded drain" + /// escalation; force mode (no signaler — build/restore/etc.) does a best-effort courtesy SIGTERM on + /// Unix (a no-op on Windows) then an immediate kill. Both modes use the same primitives — DCP + /// stop-process-tree on Windows and SIGTERM on Unix — and differ only in whether a graceful + /// budget is honored before the kill. + /// + /// + /// Whoever triggers shutdown () owns the central + /// clock; this consumes but never owns timing. + /// + private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShutdownSignaler? signaler, CancellationToken gracefulToken) + { + if (signaler is null) + { + // Force mode: no graceful budget. Best-effort courtesy SIGTERM (Unix) then hard-kill. + ForceKillChild(process); + return; + } + + // Phase 1: fire-and-forget the graceful signal so its wait does not consume the + // graceful budget. On Windows, DCP's `stop-process-tree` delivers the Ctrl+C signal + // synchronously (in milliseconds) and then BLOCKS until the target process actually + // exits. Awaiting it sequentially would burn the entire graceful window inside DCP's + // wait, leaving zero time for Phase 2's WaitForExitAsync — which then forces a + // tree-kill at the budget boundary even when the AppHost was milliseconds away from + // exiting cleanly. By running the signaler in parallel, the apphost receives the + // signal immediately AND the full graceful budget is allocated to actual exit-wait. + // Important: the signaler is invoked unconditionally (not gated on the graceful + // token) so that when the token is already cancelled at ladder-entry the signal + // still goes out — callers like `aspire stop` Expire() the budget intentionally + // and rely on the signal still being dispatched best-effort. + var signalTask = InvokeSignalerAsync(signaler, SafePid(process), gracefulToken); + + // Phase 2: wait for exit with the FULL graceful budget. When the apphost exits, + // the signaler task observes the same exit and completes shortly after. Whoever + // triggered shutdown (CCM.Cancel) owns the timing of `gracefulToken`. + try + { + await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Graceful budget expired; fall through to kill. + } + + if (process.HasExited) + { + // Best-effort: drain the signaler so its dcp shell-out doesn't outlive us as + // an orphan; safe because the process has already exited so dcp will return + // promptly. Bounded so a stuck dcp can't keep us pinned. + try + { + using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await signalTask.WaitAsync(drainCts.Token).ConfigureAwait(false); + } + catch + { + // Best-effort. + } + return; + } + + // Phase 3: ALWAYS tree-kill on escalation, regardless of OS. Even when the graceful + // signal returned cleanly, descendants may still be alive — e.g. on Windows tsx wraps + // node and swallows Ctrl+C/Ctrl+Break, leaving the child node and any further + // descendants running after the tsx shell exits. Skipping tree-kill would orphan them. + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // Process exited between HasExited check and Kill — nothing to do. + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to kill {FileName} (pid {Pid}).", _fileName, SafePid(process)); + return; + } + + // Phase 4: brief separately-bounded drain after kill — independent of the central token + // because by now the central budget has already expired. 1 s is enough for the OS to + // reap the process so the subsequent ExitCode read succeeds. + try + { + using var killDrain = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await process.WaitForExitAsync(killDrain.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Best-effort; nothing more we can do. + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error draining killed {FileName} (pid {Pid}).", _fileName, SafePid(process)); + } + } + + private void ForceKillChild(Process process) + { + // Mirrors the force path: resolve "already gone?", issue a best-effort courtesy SIGTERM on Unix + // (so a SIGTERM-aware child can flush), then hard-kill. On Windows + // ProcessSignaler.RequestGracefulShutdown is a no-op — Ctrl+C delivery to a child requires DCP's + // stop-process-tree console dance, which only the signaler-backed graceful ladder performs — so + // we skip straight to the kill. + var entireProcessTree = _options.KillEntireProcessTreeOnCancel; + try + { + if (process.HasExited) + { + _logger.LogDebug("{FileName} process {ProcessId} already exited.", _fileName, process.Id); + return; + } + + if (!OperatingSystem.IsWindows()) + { + ProcessSignaler.RequestGracefulShutdown(process.Id, expectedStartTime: null, _logger); + + if (process.HasExited) + { + return; + } + } + + _logger.LogDebug( + "Sending kill to {FileName} process {ProcessId} (entireProcessTree={EntireProcessTree}).", + _fileName, + process.Id, + entireProcessTree); + process.Kill(entireProcessTree); + } + catch (InvalidOperationException ex) + { + _logger.LogDebug( + ex, + "{FileName} process exited before termination could complete (entireProcessTree={EntireProcessTree}).", + _fileName, + entireProcessTree); + } + catch (Exception ex) + { + _logger.LogDebug( + ex, + "Failed to terminate {FileName} process (entireProcessTree={EntireProcessTree}).", + _fileName, + entireProcessTree); + } + } + + private async Task InvokeSignalerAsync(IProcessTreeGracefulShutdownSignaler signaler, int pid, CancellationToken gracefulToken) + { + try + { + // startTime is intentionally null: includeStartTimeForDcp is always false at this + // call site (the Unix branch ignores StartTime entirely; the Windows DCP branch + // only consults it when includeStartTimeForDcp is true). Querying Process.StartTime + // here would just risk an InvalidOperationException on a process whose handle has + // been closed or is in a state that disallows the read. + // + // Yield onto the thread pool so we don't block the caller while the signaler + // performs its (sometimes slow) work — DCP's stop-process-tree blocks until the + // target process actually exits, which is exactly the wait we want to avoid + // serializing in front of Phase 2's WaitForExitAsync. + await Task.Yield(); + + await signaler.RequestProcessTreeGracefulShutdownAsync( + pid, + startTime: null, + includeStartTimeForDcp: false, + gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (gracefulToken.IsCancellationRequested) + { + // Graceful budget expired before the signal could be issued; the kill path + // is responsible for terminating the process. + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to issue graceful shutdown to {FileName} (pid {Pid}); escalating to kill.", _fileName, pid); + } + } + + private static int SafePid(Process process) + { + try + { + return process.Id; + } + catch (Exception) + { + return -1; + } } /// diff --git a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs b/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs deleted file mode 100644 index 96b7c48f022..00000000000 --- a/src/Aspire.Cli/Processes/ProcessGracefulShutdownLadder.cs +++ /dev/null @@ -1,260 +0,0 @@ -// 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 Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Processes; - -/// -/// The single child-process shutdown helper for every long-running process the CLI owns. It has two -/// modes selected by whether a graceful signaler is supplied: -/// -/// -/// -/// Graceful (signaler supplied — the aspire run path): runs the -/// "graceful signal → bounded wait → force tree-kill → bounded drain" four-phase escalation -/// against the central , so the -/// user-visible shutdown shape is uniform across spawn sites (AppHost server, guest, -/// direct-launch AppHost executable). -/// -/// -/// -/// -/// Force (no signaler — every non-Run caller: build, restore, package add, layout, the -/// aspire stop force-kill tail, etc.): best-effort courtesy SIGTERM on Unix (a no-op on -/// Windows, where Ctrl+C delivery needs the signaler-backed DCP console dance) followed by an -/// immediate force-kill. There is no graceful budget on this path. -/// -/// -/// -/// Both modes use the same primitives the previous ProcessTerminator / ladder split used — -/// DCP stop-process-tree on Windows and SIGTERM on Unix — they only differ in whether a -/// graceful budget is honored before the kill. -/// -/// -/// Whoever triggers shutdown () is responsible -/// for starting the central clock. This helper only consumes the resulting token — it never -/// owns timing. -/// -internal static class ProcessGracefulShutdownLadder -{ - /// - /// Shuts down , choosing the graceful ladder or the force-kill fallback - /// based on whether is supplied. - /// - /// The child process to shut down. - /// - /// Issues the graceful signal (DCP stop-process-tree on Windows, SIGTERM on Unix). When - /// null, the force-kill fallback runs instead and is ignored. - /// - /// - /// The central bounding the graceful - /// wait. Only consulted when is non-null. - /// - /// - /// Kill scope for the force-kill fallback (used only when is - /// null). The graceful escalation always tree-kills regardless of this value, because a child - /// like tsx can swallow Ctrl+C and leave descendants running even after a clean graceful signal. - /// - /// Logger for diagnostics. - /// Short human description used in log messages (e.g. "AppHost server"). - public static async Task ShutdownAsync( - Process process, - IProcessTreeGracefulShutdownSignaler? signaler, - CancellationToken gracefulToken, - bool entireProcessTreeOnForceKill, - ILogger logger, - string processDescription) - { - ArgumentNullException.ThrowIfNull(process); - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(processDescription); - - if (signaler is null) - { - // Force mode: no graceful budget. Best-effort courtesy SIGTERM (Unix) then hard-kill. - ForceKill(process, entireProcessTreeOnForceKill, logger, processDescription); - return; - } - - // Phase 1: fire-and-forget the graceful signal so its wait does not consume the - // graceful budget. On Windows, DCP's `stop-process-tree` delivers the Ctrl+C signal - // synchronously (in milliseconds) and then BLOCKS until the target process actually - // exits. Awaiting it sequentially would burn the entire graceful window inside DCP's - // wait, leaving zero time for Phase 2's WaitForExitAsync — which then forces a - // tree-kill at the budget boundary even when the AppHost was milliseconds away from - // exiting cleanly. By running the signaler in parallel, the apphost receives the - // signal immediately AND the full graceful budget is allocated to actual exit-wait. - // Important: the signaler is invoked unconditionally (not gated on the graceful - // token) so that when the token is already cancelled at ladder-entry the signal - // still goes out — callers like `aspire stop` Expire() the budget intentionally - // and rely on the signal still being dispatched best-effort. - var signalTask = InvokeSignalerAsync(signaler, SafePid(process), gracefulToken, processDescription, logger); - - // Phase 2: wait for exit with the FULL graceful budget. When the apphost exits, - // the signaler task observes the same exit and completes shortly after. Whoever - // triggered shutdown (CCM.Cancel) owns the timing of `gracefulToken`. - try - { - await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Graceful budget expired; fall through to kill. - } - - if (process.HasExited) - { - // Best-effort: drain the signaler so its dcp shell-out doesn't outlive us as - // an orphan; safe because the process has already exited so dcp will return - // promptly. Bounded so a stuck dcp can't keep us pinned. - try - { - using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await signalTask.WaitAsync(drainCts.Token).ConfigureAwait(false); - } - catch - { - // Best-effort. - } - return; - } - - // Phase 3: ALWAYS tree-kill on escalation, regardless of OS. Even when the graceful - // signal returned cleanly, descendants may still be alive — e.g. on Windows tsx wraps - // node and swallows Ctrl+C/Ctrl+Break, leaving the child node and any further - // descendants running after the tsx shell exits. Skipping tree-kill would orphan them. - try - { - process.Kill(entireProcessTree: true); - } - catch (InvalidOperationException) - { - // Process exited between HasExited check and Kill — nothing to do. - return; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to kill {ProcessDescription} (pid {Pid}).", processDescription, SafePid(process)); - return; - } - - // Phase 4: brief separately-bounded drain after kill — independent of the central token - // because by now the central budget has already expired. 1 s is enough for the OS to - // reap the process so the subsequent ExitCode read succeeds. - try - { - using var killDrain = new CancellationTokenSource(TimeSpan.FromSeconds(1)); - await process.WaitForExitAsync(killDrain.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Best-effort; nothing more we can do. - } - catch (Exception ex) - { - logger.LogWarning(ex, "Error draining killed {ProcessDescription} (pid {Pid}).", processDescription, SafePid(process)); - } - } - - private static async Task InvokeSignalerAsync( - IProcessTreeGracefulShutdownSignaler signaler, - int pid, - CancellationToken gracefulToken, - string processDescription, - ILogger logger) - { - try - { - // startTime is intentionally null: includeStartTimeForDcp is always false at this - // call site (the Unix branch ignores StartTime entirely; the Windows DCP branch - // only consults it when includeStartTimeForDcp is true). Querying Process.StartTime - // here would just risk an InvalidOperationException on a process whose handle has - // been closed or is in a state that disallows the read. - // - // Yield onto the thread pool so we don't block the caller while the signaler - // performs its (sometimes slow) work — DCP's stop-process-tree blocks until the - // target process actually exits, which is exactly the wait we want to avoid - // serializing in front of Phase 2's WaitForExitAsync. - await Task.Yield(); - - await signaler.RequestProcessTreeGracefulShutdownAsync( - pid, - startTime: null, - includeStartTimeForDcp: false, - gracefulToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (gracefulToken.IsCancellationRequested) - { - // Graceful budget expired before the signal could be issued; the kill path - // is responsible for terminating the process. - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to issue graceful shutdown to {ProcessDescription} (pid {Pid}); escalating to kill.", processDescription, pid); - } - } - - private static void ForceKill(Process process, bool entireProcessTree, ILogger logger, string processDescription) - { - // Mirrors the previous ProcessTerminator force path: resolve "already gone?", issue a - // best-effort courtesy SIGTERM on Unix (so a SIGTERM-aware child can flush), then hard-kill. - // On Windows ProcessSignaler.RequestGracefulShutdown is a no-op — Ctrl+C delivery to a child - // requires DCP's stop-process-tree console dance, which only the signaler-backed graceful - // ladder performs — so we skip straight to the kill. - try - { - if (process.HasExited) - { - logger.LogDebug("{ProcessDescription} process {ProcessId} already exited.", processDescription, process.Id); - return; - } - - if (!OperatingSystem.IsWindows()) - { - ProcessSignaler.RequestGracefulShutdown(process.Id, expectedStartTime: null, logger); - - if (process.HasExited) - { - return; - } - } - - logger.LogDebug( - "Sending kill to {ProcessDescription} process {ProcessId} (entireProcessTree={EntireProcessTree}).", - processDescription, - process.Id, - entireProcessTree); - process.Kill(entireProcessTree); - } - catch (InvalidOperationException ex) - { - logger.LogDebug( - ex, - "{ProcessDescription} process exited before termination could complete (entireProcessTree={EntireProcessTree}).", - processDescription, - entireProcessTree); - } - catch (Exception ex) - { - logger.LogDebug( - ex, - "Failed to terminate {ProcessDescription} process (entireProcessTree={EntireProcessTree}).", - processDescription, - entireProcessTree); - } - } - - private static int SafePid(Process process) - { - try - { - return process.Id; - } - catch (Exception) - { - return -1; - } - } -} From 2bc02e2752ef002b2e4339d5c6ee7fd776cfae03 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 11:56:46 -0700 Subject: [PATCH 25/56] Split AppHostServerSession.StartAsync into Start() + WaitForExitAsync() The method named StartAsync was actually synchronous: its body spawned the process and published the socket path/PID under a lock before returning, and the Task it returned represented the process *lifetime/exit code*, not the start operation. That mismatch made the codegen call sites' '_ = StartAsync()' discards look like a fire-and-forget bug (possible UnobservedTaskException), when the completion was always observed by DisposeAsync. Make the shape honest: - void Start() performs the synchronous spawn + field publication. - Task WaitForExitAsync() hands back the lifetime/exit-code task. Codegen/scaffolding sites now call Start() with no discard; the run path calls Start() then WaitForExitAsync(). No behavior change; the synchronous-throw + DisposeAsync-observes guarantee is unchanged and still covered by Start_ProjectRunThrows_DisposesProjectAndFaultsCompletion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 16 +++--- .../Commands/Sdk/SdkGenerateCommand.cs | 8 +-- .../Projects/AppHostServerSession.cs | 56 +++++++++++-------- .../Projects/GuestAppHostProject.cs | 14 +++-- .../Projects/IAppHostServerSession.cs | 8 +-- .../Scaffolding/ScaffoldingService.cs | 8 +-- .../Projects/AppHostServerSessionTests.cs | 56 ++++++++++--------- .../TestServices/FakeAppHostServerSession.cs | 4 +- 8 files changed, 96 insertions(+), 74 deletions(-) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 606adfb45b0..2b96ca61ec6 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -196,10 +196,10 @@ private async Task DumpCapabilitiesAsync( debug: false, _logger, cancellationToken); - // Short-lived RPC session: discard the completion task; disposal flows the exit code - // through the activity scope and the only failure mode we care about surfaces via the - // RPC call below. - _ = serverSession.StartAsync(); + // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // exit-code task (WaitForExitAsync) because disposal flows the exit code through the + // activity scope and the only failure mode we care about surfaces via the RPC call below. + serverSession.Start(); // Connect and get capabilities var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -300,10 +300,10 @@ private async Task DumpCapabilitiesToDirectoryAsync( debug: false, _logger, cancellationToken); - // Short-lived RPC session: discard the completion task; disposal flows the exit code - // through the activity scope and the only failure mode we care about surfaces via the - // RPC call below. - _ = serverSession.StartAsync(); + // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // exit-code task (WaitForExitAsync) because disposal flows the exit code through the + // activity scope and the only failure mode we care about surfaces via the RPC call below. + serverSession.Start(); var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); outputDirectory.Create(); diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index ac0f0336537..152d37af6eb 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -157,10 +157,10 @@ private async Task GenerateSdkAsync( debug: false, _logger, cancellationToken); - // Short-lived RPC session: discard the completion task; disposal flows the exit code - // through the activity scope and the only failure mode we care about surfaces via the - // RPC call below. - _ = serverSession.StartAsync(); + // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // exit-code task (WaitForExitAsync) because disposal flows the exit code through the + // activity scope and the only failure mode we care about surfaces via the RPC call below. + serverSession.Start(); // Connect and generate code var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index f18856f85a4..d1374da71bc 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -12,9 +12,9 @@ namespace Aspire.Cli.Projects; /// /// Owns the lifetime of an AppHost server child process. Construction stashes configuration -/// (including the stop token) without launching anything; launches the -/// process, wires lifecycle observation, and returns a task that completes with the process exit -/// code. +/// (including the stop token) without launching anything; synchronously +/// launches the process and wires lifecycle observation, and +/// returns the task that completes with the process exit code. /// /// /// The session drives the child entirely through the returned by @@ -83,25 +83,25 @@ public AppHostServerSession( /// /// Gets the authentication token injected into the server environment. Available before - /// so callers can plumb it into the guest AppHost environment. + /// so callers can plumb it into the guest AppHost environment. /// public string AuthenticationToken => _authenticationToken; /// - /// Gets the RPC socket path, or if has not + /// Gets the RPC socket path, or if has not /// been called (or threw before the process was published). /// public string? SocketPath => _socketPath; /// /// Gets the output collector for the server's stdout/stderr, or if - /// has not been called (or threw before the process was published). + /// has not been called (or threw before the process was published). /// public OutputCollector? Output => _output; /// /// Gets whether the underlying AppHost server process has exited, or - /// if has not been called (or threw before the process was + /// if has not been called (or threw before the process was /// published). Routes through the , which encapsulates the /// isolated Windows spawn quirk (the underlying Process is obtained via /// ); see @@ -132,24 +132,24 @@ public AppHostServerSession( /// /// Gets the underlying server process id for read-only observation, or - /// if has not been called (or threw before the process was published). + /// if has not been called (or threw before the process was published). /// Prefer for has-exited checks and /// for exit-code reads. /// public int? ServerProcessId => _execution?.ProcessId; /// - /// Launches the AppHost server process. The returned task completes with the process exit - /// code when the process exits (either on its own, or because the stop token supplied to the - /// constructor was cancelled and the session ran the shutdown ladder). + /// Synchronously launches the AppHost server process and wires lifecycle observation. Returns + /// once the process has been spawned and its socket path and PID are published (the process then + /// runs in the background). Use to observe the exit code. /// - /// Thrown if has already been called. + /// Thrown if has already been called. /// Thrown if the session has been disposed. - public Task StartAsync() + public void Start() { // Hold _startGate across the entire startup body — env build, _project.Run, field // publication, and drive-loop start. DisposeAsync's top-of-method lock then either runs - // before us (and StartAsync sees _disposed and throws) or after us (and Dispose sees a + // before us (and Start sees _disposed and throws) or after us (and Dispose sees a // fully-published execution + run task). Without this widening there is a window between // _project.Run returning and the run task starting where a concurrent Dispose would orphan // the just-launched process. Every operation below is synchronous, so a Monitor lock is @@ -227,16 +227,28 @@ public Task StartAsync() // Process is obtained via Process.GetProcessById and its StartInfo is empty, so the // execution captured these at spawn time instead. _activity.SetProcessInvocation(result.Execution.FileName, result.Execution.Arguments); - - return completion.Task; } } + /// + /// Returns the task that completes with the process exit code when the server exits — either on + /// its own, or because the stop token supplied to the constructor was cancelled (or the session + /// was disposed) and the shutdown ladder ran. Returns the same task on every call. + /// + /// Thrown if has not been called. + public Task WaitForExitAsync() + { + // The completion is the lifetime signal published by Start; DriveAsync trips it (never the + // raw run task, which is hardened to never throw). Hand back the same task each call so a + // caller can capture it, poll IsCompleted, and await it without spawning new tasks. + return (_completion ?? throw NotStarted()).Task; + } + // Owns the single WaitForExitAsync call for the child. On normal exit it trips the completion // with the exit code. On cancellation (external stop OR DisposeAsync), the execution runs the // shared shutdown ladder from inside WaitForExitAsync and ALWAYS rethrows OCE even when the // ladder cleanly exited the process — so we read the (now-exited or killed) exit code and trip - // the completion with it. This preserves the "StartAsync always completes with an int, never + // the completion with it. This preserves the "WaitForExitAsync always completes with an int, never // surfaces OCE" contract that GuestAppHostProject and the codegen path depend on. private static async Task DriveAsync(IProcessExecution execution, TaskCompletionSource completion, CancellationToken stopToken) { @@ -255,7 +267,7 @@ private static async Task DriveAsync(IProcessExecution execution, TaskCompletion } /// - /// Returns an RPC client connected to the server. Must be called after . + /// Returns an RPC client connected to the server. Must be called after . /// public async Task GetRpcClientAsync(CancellationToken cancellationToken) { @@ -267,7 +279,7 @@ public async Task GetRpcClientAsync(CancellationToken cancell } var socketPath = _socketPath ?? throw NotStarted(); - // _completion is published alongside _socketPath in StartAsync, so a non-null socket + // _completion is published alongside _socketPath in Start, so a non-null socket // path guarantees the server-exit signal is available here. var serverExitTask = (_completion ?? throw NotStarted()).Task; @@ -350,7 +362,7 @@ public async ValueTask DisposeAsync() } // Observe the completion task unconditionally to prevent UnobservedTaskException if - // StartAsync's _project.Run threw synchronously and faulted the completion before the + // Start's _project.Run threw synchronously and faulted the completion before the // execution was published. When the process did start, DriveAsync has already tripped this. if (_completion is { } completion) { @@ -360,7 +372,7 @@ public async ValueTask DisposeAsync() } catch { - // Exceptions surface to the StartAsync caller; swallow during disposal. + // Exceptions surface to the Start caller; swallow during disposal. } } @@ -408,5 +420,5 @@ private static void ObserveFaultedTask(Task task) } private static InvalidOperationException NotStarted() => - new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); + new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(Start)} first."); } diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 933db7592bf..05422897a43 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -316,10 +316,10 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir _logger, cancellationToken, _profilingTelemetry); - // Fire-and-forget the completion task: for short-lived RPC sessions like this one we - // observe failures via the RPC call below, and disposal flows the exit code through the - // activity scope. Same observation model as the prior static AppHostServerSession.Start. - _ = serverSession.StartAsync(); + // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // exit-code task because disposal flows the exit code through the activity scope and the only + // failure mode we care about surfaces via the RPC call below. + serverSession.Start(); // Step 3: Connect to server var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -498,7 +498,8 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverCompletion = serverSession.StartAsync(); + serverSession.Start(); + serverCompletion = serverSession.WaitForExitAsync(); // Give the server a moment to start await Task.Delay(500, cancellationToken); @@ -1083,7 +1084,8 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverCompletion = serverSession.StartAsync(); + serverSession.Start(); + serverCompletion = serverSession.WaitForExitAsync(); try { diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs index 1621be6c4f1..ca1170ec015 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs @@ -23,11 +23,11 @@ namespace Aspire.Cli.Projects; internal interface IAppHostServerSession : IAsyncDisposable { /// - /// Launches the AppHost server process and returns a task that completes with the process - /// exit code. For codegen the result is observed indirectly (via the RPC call and disposal), - /// so callers typically fire-and-forget the returned task. + /// Synchronously launches the AppHost server process and wires lifecycle observation. Returns + /// once the process has been spawned. For codegen the process lifetime is observed indirectly + /// (via the RPC call and disposal), so this seam exposes no exit-code task. /// - Task StartAsync(); + void Start(); /// /// Connects to the running AppHost server and returns an RPC client for code generation. diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 6a8f6b076b4..72cd5edd615 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -145,10 +145,10 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can debug: false, _logger, cancellationToken); - // Short-lived RPC session: discard the completion task; disposal flows the exit code - // through the activity scope and the only failure mode we care about surfaces via the - // RPC call below. - _ = serverSession.StartAsync(); + // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // exit-code task (WaitForExitAsync) because disposal flows the exit code through the + // activity scope and the only failure mode we care about surfaces via the RPC call below. + serverSession.Start(); // Step 3: Connect to server and get scaffold templates via RPC var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index cfd23fca398..70f735d602a 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -25,7 +25,7 @@ namespace Aspire.Cli.Tests.Projects; public class AppHostServerSessionTests(ITestOutputHelper outputHelper) { [Fact] - public async Task StartAsync_DoesNotMutateCallerEnvironmentVariables() + public async Task Start_DoesNotMutateCallerEnvironmentVariables() { var project = new RecordingAppHostServerProject(); var environmentVariables = new Dictionary @@ -39,7 +39,7 @@ public async Task StartAsync_DoesNotMutateCallerEnvironmentVariables() debug: false, NullLogger.Instance, CancellationToken.None); - _ = session.StartAsync(); + session.Start(); Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken)); @@ -50,7 +50,7 @@ public async Task StartAsync_DoesNotMutateCallerEnvironmentVariables() } [Fact] - public async Task StartAsync_PropagatesProfilingContextToServerEnvironment() + public async Task Start_PropagatesProfilingContextToServerEnvironment() { var project = new RecordingAppHostServerProject(); var environmentVariables = new Dictionary @@ -69,7 +69,7 @@ public async Task StartAsync_PropagatesProfilingContextToServerEnvironment() NullLogger.Instance, CancellationToken.None, profilingTelemetry); - _ = session.StartAsync(); + session.Start(); Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken)); @@ -89,7 +89,7 @@ public async Task StartAsync_PropagatesProfilingContextToServerEnvironment() } [Fact] - public async Task StartAsync_DoesNotLeaveServerProcessActivityAmbient() + public async Task Start_DoesNotLeaveServerProcessActivityAmbient() { var project = new RecordingAppHostServerProject(); using var parentSource = new ActivitySource("test-apphost-server-parent"); @@ -109,7 +109,7 @@ public async Task StartAsync_DoesNotLeaveServerProcessActivityAmbient() NullLogger.Instance, CancellationToken.None, profilingTelemetry); - _ = session.StartAsync(); + session.Start(); Assert.Same(parentActivity, Activity.Current); @@ -146,7 +146,7 @@ public async Task GetRpcClientAsync_WhenServerExitsBeforeSocketIsAvailable_Fails debug: false, NullLogger.Instance, CancellationToken.None); - _ = session.StartAsync(); + session.Start(); // Wait for the process to exit so the stopwatch measures only the early-exit detection // latency, not the variable execution time of "dotnet --version" on loaded CI machines. @@ -183,7 +183,7 @@ public void SessionState_BeforeStart_IsNull() } [Fact] - public async Task StartAsync_CalledTwice_Throws() + public async Task Start_CalledTwice_Throws() { var project = new RecordingAppHostServerProject(); await using var session = new AppHostServerSession( @@ -193,13 +193,13 @@ public async Task StartAsync_CalledTwice_Throws() NullLogger.Instance, CancellationToken.None); - _ = session.StartAsync(); + session.Start(); - Assert.Throws(() => { _ = session.StartAsync(); }); + Assert.Throws(session.Start); } [Fact] - public async Task StartAsync_StopRequested_KillsProcessAndCompletesTask() + public async Task Start_StopRequested_KillsProcessAndCompletesTask() { var project = new LongRunningAppHostServerProject(); using var stopCts = new CancellationTokenSource(); @@ -210,7 +210,8 @@ public async Task StartAsync_StopRequested_KillsProcessAndCompletesTask() NullLogger.Instance, stopCts.Token); - var completion = session.StartAsync(); + session.Start(); + var completion = session.WaitForExitAsync(); // Process should be running before we ask the session to stop. Assert.False(completion.IsCompleted); @@ -226,7 +227,7 @@ public async Task StartAsync_StopRequested_KillsProcessAndCompletesTask() } [Fact] - public async Task StartAsync_StopRequested_WithGracefulServices_InvokesGracefulSignalerAndExits() + public async Task Start_StopRequested_WithGracefulServices_InvokesGracefulSignalerAndExits() { // External-stop flow with graceful infra wired: the session's ladder must invoke the // injected graceful signaler before falling through to wait-for-exit. Simulating "graceful @@ -262,7 +263,8 @@ public async Task StartAsync_StopRequested_WithGracefulServices_InvokesGracefulS gracefulShutdownSignaler: signaler, shutdownService: shutdownService); - var completion = session.StartAsync(); + session.Start(); + var completion = session.WaitForExitAsync(); Assert.False(completion.IsCompleted); var serverPid = session.ServerProcessId!.Value; @@ -278,7 +280,7 @@ public async Task StartAsync_StopRequested_WithGracefulServices_InvokesGracefulS } [Fact] - public async Task StartAsync_StopRequested_GracefulIgnored_ExpireEscalatesToTreeKill() + public async Task Start_StopRequested_GracefulIgnored_ExpireEscalatesToTreeKill() { // External stop fires, the fake signaler accepts the request but the process refuses to // exit (simulating a child that ignores SIGTERM/Ctrl+C). Expiring the central token must @@ -309,7 +311,8 @@ public async Task StartAsync_StopRequested_GracefulIgnored_ExpireEscalatesToTree gracefulShutdownSignaler: signaler, shutdownService: shutdownService); - var completion = session.StartAsync(); + session.Start(); + var completion = session.WaitForExitAsync(); Assert.False(completion.IsCompleted); stopCts.Cancel(); @@ -327,7 +330,7 @@ public async Task StartAsync_StopRequested_GracefulIgnored_ExpireEscalatesToTree } [Fact] - public async Task StartAsync_StopRequested_GracefulSignalerThrows_StillEscalatesToKill() + public async Task Start_StopRequested_GracefulSignalerThrows_StillEscalatesToKill() { // Best-effort contract: a thrown exception from the signaler (e.g. DCP layout discovery // failed, network blip, anything) must not strand the kill ladder. The ladder logs and @@ -352,7 +355,8 @@ public async Task StartAsync_StopRequested_GracefulSignalerThrows_StillEscalates gracefulShutdownSignaler: signaler, shutdownService: shutdownService); - var completion = session.StartAsync(); + session.Start(); + var completion = session.WaitForExitAsync(); Assert.False(completion.IsCompleted); stopCts.Cancel(); @@ -387,7 +391,8 @@ public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithout gracefulShutdownSignaler: signaler, shutdownService: shutdownService); - var completion = session.StartAsync(); + session.Start(); + var completion = session.WaitForExitAsync(); Assert.False(completion.IsCompleted); var pid = session.ServerProcessId!.Value; Assert.False(session.HasServerExited); @@ -407,7 +412,7 @@ public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithout } [Fact] - public async Task StartAsync_ProcessExitsNaturally_CompletionReturnsExitCode() + public async Task Start_ProcessExitsNaturally_CompletionReturnsExitCode() { var project = new RecordingAppHostServerProject(); await using var session = new AppHostServerSession( @@ -417,12 +422,13 @@ public async Task StartAsync_ProcessExitsNaturally_CompletionReturnsExitCode() NullLogger.Instance, CancellationToken.None); - var exitCode = await session.StartAsync().WaitAsync(TimeSpan.FromSeconds(30)); + session.Start(); + var exitCode = await session.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); Assert.Equal(0, exitCode); } [Fact] - public async Task StartAsync_ProjectRunThrows_DisposesProjectAndFaultsCompletion() + public async Task Start_ProjectRunThrows_DisposesProjectAndFaultsCompletion() { var project = new ThrowingAppHostServerProject(); @@ -447,10 +453,10 @@ void Handler(object? sender, UnobservedTaskExceptionEventArgs e) // completion task alive and UnobservedTaskException never fires. await RunScenarioAsync(project); - // DisposeAsync must observe the faulted completion task created by StartAsync's catch + // DisposeAsync must observe the faulted completion task created by Start's catch // path. Otherwise the orphaned faulted task surfaces as an UnobservedTaskException once // GC reaps the TaskCompletionSource — silent corruption for short-lived RPC callers - // that intentionally discard the StartAsync result. + // that intentionally never call WaitForExitAsync. GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); @@ -472,7 +478,7 @@ static async Task RunScenarioAsync(ThrowingAppHostServerProject project) NullLogger.Instance, CancellationToken.None); - Assert.Throws(() => { _ = session.StartAsync(); }); + Assert.Throws(session.Start); await session.DisposeAsync(); } diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs index 5a2030e9adf..b9972c0b493 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs @@ -15,7 +15,9 @@ internal sealed class FakeAppHostServerSession : IAppHostServerSession { private readonly FakeAppHostRpcClient _rpcClient = new(); - public Task StartAsync() => Task.FromResult(0); + public void Start() + { + } public Task GetRpcClientAsync(CancellationToken cancellationToken) => Task.FromResult(_rpcClient); From f6459c1d390b9c1ba094a0b3fce0827e100436c9 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 12:17:05 -0700 Subject: [PATCH 26/56] Make AppHostServerSession start async (SemaphoreSlim gate + RunAsync) Convert the session start path to genuine async so the construction-failure disposal can be awaited instead of sync-blocking via DisposeAsync().AsTask().GetAwaiter().GetResult(). - AppHostServerSession: _startGate Monitor lock -> SemaphoreSlim(1,1); Start() -> async StartAsync() awaiting _project.RunAsync; DisposeAsync acquires the same gate to preserve the start-vs-dispose atomicity guarantee. - IAppHostServerProject.Run -> async RunAsync; DotNetBased/Prebuilt server impls await execution.DisposeAsync() in the spawn-failure catch. - IAppHostServerSession.Start -> StartAsync; all codegen/run call sites await it, which also resolves the prior UnobservedTaskException concern (failures now surface directly at the awaited StartAsync). - Update fakes and tests accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 4 +- .../Commands/Sdk/SdkGenerateCommand.cs | 2 +- .../Projects/AppHostServerSession.cs | 93 ++++++++++++------- .../DotNetBasedAppHostServerProject.cs | 4 +- .../Projects/GuestAppHostProject.cs | 6 +- .../Projects/IAppHostServerProject.cs | 8 +- .../Projects/IAppHostServerSession.cs | 8 +- .../Projects/PrebuiltAppHostServer.cs | 4 +- .../Scaffolding/ScaffoldingService.cs | 2 +- .../Projects/AppHostServerSessionTests.cs | 40 ++++---- .../Scaffolding/ChannelReseedTests.cs | 2 +- .../TestServices/FakeAppHostServerSession.cs | 4 +- .../FakeFailingAppHostServerProject.cs | 2 +- .../FakeSucceedingAppHostServerProject.cs | 4 +- 14 files changed, 103 insertions(+), 80 deletions(-) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 2b96ca61ec6..794e6ba6b8d 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -199,7 +199,7 @@ private async Task DumpCapabilitiesAsync( // Short-lived RPC session: Start() spawns the server synchronously. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. - serverSession.Start(); + await serverSession.StartAsync(); // Connect and get capabilities var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -303,7 +303,7 @@ private async Task DumpCapabilitiesToDirectoryAsync( // Short-lived RPC session: Start() spawns the server synchronously. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. - serverSession.Start(); + await serverSession.StartAsync(); var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); outputDirectory.Create(); diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 152d37af6eb..1c53de073b8 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -160,7 +160,7 @@ private async Task GenerateSdkAsync( // Short-lived RPC session: Start() spawns the server synchronously. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. - serverSession.Start(); + await serverSession.StartAsync(); // Connect and generate code var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index d1374da71bc..d7e701aa950 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -12,13 +12,13 @@ namespace Aspire.Cli.Projects; /// /// Owns the lifetime of an AppHost server child process. Construction stashes configuration -/// (including the stop token) without launching anything; synchronously -/// launches the process and wires lifecycle observation, and +/// (including the stop token) without launching anything; launches the +/// process and wires lifecycle observation, and /// returns the task that completes with the process exit code. /// /// /// The session drives the child entirely through the returned by -/// . Termination is requested either by cancelling the +/// . Termination is requested either by cancelling the /// stopRequested token passed to the constructor, or by calling . /// Both routes cancel the same internal linked CTS, which the drive loop passes to /// ; the execution runs the @@ -39,7 +39,12 @@ internal sealed class AppHostServerSession : IAppHostServerSession private readonly IGracefulShutdownWindow? _shutdownService; private readonly bool _isolateConsole; - private readonly object _startGate = new(); + // Serializes StartAsync against DisposeAsync so a concurrent dispose cannot orphan a + // just-spawned process (see StartAsync for the ordering guarantee). A SemaphoreSlim is used + // rather than a Monitor lock because StartAsync now awaits the spawn (_project.RunAsync) and + // DisposeAsync awaits while holding the gate. We never touch AvailableWaitHandle, so the + // semaphore allocates no wait handle and does not need disposal. + private readonly SemaphoreSlim _startGate = new(1, 1); private bool _startInvoked; private bool _disposed; @@ -83,25 +88,25 @@ public AppHostServerSession( /// /// Gets the authentication token injected into the server environment. Available before - /// so callers can plumb it into the guest AppHost environment. + /// so callers can plumb it into the guest AppHost environment. /// public string AuthenticationToken => _authenticationToken; /// - /// Gets the RPC socket path, or if has not + /// Gets the RPC socket path, or if has not /// been called (or threw before the process was published). /// public string? SocketPath => _socketPath; /// /// Gets the output collector for the server's stdout/stderr, or if - /// has not been called (or threw before the process was published). + /// has not been called (or threw before the process was published). /// public OutputCollector? Output => _output; /// /// Gets whether the underlying AppHost server process has exited, or - /// if has not been called (or threw before the process was + /// if has not been called (or threw before the process was /// published). Routes through the , which encapsulates the /// isolated Windows spawn quirk (the underlying Process is obtained via /// ); see @@ -132,29 +137,31 @@ public AppHostServerSession( /// /// Gets the underlying server process id for read-only observation, or - /// if has not been called (or threw before the process was published). + /// if has not been called (or threw before the process was published). /// Prefer for has-exited checks and /// for exit-code reads. /// public int? ServerProcessId => _execution?.ProcessId; /// - /// Synchronously launches the AppHost server process and wires lifecycle observation. Returns - /// once the process has been spawned and its socket path and PID are published (the process then - /// runs in the background). Use to observe the exit code. + /// Launches the AppHost server process and wires lifecycle observation. The returned task + /// completes once the process has been spawned and its socket path and PID are published (the + /// process then runs in the background). Use to observe the exit + /// code. /// - /// Thrown if has already been called. + /// Thrown if has already been called. /// Thrown if the session has been disposed. - public void Start() + public async Task StartAsync() { - // Hold _startGate across the entire startup body — env build, _project.Run, field - // publication, and drive-loop start. DisposeAsync's top-of-method lock then either runs - // before us (and Start sees _disposed and throws) or after us (and Dispose sees a - // fully-published execution + run task). Without this widening there is a window between - // _project.Run returning and the run task starting where a concurrent Dispose would orphan - // the just-launched process. Every operation below is synchronous, so a Monitor lock is - // safe (no await inside). - lock (_startGate) + // Hold _startGate across the entire startup body — env build, _project.RunAsync, field + // publication, and drive-loop start. DisposeAsync also acquires _startGate before flipping + // _disposed, so it either runs before us (and StartAsync sees _disposed and throws) or after + // us (and Dispose sees a fully-published execution + run task). Without this widening there + // is a window between _project.RunAsync returning and the run task starting where a + // concurrent Dispose would orphan the just-launched process. The await on RunAsync is why + // this is a SemaphoreSlim rather than a Monitor lock. + await _startGate.WaitAsync().ConfigureAwait(false); + try { ObjectDisposedException.ThrowIf(_disposed, this); @@ -190,17 +197,18 @@ public void Start() AppHostServerRunResult result; try { - result = _project.Run( + result = await _project.RunAsync( Environment.ProcessId, serverEnvironmentVariables, debug: _debug, - runControl: new AppHostServerRunControl(_isolateConsole, _gracefulShutdownSignaler, _shutdownService)); + runControl: new AppHostServerRunControl(_isolateConsole, _gracefulShutdownSignaler, _shutdownService)).ConfigureAwait(false); } catch (Exception ex) { _activity.SetError(ex.Message); _activity.Dispose(); (_project as IDisposable)?.Dispose(); + // Trip the completion so DisposeAsync (which awaits it unconditionally) doesn't hang. completion.TrySetException(ex); throw; } @@ -228,6 +236,10 @@ public void Start() // execution captured these at spawn time instead. _activity.SetProcessInvocation(result.Execution.FileName, result.Execution.Arguments); } + finally + { + _startGate.Release(); + } } /// @@ -235,7 +247,7 @@ public void Start() /// its own, or because the stop token supplied to the constructor was cancelled (or the session /// was disposed) and the shutdown ladder ran. Returns the same task on every call. /// - /// Thrown if has not been called. + /// Thrown if has not been called. public Task WaitForExitAsync() { // The completion is the lifetime signal published by Start; DriveAsync trips it (never the @@ -267,7 +279,7 @@ private static async Task DriveAsync(IProcessExecution execution, TaskCompletion } /// - /// Returns an RPC client connected to the server. Must be called after . + /// Returns an RPC client connected to the server. Must be called after . /// public async Task GetRpcClientAsync(CancellationToken cancellationToken) { @@ -316,14 +328,27 @@ exitCode is { } code public async ValueTask DisposeAsync() { - lock (_startGate) + // Acquire _startGate the same way StartAsync does so dispose is serialized against an + // in-flight start: we either flip _disposed before StartAsync acquires the gate (it then + // throws ObjectDisposedException and spawns nothing) or after it has fully published the + // execution + run task (we then tear that down below). The gate is released immediately; + // the teardown runs outside it so a late StartAsync observes _disposed and bails fast. + bool alreadyDisposed; + await _startGate.WaitAsync().ConfigureAwait(false); + try { - if (_disposed) - { - return; - } + alreadyDisposed = _disposed; _disposed = true; } + finally + { + _startGate.Release(); + } + + if (alreadyDisposed) + { + return; + } // Cancel the stop trigger. This drives the run loop's WaitForExitAsync(_stopCts.Token) // into the shared shutdown ladder (graceful or force-kill, decided centrally), which then @@ -362,8 +387,8 @@ public async ValueTask DisposeAsync() } // Observe the completion task unconditionally to prevent UnobservedTaskException if - // Start's _project.Run threw synchronously and faulted the completion before the - // execution was published. When the process did start, DriveAsync has already tripped this. + // StartAsync's _project.RunAsync faulted the completion before the execution was published. + // When the process did start, DriveAsync has already tripped this. if (_completion is { } completion) { try @@ -420,5 +445,5 @@ private static void ObserveFaultedTask(Task task) } private static InvalidOperationException NotStarted() => - new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(Start)} first."); + new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); } diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 227c655e446..ceadba44130 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -466,7 +466,7 @@ public async Task PrepareAsync( public string GetInstanceIdentifier() => GetProjectFilePath(); /// - public AppHostServerRunResult Run( + public async Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, @@ -582,7 +582,7 @@ void OnStderr(string line) } catch { - execution.DisposeAsync().AsTask().GetAwaiter().GetResult(); + await execution.DisposeAsync().ConfigureAwait(false); throw; } diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 05422897a43..fbdf97b997c 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -319,7 +319,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir // Short-lived RPC session: Start() spawns the server synchronously. We never observe the // exit-code task because disposal flows the exit code through the activity scope and the only // failure mode we care about surfaces via the RPC call below. - serverSession.Start(); + await serverSession.StartAsync(); // Step 3: Connect to server var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -498,7 +498,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverSession.Start(); + await serverSession.StartAsync(); serverCompletion = serverSession.WaitForExitAsync(); // Give the server a moment to start @@ -1084,7 +1084,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverSession.Start(); + await serverSession.StartAsync(); serverCompletion = serverSession.WaitForExitAsync(); try diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index 693aaa74fc0..8484181bc03 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -22,7 +22,7 @@ internal sealed record AppHostServerPrepareResult( bool NeedsCodeGeneration = false); /// -/// Result of — a launched AppHost server process plus the +/// Result of — a launched AppHost server process plus the /// captured output. /// /// RPC socket the server is publishing on. @@ -43,7 +43,7 @@ internal sealed record AppHostServerRunResult( IProcessExecution Execution); /// -/// Controls how spawns and tears down the server child. +/// Controls how spawns and tears down the server child. /// The default (all-null / false) preserves today's force-kill-on-cancel behavior for the non-Run /// callers (SDK gen, scaffolding, publish, dump). The run path supplies the graceful infrastructure. /// @@ -110,8 +110,8 @@ Task PrepareAsync( /// default) preserves force-kill-on-cancel semantics for non-Run callers (SDK gen, scaffolding, /// publish, dump). The run path passes a populated . /// - /// The launched server process execution and its captured output. - AppHostServerRunResult Run( + /// A task producing the launched server process execution and its captured output. + Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs index ca1170ec015..dcc0548a961 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs @@ -23,11 +23,11 @@ namespace Aspire.Cli.Projects; internal interface IAppHostServerSession : IAsyncDisposable { /// - /// Synchronously launches the AppHost server process and wires lifecycle observation. Returns - /// once the process has been spawned. For codegen the process lifetime is observed indirectly - /// (via the RPC call and disposal), so this seam exposes no exit-code task. + /// Launches the AppHost server process and wires lifecycle observation. The returned task + /// completes once the process has been spawned. For codegen the process lifetime is observed + /// indirectly (via the RPC call and disposal), so this seam exposes no exit-code task. /// - void Start(); + Task StartAsync(); /// /// Connects to the running AppHost server and returns an RPC client for code generation. diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index bd94b8284a8..d5e636847f0 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -915,7 +915,7 @@ private static string GetRestoreVersion(string packageName, string version, bool internal static string RedactSourceForDisplay(string source) => PackageSourceRedactor.RedactForDisplay(source); /// - public AppHostServerRunResult Run( + public async Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, @@ -972,7 +972,7 @@ void OnStderr(string line) } catch { - execution.DisposeAsync().AsTask().GetAwaiter().GetResult(); + await execution.DisposeAsync().ConfigureAwait(false); throw; } diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 72cd5edd615..ea3639ef54d 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -148,7 +148,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can // Short-lived RPC session: Start() spawns the server synchronously. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. - serverSession.Start(); + await serverSession.StartAsync(); // Step 3: Connect to server and get scaffold templates via RPC var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 70f735d602a..aecf4a91188 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -39,7 +39,7 @@ public async Task Start_DoesNotMutateCallerEnvironmentVariables() debug: false, NullLogger.Instance, CancellationToken.None); - session.Start(); + await session.StartAsync(); Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken)); @@ -69,7 +69,7 @@ public async Task Start_PropagatesProfilingContextToServerEnvironment() NullLogger.Instance, CancellationToken.None, profilingTelemetry); - session.Start(); + await session.StartAsync(); Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken)); @@ -109,7 +109,7 @@ public async Task Start_DoesNotLeaveServerProcessActivityAmbient() NullLogger.Instance, CancellationToken.None, profilingTelemetry); - session.Start(); + await session.StartAsync(); Assert.Same(parentActivity, Activity.Current); @@ -146,7 +146,7 @@ public async Task GetRpcClientAsync_WhenServerExitsBeforeSocketIsAvailable_Fails debug: false, NullLogger.Instance, CancellationToken.None); - session.Start(); + await session.StartAsync(); // Wait for the process to exit so the stopwatch measures only the early-exit detection // latency, not the variable execution time of "dotnet --version" on loaded CI machines. @@ -193,9 +193,9 @@ public async Task Start_CalledTwice_Throws() NullLogger.Instance, CancellationToken.None); - session.Start(); + await session.StartAsync(); - Assert.Throws(session.Start); + await Assert.ThrowsAsync(session.StartAsync); } [Fact] @@ -210,7 +210,7 @@ public async Task Start_StopRequested_KillsProcessAndCompletesTask() NullLogger.Instance, stopCts.Token); - session.Start(); + await session.StartAsync(); var completion = session.WaitForExitAsync(); // Process should be running before we ask the session to stop. @@ -263,7 +263,7 @@ public async Task Start_StopRequested_WithGracefulServices_InvokesGracefulSignal gracefulShutdownSignaler: signaler, shutdownService: shutdownService); - session.Start(); + await session.StartAsync(); var completion = session.WaitForExitAsync(); Assert.False(completion.IsCompleted); var serverPid = session.ServerProcessId!.Value; @@ -311,7 +311,7 @@ public async Task Start_StopRequested_GracefulIgnored_ExpireEscalatesToTreeKill( gracefulShutdownSignaler: signaler, shutdownService: shutdownService); - session.Start(); + await session.StartAsync(); var completion = session.WaitForExitAsync(); Assert.False(completion.IsCompleted); @@ -355,7 +355,7 @@ public async Task Start_StopRequested_GracefulSignalerThrows_StillEscalatesToKil gracefulShutdownSignaler: signaler, shutdownService: shutdownService); - session.Start(); + await session.StartAsync(); var completion = session.WaitForExitAsync(); Assert.False(completion.IsCompleted); @@ -391,7 +391,7 @@ public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithout gracefulShutdownSignaler: signaler, shutdownService: shutdownService); - session.Start(); + await session.StartAsync(); var completion = session.WaitForExitAsync(); Assert.False(completion.IsCompleted); var pid = session.ServerProcessId!.Value; @@ -422,7 +422,7 @@ public async Task Start_ProcessExitsNaturally_CompletionReturnsExitCode() NullLogger.Instance, CancellationToken.None); - session.Start(); + await session.StartAsync(); var exitCode = await session.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); Assert.Equal(0, exitCode); } @@ -478,7 +478,7 @@ static async Task RunScenarioAsync(ThrowingAppHostServerProject project) NullLogger.Instance, CancellationToken.None); - Assert.Throws(session.Start); + await Assert.ThrowsAsync(session.StartAsync); await session.DisposeAsync(); } @@ -615,7 +615,7 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public AppHostServerRunResult Run( + public Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, @@ -638,10 +638,10 @@ public AppHostServerRunResult Run( execution.Start(); StartedExecution = execution; - return new AppHostServerRunResult( + return Task.FromResult(new AppHostServerRunResult( SocketPath: "test.sock", OutputCollector: new OutputCollector(), - Execution: execution); + Execution: execution)); } } @@ -659,7 +659,7 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public AppHostServerRunResult Run( + public Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, @@ -686,10 +686,10 @@ public AppHostServerRunResult Run( var execution = CreateServerExecution(startInfo, runControl); execution.Start(); - return new AppHostServerRunResult( + return Task.FromResult(new AppHostServerRunResult( SocketPath: "test.sock", OutputCollector: new OutputCollector(), - Execution: execution); + Execution: execution)); } } @@ -709,7 +709,7 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public AppHostServerRunResult Run( + public Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index bc82a7965e9..abc05531f35 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -138,7 +138,7 @@ public Task PrepareAsync( return Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); } - public AppHostServerRunResult Run( + public Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs index b9972c0b493..33f726cb1f9 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs @@ -15,9 +15,7 @@ internal sealed class FakeAppHostServerSession : IAppHostServerSession { private readonly FakeAppHostRpcClient _rpcClient = new(); - public void Start() - { - } + public Task StartAsync() => Task.CompletedTask; public Task GetRpcClientAsync(CancellationToken cancellationToken) => Task.FromResult(_rpcClient); diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index 9603fce3e08..c280e31d0e0 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -29,7 +29,7 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); - public AppHostServerRunResult Run( + public Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs index 05b873ce6ba..691ec0994a5 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs @@ -10,7 +10,7 @@ namespace Aspire.Cli.Tests.TestServices; /// whose returns success. /// Used with a fake codegen session ( via an injected /// ) that bypasses , -/// so is never called. +/// so is never called. /// internal sealed class FakeSucceedingAppHostServerProject(string appDirectoryPath) : IAppHostServerProject, IDisposable { @@ -26,7 +26,7 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => Task.FromResult(new AppHostServerPrepareResult(Success: true, Output: null)); - public AppHostServerRunResult Run( + public Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, From 900eb026decd6a60035d1cf2adddd7fd65d97dbb Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 12:22:28 -0700 Subject: [PATCH 27/56] Make LayoutProcessRunner.Start async to await disposal on spawn failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/DashboardRunCommand.cs | 2 +- src/Aspire.Cli/Layout/LayoutProcessRunner.cs | 7 ++----- src/Aspire.Cli/Profiling/ProfileCaptureService.cs | 4 ++-- .../Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Cli/Commands/DashboardRunCommand.cs b/src/Aspire.Cli/Commands/DashboardRunCommand.cs index 8508391775f..d86293e4c65 100644 --- a/src/Aspire.Cli/Commands/DashboardRunCommand.cs +++ b/src/Aspire.Cli/Commands/DashboardRunCommand.cs @@ -371,7 +371,7 @@ private async Task ExecuteForegroundAsync(string managedPath, Lis IProcessExecution process; try { - process = _layoutProcessRunner.Start(managedPath, dashboardArgs, environmentVariables: environmentVariables, options: options); + process = await _layoutProcessRunner.StartAsync(managedPath, dashboardArgs, environmentVariables: environmentVariables, options: options).ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs index 8039e9d3835..7885b7f5196 100644 --- a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs +++ b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs @@ -45,7 +45,7 @@ internal sealed class LayoutProcessRunner(IProcessExecutionFactory executionFact } /// - public IProcessExecution Start( + public async Task StartAsync( string toolPath, IEnumerable arguments, string? workingDirectory = null, @@ -59,10 +59,7 @@ public IProcessExecution Start( if (!execution.Start()) { - // Start() returning false means nothing was spawned, so disposal is a synchronous - // no-op (ProcessExecution has no child to tear down). Drive it to completion here so - // the sync Start contract is preserved without a sync-over-async hazard on the real path. - execution.DisposeAsync().AsTask().GetAwaiter().GetResult(); + await execution.DisposeAsync().ConfigureAwait(false); throw new InvalidOperationException($"Failed to start process: {toolPath}"); } diff --git a/src/Aspire.Cli/Profiling/ProfileCaptureService.cs b/src/Aspire.Cli/Profiling/ProfileCaptureService.cs index 8687dae73ae..ec2dd0f2b23 100644 --- a/src/Aspire.Cli/Profiling/ProfileCaptureService.cs +++ b/src/Aspire.Cli/Profiling/ProfileCaptureService.cs @@ -105,11 +105,11 @@ internal async Task StartAsync( // Launch aspire-managed directly instead of calling `aspire dashboard run`. Calling // back through the CLI being profiled would recursively apply --capture-profile and // make the collector part of the measurement. - dashboardProcess = layoutProcessRunner.Start( + dashboardProcess = await layoutProcessRunner.StartAsync( managedPath, dashboardArgs, environmentVariables: environmentVariables, - options: processOptions); + options: processOptions).ConfigureAwait(false); } catch (Exception ex) { diff --git a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs index 66fabba0b16..9b089a2bcb1 100644 --- a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs @@ -306,7 +306,7 @@ public async Task DashboardRunCommand_ProcessFailsToStart_DisplaysErrorAndReturn var (services, managedPath, executionFactory) = CreateServicesWithLayout(workspace, interactionService: testInteractionService); // Make CreateExecution return an execution whose Start() returns false, - // which causes LayoutProcessRunner.Start to throw InvalidOperationException. + // which causes LayoutProcessRunner.StartAsync to throw InvalidOperationException. executionFactory.CreateExecutionCallback = (_, _, _, _) => new TestProcessExecution( "fake", From b9b6cfffeaf6e3871e072a086094a4bc1157d79b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 12:33:02 -0700 Subject: [PATCH 28/56] Update stale StartAsync call comments referencing synchronous Start() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 4 ++-- src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs | 2 +- src/Aspire.Cli/Projects/GuestAppHostProject.cs | 2 +- src/Aspire.Cli/Scaffolding/ScaffoldingService.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 794e6ba6b8d..7571750e3fa 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -196,7 +196,7 @@ private async Task DumpCapabilitiesAsync( debug: false, _logger, cancellationToken); - // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. await serverSession.StartAsync(); @@ -300,7 +300,7 @@ private async Task DumpCapabilitiesToDirectoryAsync( debug: false, _logger, cancellationToken); - // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. await serverSession.StartAsync(); diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 1c53de073b8..7c37116b48d 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -157,7 +157,7 @@ private async Task GenerateSdkAsync( debug: false, _logger, cancellationToken); - // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. await serverSession.StartAsync(); diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index fbdf97b997c..7d1c6ca15a2 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -316,7 +316,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir _logger, cancellationToken, _profilingTelemetry); - // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task because disposal flows the exit code through the activity scope and the only // failure mode we care about surfaces via the RPC call below. await serverSession.StartAsync(); diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index ea3639ef54d..661b7663209 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -145,7 +145,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can debug: false, _logger, cancellationToken); - // Short-lived RPC session: Start() spawns the server synchronously. We never observe the + // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. await serverSession.StartAsync(); From 30829b792ea02973c1ced48b1948b2eb5fc8edb0 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 13:06:30 -0700 Subject: [PATCH 29/56] Address PR review: fix env guard, document ProcessExit + RunAsync contracts - TestProcessExecutionFactory: drop dead startInfo.Environment.Count guard (Environment is always lazily seeded with the full parent env) and forward the authoritative resolved env, correcting the misleading comment. - ConsoleCancellationManager.OnProcessExit: document that the graceful ladder is best-effort only within the bounded ProcessExit window. - IAppHostServerProject.RunAsync: document the prompt-return contract since its latency couples to AppHostServerSession disposal responsiveness via the gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/ConsoleCancellationManager.cs | 6 ++++++ .../Projects/IAppHostServerProject.cs | 7 +++++++ .../TestProcessExecutionFactory.cs | 18 +++++++++--------- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index 413fa6227e8..ec93e1ad898 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -267,6 +267,12 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) Cancel(SigIntExitCode); } + // ProcessExit fires when the runtime is already tearing the process down (e.g. an unhandled + // exception elsewhere, or the host ending). The handler has a bounded execution window (~2s on + // .NET) before the runtime force-terminates the process, and that window is not enough to run the + // full graceful-then-drain ladder Cancel() schedules — the Task.Delay continuations may never be + // serviced before the process dies. So on this path the ladder is best-effort only; we still call + // Cancel() to request cooperative shutdown, but rely on the OS tearing everything down regardless. private void OnProcessExit(object? sender, EventArgs e) => Cancel(SigTermExitCode); internal void Cancel(int exitCode) diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index 8484181bc03..1b104b1b710 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -101,6 +101,13 @@ Task PrepareAsync( /// /// Runs the AppHost server process. /// + /// + /// Implementations should return promptly once the child process has been spawned and its + /// identity (socket path, PID) is available; long-running initialization should happen after the + /// returned task completes. awaits this call while holding its + /// start gate, and DisposeAsync acquires the same gate — so any latency here directly + /// delays how quickly a concurrent dispose can begin tearing the session down. + /// /// The host process ID (CLI) for orphan detection. /// Environment variables to pass to the server. /// Additional command-line arguments. diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs index 3b874dc6a94..eb1ca2dabd0 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProcessExecutionFactory.cs @@ -119,15 +119,15 @@ public IProcessExecution CreateExecution(System.Diagnostics.ProcessStartInfo sta // the PSI overload) flow through the same assertion + callback machinery as every other caller. var args = startInfo.ArgumentList.ToArray(); - // Only forward an env dictionary when the caller supplied custom values; otherwise leave it null - // so we don't materialize (and assert against) the entire inherited parent environment. - IDictionary? env = null; - if (startInfo.Environment.Count > 0) - { - env = startInfo.Environment - .Where(static kvp => kvp.Value is not null) - .ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value!); - } + // ProcessStartInfo.Environment is lazily seeded with the full parent-process environment on + // first access (caller-supplied overrides are layered on top), so it is always populated. + // Forward the whole resolved set as the authoritative environment for the spawn — this mirrors + // the production ProcessExecutionFactory PSI overload, which also treats startInfo.Environment + // as authoritative. Tests that assert on env should look up the specific keys they set rather + // than expecting only caller-supplied vars to be present. + IDictionary env = startInfo.Environment + .Where(static kvp => kvp.Value is not null) + .ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value!); var workingDirectory = new DirectoryInfo( string.IsNullOrEmpty(startInfo.WorkingDirectory) ? Directory.GetCurrentDirectory() : startInfo.WorkingDirectory); From 7aba974f65c8634d0ff1ce0cfcb93f89b73ba1ef Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 13:22:56 -0700 Subject: [PATCH 30/56] Streamline signaler drain in ShutdownLadderAsync Skip the timer-backed CancellationTokenSource allocation when the signaler task has already completed (the common case, since it observed the same exit), and replace the best-effort try/catch with ConfigureAwaitOptions.SuppressThrowing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/ProcessExecution.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 1bbada1ecc6..37b401f17bf 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -203,16 +203,16 @@ private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShut { // Best-effort: drain the signaler so its dcp shell-out doesn't outlive us as // an orphan; safe because the process has already exited so dcp will return - // promptly. Bounded so a stuck dcp can't keep us pinned. - try + // promptly. Skip the timer allocation when the signaler already finished (the + // common case — it observed the same exit). SuppressThrowing swallows both the + // drain timeout and any signaler fault without a try/catch; bounded so a stuck + // dcp can't keep us pinned. + if (!signalTask.IsCompleted) { using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await signalTask.WaitAsync(drainCts.Token).ConfigureAwait(false); - } - catch - { - // Best-effort. + await signalTask.WaitAsync(drainCts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); } + return; } From 0151ba2d8fb6059e1dd9b4565dc9c6964de54f86 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 13:47:12 -0700 Subject: [PATCH 31/56] Fix Windows isolated-process exit wait and signaler drain race On Windows the isolated spawn path obtains its Process via Process.GetProcessById, whose WaitForExitAsync can complete before the kernel marks the process exited (dotnet/runtime#45003), so an immediately-following ExitCode read threw 'Process has not exited'. Override IsolatedProcess.WaitForExitAsync to wait on the kept CreateProcess handle via a new WindowsProcessInterop.WaitForExitAsync helper (RegisterWaitForSingleObject over the pinned handle), keeping the wait consistent with the ExitCode/HasExited reads that already use that handle. Also restructure ProcessExecution.ShutdownLadderAsync so the yielded signaler task is drained on every return path (try/finally), not just the clean-exit branch. The tree-kill escalation path previously returned before the Task.Yield continuation ran, so the graceful signal was never dispatched and the target pid was never recorded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/ProcessExecution.cs | 123 ++++++++++-------- .../Processes/IsolatedProcess.Windows.cs | 3 +- src/Aspire.Cli/Processes/IsolatedProcess.cs | 27 +++- .../Processes/WindowsProcessInterop.cs | 89 +++++++++++++ 4 files changed, 183 insertions(+), 59 deletions(-) diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 37b401f17bf..67001af635b 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -187,69 +187,84 @@ private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShut // and rely on the signal still being dispatched best-effort. var signalTask = InvokeSignalerAsync(signaler, SafePid(process), gracefulToken); - // Phase 2: wait for exit with the FULL graceful budget. When the apphost exits, - // the signaler task observes the same exit and completes shortly after. Whoever - // triggered shutdown (CCM.Cancel) owns the timing of `gracefulToken`. try { - await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Graceful budget expired; fall through to kill. - } - - if (process.HasExited) - { - // Best-effort: drain the signaler so its dcp shell-out doesn't outlive us as - // an orphan; safe because the process has already exited so dcp will return - // promptly. Skip the timer allocation when the signaler already finished (the - // common case — it observed the same exit). SuppressThrowing swallows both the - // drain timeout and any signaler fault without a try/catch; bounded so a stuck - // dcp can't keep us pinned. - if (!signalTask.IsCompleted) + // Phase 2: wait for exit with the FULL graceful budget. When the apphost exits, + // the signaler task observes the same exit and completes shortly after. Whoever + // triggered shutdown (CCM.Cancel) owns the timing of `gracefulToken`. + try { - using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await signalTask.WaitAsync(drainCts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Graceful budget expired; fall through to kill. } - return; - } + if (process.HasExited) + { + return; + } - // Phase 3: ALWAYS tree-kill on escalation, regardless of OS. Even when the graceful - // signal returned cleanly, descendants may still be alive — e.g. on Windows tsx wraps - // node and swallows Ctrl+C/Ctrl+Break, leaving the child node and any further - // descendants running after the tsx shell exits. Skipping tree-kill would orphan them. - try - { - process.Kill(entireProcessTree: true); - } - catch (InvalidOperationException) - { - // Process exited between HasExited check and Kill — nothing to do. - return; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to kill {FileName} (pid {Pid}).", _fileName, SafePid(process)); - return; - } + // Phase 3: ALWAYS tree-kill on escalation, regardless of OS. Even when the graceful + // signal returned cleanly, descendants may still be alive — e.g. on Windows tsx wraps + // node and swallows Ctrl+C/Ctrl+Break, leaving the child node and any further + // descendants running after the tsx shell exits. Skipping tree-kill would orphan them. + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // Process exited between HasExited check and Kill — nothing to do. + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to kill {FileName} (pid {Pid}).", _fileName, SafePid(process)); + return; + } - // Phase 4: brief separately-bounded drain after kill — independent of the central token - // because by now the central budget has already expired. 1 s is enough for the OS to - // reap the process so the subsequent ExitCode read succeeds. - try - { - using var killDrain = new CancellationTokenSource(TimeSpan.FromSeconds(1)); - await process.WaitForExitAsync(killDrain.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Best-effort; nothing more we can do. + // Phase 4: brief separately-bounded drain after kill — independent of the central token + // because by now the central budget has already expired. 1 s is enough for the OS to + // reap the process so the subsequent ExitCode read succeeds. + try + { + using var killDrain = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await process.WaitForExitAsync(killDrain.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Best-effort; nothing more we can do. + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error draining killed {FileName} (pid {Pid}).", _fileName, SafePid(process)); + } } - catch (Exception ex) + finally { - _logger.LogWarning(ex, "Error draining killed {FileName} (pid {Pid}).", _fileName, SafePid(process)); + // Always observe the signaler before returning — on EVERY path (clean exit, tree-kill + // escalation, or an early return from the catch arms above). Two reasons: + // 1. The signaler begins with `await Task.Yield()` (see InvokeSignalerAsync), so its + // body — which records the target pid and dispatches the dcp/SIGTERM signal — runs + // on a thread-pool continuation. Returning from the kill path WITHOUT awaiting it + // could abandon the ladder before that continuation runs, so the signal would never + // be dispatched. Awaiting here guarantees it ran. + // 2. Draining prevents the signaler's dcp shell-out from outliving us as an orphan. + // By now the process has exited or been tree-killed, so dcp returns promptly. Skip the + // timer allocation when the signaler already finished (the common case — it observed the + // same exit). SuppressThrowing swallows both the bounded drain timeout and any signaler + // fault without a try/catch. + if (signalTask.IsCompleted) + { + await signalTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + else + { + using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await signalTask.WaitAsync(drainCts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } } } diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs b/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs index 7fe46ae8a99..ae07dad116a 100644 --- a/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs +++ b/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs @@ -213,7 +213,8 @@ bool GetHasExited() standardErrorHandler, ExtraDispose, exitCodeProvider: GetExitCode, - hasExitedProvider: GetHasExited); + hasExitedProvider: GetHasExited, + waitForExitProvider: ct => WindowsProcessInterop.WaitForExitAsync(capturedProcessHandle, ct)); } catch { diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.cs b/src/Aspire.Cli/Processes/IsolatedProcess.cs index fc9feb5e3b1..fc825350732 100644 --- a/src/Aspire.Cli/Processes/IsolatedProcess.cs +++ b/src/Aspire.Cli/Processes/IsolatedProcess.cs @@ -120,6 +120,7 @@ internal sealed partial class IsolatedProcess : IAsyncDisposable private readonly Func _disposeAsync; private readonly Func? _exitCodeProvider; private readonly Func? _hasExitedProvider; + private readonly Func? _waitForExitProvider; private int _disposed; private IsolatedProcess( @@ -130,7 +131,8 @@ private IsolatedProcess( Task standardErrorClosed, Func disposeAsync, Func? exitCodeProvider, - Func? hasExitedProvider) + Func? hasExitedProvider, + Func? waitForExitProvider) { Process = process; Id = process.Id; @@ -141,6 +143,7 @@ private IsolatedProcess( _disposeAsync = disposeAsync; _exitCodeProvider = exitCodeProvider; _hasExitedProvider = hasExitedProvider; + _waitForExitProvider = waitForExitProvider; } /// The underlying for escape-hatch scenarios. @@ -191,8 +194,16 @@ private IsolatedProcess( public Task StandardErrorClosed { get; } /// Mirrors . + /// + /// The Windows spawn path overrides this to wait on the kept CreateProcess handle rather + /// than the instance, whose + /// can complete before the kernel marks + /// the process exited — which would make an immediately-following read + /// throw. Routing the wait through the same kept handle as / + /// keeps them consistent. See https://github.com/dotnet/runtime/issues/45003. + /// public Task WaitForExitAsync(CancellationToken cancellationToken = default) - => Process.WaitForExitAsync(cancellationToken); + => _waitForExitProvider?.Invoke(cancellationToken) ?? Process.WaitForExitAsync(cancellationToken); /// /// Mirrors . Defaults to @@ -367,6 +378,12 @@ private static IsolatedProcess StartRedirected( /// — Windows reads via WaitForSingleObject /// against the kept handle; Unix uses the default . /// + /// + /// Optional override for . Same rationale as + /// — Windows waits on the kept handle so the wait stays + /// consistent with the ExitCode/HasExited reads; Unix uses the default + /// . + /// private static IsolatedProcess WrapStartedProcess( IsolatedProcessStartInfo startInfo, Process process, @@ -376,7 +393,8 @@ private static IsolatedProcess WrapStartedProcess( Action standardErrorHandler, Func? extraDispose, Func? exitCodeProvider = null, - Func? hasExitedProvider = null) + Func? hasExitedProvider = null, + Func? waitForExitProvider = null) { // Snapshot identity off startInfo now — the caller may mutate the startInfo after // we return and we don't want the wrapper to observe those changes. @@ -438,7 +456,8 @@ async ValueTask DisposeAsync(TimeSpan drainTimeout) standardErrorClosed: errorTcs.Task, DisposeAsync, exitCodeProvider, - hasExitedProvider); + hasExitedProvider, + waitForExitProvider); // The pumps capture 'isolated' as the handler's "sender". The assignment is fully // visible to the pump's Task.Run worker by happens-before semantics. diff --git a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs index 8f6ea90bd1e..fd01b1a924d 100644 --- a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs +++ b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs @@ -635,4 +635,93 @@ public static nint BuildEnvironmentBlock(IReadOnlyDictionary en return ptr; } + + /// + /// Asynchronously waits for the process owned by to exit by + /// registering a thread-pool wait on the kernel process object. + /// + /// + /// on a + /// instance is unreliable on + /// Windows (see https://github.com/dotnet/runtime/issues/45003): it can complete before the + /// kernel has marked the process exited, so a subsequent GetExitCodeProcess read still + /// reports STILL_ACTIVE. This waits on the kept CreateProcess handle — the same + /// authoritative source and the zero-timeout + /// read from — so when this completes an ExitCode read is + /// guaranteed to succeed. + /// + [SupportedOSPlatform("windows")] + public static Task WaitForExitAsync(SafeProcessHandle processHandle, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (processHandle.IsClosed || processHandle.IsInvalid) + { + // No live handle to wait on (the wrapper disposed it); the process is gone as far as we + // can observe, so mirror a completed wait rather than throwing. + return Task.CompletedTask; + } + + // Fast path: already signaled, so skip the thread-pool registration entirely. + if (WaitForSingleObject(processHandle, 0) == WaitObject0) + { + return Task.CompletedTask; + } + + return WaitForExitCoreAsync(processHandle, cancellationToken); + + static async Task WaitForExitCoreAsync(SafeProcessHandle processHandle, CancellationToken cancellationToken) + { + // Pin the SafeProcessHandle for the duration of the wait so a concurrent Dispose (the + // wrapper's ExtraDispose closes this handle) cannot recycle the raw handle out from + // under the registered thread-pool wait. If the handle was closed in the meantime, + // DangerousAddRef throws ObjectDisposedException — treat that as "already gone". + var added = false; + try + { + processHandle.DangerousAddRef(ref added); + } + catch (ObjectDisposedException) + { + return; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // RegisterWaitForSingleObject needs a managed WaitHandle. Wrap the raw process handle in + // a non-owning SafeWaitHandle (the SafeProcessHandle keeps ownership) and graft it onto a + // throwaway ManualResetEvent — the canonical way to wait on a foreign kernel handle. + var waitHandle = new ManualResetEvent(false); + var placeholder = waitHandle.SafeWaitHandle; + waitHandle.SafeWaitHandle = new SafeWaitHandle(processHandle.DangerousGetHandle(), ownsHandle: false); + placeholder.Dispose(); + + RegisteredWaitHandle? registration = null; + var ctr = cancellationToken.Register(static s => ((TaskCompletionSource)s!).TrySetCanceled(), tcs); + try + { + registration = ThreadPool.RegisterWaitForSingleObject( + waitHandle, + static (state, _) => ((TaskCompletionSource)state!).TrySetResult(), + tcs, + millisecondsTimeOutInterval: Timeout.Infinite, + executeOnlyOnce: true); + + await tcs.Task.ConfigureAwait(false); + } + finally + { + ctr.Dispose(); + registration?.Unregister(null); + waitHandle.Dispose(); + if (added) + { + processHandle.DangerousRelease(); + } + } + } + } } From 192cf1b8315b7405bb0c0297a1387784ce8ffe46 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 14:03:13 -0700 Subject: [PATCH 32/56] Trim over-justified comments in shutdown path Drop the SemaphoreSlim-vs-Monitor-lock rationale and stop citing DCP's stop-process-tree internals as the justification for behavior in the shutdown ladder and console-isolation comments. The WHY is preserved; only the low-level implementation-detail justification is removed. DCP references are kept in the signaler abstraction that actually wraps the tool. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/ConsoleCancellationManager.cs | 2 +- src/Aspire.Cli/DotNet/ProcessExecution.cs | 68 ++++++++----------- src/Aspire.Cli/Processes/IsolatedProcess.cs | 10 +-- .../Projects/AppHostServerSession.cs | 8 +-- .../Projects/GuestAppHostProject.cs | 2 +- .../Projects/IAppHostServerProject.cs | 2 +- .../Projects/IGuestProcessLauncher.cs | 4 +- .../Projects/ProcessGuestLauncher.cs | 4 +- 8 files changed, 42 insertions(+), 58 deletions(-) diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index ec93e1ad898..1fd436720d0 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -180,7 +180,7 @@ public ConsoleCancellationManager(TimeSpan finalDrainBudget) /// Sets the graceful-shutdown budget for the currently-executing command. Default is zero, meaning /// ladders that consume fall through to escalation immediately /// (preserving today's behavior for every command that doesn't opt in). The aspire run handler - /// calls this so DCP and the AppHost get a real cooperative-shutdown window before escalation. + /// calls this so the AppHost gets a real cooperative-shutdown window before escalation. /// public void ConfigureForCommand(TimeSpan gracefulBudget) { diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 67001af635b..63af87514f6 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -156,9 +156,8 @@ private Task ShutdownOnCancelAsync(Process process) /// is supplied. Graceful mode (signaler present — aspire run) /// runs the four-phase "graceful signal → bounded wait → force tree-kill → bounded drain" /// escalation; force mode (no signaler — build/restore/etc.) does a best-effort courtesy SIGTERM on - /// Unix (a no-op on Windows) then an immediate kill. Both modes use the same primitives — DCP - /// stop-process-tree on Windows and SIGTERM on Unix — and differ only in whether a graceful - /// budget is honored before the kill. + /// Unix (a no-op on Windows) then an immediate kill. Both modes tree-kill the same way and differ + /// only in whether a graceful budget is honored before the kill. /// /// /// Whoever triggers shutdown () owns the central @@ -173,18 +172,14 @@ private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShut return; } - // Phase 1: fire-and-forget the graceful signal so its wait does not consume the - // graceful budget. On Windows, DCP's `stop-process-tree` delivers the Ctrl+C signal - // synchronously (in milliseconds) and then BLOCKS until the target process actually - // exits. Awaiting it sequentially would burn the entire graceful window inside DCP's - // wait, leaving zero time for Phase 2's WaitForExitAsync — which then forces a - // tree-kill at the budget boundary even when the AppHost was milliseconds away from - // exiting cleanly. By running the signaler in parallel, the apphost receives the - // signal immediately AND the full graceful budget is allocated to actual exit-wait. - // Important: the signaler is invoked unconditionally (not gated on the graceful - // token) so that when the token is already cancelled at ladder-entry the signal - // still goes out — callers like `aspire stop` Expire() the budget intentionally - // and rely on the signal still being dispatched best-effort. + // Phase 1: fire-and-forget the graceful signal so its own wait does not consume the + // graceful budget. The signal request blocks until the target process exits, so awaiting + // it sequentially would burn the entire graceful window and leave nothing for Phase 2's + // exit-wait — forcing a tree-kill even when the apphost was about to exit cleanly. Running + // it in parallel lets the apphost receive the signal immediately while the full budget goes + // to the exit-wait. The signal is dispatched unconditionally (not gated on the graceful + // token) so callers that intentionally Expire() the budget (e.g. `aspire stop`) still get a + // best-effort signal. var signalTask = InvokeSignalerAsync(signaler, SafePid(process), gracefulToken); try @@ -244,18 +239,15 @@ private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShut } finally { - // Always observe the signaler before returning — on EVERY path (clean exit, tree-kill - // escalation, or an early return from the catch arms above). Two reasons: - // 1. The signaler begins with `await Task.Yield()` (see InvokeSignalerAsync), so its - // body — which records the target pid and dispatches the dcp/SIGTERM signal — runs - // on a thread-pool continuation. Returning from the kill path WITHOUT awaiting it - // could abandon the ladder before that continuation runs, so the signal would never - // be dispatched. Awaiting here guarantees it ran. - // 2. Draining prevents the signaler's dcp shell-out from outliving us as an orphan. - // By now the process has exited or been tree-killed, so dcp returns promptly. Skip the - // timer allocation when the signaler already finished (the common case — it observed the - // same exit). SuppressThrowing swallows both the bounded drain timeout and any signaler - // fault without a try/catch. + // Always observe the signaler before returning, on EVERY path (clean exit, tree-kill + // escalation, or an early return from a catch arm above). The signaler begins with + // `await Task.Yield()` (see InvokeSignalerAsync), so its body — which records the target + // pid and dispatches the signal — runs on a thread-pool continuation; returning without + // awaiting it could abandon the ladder before that continuation runs, so the signal would + // never be dispatched. Awaiting here also drains it so a slow signal can't outlive us as + // an orphan. By now the process has exited or been tree-killed, so the signal returns + // promptly. Skip the timer allocation when it already finished; SuppressThrowing swallows + // both the bounded drain timeout and any signaler fault without a try/catch. if (signalTask.IsCompleted) { await signalTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); @@ -271,10 +263,9 @@ private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShut private void ForceKillChild(Process process) { // Mirrors the force path: resolve "already gone?", issue a best-effort courtesy SIGTERM on Unix - // (so a SIGTERM-aware child can flush), then hard-kill. On Windows - // ProcessSignaler.RequestGracefulShutdown is a no-op — Ctrl+C delivery to a child requires DCP's - // stop-process-tree console dance, which only the signaler-backed graceful ladder performs — so - // we skip straight to the kill. + // (so a SIGTERM-aware child can flush), then hard-kill. On Windows there is no graceful signal + // to send here — Ctrl+C delivery only happens on the signaler-backed graceful ladder — so we + // skip straight to the kill. var entireProcessTree = _options.KillEntireProcessTreeOnCancel; try { @@ -323,16 +314,13 @@ private async Task InvokeSignalerAsync(IProcessTreeGracefulShutdownSignaler sign { try { - // startTime is intentionally null: includeStartTimeForDcp is always false at this - // call site (the Unix branch ignores StartTime entirely; the Windows DCP branch - // only consults it when includeStartTimeForDcp is true). Querying Process.StartTime - // here would just risk an InvalidOperationException on a process whose handle has - // been closed or is in a state that disallows the read. + // startTime is null because includeStartTimeForDcp is false here: neither the Unix nor + // the Windows signal path consults StartTime at this call site, and querying + // Process.StartTime could throw on a process whose handle has already been closed. // - // Yield onto the thread pool so we don't block the caller while the signaler - // performs its (sometimes slow) work — DCP's stop-process-tree blocks until the - // target process actually exits, which is exactly the wait we want to avoid - // serializing in front of Phase 2's WaitForExitAsync. + // Yield onto the thread pool first: the signal request blocks until the target process + // exits, which is exactly the wait we don't want to serialize in front of Phase 2's + // exit-wait (see ShutdownLadderAsync Phase 1). await Task.Yield(); await signaler.RequestProcessTreeGracefulShutdownAsync( diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.cs b/src/Aspire.Cli/Processes/IsolatedProcess.cs index fc825350732..95cfa0c4220 100644 --- a/src/Aspire.Cli/Processes/IsolatedProcess.cs +++ b/src/Aspire.Cli/Processes/IsolatedProcess.cs @@ -51,8 +51,8 @@ internal sealed class IsolatedProcessStartInfo /// /// When (the default) the child is spawned in its own hidden console - /// group on Windows (CREATE_NEW_CONSOLE | SW_HIDE) so DCP's stop-process-tree CTRL+C - /// dance can target it without also signalling the CLI. When the child + /// group on Windows (CREATE_NEW_CONSOLE | SW_HIDE) so a graceful CTRL+C can target it without + /// also signalling the CLI. When the child /// is spawned via an ordinary redirected — no new /// console, no , and stdin wired to an empty pipe — which is the shape /// every non-isolated CLI subprocess (build, restore, package add, …) uses. On Unix both modes @@ -98,9 +98,9 @@ internal sealed class IsolatedProcessStartInfo /// /// Mirrors for a child spawned by . -/// On Windows the child gets its own hidden console (CREATE_NEW_CONSOLE | SW_HIDE) so DCP's -/// stop-process-tree can AttachConsole + post CTRL_C_EVENT at it without -/// also signalling the CLI; on Unix it's a thin +/// On Windows the child gets its own hidden console (CREATE_NEW_CONSOLE | SW_HIDE) so a graceful +/// shutdown can AttachConsole + post CTRL_C_EVENT at it without also signalling the +/// CLI; on Unix it's a thin /// wrapper because SIGTERM via the process group is enough. /// /// diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index d7e701aa950..bf3c612a595 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -40,10 +40,7 @@ internal sealed class AppHostServerSession : IAppHostServerSession private readonly bool _isolateConsole; // Serializes StartAsync against DisposeAsync so a concurrent dispose cannot orphan a - // just-spawned process (see StartAsync for the ordering guarantee). A SemaphoreSlim is used - // rather than a Monitor lock because StartAsync now awaits the spawn (_project.RunAsync) and - // DisposeAsync awaits while holding the gate. We never touch AvailableWaitHandle, so the - // semaphore allocates no wait handle and does not need disposal. + // just-spawned process (see StartAsync for the ordering guarantee). private readonly SemaphoreSlim _startGate = new(1, 1); private bool _startInvoked; private bool _disposed; @@ -158,8 +155,7 @@ public async Task StartAsync() // _disposed, so it either runs before us (and StartAsync sees _disposed and throws) or after // us (and Dispose sees a fully-published execution + run task). Without this widening there // is a window between _project.RunAsync returning and the run task starting where a - // concurrent Dispose would orphan the just-launched process. The await on RunAsync is why - // this is a SemaphoreSlim rather than a Monitor lock. + // concurrent Dispose would orphan the just-launched process. await _startGate.WaitAsync().ConfigureAwait(false); try { diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 7d1c6ca15a2..1a21f058e3e 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -678,7 +678,7 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() // Pass appHostSystemToken so a fatal backchannel failure (or user cancellation, since // appHostSystemCts is linked to cancellationToken AND CCM.Token) tears down the guest // process. The launcher will kill the guest's process tree when this token cancels. - // Pass GuestLaunchOptions so the launcher uses an isolated console + DCP graceful + // Pass GuestLaunchOptions so the launcher uses an isolated console + graceful // shutdown on Windows, matching what the AppHost server already does. var guestLaunchOptions = new GuestLaunchOptions( IsolateConsoleForGracefulShutdown: true, diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index 1b104b1b710..acac552dba1 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -49,7 +49,7 @@ internal sealed record AppHostServerRunResult( /// /// /// When , on Windows the server is spawned via -/// into its own hidden console (CREATE_NEW_CONSOLE | SW_HIDE) so DCP's stop-process-tree can +/// into its own hidden console (CREATE_NEW_CONSOLE | SW_HIDE) so a graceful shutdown can /// AttachConsole + post CTRL_C_EVENT against the server without also signalling the CLI, /// and the child is bound to the process-wide kill-on-close /// safety net. On Unix the spawn is effectively the same as today's path. diff --git a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs index 2ae91127531..bcc2791d291 100644 --- a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs +++ b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs @@ -17,8 +17,8 @@ namespace Aspire.Cli.Projects; /// /// When , spawn the guest via /// so it lands in its own hidden console -/// group. Required on Windows for the AttachConsole + GenerateConsoleCtrlEvent dance -/// (executed by ) to target the guest +/// group. Required on Windows so the graceful CTRL+C signal (issued by +/// ) can target the guest /// without also signalling the CLI itself. No-op on Unix where SIGTERM is sufficient. /// /// diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index a52a10ca578..8b604208dd3 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -128,8 +128,8 @@ void HandleStderrLine(string line) { StandardOutputCallback = HandleStdoutLine, StandardErrorCallback = HandleStderrLine, - // Run-path spawn: isolated console group + anonymous-pipe stdio so DCP's AttachConsole + - // GenerateConsoleCtrlEvent dance can target the guest without also signalling the CLI. + // Run-path spawn: isolated console group + anonymous-pipe stdio so a graceful CTRL+C can + // target the guest without also signalling the CLI. IsolateConsole = options?.IsolateConsoleForGracefulShutdown == true, GracefulShutdownSignaler = options?.GracefulShutdownSignaler, ShutdownService = options?.ShutdownService, From 37cc223802b5f61f9f0ce0978b4a3e823c7a055a Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 14:51:55 -0700 Subject: [PATCH 33/56] Throw plain OperationCanceledException from Windows WaitForExitAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kept-handle WaitForExitAsync helper surfaced cancellation as TaskCanceledException (via Task.FromCanceled and TrySetCanceled), but Process.WaitForExitAsync — which the non-isolated path uses — throws a plain OperationCanceledException. ProcessExecutionTests assert the exact type via Assert.ThrowsAsync, so the isolateConsole:True variants regressed on Windows. Use ThrowIfCancellationRequested for the pre-cancelled fast path and complete (rather than cancel) the TCS on token registration, then re-check cancellation after the await so the helper throws the base exception type and matches the BCL contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Processes/WindowsProcessInterop.cs | 106 +++++++++--------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs index fd01b1a924d..829863a9ba8 100644 --- a/src/Aspire.Cli/Processes/WindowsProcessInterop.cs +++ b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs @@ -651,76 +651,78 @@ public static nint BuildEnvironmentBlock(IReadOnlyDictionary en /// guaranteed to succeed. /// [SupportedOSPlatform("windows")] - public static Task WaitForExitAsync(SafeProcessHandle processHandle, CancellationToken cancellationToken) + public static async Task WaitForExitAsync(SafeProcessHandle processHandle, CancellationToken cancellationToken) { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } + // Throw a plain OperationCanceledException (not a TaskCanceledException) to match + // Process.WaitForExitAsync, which callers and tests assert on by exact type. + cancellationToken.ThrowIfCancellationRequested(); if (processHandle.IsClosed || processHandle.IsInvalid) { // No live handle to wait on (the wrapper disposed it); the process is gone as far as we // can observe, so mirror a completed wait rather than throwing. - return Task.CompletedTask; + return; } // Fast path: already signaled, so skip the thread-pool registration entirely. if (WaitForSingleObject(processHandle, 0) == WaitObject0) { - return Task.CompletedTask; + return; } - return WaitForExitCoreAsync(processHandle, cancellationToken); - - static async Task WaitForExitCoreAsync(SafeProcessHandle processHandle, CancellationToken cancellationToken) + // Pin the SafeProcessHandle for the duration of the wait so a concurrent Dispose (the + // wrapper's ExtraDispose closes this handle) cannot recycle the raw handle out from + // under the registered thread-pool wait. If the handle was closed in the meantime, + // DangerousAddRef throws ObjectDisposedException — treat that as "already gone". + var added = false; + try { - // Pin the SafeProcessHandle for the duration of the wait so a concurrent Dispose (the - // wrapper's ExtraDispose closes this handle) cannot recycle the raw handle out from - // under the registered thread-pool wait. If the handle was closed in the meantime, - // DangerousAddRef throws ObjectDisposedException — treat that as "already gone". - var added = false; - try - { - processHandle.DangerousAddRef(ref added); - } - catch (ObjectDisposedException) - { - return; - } + processHandle.DangerousAddRef(ref added); + } + catch (ObjectDisposedException) + { + return; + } - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - // RegisterWaitForSingleObject needs a managed WaitHandle. Wrap the raw process handle in - // a non-owning SafeWaitHandle (the SafeProcessHandle keeps ownership) and graft it onto a - // throwaway ManualResetEvent — the canonical way to wait on a foreign kernel handle. - var waitHandle = new ManualResetEvent(false); - var placeholder = waitHandle.SafeWaitHandle; - waitHandle.SafeWaitHandle = new SafeWaitHandle(processHandle.DangerousGetHandle(), ownsHandle: false); - placeholder.Dispose(); + // RegisterWaitForSingleObject needs a managed WaitHandle. Wrap the raw process handle in + // a non-owning SafeWaitHandle (the SafeProcessHandle keeps ownership) and graft it onto a + // throwaway ManualResetEvent — the canonical way to wait on a foreign kernel handle. + var waitHandle = new ManualResetEvent(false); + var placeholder = waitHandle.SafeWaitHandle; + waitHandle.SafeWaitHandle = new SafeWaitHandle(processHandle.DangerousGetHandle(), ownsHandle: false); + placeholder.Dispose(); - RegisteredWaitHandle? registration = null; - var ctr = cancellationToken.Register(static s => ((TaskCompletionSource)s!).TrySetCanceled(), tcs); - try - { - registration = ThreadPool.RegisterWaitForSingleObject( - waitHandle, - static (state, _) => ((TaskCompletionSource)state!).TrySetResult(), - tcs, - millisecondsTimeOutInterval: Timeout.Infinite, - executeOnlyOnce: true); - - await tcs.Task.ConfigureAwait(false); - } - finally + RegisteredWaitHandle? registration = null; + + // Cancellation completes the TCS instead of faulting it, so the await never throws a + // TaskCanceledException; we surface a plain OperationCanceledException via + // ThrowIfCancellationRequested below. + var ctr = cancellationToken.Register(static s => ((TaskCompletionSource)s!).TrySetResult(), tcs); + try + { + registration = ThreadPool.RegisterWaitForSingleObject( + waitHandle, + static (state, _) => ((TaskCompletionSource)state!).TrySetResult(), + tcs, + millisecondsTimeOutInterval: Timeout.Infinite, + executeOnlyOnce: true); + + await tcs.Task.ConfigureAwait(false); + + // The TCS completes when the handle signals OR the token fires. Prefer cancellation when + // both happened, matching Process.WaitForExitAsync's cancellation-wins behavior. + cancellationToken.ThrowIfCancellationRequested(); + } + finally + { + ctr.Dispose(); + registration?.Unregister(null); + waitHandle.Dispose(); + if (added) { - ctr.Dispose(); - registration?.Unregister(null); - waitHandle.Dispose(); - if (added) - { - processHandle.DangerousRelease(); - } + processHandle.DangerousRelease(); } } } From 4ad3154fee009757fedd81d782b7b21a9ca4d4fd Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 19 Jun 2026 15:10:23 -0700 Subject: [PATCH 34/56] Harden pid-file read against Windows sharing violation in ProcessGuestLauncherTests WaitForPidFileAsync read descendant.pid with File.ReadAllTextAsync (FileShare.Read) while the spawning child (PowerShell Set-Content) could still hold the file open, producing an intermittent Windows IOException sharing violation that escaped the polling loop and failed the test. Open with FileShare.ReadWrite and treat a transient IOException as 'not ready yet', retrying until the deadline; the existing int.TryParse guard already rejects a partially-written value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/ProcessGuestLauncherTests.cs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs index 375feb7b234..12717f9b31c 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -322,10 +322,24 @@ private static async Task WaitForPidFileAsync(FileInfo pidFile) { if (pidFile.Exists) { - var text = await File.ReadAllTextAsync(pidFile.FullName); - if (int.TryParse(text.Trim(), out var pid)) + try { - return pid; + // The writer (PowerShell Set-Content on Windows / shell redirect on Unix) may + // still hold the file open when we observe it exists, so reading can race into a + // Windows sharing violation. Open with FileShare.ReadWrite and treat any + // IOException as "not ready yet" — the int.TryParse guard below also rejects a + // partially-written value — then retry until the deadline. + using var stream = new FileStream(pidFile.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + var text = await reader.ReadToEndAsync(); + if (int.TryParse(text.Trim(), out var pid)) + { + return pid; + } + } + catch (IOException) + { + // File briefly locked by the writer; fall through and retry. } } From 8cd91e7a6b2bb7f67f17fecd118f676601f84472 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 22 Jun 2026 10:55:33 -0700 Subject: [PATCH 35/56] Restore IAppHostServerSessionFactory seam for short-lived codegen sessions The shutdown-unification work replaced the IAppHostServerSessionFactory interface with an AppHostServerCodegenSessionFactory delegate and scattered consumers between the delegate (GuestAppHostProject) and direct 'new AppHostServerSession(...)' construction (SdkGenerateCommand, ScaffoldingService, SdkDumpCommand). The only genuine driver was that run/publish need graceful-shutdown constructor parameters that don't fit a factory; the codegen seam never needed those and didn't need to change shape. Restore IAppHostServerSessionFactory (with a single Create(project, stopRequested) method) as the one seam for the short-lived codegen/scaffolding session, route all four consumers through it, and inject it via DI. This keeps the original abstraction and name, gives uniform testability (a fake factory returns a fake session), and leaves run/publish on direct construction where the shutdown parameters are required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 17 +++------- .../Commands/Sdk/SdkGenerateCommand.cs | 10 +++--- src/Aspire.Cli/Program.cs | 1 + .../Projects/AppHostServerSession.cs | 27 ++++++++++++++++ .../Projects/GuestAppHostProject.cs | 32 +++---------------- .../Projects/IAppHostServerSession.cs | 24 +++++++------- .../Scaffolding/ScaffoldingService.cs | 10 +++--- .../Projects/GuestAppHostProjectTests.cs | 12 +++---- .../Scaffolding/ChannelReseedTests.cs | 2 ++ .../TestServices/FakeAppHostServerSession.cs | 10 ++++++ .../FakeSucceedingAppHostServerProject.cs | 2 +- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 4 ++- 12 files changed, 81 insertions(+), 70 deletions(-) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 7571750e3fa..1fcae5d9cec 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -33,6 +33,7 @@ namespace Aspire.Cli.Commands.Sdk; internal sealed class SdkDumpCommand : BaseCommand { private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; + private readonly IAppHostServerSessionFactory _serverSessionFactory; private readonly ILogger _logger; private static readonly Argument s_integrationArgument = new("integrations") @@ -55,11 +56,13 @@ internal sealed class SdkDumpCommand : BaseCommand public SdkDumpCommand( IAppHostServerProjectFactory appHostServerProjectFactory, + IAppHostServerSessionFactory serverSessionFactory, ILogger logger, CommonCommandServices services) : base("dump", "Dump ATS capabilities from Aspire integration libraries.", services) { _appHostServerProjectFactory = appHostServerProjectFactory; + _serverSessionFactory = serverSessionFactory; _logger = logger; Arguments.Add(s_integrationArgument); @@ -190,12 +193,7 @@ private async Task DumpCapabilitiesAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = new AppHostServerSession( - appHostServerProject, - environmentVariables: null, - debug: false, - _logger, - cancellationToken); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. @@ -294,12 +292,7 @@ private async Task DumpCapabilitiesToDirectoryAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = new AppHostServerSession( - appHostServerProject, - environmentVariables: null, - debug: false, - _logger, - cancellationToken); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 7c37116b48d..1c3eca3870d 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -20,6 +20,7 @@ internal sealed class SdkGenerateCommand : BaseCommand { private readonly ILanguageDiscovery _languageDiscovery; private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; + private readonly IAppHostServerSessionFactory _serverSessionFactory; private readonly ILogger _logger; private static readonly Argument s_integrationArgument = new("integration") @@ -40,12 +41,14 @@ internal sealed class SdkGenerateCommand : BaseCommand public SdkGenerateCommand( ILanguageDiscovery languageDiscovery, IAppHostServerProjectFactory appHostServerProjectFactory, + IAppHostServerSessionFactory serverSessionFactory, ILogger logger, CommonCommandServices services) : base("generate", "Generate typed SDKs from an Aspire integration library for use in other languages.", services) { _languageDiscovery = languageDiscovery; _appHostServerProjectFactory = appHostServerProjectFactory; + _serverSessionFactory = serverSessionFactory; _logger = logger; Arguments.Add(s_integrationArgument); @@ -151,12 +154,7 @@ private async Task GenerateSdkAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = new AppHostServerSession( - appHostServerProject, - environmentVariables: null, - debug: false, - _logger, - cancellationToken); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 0d67f1d20d9..cfcb9b4ed33 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -502,6 +502,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new FirstTimeUseNoticeSentinel(GetUsersAspirePath())); builder.Services.AddSingleton(); diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index bf3c612a595..90251e84470 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -443,3 +443,30 @@ private static void ObserveFaultedTask(Task task) private static InvalidOperationException NotStarted() => new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); } + +/// +/// Default that constructs real +/// instances for the short-lived codegen/scaffolding path. +/// Sessions are created without graceful-shutdown parameters because they are transient: started, +/// queried over RPC, and disposed within a single operation. +/// +internal sealed class AppHostServerSessionFactory : IAppHostServerSessionFactory +{ + private readonly ILogger _logger; + private readonly ProfilingTelemetry _profilingTelemetry; + + public AppHostServerSessionFactory(ILogger logger, ProfilingTelemetry profilingTelemetry) + { + _logger = logger; + _profilingTelemetry = profilingTelemetry; + } + + public IAppHostServerSession Create(IAppHostServerProject appHostServerProject, CancellationToken stopRequested) => + new AppHostServerSession( + appHostServerProject, + environmentVariables: null, + debug: false, + _logger, + stopRequested, + _profilingTelemetry); +} diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 1a21f058e3e..abd391c43ac 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -50,7 +50,7 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly ProfilingTelemetry _profilingTelemetry; private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; private readonly IGracefulShutdownWindow _shutdownService; - private readonly AppHostServerCodegenSessionFactory _codegenSessionFactory; + private readonly IAppHostServerSessionFactory _serverSessionFactory; // Language is always resolved via constructor private readonly LanguageInfo _resolvedLanguage; @@ -73,8 +73,8 @@ public GuestAppHostProject( ProfilingTelemetry profilingTelemetry, IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, IGracefulShutdownWindow shutdownService, - TimeProvider? timeProvider = null, - AppHostServerCodegenSessionFactory? codegenSessionFactory = null) + IAppHostServerSessionFactory serverSessionFactory, + TimeProvider? timeProvider = null) { _resolvedLanguage = language; _interactionService = interactionService; @@ -94,27 +94,9 @@ public GuestAppHostProject( _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider, _profilingTelemetry); _gracefulShutdownSignaler = gracefulShutdownSignaler; _shutdownService = shutdownService; - - // Default to constructing a real session for code generation. This uses the simple - // construction (no graceful-shutdown parameters) because the codegen session is transient: - // it is started, queried over RPC, and disposed within BuildAndGenerateSdkAsync. Tests - // override this to supply a fake session that returns canned RPC results. - _codegenSessionFactory = codegenSessionFactory ?? CreateCodegenSession; + _serverSessionFactory = serverSessionFactory; } - private static IAppHostServerSession CreateCodegenSession( - IAppHostServerProject project, - ILogger logger, - CancellationToken stopRequested, - ProfilingTelemetry? profilingTelemetry) => - new AppHostServerSession( - project, - environmentVariables: null, - debug: false, - logger, - stopRequested, - profilingTelemetry); - // ═══════════════════════════════════════════════════════════════ // IDENTITY (Always resolved via constructor) // ═══════════════════════════════════════════════════════════════ @@ -311,11 +293,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir } // Step 2: Start the AppHost server temporarily for code generation - await using var serverSession = _codegenSessionFactory( - appHostServerProject, - _logger, - cancellationToken, - _profilingTelemetry); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task because disposal flows the exit code through the activity scope and the only // failure mode we care about surfaces via the RPC call below. diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs index dcc0548a961..6276e1bdbf1 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Cli.Telemetry; -using Microsoft.Extensions.Logging; - namespace Aspire.Cli.Projects; /// @@ -36,12 +33,17 @@ internal interface IAppHostServerSession : IAsyncDisposable } /// -/// Creates the short-lived used for SDK code generation. -/// Production wires this to construct a real ; tests inject a -/// factory that returns a fake session. +/// Creates the short-lived used for SDK code generation and +/// scaffolding. Production wires this to construct a real ; +/// tests inject a factory that returns a fake session. /// -internal delegate IAppHostServerSession AppHostServerCodegenSessionFactory( - IAppHostServerProject project, - ILogger logger, - CancellationToken stopRequested, - ProfilingTelemetry? profilingTelemetry); +internal interface IAppHostServerSessionFactory +{ + /// + /// Creates an unstarted session for the already-prepared . + /// The caller drives it via , + /// , then disposal. Cancelling + /// (or disposing the session) terminates the server process. + /// + IAppHostServerSession Create(IAppHostServerProject appHostServerProject, CancellationToken stopRequested); +} diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 661b7663209..accf28116c9 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -38,6 +38,7 @@ internal sealed class ScaffoldingService : IScaffoldingService }; private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; + private readonly IAppHostServerSessionFactory _serverSessionFactory; private readonly ILanguageDiscovery _languageDiscovery; private readonly IInteractionService _interactionService; private readonly ILogger _logger; @@ -45,12 +46,14 @@ internal sealed class ScaffoldingService : IScaffoldingService public ScaffoldingService( IAppHostServerProjectFactory appHostServerProjectFactory, + IAppHostServerSessionFactory serverSessionFactory, ILanguageDiscovery languageDiscovery, IInteractionService interactionService, ILogger logger, CliExecutionContext executionContext) { _appHostServerProjectFactory = appHostServerProjectFactory; + _serverSessionFactory = serverSessionFactory; _languageDiscovery = languageDiscovery; _interactionService = interactionService; _logger = logger; @@ -139,12 +142,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can } // Step 2: Start the server temporarily for scaffolding and code generation - await using var serverSession = new AppHostServerSession( - appHostServerProject, - environmentVariables: null, - debug: false, - _logger, - cancellationToken); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 622b3b75658..ae24f0e9017 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -1004,12 +1004,12 @@ await File.WriteAllTextAsync(configPath, $$""" Task.FromResult(new FakeSucceedingAppHostServerProject(appPath)) }; - AppHostServerCodegenSessionFactory sessionFactory = (_, _, _, _) => new FakeAppHostServerSession(); + IAppHostServerSessionFactory sessionFactory = new FakeAppHostServerSessionFactory(); var project = CreateGuestAppHostProject( interactionService: interactionService, appHostServerProjectFactory: factory, - codegenSessionFactory: sessionFactory); + serverSessionFactory: sessionFactory); var context = new UpdatePackagesContext { @@ -1088,12 +1088,12 @@ await File.WriteAllTextAsync(configPath, $$""" Task.FromResult(new FakeSucceedingAppHostServerProject(appPath)) }; - AppHostServerCodegenSessionFactory sessionFactory = (_, _, _, _) => new FakeAppHostServerSession(); + IAppHostServerSessionFactory sessionFactory = new FakeAppHostServerSessionFactory(); var project = CreateGuestAppHostProject( interactionService: interactionService, appHostServerProjectFactory: factory, - codegenSessionFactory: sessionFactory); + serverSessionFactory: sessionFactory); var context = new UpdatePackagesContext { @@ -1155,7 +1155,7 @@ private GuestAppHostProject CreateGuestAppHostProject( TestInteractionService? interactionService = null, string identityChannel = "local", TestAppHostServerProjectFactory? appHostServerProjectFactory = null, - AppHostServerCodegenSessionFactory? codegenSessionFactory = null, + IAppHostServerSessionFactory? serverSessionFactory = null, bool identityOverridden = false) { var language = new LanguageInfo( @@ -1197,7 +1197,7 @@ private GuestAppHostProject CreateGuestAppHostProject( profilingTelemetry: _profilingTelemetry, gracefulShutdownSignaler: new NoOpGracefulSignaler(), shutdownService: shutdownWindow, - codegenSessionFactory: codegenSessionFactory); + serverSessionFactory: serverSessionFactory ?? new FakeAppHostServerSessionFactory()); } private sealed class NoOpGracefulSignaler : IProcessTreeGracefulShutdownSignaler diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index abc05531f35..20a902fa736 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -83,6 +83,7 @@ public async Task ScaffoldAsync_PassesPackageSourceOverrideToPrepareAsync() { CreateAsyncCallback = (_, _) => Task.FromResult(appHostServerProject) }, + serverSessionFactory: new FakeAppHostServerSessionFactory(), languageDiscovery: new TestLanguageDiscovery(language), interactionService: new TestInteractionService(), logger: NullLogger.Instance, @@ -113,6 +114,7 @@ private static ScaffoldingService CreateScaffoldingService(TemporaryWorkspace wo { return new ScaffoldingService( appHostServerProjectFactory: new TestAppHostServerProjectFactory(), + serverSessionFactory: new FakeAppHostServerSessionFactory(), languageDiscovery: new TestLanguageDiscovery(s_testLanguage), interactionService: new TestInteractionService(), logger: NullLogger.Instance, diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs index 33f726cb1f9..7998b16e000 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs @@ -23,6 +23,16 @@ public Task GetRpcClientAsync(CancellationToken cancellationT public ValueTask DisposeAsync() => ValueTask.CompletedTask; } +/// +/// Fake that hands back a +/// without building or launching a real AppHost server. +/// +internal sealed class FakeAppHostServerSessionFactory : IAppHostServerSessionFactory +{ + public IAppHostServerSession Create(IAppHostServerProject appHostServerProject, CancellationToken stopRequested) + => new FakeAppHostServerSession(); +} + /// /// Fake RPC client that returns empty results for all operations. /// Used to exercise code paths that run after RPC connection without needing a real server. diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs index 691ec0994a5..788a460d730 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs @@ -9,7 +9,7 @@ namespace Aspire.Cli.Tests.TestServices; /// /// whose returns success. /// Used with a fake codegen session ( via an injected -/// ) that bypasses , +/// ) that bypasses , /// so is never called. /// internal sealed class FakeSucceedingAppHostServerProject(string appDirectoryPath) : IAppHostServerProject, IDisposable diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 6f176388019..6cbad6c9634 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -152,6 +152,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(); services.AddSingleton(options.ScaffoldingServiceFactory); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(options.LanguageServiceFactory); services.AddSingleton(); @@ -496,11 +497,12 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser public Func ScaffoldingServiceFactory { get; set; } = (IServiceProvider serviceProvider) => { var appHostServerProjectFactory = serviceProvider.GetRequiredService(); + var serverSessionFactory = serviceProvider.GetRequiredService(); var languageDiscovery = serviceProvider.GetRequiredService(); var interactionService = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); var executionContext = serviceProvider.GetRequiredService(); - return new ScaffoldingService(appHostServerProjectFactory, languageDiscovery, interactionService, logger, executionContext); + return new ScaffoldingService(appHostServerProjectFactory, serverSessionFactory, languageDiscovery, interactionService, logger, executionContext); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => From c884c083d486a088e2350b0c79a674928f1e1fd1 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 22 Jun 2026 10:55:38 -0700 Subject: [PATCH 36/56] Minor CLI cleanups: GetSafePid rename and Processes namespace import Rename ProcessExecution.SafePid to GetSafePid for naming consistency, and import the Aspire.Cli.Processes namespace in DotNetCliRunner so IProcessTreeGracefulShutdownSignaler and the WindowsConsoleProcessJob doc cref no longer need the Processes. qualifier. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 5 +++-- src/Aspire.Cli/DotNet/ProcessExecution.cs | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 88e6eed9d32..ffef2851f02 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -13,6 +13,7 @@ using Aspire.Cli.Caching; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; +using Aspire.Cli.Processes; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; @@ -74,7 +75,7 @@ internal sealed class ProcessInvocationOptions /// /// Pair with and . /// On Windows the spawned process is bound to the process-wide - /// kill-on-close job automatically. + /// kill-on-close job automatically. /// Leaving the signaler/service unset means cancellation falls back to /// 's force-kill mode, preserving back-compat /// for the many non-Run callers (build, restore, package add, layout, etc.). @@ -86,7 +87,7 @@ internal sealed class ProcessInvocationOptions /// stop-process-tree on Windows, SIGTERM on Unix). When null, the cancellation /// path uses 's force-kill mode. /// - public Processes.IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler { get; set; } + public IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler { get; set; } /// /// The central graceful-shutdown window whose diff --git a/src/Aspire.Cli/DotNet/ProcessExecution.cs b/src/Aspire.Cli/DotNet/ProcessExecution.cs index 63af87514f6..aff4fd33e12 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -180,7 +180,7 @@ private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShut // to the exit-wait. The signal is dispatched unconditionally (not gated on the graceful // token) so callers that intentionally Expire() the budget (e.g. `aspire stop`) still get a // best-effort signal. - var signalTask = InvokeSignalerAsync(signaler, SafePid(process), gracefulToken); + var signalTask = InvokeSignalerAsync(signaler, GetSafePid(process), gracefulToken); try { @@ -216,7 +216,7 @@ private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShut } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to kill {FileName} (pid {Pid}).", _fileName, SafePid(process)); + _logger.LogWarning(ex, "Failed to kill {FileName} (pid {Pid}).", _fileName, GetSafePid(process)); return; } @@ -234,7 +234,7 @@ private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShut } catch (Exception ex) { - _logger.LogWarning(ex, "Error draining killed {FileName} (pid {Pid}).", _fileName, SafePid(process)); + _logger.LogWarning(ex, "Error draining killed {FileName} (pid {Pid}).", _fileName, GetSafePid(process)); } } finally @@ -340,7 +340,7 @@ await signaler.RequestProcessTreeGracefulShutdownAsync( } } - private static int SafePid(Process process) + private static int GetSafePid(Process process) { try { From 4b96756b7039b32f2b29f0e993cb7d57c37e2fbe Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 22 Jun 2026 11:00:17 -0700 Subject: [PATCH 37/56] Remove redundant ConsoleCancellationManager DI registration BuildApplicationAsync registered the cancellation manager twice: once unconditionally via startupContext.CancellationManager, and again through a conditional 'cancellationManager' parameter. In production Program.Main passes the same instance both ways (it lives inside the CliStartupContext), so the conditional branch re-registered the identical instance; test callers never passed the parameter. Drop the dead parameter and the conditional registration, keeping the single unconditional registration, and fold the disposal-ownership rationale into its comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Program.cs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index cfcb9b4ed33..1e79a96feda 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -279,7 +279,7 @@ internal static (ILoggerFactory LoggerFactory, FileLoggerProvider FileLoggerProv return (factory, fileLoggerProvider); } - internal static async Task BuildApplicationAsync(string[] args, CliStartupContext startupContext, Dictionary? configurationValues = null, ConsoleCancellationManager? cancellationManager = null) + internal static async Task BuildApplicationAsync(string[] args, CliStartupContext startupContext, Dictionary? configurationValues = null) { // Check for --non-interactive flag early var nonInteractive = args?.Any(a => a == CommonOptionNames.NonInteractive) ?? false; @@ -324,7 +324,9 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(startupContext.LoggerFactory); builder.Services.TryAddSingleton(typeof(ILogger<>), typeof(Logger<>)); - // Register the cancellation manager so commands can observe forced-termination signals + // Register the cancellation manager so commands can observe forced-termination signals. + // It is registered as the existing instance (not a type) so disposal ownership stays with + // Program.Main, which owns its lifetime via a `using` statement. builder.Services.AddSingleton(startupContext.CancellationManager); // Register file logger provider for components that write directly to the log file @@ -336,19 +338,10 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu // Register logging options so components can read the user's chosen log level builder.Services.AddSingleton(startupContext.LoggingOptions); - // Register CCM as an instance singleton so the DI container does not take disposal ownership — - // Program.Main owns the lifetime via a `using` statement. Tests that drive BuildApplicationAsync - // directly without passing it in still work because startupContext.CancellationManager is - // registered unconditionally above. - if (cancellationManager is not null) - { - builder.Services.AddSingleton(cancellationManager); - } - // The graceful-shutdown window is the same object as the CCM (CCM owns the OS-signal // registration AND the graceful budget/clock/token). Map the consumer-facing interface to - // whichever CCM singleton was registered so per-child shutdown ladders depend on the narrow - // contract rather than the whole signal manager. + // the CCM singleton so per-child shutdown ladders depend on the narrow contract rather than + // the whole signal manager. builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Configure OpenTelemetry tracing. TelemetryManager reads configuration and creates @@ -994,7 +987,7 @@ public static async Task Main(string[] args) IHost? app = null; try { - app = await BuildApplicationAsync(args, startupContext, cancellationManager: cancellationManager); + app = await BuildApplicationAsync(args, startupContext); await app.StartAsync().ConfigureAwait(false); } catch (Exception ex) From da58bb000522552940cf3d64e951bfe06848af01 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 22 Jun 2026 11:06:19 -0700 Subject: [PATCH 38/56] Dispose _startGate semaphore in AppHostServerSession.DisposeAsync The SemaphoreSlim that serializes StartAsync against DisposeAsync was never disposed. Add _startGate.Dispose() as the final teardown step. DisposeAsync is idempotent (the alreadyDisposed guard returns before reaching this point on a second call), so the gate is disposed exactly once after it has been released and is no longer used. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/AppHostServerSession.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 90251e84470..7994aaa32bb 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -429,6 +429,7 @@ public async ValueTask DisposeAsync() _stopCts.Dispose(); (_project as IDisposable)?.Dispose(); _activity.Dispose(); + _startGate.Dispose(); } private static void ObserveFaultedTask(Task task) From 8be597a859976c2d03b3d03442e7fab1b11c8072 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 22 Jun 2026 11:08:40 -0700 Subject: [PATCH 39/56] Avoid hard-casting IProcessExecution to ProcessExecution The AppHost server Run paths declared their execution local as the concrete ProcessExecution and cast the IProcessExecutionFactory.CreateExecution result accordingly, even though every member used (ProcessId, Start, DisposeAsync) is on the IProcessExecution interface and AppHostServerRunResult.Execution is already typed as the interface. Declare the local as IProcessExecution and drop the casts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs | 4 ++-- src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index ceadba44130..d87832cf993 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -547,7 +547,7 @@ public async Task RunAsync( // child's pid per line. ProcessInvocationOptions.StandardOutputCallback is Action // (line only), but the AppHost wants the pid in each trace line (#16729). The callbacks // only fire after Start(), by which point `execution` is assigned and ProcessId is valid. - ProcessExecution execution = null!; + IProcessExecution execution = null!; void OnStdout(string line) { @@ -574,7 +574,7 @@ void OnStderr(string line) KillEntireProcessTreeOnCancel = !OperatingSystem.IsWindows(), }; - execution = (ProcessExecution)_processExecutionFactory.CreateExecution(startInfo, options); + execution = _processExecutionFactory.CreateExecution(startInfo, options); try { diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index d5e636847f0..cb56b6c754c 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -930,7 +930,7 @@ public async Task RunAsync( // log level + prefix differ from the dotnet-based server (#16729); keeping them here keeps // this server's per-line behavior in one place. Callbacks only fire after Start(), so // `execution` is assigned and ProcessId is valid by then. - ProcessExecution execution = null!; + IProcessExecution execution = null!; void OnStdout(string line) { @@ -964,7 +964,7 @@ void OnStderr(string line) KillEntireProcessTreeOnCancel = !OperatingSystem.IsWindows(), }; - execution = (ProcessExecution)_processExecutionFactory.CreateExecution(startInfo, options); + execution = _processExecutionFactory.CreateExecution(startInfo, options); try { From 8159ae36f98206b9e3a2dc512d3d7135d089af18 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 22 Jun 2026 11:18:25 -0700 Subject: [PATCH 40/56] Drop unnecessary Interlocked machinery in GuestAppHostProject.RunAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backchannel-fault continuation guarded appHostSystemCts.Cancel() with a runEnded gate (CAS-flipped by both the continuation and a finally) on top of a try/catch (ObjectDisposedException) around the Cancel call. The CTS is disposed as the try unwinds, before the finally runs, so the ObjectDisposedException catch — not the gate — is what actually protects the disposal race. The gate only skipped one caught exception in the rare late-fire path. Remove runEnded, both CompareExchange calls, and the finally. internalFaultCode is written at most once by the single ContinueWith continuation, so the first-writer-wins CompareExchange protected a race that cannot occur. Replace it with a plain assignment; appHostSystemCts.Cancel() supplies the barrier for the catch's read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/GuestAppHostProject.cs | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index abd391c43ac..378f9921734 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -367,16 +367,12 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken _logger.LogDebug("Running {Language} AppHost: {AppHostFile}", DisplayName, appHostFile.FullName); var startProjectContext = Activity.Current?.Context ?? default; - // Guard for the backchannel continuation below. Declared at method scope so the finally - // (which sets it) and the inner-try declarations of serverStopCts/etc remain consistent - // regardless of where we exit. - var runEnded = 0; - // Captures an exit code surfaced by an internal teardown trigger (currently only the // backchannel-fault continuation). Read by the outer OCE catch when the in-flight await // throws OCE because we cancelled appHostSystemCts ourselves. -1 = "no internal failure // recorded; fall back to Cancelled (130)". Declared at method scope so the catch can read - // it. + // it. Written at most once by the single backchannel continuation, so a plain assignment + // is sufficient; appHostSystemCts.Cancel() below provides the barrier for the catch's read. var internalFaultCode = -1; try @@ -539,11 +535,10 @@ await GenerateCodeViaRpcAsync( using var appHostSystemCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var appHostSystemToken = appHostSystemCts.Token; - // Guard the backchannel continuation from firing after RunAsync has already returned. - // The continuation can fault late and would otherwise touch the disposed - // appHostSystemCts. The continuation uses an atomic CAS on runEnded (rather than a - // plain read) so it races safely with the finally below — exactly one of (continuation, - // finally) flips the gate 0→1, and the loser observes the flag already set and no-ops. + // Guard the backchannel continuation from touching the disposed appHostSystemCts after + // RunAsync has already returned. The continuation can fault late, so the Cancel() call + // is wrapped in a try/catch (ObjectDisposedException) — that catch is the authoritative + // protection against the disposal race. // When the backchannel polling task gives up (timeout, server process exit, or other // fatal connection error), escalate to tearing down the whole AppHost system by @@ -554,14 +549,11 @@ await GenerateCodeViaRpcAsync( _ = backchannelCompletionSource.Task.ContinueWith( t => { - // CAS-flip runEnded 0→1 atomically with the fault check. If the finally below - // wins the race the CAS fails and we no-op; otherwise this is the authoritative - // shutdown driver for the backchannel-fault path. - if (t.IsFaulted && Interlocked.CompareExchange(ref runEnded, 1, 0) == 0) + if (t.IsFaulted) { - // First-writer-wins on the surfaced exit code. The outer OCE catch reads - // this when the in-flight await throws because we cancelled below. - Interlocked.CompareExchange(ref internalFaultCode, CliExitCodes.FailedToDotnetRunAppHost, -1); + // The outer OCE catch reads this when the in-flight await throws because we + // cancelled below. + internalFaultCode = CliExitCodes.FailedToDotnetRunAppHost; try { appHostSystemCts.Cancel(); @@ -759,13 +751,6 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() _interactionService.DisplayError($"Failed to run {DisplayName} AppHost: {ex.Message}"); return CliExitCodes.FailedToDotnetRunAppHost; } - finally - { - // Mark the run as ended so the backchannel ContinueWith no-ops if it fires late. - // Uses CompareExchange to race safely with the continuation: whichever side flips the - // gate first wins; the loser observes the gate already set and skips the cancel call. - Interlocked.CompareExchange(ref runEnded, 1, 0); - } } internal Dictionary GetServerEnvironmentVariables( From e15fcb99c52f1fe2450a060802603bd7a3086869 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 10:23:28 -0700 Subject: [PATCH 41/56] Hoist graceful-shutdown budget to a virtual BaseCommand property Per PR feedback, move the per-command graceful-shutdown budget off an explicit ConfigureForCommand call in RunCommand.ExecuteAsync and onto a virtual GracefulShutdownBudget property on BaseCommand. BaseCommand reads the property and configures the shared ConsoleCancellationManager centrally before invoking ExecuteAsync, so every command opts in declaratively. The zero default preserves force-kill-on-cancel for all commands except aspire run, which overrides it to 5s. Drops the now-unused _cancellationManager field from RunCommand. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/BaseCommand.cs | 15 +++++++++++++++ src/Aspire.Cli/Commands/RunCommand.cs | 10 ++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Cli/Commands/BaseCommand.cs b/src/Aspire.Cli/Commands/BaseCommand.cs index dc5525d6e94..74218d26dea 100644 --- a/src/Aspire.Cli/Commands/BaseCommand.cs +++ b/src/Aspire.Cli/Commands/BaseCommand.cs @@ -26,6 +26,15 @@ internal abstract class BaseCommand : Command /// internal virtual HelpGroup HelpGroup => HelpGroup.None; + /// + /// The graceful-shutdown budget this command grants its child processes before shutdown ladders + /// escalate to forceful termination. reads this and configures the + /// shared centrally before invoking . + /// The default of zero preserves force-kill-on-cancel behavior for every command that does not opt in; + /// aspire run overrides it to give the AppHost a real cooperative-shutdown window. + /// + protected virtual TimeSpan GracefulShutdownBudget => TimeSpan.Zero; + private readonly CliExecutionContext _executionContext; protected CliExecutionContext ExecutionContext => _executionContext; @@ -60,6 +69,12 @@ protected BaseCommand(string name, string description, CommonCommandServices ser var stoppingMessageShown = false; try { + // Configure the shared shutdown manager with this command's graceful-shutdown budget + // before the handler starts spawning child processes, so every per-child shutdown + // ladder observes the correct window from the first user signal. Commands that don't + // override GracefulShutdownBudget get the zero default (force-kill on cancel). + services.CancellationManager.ConfigureForCommand(GracefulShutdownBudget); + var handlerTask = ExecuteAsync(parseResult, cancellationToken); services.CancellationManager.SetStartedHandler(handlerTask); diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 4ff7e80ed3a..aa6e6a1ea42 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -72,7 +72,6 @@ internal sealed class RunCommand : BaseCommand private readonly ICliHostEnvironment _hostEnvironment; private readonly ProfilingTelemetry _profilingTelemetry; private readonly TimeProvider _timeProvider; - private readonly ConsoleCancellationManager _cancellationManager; private bool _isDetachMode; private const int MaxDisplayedAppHostStartupOutputLines = 80; @@ -95,6 +94,8 @@ internal sealed class RunCommand : BaseCommand protected override bool UpdateNotificationsEnabled => !_isDetachMode; + protected override TimeSpan GracefulShutdownBudget => s_gracefulShutdownBudget; + private static readonly Option s_detachOption = new("--detach") { Description = RunCommandStrings.DetachArgumentDescription @@ -133,7 +134,6 @@ public RunCommand( _hostEnvironment = hostEnvironment; _profilingTelemetry = profilingTelemetry; _timeProvider = timeProvider; - _cancellationManager = services.CancellationManager; Options.Add(s_detachOption); Options.Add(s_noBuildOption); @@ -144,12 +144,6 @@ public RunCommand( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - // Give DCP a cooperative window to drain resources before the central drain budget - // arms and shutdown ladders escalate to forceful kill. Without this, GracefulShutdownToken - // fires immediately on the first user signal and isolation/AttachConsole buys nothing - // because every ladder sees the graceful window already expired. - _cancellationManager.ConfigureForCommand(s_gracefulShutdownBudget); - var passedAppHostProjectFile = parseResult.GetValue(AppHostLauncher.s_appHostOption); var detach = parseResult.GetValue(s_detachOption); _isDetachMode = detach; From f5f441226bfb4e46deab3ef5edda82e60f1468ce Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 10:37:52 -0700 Subject: [PATCH 42/56] Make AppHostServerSession ctor params required; reorder token last Remove the optional defaults from gracefulShutdownSignaler, shutdownService, isolateConsole, and profilingTelemetry so the production contract is explicit, and move the cancellation token to the final parameter position. Update the three production call sites (factory, run path, publish path) accordingly. Route the 14 test construction sites through a new private CreateSession helper that supplies the shared test defaults (null logger, no profiling, no graceful wiring), keeping each call focused on the arguments its scenario cares about while the production constructor stays strict. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/ShutdownSmoke/.gitignore | 3 + .../Projects/AppHostServerSession.cs | 17 +-- .../Projects/GuestAppHostProject.cs | 11 +- .../Projects/AppHostServerSessionTests.cs | 108 ++++++++---------- 4 files changed, 65 insertions(+), 74 deletions(-) create mode 100644 playground/ShutdownSmoke/.gitignore diff --git a/playground/ShutdownSmoke/.gitignore b/playground/ShutdownSmoke/.gitignore new file mode 100644 index 00000000000..f77034ae08f --- /dev/null +++ b/playground/ShutdownSmoke/.gitignore @@ -0,0 +1,3 @@ +# Throwaway smoke-test scaffold. Do not commit anything from this folder. +* +!.gitignore diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 7994aaa32bb..2e26e0a07c7 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -58,11 +58,11 @@ public AppHostServerSession( Dictionary? environmentVariables, bool debug, ILogger logger, - CancellationToken stopRequested, - ProfilingTelemetry? profilingTelemetry = null, - IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler = null, - IGracefulShutdownWindow? shutdownService = null, - bool isolateConsole = false) + ProfilingTelemetry? profilingTelemetry, + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler, + IGracefulShutdownWindow? shutdownService, + bool isolateConsole, + CancellationToken stopRequested) { _project = project ?? throw new ArgumentNullException(nameof(project)); _callerEnvironmentVariables = environmentVariables; @@ -468,6 +468,9 @@ public IAppHostServerSession Create(IAppHostServerProject appHostServerProject, environmentVariables: null, debug: false, _logger, - stopRequested, - _profilingTelemetry); + _profilingTelemetry, + gracefulShutdownSignaler: null, + shutdownService: null, + isolateConsole: false, + stopRequested); } diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 378f9921734..b3f7caa4312 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -463,11 +463,11 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken launchSettingsEnvVars, context.Debug, _logger, - serverStopCts.Token, _profilingTelemetry, gracefulShutdownSignaler: _gracefulShutdownSignaler, shutdownService: _shutdownService, - isolateConsole: true); + isolateConsole: true, + serverStopCts.Token); Task serverCompletion; IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) @@ -1041,8 +1041,11 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca launchSettingsEnvVars, context.Debug, _logger, - serverStopCts.Token, - _profilingTelemetry); + _profilingTelemetry, + gracefulShutdownSignaler: null, + shutdownService: null, + isolateConsole: false, + serverStopCts.Token); Task serverCompletion; IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index aecf4a91188..8c09367453d 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.Layout; using Aspire.Cli.NuGet; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.Mcp; @@ -33,12 +34,10 @@ public async Task Start_DoesNotMutateCallerEnvironmentVariables() ["EXISTING_VALUE"] = "present" }; - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables, - debug: false, - NullLogger.Instance, - CancellationToken.None); + CancellationToken.None, + environmentVariables: environmentVariables); await session.StartAsync(); Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); @@ -62,13 +61,11 @@ public async Task Start_PropagatesProfilingContextToServerEnvironment() (ProfilingTelemetry.EnvironmentVariables.SessionId, "session-1"))); using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource); - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables, - debug: false, - NullLogger.Instance, CancellationToken.None, - profilingTelemetry); + environmentVariables: environmentVariables, + profilingTelemetry: profilingTelemetry); await session.StartAsync(); Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); @@ -102,13 +99,10 @@ public async Task Start_DoesNotLeaveServerProcessActivityAmbient() using var parentActivity = parentSource.StartActivity("aspire/cli/run"); Assert.NotNull(parentActivity); - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, CancellationToken.None, - profilingTelemetry); + profilingTelemetry: profilingTelemetry); await session.StartAsync(); Assert.Same(parentActivity, Activity.Current); @@ -121,11 +115,8 @@ public async Task Start_DoesNotLeaveServerProcessActivityAmbient() public void AuthenticationToken_IsAvailableBeforeStart() { var project = new RecordingAppHostServerProject(); - var session = new AppHostServerSession( + var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, CancellationToken.None); Assert.False(string.IsNullOrEmpty(session.AuthenticationToken)); @@ -140,11 +131,8 @@ public async Task GetRpcClientAsync_WhenServerExitsBeforeSocketIsAvailable_Fails // fast rather than burning the full connection-retry timeout. var project = new RecordingAppHostServerProject(); - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, CancellationToken.None); await session.StartAsync(); @@ -170,11 +158,8 @@ public async Task GetRpcClientAsync_WhenServerExitsBeforeSocketIsAvailable_Fails public void SessionState_BeforeStart_IsNull() { var project = new RecordingAppHostServerProject(); - var session = new AppHostServerSession( + var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, CancellationToken.None); Assert.Null(session.SocketPath); @@ -186,11 +171,8 @@ public void SessionState_BeforeStart_IsNull() public async Task Start_CalledTwice_Throws() { var project = new RecordingAppHostServerProject(); - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, CancellationToken.None); await session.StartAsync(); @@ -203,11 +185,8 @@ public async Task Start_StopRequested_KillsProcessAndCompletesTask() { var project = new LongRunningAppHostServerProject(); using var stopCts = new CancellationTokenSource(); - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, stopCts.Token); await session.StartAsync(); @@ -253,13 +232,9 @@ public async Task Start_StopRequested_WithGracefulServices_InvokesGracefulSignal return Task.FromResult(true); }); - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, stopCts.Token, - profilingTelemetry: null, gracefulShutdownSignaler: signaler, shutdownService: shutdownService); @@ -301,13 +276,9 @@ public async Task Start_StopRequested_GracefulIgnored_ExpireEscalatesToTreeKill( return Task.FromResult(true); }); - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, stopCts.Token, - profilingTelemetry: null, gracefulShutdownSignaler: signaler, shutdownService: shutdownService); @@ -345,13 +316,9 @@ public async Task Start_StopRequested_GracefulSignalerThrows_StillEscalatesToKil var signaler = new RecordingGracefulSignaler(onSignal: _ => throw new InvalidOperationException("simulated DCP failure")); - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, stopCts.Token, - profilingTelemetry: null, gracefulShutdownSignaler: signaler, shutdownService: shutdownService); @@ -381,13 +348,9 @@ public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithout using var shutdownService = new TestGracefulShutdownWindow { IsEnabled = false }; var signaler = new RecordingGracefulSignaler(); - var session = new AppHostServerSession( + var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, stopCts.Token, - profilingTelemetry: null, gracefulShutdownSignaler: signaler, shutdownService: shutdownService); @@ -415,11 +378,8 @@ public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithout public async Task Start_ProcessExitsNaturally_CompletionReturnsExitCode() { var project = new RecordingAppHostServerProject(); - await using var session = new AppHostServerSession( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, CancellationToken.None); await session.StartAsync(); @@ -471,11 +431,8 @@ void Handler(object? sender, UnobservedTaskExceptionEventArgs e) static async Task RunScenarioAsync(ThrowingAppHostServerProject project) { - var session = new AppHostServerSession( + var session = CreateSession( project, - environmentVariables: null, - debug: false, - NullLogger.Instance, CancellationToken.None); await Assert.ThrowsAsync(session.StartAsync); @@ -576,6 +533,31 @@ private static IConfiguration CreateConfiguration(params (string Key, string? Va .Build(); } + // Constructs a session with the test-default wiring so call sites stay focused on the one or two + // arguments each scenario actually cares about. The production constructor is intentionally strict + // (every parameter required); this helper centralizes the defaults that virtually every test shares: + // a null logger, no profiling, and the codegen-style "no graceful shutdown / no console isolation" + // configuration. Scenarios that exercise the graceful ladder opt in via the optional parameters. + private static AppHostServerSession CreateSession( + IAppHostServerProject project, + CancellationToken stopRequested, + Dictionary? environmentVariables = null, + bool debug = false, + ProfilingTelemetry? profilingTelemetry = null, + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler = null, + IGracefulShutdownWindow? shutdownService = null, + bool isolateConsole = false) => + new( + project, + environmentVariables, + debug, + NullLogger.Instance, + profilingTelemetry, + gracefulShutdownSignaler, + shutdownService, + isolateConsole, + stopRequested); + private static AppHostServerProjectFactory CreateAppHostServerProjectFactory() { var executionContext = TestExecutionContextFactory.CreateTestContext(); From ec0acff797f7e48410d6d0da722fa751bd68df9e Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 10:45:49 -0700 Subject: [PATCH 43/56] Collapse Ctrl+C ladder to a two-press flow The signal counter had a third rung (n>=3) that fired ProcessTerminationCompletionSource immediately, skipping even the bounded final drain. That was a marginal "impatience" shortcut, not a distinct phase: the bounded drain armed on the first signal already guarantees the process exits after the second press, so the third rung only shaved up to the drain budget off an impatient user's wait while complicating the contract. Remove the n>=3 branch so the ladder is a clean two-press flow: first signal starts graceful shutdown, second collapses it into the bounded final drain that forces exit, and third/later signals are no-ops. Update the XML docs, comments, and tests to describe two stages (ThirdSignal_FiresProcessTerminationImmediately becomes AdditionalSignals_AfterSecond_AreIgnored). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/ConsoleCancellationManager.cs | 39 +++++++++---------- .../ConsoleCancellationManagerTests.cs | 18 +++++---- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index 1fd436720d0..0fcff2dd76d 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -23,13 +23,13 @@ namespace Aspire.Cli; /// so Program.Main abandons the handler task and returns the captured exit code. /// /// -/// The three-stage signal counter mirrors the same ladder: +/// The two-stage signal counter mirrors the same ladder: /// /// /// First signal — primary cancels and the graceful watcher starts. /// Second signal — the graceful window is collapsed via ; ladders see -/// fire immediately and escalate. -/// Third (or later) signal — fires; Main exits NOW. +/// fire immediately and escalate, then the bounded final +/// drain forces exit. Third and later signals are ignored. /// /// /// Graceful shutdown is all-or-nothing per command: reflects whether a positive @@ -47,7 +47,7 @@ namespace Aspire.Cli; /// /// /// The completion source completing is treated as a strict superset of graceful expiration: when the source -/// completes for any reason (drain timeout, third signal, future external triggers), is +/// completes for any reason (drain timeout, future external triggers), is /// invoked synchronously so ladders observing only the graceful token unblock in time to issue a kill before /// Main abandons them. /// @@ -79,18 +79,17 @@ internal sealed class ConsoleCancellationManager : IDisposable, IGracefulShutdow private ILogger _logger; private Task? _startedHandler; // Number of termination signals (Ctrl+C, SIGINT, SIGTERM, SIGQUIT, ProcessExit) received. - // Drives the three-stage ladder: 1 = start graceful watcher; 2 = collapse graceful; - // 3+ = force-exit. Internal teardown paths (guest failures, normal completion) do NOT - // drive this counter — they rely on disposable-based cleanup (`await using` of the - // server session + guest launcher) to run the per-process shutdown ladders. + // Drives the two-stage ladder: 1 = start graceful watcher; 2 = collapse graceful so the bounded + // final drain forces exit. Third and later signals are ignored. Internal teardown paths (guest + // failures, normal completion) do NOT drive this counter — they rely on disposable-based cleanup + // (`await using` of the server session + guest launcher) to run the per-process shutdown ladders. private int _signalCount; private readonly TaskCompletionSource _processTerminationCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); /// /// A completion source that is signaled with a native exit code when the running handler - /// does not complete within the configured drain budget after a termination signal, - /// or when a third Ctrl+C arrives. + /// does not complete within the configured drain budget after a termination signal. /// internal TaskCompletionSource ProcessTerminationCompletionSource => _processTerminationCompletionSource; @@ -115,8 +114,8 @@ public ConsoleCancellationManager(TimeSpan finalDrainBudget) _token = _cts.Token; _gracefulToken = _gracefulCts.Token; - // Phase 3 → Phase 2 fallthrough. When the termination completion source completes for any reason - // (drain timeout, third Ctrl+C, future external triggers), any ladder still observing only the + // Completion-source → graceful fallthrough. When the termination completion source completes for + // any reason (drain timeout, future external triggers), any ladder still observing only the // graceful token would otherwise sit on a Task.Delay(budget, GracefulShutdownToken) and miss its // last chance to issue a kill before Main abandons it. Cancel synchronously so this fires before // continuations of the completion source observe completion. Expire() is idempotent — multiple @@ -300,18 +299,16 @@ internal void Cancel(int exitCode) } else if (n == 2) { - // Second signal: collapse Phase 1 immediately. Ladders observing the graceful token - // unblock and escalate to forceful termination; the watcher's Task.Delay(graceful) gets - // cancelled and moves on to Phase 2 (final drain). + // Second (final) signal: collapse Phase 1 immediately. Ladders observing the graceful + // token unblock and escalate to forceful termination; the watcher's Task.Delay(graceful) + // gets cancelled and moves on to Phase 2 (the bounded final drain), which guarantees exit. _logger.LogWarning("Second termination signal received, expiring graceful shutdown window."); Expire(); } - else - { - // Third (or later) signal: caller wants out NOW. Skip both graceful and drain budgets. - _logger.LogWarning("Third termination signal received, forcing immediate exit."); - _processTerminationCompletionSource.TrySetResult(exitCode); - } + + // Third and later signals are intentionally ignored. The two-press ladder is complete after the + // second signal: Phase 2's bounded final drain (armed on the first signal) already guarantees the + // process exits, so there is nothing left to escalate. } private async Task ExpireGracefulThenFinalDrainAsync(int forcedTerminationExitCode) diff --git a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs index f432803968d..c27c3ba40e4 100644 --- a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs +++ b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs @@ -91,7 +91,7 @@ public async Task SecondSignal_ExpiresGracefulImmediately() // Large graceful budget — without a 2nd signal the token would not fire for 30s. manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); - // Set a handler that never completes; 2nd signal should ONLY collapse graceful (not Phase 3). + // Set a handler that never completes; 2nd signal should ONLY collapse graceful (not force exit). manager.SetStartedHandler(new TaskCompletionSource().Task); manager.Cancel(130); @@ -107,27 +107,31 @@ public async Task SecondSignal_ExpiresGracefulImmediately() // 2nd signal expires graceful synchronously. Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); - // But the completion source should NOT have fired — that's Phase 3, requires a 3rd signal or drain timeout. + // But the completion source should NOT have fired yet — forced exit now happens only when the + // bounded final drain (here 30s) elapses, not synchronously on the 2nd signal. await Task.Delay(100); Assert.False(manager.ProcessTerminationCompletionSource.Task.IsCompleted); } [Fact] - public async Task ThirdSignal_FiresProcessTerminationImmediately() + public async Task AdditionalSignals_AfterSecond_AreIgnored() { using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); - // Long graceful budget so the watcher would not naturally complete in the test window. + // Long graceful and drain budgets so neither elapses during the test window. manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); - // Handler that never completes so Phase 2's WhenAny doesn't resolve via the handler branch. + // Handler that never completes so the only way the completion source could fire promptly is an + // explicit force path — which the two-press ladder no longer has. manager.SetStartedHandler(new TaskCompletionSource().Task); manager.Cancel(130); manager.Cancel(130); manager.Cancel(130); - var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); - Assert.Equal(130, exitCode); + // Third and later signals are no-ops: exit is driven solely by the bounded final drain elapsing, + // so the completion source must NOT have fired within this short window. + await Task.Delay(100); + Assert.False(manager.ProcessTerminationCompletionSource.Task.IsCompleted); } [Fact] From 52dedf88d4e6f1b3cc954d67b61740fe1647b772 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 10:52:06 -0700 Subject: [PATCH 44/56] Rename signal-count local n to signalNumber in Cancel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/ConsoleCancellationManager.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index 0fcff2dd76d..ac8a04656c1 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -276,9 +276,9 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) internal void Cancel(int exitCode) { - var n = Interlocked.Increment(ref _signalCount); + var signalNumber = Interlocked.Increment(ref _signalCount); - if (n == 1) + if (signalNumber == 1) { // First signal: request cooperative cancellation and schedule the graceful-then-drain // watcher. The signal handler returns immediately so Program.Main's Task.WhenAny observes @@ -297,7 +297,7 @@ internal void Cancel(int exitCode) _ = ExpireGracefulThenFinalDrainAsync(exitCode); } - else if (n == 2) + else if (signalNumber == 2) { // Second (final) signal: collapse Phase 1 immediately. Ladders observing the graceful // token unblock and escalate to forceful termination; the watcher's Task.Delay(graceful) From 663e9ce6fb50336ba6b4ac6b82125c3f5e37f9ef Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 11:03:29 -0700 Subject: [PATCH 45/56] Add CreatePrebuiltAppHostServer test helper to reduce boilerplate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/PrebuiltAppHostServerTests.cs | 247 +++++------------- 1 file changed, 63 insertions(+), 184 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 2229a614369..dd47c56d09f 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -232,18 +232,7 @@ public void Constructor_UsesWorkspaceAspireDirectoryForWorkingDirectory() using var workspace = TemporaryWorkspace.Create(outputHelper); var appHostDirectory = workspace.CreateDirectory("apphost"); - var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), new TestFeatures(), TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - var server = new PrebuiltAppHostServer( - appHostDirectory.FullName, - "test.sock", - new LayoutConfiguration(), - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), - Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), - new TestProcessExecutionFactory(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace, appPath: appHostDirectory.FullName); var workingDirectory = Assert.IsType( typeof(PrebuiltAppHostServer) @@ -277,24 +266,8 @@ public void Constructor_UsesDistinctWorkingDirectoriesForMultipleAppHostsInSameW var firstAppHost = workspace.CreateDirectory(Path.Combine("apps", "api")); var secondAppHost = workspace.CreateDirectory(Path.Combine("apps", "web")); - var nugetService = new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - TestExecutionContextFactory.CreateTestContext(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - PrebuiltAppHostServer CreateServer(string appHostDirectory) => new( - appHostDirectory, - "test.sock", - new LayoutConfiguration(), - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), - Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), - new TestProcessExecutionFactory(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + PrebuiltAppHostServer CreateServer(string appHostDirectory) => + CreatePrebuiltAppHostServer(workspace, appPath: appHostDirectory); var firstServer = CreateServer(firstAppHost.FullName); var secondServer = CreateServer(secondAppHost.FullName); @@ -909,24 +882,7 @@ public async Task GetNuGetSources_NonStagingRequest_NotAffectedByStagingUnavaila GetStagingChannelUnavailableReasonCallback = () => "Staging unavailable" }; - var nugetService = new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - executionContext, - NullLogger.Instance); - - var server = new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - new LayoutConfiguration(), - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - packagingService, - executionContext, - new TestProcessExecutionFactory(), - NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace, packagingService: packagingService, executionContext: executionContext); var sources = await server.GetNuGetSourcesAsync("daily", packageSourceOverride: null, CancellationToken.None); @@ -956,7 +912,35 @@ private static PrebuiltAppHostServer CreateServerWithUnavailableStagingChannel( GetStagingChannelUnavailableReasonCallback = () => unavailableReason }; - var nugetService = new BundleNuGetService( + return CreatePrebuiltAppHostServer(workspace, packagingService: packagingService, executionContext: executionContext); + } + + private static CliExecutionContext CreateContextWithIdentityChannel(string identityChannel) => + new(new DirectoryInfo(Path.GetTempPath()), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "cache")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "logs")), + "test.log", + identityChannel: identityChannel); + + // Builds a PrebuiltAppHostServer with the constant test wiring (socket name, SDK installer, process + // execution factory, logger) so individual tests only specify the parameters their scenario exercises. + // The execution context defaults to a fresh test context and is shared with the default NuGet service. + // Tests that need bundle-layout discovery (FixedLayoutDiscovery) or a custom process runner build their + // own BundleNuGetService and pass it via nugetService. + private static PrebuiltAppHostServer CreatePrebuiltAppHostServer( + TemporaryWorkspace workspace, + string? appPath = null, + LayoutConfiguration? layout = null, + TestDotNetCliRunner? dotNetCliRunner = null, + IPackagingService? packagingService = null, + CliExecutionContext? executionContext = null, + BundleNuGetService? nugetService = null) + { + executionContext ??= TestExecutionContextFactory.CreateTestContext(); + + nugetService ??= new BundleNuGetService( new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), new TestFeatures(), @@ -964,27 +948,18 @@ private static PrebuiltAppHostServer CreateServerWithUnavailableStagingChannel( NullLogger.Instance); return new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, + appPath ?? workspace.WorkspaceRoot.FullName, "test.sock", - new LayoutConfiguration(), + layout ?? new LayoutConfiguration(), nugetService, - new TestDotNetCliRunner(), + dotNetCliRunner ?? new TestDotNetCliRunner(), new TestDotNetSdkInstaller(), - packagingService, + packagingService ?? MockPackagingServiceFactory.Create(), executionContext, new TestProcessExecutionFactory(), NullLogger.Instance); } - private static CliExecutionContext CreateContextWithIdentityChannel(string identityChannel) => - new(new DirectoryInfo(Path.GetTempPath()), - new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), - new DirectoryInfo(Path.Combine(Path.GetTempPath(), "cache")), - new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks")), - new DirectoryInfo(Path.Combine(Path.GetTempPath(), "logs")), - "test.log", - identityChannel: identityChannel); - private static PrebuiltAppHostServer CreateServerWithExplicitChannel( TemporaryWorkspace workspace, string channelName, @@ -1019,25 +994,7 @@ private static PrebuiltAppHostServer CreateServerWithPackagingService( IPackagingService packagingService, CliExecutionContext? executionContext = null) { - executionContext ??= TestExecutionContextFactory.CreateTestContext(); - var nugetService = new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - executionContext, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - return new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - new LayoutConfiguration(), - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - packagingService, - executionContext, - new TestProcessExecutionFactory(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + return CreatePrebuiltAppHostServer(workspace, packagingService: packagingService, executionContext: executionContext); } private static async Task InvokeTryCreateTemporaryNuGetConfigAsync( @@ -1081,18 +1038,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ } """); - var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), new TestFeatures(), TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - var server = new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - new LayoutConfiguration(), - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), - Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), - new TestProcessExecutionFactory(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace); var channel = server.ResolveRequestedChannel(); @@ -1104,18 +1050,7 @@ public async Task PrepareAsync_WithNoIntegrations_WritesDefaultAppSettings() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), new TestFeatures(), TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - var server = new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - new LayoutConfiguration(), - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - MockPackagingServiceFactory.Create(), - TestExecutionContextFactory.CreateTestContext(), - new TestProcessExecutionFactory(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace); var workingDirectory = GetWorkingDirectory(server); @@ -1764,23 +1699,10 @@ await File.WriteAllTextAsync(aspireConfigPath, """ GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]) }; - var nugetService = new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - TestExecutionContextFactory.CreateTestContext(), - NullLogger.Instance); - var server = new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - new LayoutConfiguration(), - nugetService, - dotNetCliRunner, - new TestDotNetSdkInstaller(), - packagingService, - TestExecutionContextFactory.CreateTestContext(), - new TestProcessExecutionFactory(), - NullLogger.Instance); + var server = CreatePrebuiltAppHostServer( + workspace, + dotNetCliRunner: dotNetCliRunner, + packagingService: packagingService); var workingDirectory = GetWorkingDirectory(server); try @@ -1909,23 +1831,7 @@ public async Task PrepareAsync_WithProjectReferencesAndPackageSourceOverride_Use return 0; } }; - var nugetService = new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - TestExecutionContextFactory.CreateTestContext(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - var server = new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - new LayoutConfiguration(), - nugetService, - dotNetCliRunner, - new TestDotNetSdkInstaller(), - MockPackagingServiceFactory.Create(), - TestExecutionContextFactory.CreateTestContext(), - new TestProcessExecutionFactory(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace, dotNetCliRunner: dotNetCliRunner); var workingDirectory = GetWorkingDirectory(server); try @@ -2011,17 +1917,13 @@ public async Task PrepareAsync_WithStagingPinnedProjectOutsideLaunchDirectory_Us GetChannelsAsyncCallback = _ => Task.FromResult>([stagingChannel]) }; - var server = new PrebuiltAppHostServer( - projectDirectory.FullName, - "test.sock", - layout, - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - packagingService, - executionContext, - new TestProcessExecutionFactory(), - NullLogger.Instance); + var server = CreatePrebuiltAppHostServer( + workspace, + appPath: projectDirectory.FullName, + layout: layout, + packagingService: packagingService, + executionContext: executionContext, + nugetService: nugetService); var workingDirectory = GetWorkingDirectory(server); try @@ -2493,18 +2395,7 @@ private static PrebuiltAppHostServer CreateProjectReferenceServer( } }; - var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), new TestFeatures(), TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - return new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - layout ?? new LayoutConfiguration(), - nugetService, - dotNetCliRunner, - new TestDotNetSdkInstaller(), - MockPackagingServiceFactory.Create(), - TestExecutionContextFactory.CreateTestContext(), - new TestProcessExecutionFactory(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + return CreatePrebuiltAppHostServer(workspace, layout: layout, dotNetCliRunner: dotNetCliRunner); } private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory ExecutionFactory) CreatePackageReferenceServer(TemporaryWorkspace workspace) @@ -2525,17 +2416,11 @@ private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory Execut TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - var server = new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - layout, - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - packagingService, - TestExecutionContextFactory.CreateTestContext(), - new TestProcessExecutionFactory(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer( + workspace, + layout: layout, + packagingService: packagingService, + nugetService: nugetService); return (server, executionFactory); } @@ -2672,17 +2557,11 @@ public void CreateStartInfo_SetsCliLogFilePathEnvironmentVariable() executionContext, NullLogger.Instance); - var server = new PrebuiltAppHostServer( - workspace.WorkspaceRoot.FullName, - "test.sock", - layout, - nugetService, - new TestDotNetCliRunner(), - new TestDotNetSdkInstaller(), - MockPackagingServiceFactory.Create(), - executionContext, - new TestProcessExecutionFactory(), - NullLogger.Instance); + var server = CreatePrebuiltAppHostServer( + workspace, + layout: layout, + executionContext: executionContext, + nugetService: nugetService); var startInfo = server.CreateStartInfo(123); From e20586a69856ad8b6573de40e03ee4dc494b4847 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 11:05:08 -0700 Subject: [PATCH 46/56] Remove accidentally committed ShutdownSmoke smoke-test project Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- playground/ShutdownSmoke/.gitignore | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 playground/ShutdownSmoke/.gitignore diff --git a/playground/ShutdownSmoke/.gitignore b/playground/ShutdownSmoke/.gitignore deleted file mode 100644 index f77034ae08f..00000000000 --- a/playground/ShutdownSmoke/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Throwaway smoke-test scaffold. Do not commit anything from this folder. -* -!.gitignore From a0dadd06153fa3455d811d983548d476fa7532d9 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 11:13:38 -0700 Subject: [PATCH 47/56] Replace NotStarted factory with SessionNotStartedException type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/AppHostServerSession.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 2e26e0a07c7..c8639bab41f 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -249,7 +249,7 @@ public Task WaitForExitAsync() // The completion is the lifetime signal published by Start; DriveAsync trips it (never the // raw run task, which is hardened to never throw). Hand back the same task each call so a // caller can capture it, poll IsCompleted, and await it without spawning new tasks. - return (_completion ?? throw NotStarted()).Task; + return (_completion ?? throw new SessionNotStartedException()).Task; } // Owns the single WaitForExitAsync call for the child. On normal exit it trips the completion @@ -286,10 +286,10 @@ public async Task GetRpcClientAsync(CancellationToken cancell return _rpcClient; } - var socketPath = _socketPath ?? throw NotStarted(); + var socketPath = _socketPath ?? throw new SessionNotStartedException(); // _completion is published alongside _socketPath in Start, so a non-null socket // path guarantees the server-exit signal is available here. - var serverExitTask = (_completion ?? throw NotStarted()).Task; + var serverExitTask = (_completion ?? throw new SessionNotStartedException()).Task; // ConnectAsync already retries until the RPC socket is available. Race it against the // server-exit signal instead of sleeping first, so fast startups connect immediately and @@ -441,8 +441,8 @@ private static void ObserveFaultedTask(Task task) TaskScheduler.Default); } - private static InvalidOperationException NotStarted() => - new($"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); + private sealed class SessionNotStartedException() : InvalidOperationException( + $"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); } /// From b1c663101737bc18723c86116eea66c88868d140 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 11:33:48 -0700 Subject: [PATCH 48/56] Route aspire run session through IAppHostServerSessionFactory Collapse the factory to a single Create that mirrors the AppHostServerSession constructor (factory injects logger/profiling), with the graceful-shutdown params nullable. The run path now goes through the factory instead of constructing AppHostServerSession directly, so tests can substitute a fake session. Codegen/scaffolding callers pass null graceful wiring explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 4 +- .../Commands/Sdk/SdkGenerateCommand.cs | 2 +- .../Projects/AppHostServerSession.cs | 26 ++++-- .../Projects/GuestAppHostProject.cs | 14 ++-- .../Projects/IAppHostServerSession.cs | 82 +++++++++++++++---- .../Scaffolding/ScaffoldingService.cs | 2 +- .../TestServices/FakeAppHostServerSession.cs | 30 ++++++- 7 files changed, 120 insertions(+), 40 deletions(-) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 1fcae5d9cec..53695daf237 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -193,7 +193,7 @@ private async Task DumpCapabilitiesAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. @@ -292,7 +292,7 @@ private async Task DumpCapabilitiesToDirectoryAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 1c3eca3870d..10ba5300625 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -154,7 +154,7 @@ private async Task GenerateSdkAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index c8639bab41f..f96d13c09d3 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -447,9 +447,10 @@ private sealed class SessionNotStartedException() : InvalidOperationException( /// /// Default that constructs real -/// instances for the short-lived codegen/scaffolding path. -/// Sessions are created without graceful-shutdown parameters because they are transient: started, -/// queried over RPC, and disposed within a single operation. +/// instances. The factory injects the ambient +/// and ; callers supply +/// the per-session configuration, including the optional graceful-shutdown wiring used by the +/// aspire run path. /// internal sealed class AppHostServerSessionFactory : IAppHostServerSessionFactory { @@ -462,15 +463,22 @@ public AppHostServerSessionFactory(ILogger logger, Profili _profilingTelemetry = profilingTelemetry; } - public IAppHostServerSession Create(IAppHostServerProject appHostServerProject, CancellationToken stopRequested) => + public IAppHostServerSession Create( + IAppHostServerProject appHostServerProject, + Dictionary? environmentVariables, + bool debug, + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler, + IGracefulShutdownWindow? shutdownService, + bool isolateConsole, + CancellationToken stopRequested) => new AppHostServerSession( appHostServerProject, - environmentVariables: null, - debug: false, + environmentVariables, + debug, _logger, _profilingTelemetry, - gracefulShutdownSignaler: null, - shutdownService: null, - isolateConsole: false, + gracefulShutdownSignaler, + shutdownService, + isolateConsole, stopRequested); } diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index b3f7caa4312..e144bf69297 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -293,7 +293,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir } // Step 2: Start the AppHost server temporarily for code generation - await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task because disposal flows the exit code through the activity scope and the only // failure mode we care about surfaces via the RPC call below. @@ -458,14 +458,14 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // is how we ask the session to kill its child process. The outer cancellationToken IS // CCM.Token (see Program.Main), so a user Ctrl+C lands here automatically. using var serverStopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - await using var serverSession = new AppHostServerSession( + await using var serverSession = _serverSessionFactory.Create( appHostServerProject, launchSettingsEnvVars, context.Debug, - _logger, - _profilingTelemetry, - gracefulShutdownSignaler: _gracefulShutdownSignaler, - shutdownService: _shutdownService, + _gracefulShutdownSignaler, + _shutdownService, + // Isolate the child console so a user Ctrl+C reaches the CLI (and drives the shared + // shutdown ladder) rather than terminating the server child directly. isolateConsole: true, serverStopCts.Token); Task serverCompletion; @@ -1242,7 +1242,7 @@ private static void MergeServerOutputIntoContextCollector(AppHostProjectContext /// Starts connecting to the AppHost server's backchannel server. /// private async Task StartBackchannelConnectionAsync( - AppHostServerSession serverSession, + IAppHostServerSession serverSession, string socketPath, TaskCompletionSource backchannelCompletionSource, bool enableHotReload, diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs index 6276e1bdbf1..aa345d7d149 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs @@ -1,31 +1,63 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Processes; +using Aspire.Cli.Utils; + namespace Aspire.Cli.Projects; /// -/// Narrow abstraction over the short-lived AppHost server session used to generate SDK code -/// over RPC. +/// Abstraction over an AppHost server child-process session. Two shapes are served through this +/// seam: the short-lived codegen/scaffolding session (start, grab an RPC client, dispose) and the +/// longer-lived aspire run session that additionally exposes the process lifetime +/// (), captured output, and the connection details the guest AppHost +/// needs. Tests substitute a fake implementation that returns canned RPC results without launching +/// a real process or opening a socket. /// -/// -/// This seam exists only for the code-generation path. That path needs nothing more than -/// "start the server, hand me an RPC client, then dispose", so the interface is intentionally -/// limited to those operations. The run and publish paths construct -/// directly instead of going through this seam, because they -/// depend on the graceful-shutdown constructor parameters (caller-owned stop token, signaler, -/// shutdown service, Windows console isolation) that have no meaning for a transient codegen -/// session. Tests substitute a fake implementation that returns canned RPC results without -/// launching a real process or opening a socket. -/// internal interface IAppHostServerSession : IAsyncDisposable { + /// + /// Gets the authentication token injected into the server environment. Available before + /// so callers can plumb it into the guest AppHost environment. + /// + string AuthenticationToken { get; } + + /// + /// Gets the RPC socket path, or if has not + /// been called (or threw before the process was published). + /// + string? SocketPath { get; } + + /// + /// Gets the output collector for the server's stdout/stderr, or if + /// has not been called (or threw before the process was published). + /// + OutputCollector? Output { get; } + + /// + /// Gets whether the underlying AppHost server process has exited, or + /// if has not been called (or threw before the process was published). + /// + bool? HasServerExited { get; } + + /// + /// Reads the AppHost server process's exit code if it has exited, or + /// if the server is still running, has not been started, or the exit code cannot be read. + /// + int? TryGetServerExitCode(); + /// /// Launches the AppHost server process and wires lifecycle observation. The returned task - /// completes once the process has been spawned. For codegen the process lifetime is observed - /// indirectly (via the RPC call and disposal), so this seam exposes no exit-code task. + /// completes once the process has been spawned. /// Task StartAsync(); + /// + /// Returns the task that completes with the server process exit code. Used by the run path to + /// observe the server lifetime alongside the guest AppHost. + /// + Task WaitForExitAsync(); + /// /// Connects to the running AppHost server and returns an RPC client for code generation. /// @@ -33,9 +65,8 @@ internal interface IAppHostServerSession : IAsyncDisposable } /// -/// Creates the short-lived used for SDK code generation and -/// scaffolding. Production wires this to construct a real ; -/// tests inject a factory that returns a fake session. +/// Creates instances. Production wires this to construct real +/// instances; tests inject a factory that returns a fake session. /// internal interface IAppHostServerSessionFactory { @@ -45,5 +76,20 @@ internal interface IAppHostServerSessionFactory /// , then disposal. Cancelling /// (or disposing the session) terminates the server process. /// - IAppHostServerSession Create(IAppHostServerProject appHostServerProject, CancellationToken stopRequested); + /// + /// The short-lived codegen/scaffolding path passes no graceful-shutdown wiring + /// ( and are + /// , is ) because + /// that session is started, queried over RPC, and disposed within a single operation. The + /// aspire run path supplies the real signaler/window and isolates the console so a user + /// Ctrl+C reaches the CLI and drives the shared shutdown ladder rather than the child directly. + /// + IAppHostServerSession Create( + IAppHostServerProject appHostServerProject, + Dictionary? environmentVariables, + bool debug, + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler, + IGracefulShutdownWindow? shutdownService, + bool isolateConsole, + CancellationToken stopRequested); } diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index accf28116c9..368c5cf6ba5 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -142,7 +142,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can } // Step 2: Start the server temporarily for scaffolding and code generation - await using var serverSession = _serverSessionFactory.Create(appHostServerProject, cancellationToken); + await using var serverSession = _serverSessionFactory.Create(appHostServerProject, environmentVariables: null, debug: false, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, cancellationToken); // Short-lived RPC session: StartAsync() spawns the server. We never observe the // exit-code task (WaitForExitAsync) because disposal flows the exit code through the // activity scope and the only failure mode we care about surfaces via the RPC call below. diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs index 7998b16e000..132d3bf7bc4 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Commands.Sdk; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; +using Aspire.Cli.Utils; using Aspire.TypeSystem; namespace Aspire.Cli.Tests.TestServices; @@ -14,13 +16,30 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class FakeAppHostServerSession : IAppHostServerSession { private readonly FakeAppHostRpcClient _rpcClient = new(); + private readonly TaskCompletionSource _exit = new(); + + public string AuthenticationToken { get; } = "fake-token"; + + public string? SocketPath { get; } = "fake.sock"; + + public OutputCollector? Output { get; } = new(); + + public bool? HasServerExited => _exit.Task.IsCompleted; + + public int? TryGetServerExitCode() => _exit.Task.IsCompletedSuccessfully ? _exit.Task.Result : null; public Task StartAsync() => Task.CompletedTask; + public Task WaitForExitAsync() => _exit.Task; + public Task GetRpcClientAsync(CancellationToken cancellationToken) => Task.FromResult(_rpcClient); - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() + { + _exit.TrySetResult(0); + return ValueTask.CompletedTask; + } } /// @@ -29,7 +48,14 @@ public Task GetRpcClientAsync(CancellationToken cancellationT /// internal sealed class FakeAppHostServerSessionFactory : IAppHostServerSessionFactory { - public IAppHostServerSession Create(IAppHostServerProject appHostServerProject, CancellationToken stopRequested) + public IAppHostServerSession Create( + IAppHostServerProject appHostServerProject, + Dictionary? environmentVariables, + bool debug, + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler, + IGracefulShutdownWindow? shutdownService, + bool isolateConsole, + CancellationToken stopRequested) => new FakeAppHostServerSession(); } From 885b55d8d28abaf416b755a23de89d914c79b623 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 11:36:34 -0700 Subject: [PATCH 49/56] Route aspire publish session through IAppHostServerSessionFactory The publish path was the last direct 'new AppHostServerSession(...)' outside the factory. Route it through Create with null graceful wiring so the only construction site lives in AppHostServerSessionFactory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/GuestAppHostProject.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index e144bf69297..1f5c5eb714a 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -1036,12 +1036,10 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca // Step 2: Start the AppHost server process(it opens the backchannel for progress reporting) // Linked stop CTS is the only termination trigger we hand to the session. using var serverStopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - await using var serverSession = new AppHostServerSession( + await using var serverSession = _serverSessionFactory.Create( appHostServerProject, launchSettingsEnvVars, context.Debug, - _logger, - _profilingTelemetry, gracefulShutdownSignaler: null, shutdownService: null, isolateConsole: false, From 6cd35b7829af5342e47bb985e77a8e6f87dc20fa Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 11:55:17 -0700 Subject: [PATCH 50/56] Clarify undisposed execution comment for IAsyncDisposable DisposeAsync is not invoked by the finalizer, so update the stale 'let the GC handle cleanup' note to explain that the Process native handles are reclaimed by SafeHandle finalizers instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index b2804e97acd..d5206a3b6dd 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -176,10 +176,13 @@ private async Task ExecuteAsync( var outputCounters = new ProcessOutputCounters(); var instrumentedOptions = CreateInstrumentedProcessOptions(options, processActivity, outputCounters); - // Do not use 'using' here: StartBackchannelAsync runs fire-and-forget and + // Do not use 'await using' here: StartBackchannelAsync runs fire-and-forget and // accesses execution.HasExited / ExitCode after this method returns. Disposing // the underlying Process while the backchannel task is still polling would - // cause ObjectDisposedException. Let the GC handle cleanup instead. + // cause ObjectDisposedException. We intentionally never dispose this execution: + // IAsyncDisposable.DisposeAsync is not called by the finalizer, but the Process's + // native handles are still reclaimed by the SafeHandle finalizers, so this is not + // a resource leak. var execution = executionFactory.CreateExecution(processFileName, effectiveArgs, finalEnv, workingDirectory, instrumentedOptions); // Get socket path from env if present From a5c7c1211d712a10756129c5bdbc34f19f3ad2b0 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 12:05:03 -0700 Subject: [PATCH 51/56] Avoid parent-env snapshot in replace-don't-overlay spawn path ProcessExecutionFactory's replace path called Environment.Clear(), which lazily seeded ~50-100 parent env entries only to discard them immediately. Add IsolatedProcessStartInfo.UseEmptyEnvironment() to initialize the env block empty (skipping the parent snapshot) and mark HasCustomEnvironment, and route the replace path through it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DotNet/ProcessExecutionFactory.cs | 9 ++++---- src/Aspire.Cli/Processes/IsolatedProcess.cs | 23 +++++++++++++++---- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs index 47e3ad4198e..2822bfba571 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs @@ -83,16 +83,17 @@ public IProcessExecution CreateExecution(System.Diagnostics.ProcessStartInfo sta // configured, to suppress any value the parent CLI happens to have set in its own env. // ProcessStartInfo.Environment is eagerly snapshotted from the parent, so iterating it gives // the authoritative "what the child should see" view; a missing key really means "do not pass - // this to the child". (Clear() first seeds the parent env then empties it, so HasCustomEnvironment - // is set and the spawn uses our explicit block rather than re-inheriting the parent.) - isolatedStartInfo.Environment.Clear(); + // this to the child". Start from an empty block (UseEmptyEnvironment skips the parent snapshot + // we would otherwise allocate and immediately discard) so HasCustomEnvironment is set and the + // spawn uses our explicit block rather than re-inheriting the parent. + var childEnvironment = isolatedStartInfo.UseEmptyEnvironment(); foreach (var (key, value) in startInfo.Environment) { // Match ProcessStartInfo.Environment semantics: a null value means "do not set this // variable in the child" — we get there by simply not adding it. if (value is not null) { - isolatedStartInfo.Environment[key] = value; + childEnvironment[key] = value; } } diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.cs b/src/Aspire.Cli/Processes/IsolatedProcess.cs index 95cfa0c4220..39154d98db7 100644 --- a/src/Aspire.Cli/Processes/IsolatedProcess.cs +++ b/src/Aspire.Cli/Processes/IsolatedProcess.cs @@ -77,23 +77,36 @@ internal sealed class IsolatedProcessStartInfo internal IReadOnlyDictionary? GetEnvironmentForSpawn() => _environment; + /// + /// Initializes as an empty block and returns it, skipping the + /// lazy parent-environment snapshot that would otherwise trigger. + /// Use this for the "replace, don't overlay" path where the caller is about to write the + /// child's entire environment from an authoritative source: seeding ~50-100 parent entries + /// only to clear them immediately is pure waste. Marks + /// so the spawn uses this explicit block rather than re-inheriting the parent. + /// + internal IDictionary UseEmptyEnvironment() + => _environment = new Dictionary(EnvironmentComparer); + private static Dictionary LoadParentEnvironment() { // Snapshot the parent env on first access. ProcessStartInfo.Environment has the // same semantics — touching the property materializes the inherited block so the // caller can mutate it freely without affecting the parent process. var parent = System.Environment.GetEnvironmentVariables(); - // OrdinalIgnoreCase mirrors ProcessStartInfo's behavior on Windows (env vars are - // case-insensitive). Using it on all platforms is slightly less strict than the - // Unix kernel (which treats env names as bytes) but it matches what ProcessStartInfo - // does and prevents the trap of accidentally having both "Path" and "PATH" entries. - var dict = new Dictionary(parent.Count, StringComparer.OrdinalIgnoreCase); + var dict = new Dictionary(parent.Count, EnvironmentComparer); foreach (System.Collections.DictionaryEntry entry in parent) { dict[(string)entry.Key] = entry.Value as string; } return dict; } + + // OrdinalIgnoreCase mirrors ProcessStartInfo's behavior on Windows (env vars are + // case-insensitive). Using it on all platforms is slightly less strict than the + // Unix kernel (which treats env names as bytes) but it matches what ProcessStartInfo + // does and prevents the trap of accidentally having both "Path" and "PATH" entries. + private static StringComparer EnvironmentComparer => StringComparer.OrdinalIgnoreCase; } /// From e1910058aaa11397381ae8341fc432dae7f3720b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 12:11:02 -0700 Subject: [PATCH 52/56] Use Assert.Skip for debugger-attached timing tests Replace 'if (Debugger.IsAttached) return;' early exits with Assert.Skip so the tests surface as skipped (not silently passed) when their CancelAfter-based timing is disabled under a debugger. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConsoleCancellationManagerTests.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs index c27c3ba40e4..f3fbad4bb79 100644 --- a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs +++ b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs @@ -61,10 +61,11 @@ public async Task FirstSignal_DefaultZeroBudget_ExpiresGracefulImmediately() public async Task FirstSignal_NonZeroBudget_DelaysExpireUntilBudgetElapses() { // The graceful clock is armed via CancelAfter, which is a no-op while a debugger is - // attached (developers need unlimited stepping time). + // attached (developers need unlimited stepping time), so the timing this test asserts + // never happens. Surface that as a real skip rather than a silent pass. if (Debugger.IsAttached) { - return; + Assert.Skip("Graceful CancelAfter timing is disabled while a debugger is attached."); } using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); @@ -352,10 +353,11 @@ public void BeginGracefulWindow_ZeroBudget_ExpiresImmediately() public async Task BeginGracefulWindow_PositiveBudget_FiresTokenAfterBudget() { // BeginGracefulWindow arms CancelAfter, which is a no-op while a debugger is attached - // (developers need unlimited stepping time). Skip the timing assertion in that case. + // (developers need unlimited stepping time), so the timing this test asserts never + // happens. Surface that as a real skip rather than a silent pass. if (Debugger.IsAttached) { - return; + Assert.Skip("Graceful CancelAfter timing is disabled while a debugger is attached."); } using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); @@ -372,9 +374,12 @@ public async Task BeginGracefulWindow_PositiveBudget_FiresTokenAfterBudget() [Fact] public async Task BeginGracefulWindow_SecondCall_DoesNotResetTimer() { + // BeginGracefulWindow arms CancelAfter, which is a no-op while a debugger is attached + // (developers need unlimited stepping time), so the timing this test asserts never + // happens. Surface that as a real skip rather than a silent pass. if (Debugger.IsAttached) { - return; + Assert.Skip("Graceful CancelAfter timing is disabled while a debugger is attached."); } using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); From c0189bff76bdb1a635f74ee6f5a1fabc42fde69a Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 12:16:03 -0700 Subject: [PATCH 53/56] Remove wasteful startup delays from guest apphost server startup The run and publish paths each slept a fixed 500ms after starting the AppHost server, then checked serverCompletion.IsCompleted to detect an early crash. This was both wasteful (every healthy startup paid the full delay) and fragile (a slow-spawning server could still race the check). The delay+precheck is redundant: GetRpcClientAsync already races the RPC connect against the server-exit signal and surfaces an early crash immediately, and the surrounding catch blocks display the same output + "App host exited unexpectedly." error and return the same exit code. On the publish path the catch additionally faults the backchannel waiter, which the precheck never did, so relying on it is strictly better. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/GuestAppHostProject.cs | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 1f5c5eb714a..86ec12bcd01 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -475,20 +475,12 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken await serverSession.StartAsync(); serverCompletion = serverSession.WaitForExitAsync(); - // Give the server a moment to start - await Task.Delay(500, cancellationToken); - - if (serverCompletion.IsCompleted) - { - _interactionService.DisplayLines(serverSession.Output!.GetLines()); - _interactionService.DisplayError("App host exited unexpectedly."); - return CliExitCodes.FailedToDotnetRunAppHost; - } - try { // Step 5: Connect to server for RPC calls. The connection helper retries until - // the RPC socket is available and fails early if the server process exits. + // the RPC socket is available and fails early if the server process exits — it + // races the connect against the server-exit signal, so a server that crashes on + // startup surfaces immediately (no fixed start-up delay to wait through). rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); // Step 6: Generate SDK code via RPC if needed @@ -1053,18 +1045,10 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca try { - // Give the server a moment to start - await Task.Delay(500, cancellationToken); - - if (serverCompletion.IsCompleted) - { - _interactionService.DisplayLines(serverSession.Output!.GetLines()); - _interactionService.DisplayError("App host exited unexpectedly."); - return CliExitCodes.FailedToDotnetRunAppHost; - } - // Step 3: Connect to server for RPC calls. The connection helper retries until - // the RPC socket is available and fails early if the server process exits. + // the RPC socket is available and fails early if the server process exits — it + // races the connect against the server-exit signal, so a server that crashes on + // startup surfaces immediately (no fixed start-up delay to wait through). rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); // Step 4: Generate code via RPC if needed From ad3cb779b39ddfdb9a0e7130af3577a362fc8d51 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 12:31:22 -0700 Subject: [PATCH 54/56] Make ProcessGuestLauncher ctor and LaunchAsync args non-optional The ProcessGuestLauncher constructor previously defaulted fileLoggerProvider, commandResolver, and processExecutionFactory purely to avoid updating call sites. Make them required and push the defaults (the NullLogger-backed execution factory and the PATH command resolver) to the callers that actually own those choices. Likewise, the GuestLaunchOptions argument added to IGuestProcessLauncher.LaunchAsync in this PR (and the pre-existing afterLaunchAsync) were optional only for call-site brevity. Make them non-optional and move cancellationToken to the end, matching the convention used elsewhere in the PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/ExtensionGuestLauncher.cs | 6 +-- src/Aspire.Cli/Projects/GuestRuntime.cs | 19 ++++++++-- .../Projects/IGuestProcessLauncher.cs | 6 +-- .../Projects/ProcessGuestLauncher.cs | 22 +++++------ .../Projects/ExtensionGuestLauncherTests.cs | 12 +++--- .../Projects/GuestRuntimeTests.cs | 36 ++++++++++++------ .../Projects/ProcessGuestLauncherTests.cs | 37 ++++++++++++------- 7 files changed, 87 insertions(+), 51 deletions(-) diff --git a/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs b/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs index fa911add0de..644f0cc4a4f 100644 --- a/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs @@ -33,9 +33,9 @@ public ExtensionGuestLauncher( string[] args, DirectoryInfo workingDirectory, IDictionary environmentVariables, - CancellationToken cancellationToken, - Func? afterLaunchAsync = null, - GuestLaunchOptions? options = null) + Func? afterLaunchAsync, + GuestLaunchOptions? options, + CancellationToken cancellationToken) { // GuestLaunchOptions is intentionally ignored — the extension owns the debug-session // lifecycle and has its own teardown path (it does not spawn a child process here, so diff --git a/src/Aspire.Cli/Projects/GuestRuntime.cs b/src/Aspire.Cli/Projects/GuestRuntime.cs index 280f5c0307e..6912f7a1852 100644 --- a/src/Aspire.Cli/Projects/GuestRuntime.cs +++ b/src/Aspire.Cli/Projects/GuestRuntime.cs @@ -2,10 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Diagnostics; +using Aspire.Cli.DotNet; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Aspire.TypeSystem; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Projects; @@ -85,6 +87,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo args, directory, environmentVariables, + afterLaunchAsync: null, + options: null, cancellationToken); activity.SetProcessExitCode(exitCode); if (exitCode != 0) @@ -125,6 +129,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo args, directory, environmentVariables, + afterLaunchAsync: null, + options: null, cancellationToken); activity.SetProcessExitCode(exitCode); if (exitCode != 0) @@ -246,7 +252,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo using var activity = _profilingTelemetry is null ? default : _profilingTelemetry.StartGuestExecuteCommand(_spec.Language, _spec.DisplayName, commandSpec.Command, args, directory, ProfilingTelemetry.Values.GuestCommandPhasePreExecute); - var (exitCode, output) = await preExecuteLauncher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, cancellationToken); + var (exitCode, output) = await preExecuteLauncher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, afterLaunchAsync: null, options: null, cancellationToken); activity.SetProcessExitCode(exitCode); if (exitCode != 0) { @@ -278,7 +284,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, FileLoggerProvider? fileLo using var activity = _profilingTelemetry is null ? default : _profilingTelemetry.StartGuestExecuteCommand(_spec.Language, _spec.DisplayName, commandSpec.Command, args, directory, phase); - var (exitCode, output) = await launcher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, cancellationToken, afterLaunchAsync: afterLaunchAsync, options: launchOptions); + var (exitCode, output) = await launcher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, afterLaunchAsync: afterLaunchAsync, options: launchOptions, cancellationToken); activity.SetProcessExitCode(exitCode); if (exitCode != 0) { @@ -329,7 +335,14 @@ private async Task EnsureMigrationFilesExistAsync(DirectoryInfo directory, Cance /// /// Creates the default process-based launcher for this runtime. /// - public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, fileLoggerProvider: _fileLoggerProvider, commandResolver: _commandResolver); + public ProcessGuestLauncher CreateDefaultLauncher() => new( + _spec.Language, + _logger, + fileLoggerProvider: _fileLoggerProvider, + commandResolver: _commandResolver, + // The launcher logs each guest stdout/stderr line itself, so the execution factory is given + // a NullLogger to avoid double-logging those lines. + processExecutionFactory: new ProcessExecutionFactory(NullLogger.Instance)); /// /// Replaces placeholders in command arguments with actual values. diff --git a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs index bcc2791d291..c8a8c056a98 100644 --- a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs +++ b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs @@ -51,7 +51,7 @@ internal interface IGuestProcessLauncher string[] args, DirectoryInfo workingDirectory, IDictionary environmentVariables, - CancellationToken cancellationToken, - Func? afterLaunchAsync = null, - GuestLaunchOptions? options = null); + Func? afterLaunchAsync, + GuestLaunchOptions? options, + CancellationToken cancellationToken); } diff --git a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs index 8b604208dd3..f6026625280 100644 --- a/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ProcessGuestLauncher.cs @@ -7,7 +7,6 @@ using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Projects; @@ -25,19 +24,18 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher public ProcessGuestLauncher( string language, ILogger logger, - FileLoggerProvider? fileLoggerProvider = null, - Func? commandResolver = null, - IProcessExecutionFactory? processExecutionFactory = null) + FileLoggerProvider? fileLoggerProvider, + Func commandResolver, + IProcessExecutionFactory processExecutionFactory) { _language = language; _logger = logger; _fileLoggerProvider = fileLoggerProvider; - _commandResolver = commandResolver ?? PathLookupHelper.FindFullPathFromPath; + _commandResolver = commandResolver; // The guest launcher does its own per-line trace logging via the per-line callbacks below, - // so the execution's logger is suppressed to avoid double-logging each stdout/stderr line. - // Defaulting here (rather than requiring DI threading through GuestRuntime/ScaffoldingService) - // keeps the construction sites unchanged; the factory is stateless. - _processExecutionFactory = processExecutionFactory ?? new ProcessExecutionFactory(NullLogger.Instance); + // so callers pass a factory whose execution logger is suppressed (NullLogger) to avoid + // double-logging each stdout/stderr line. + _processExecutionFactory = processExecutionFactory; } public async Task<(int ExitCode, OutputCollector? Output)> LaunchAsync( @@ -45,9 +43,9 @@ public ProcessGuestLauncher( string[] args, DirectoryInfo workingDirectory, IDictionary environmentVariables, - CancellationToken cancellationToken, - Func? afterLaunchAsync = null, - GuestLaunchOptions? options = null) + Func? afterLaunchAsync, + GuestLaunchOptions? options, + CancellationToken cancellationToken) { var activity = GetCurrentProfilingActivity(); AddEvent(activity, ProfilingTelemetry.Events.GuestProcessResolveStart); diff --git a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs index a8d8798b104..7a15c5f8f45 100644 --- a/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ExtensionGuestLauncherTests.cs @@ -31,6 +31,8 @@ await launcher.LaunchAsync( ["tsx", "/tmp/apphost.ts"], new DirectoryInfo("/tmp"), new Dictionary(), + afterLaunchAsync: null, + options: null, CancellationToken.None); Assert.NotNull(capturedArgs); @@ -51,7 +53,7 @@ public async Task LaunchAsync_PassesAppHostFileAsProjectFile() var appHostFile = new FileInfo("/home/user/project/apphost.ts"); var launcher = new ExtensionGuestLauncher(service, appHostFile, debug: true); - await launcher.LaunchAsync("npx", ["tsx"], new DirectoryInfo("/tmp"), new Dictionary(), CancellationToken.None); + await launcher.LaunchAsync("npx", ["tsx"], new DirectoryInfo("/tmp"), new Dictionary(), afterLaunchAsync: null, options: null, CancellationToken.None); Assert.Equal(appHostFile.FullName, capturedProjectFile); } @@ -66,7 +68,7 @@ public async Task LaunchAsync_PassesDebugFlag() }); var launcher = new ExtensionGuestLauncher(service, new FileInfo("/tmp/apphost.ts"), debug: true); - await launcher.LaunchAsync("npx", [], new DirectoryInfo("/tmp"), new Dictionary(), CancellationToken.None); + await launcher.LaunchAsync("npx", [], new DirectoryInfo("/tmp"), new Dictionary(), afterLaunchAsync: null, options: null, CancellationToken.None); Assert.True(capturedDebug); } @@ -87,7 +89,7 @@ public async Task LaunchAsync_PassesEnvironmentAsEnvVars() ["NODE_ENV"] = "development" }; - await launcher.LaunchAsync("npx", ["tsx"], new DirectoryInfo("/tmp"), envVars, CancellationToken.None); + await launcher.LaunchAsync("npx", ["tsx"], new DirectoryInfo("/tmp"), envVars, afterLaunchAsync: null, options: null, CancellationToken.None); Assert.NotNull(capturedEnv); Assert.Equal(2, capturedEnv.Count); @@ -101,7 +103,7 @@ public async Task LaunchAsync_ReturnsZeroExitCodeAndNullOutput() var service = new FakeLaunchExtensionService((_, _, _, _) => { }); var launcher = new ExtensionGuestLauncher(service, new FileInfo("/tmp/apphost.ts"), debug: false); - var (exitCode, output) = await launcher.LaunchAsync("cmd", [], new DirectoryInfo("/tmp"), new Dictionary(), CancellationToken.None); + var (exitCode, output) = await launcher.LaunchAsync("cmd", [], new DirectoryInfo("/tmp"), new Dictionary(), afterLaunchAsync: null, options: null, CancellationToken.None); Assert.Equal(0, exitCode); Assert.Null(output); @@ -117,7 +119,7 @@ public async Task LaunchAsync_WithEmptyArgs_OnlyContainsCommand() }); var launcher = new ExtensionGuestLauncher(service, new FileInfo("/tmp/apphost.ts"), debug: false); - await launcher.LaunchAsync("python", [], new DirectoryInfo("/tmp"), new Dictionary(), CancellationToken.None); + await launcher.LaunchAsync("python", [], new DirectoryInfo("/tmp"), new Dictionary(), afterLaunchAsync: null, options: null, CancellationToken.None); Assert.NotNull(capturedArgs); Assert.Single(capturedArgs); diff --git a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs index 59e5d7365e8..2d60e2f8d06 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using Aspire.Cli.Diagnostics; +using Aspire.Cli.DotNet; using Aspire.Cli.Projects; using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.TestServices; @@ -12,6 +13,7 @@ using Aspire.TypeSystem; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Projects; @@ -19,6 +21,16 @@ public class GuestRuntimeTests(ITestOutputHelper outputHelper) { private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper)); + private ProcessGuestLauncher CreateLauncher( + FileLoggerProvider? fileLoggerProvider = null, + Func? commandResolver = null) + => new( + "test", + _loggerFactory.CreateLogger(), + fileLoggerProvider: fileLoggerProvider, + commandResolver: commandResolver ?? PathLookupHelper.FindFullPathFromPath, + processExecutionFactory: new ProcessExecutionFactory(NullLogger.Instance)); + private GuestRuntime CreateRuntime( RuntimeSpec? spec = null, Func? commandResolver = null, @@ -714,9 +726,7 @@ public async Task ProcessGuestLauncher_WritesOutputToLogFile() { using var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()); - var launcher = new ProcessGuestLauncher( - "test", - _loggerFactory.CreateLogger(), + var launcher = CreateLauncher( fileLoggerProvider: fileLoggerProvider, commandResolver: cmd => cmd == "dotnet" ? "dotnet" : null); @@ -725,6 +735,8 @@ public async Task ProcessGuestLauncher_WritesOutputToLogFile() ["--version"], new DirectoryInfo(Path.GetTempPath()), new Dictionary(), + afterLaunchAsync: null, + options: null, CancellationToken.None); Assert.Equal(0, exitCode); @@ -765,9 +777,7 @@ public async Task ProcessGuestLauncher_AnnotatesAmbientGuestProfilingActivity() using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource, onActivityStopped: stoppedActivities.Add); using var tempDirectory = new TestTempDirectory(); - var launcher = new ProcessGuestLauncher( - "test", - _loggerFactory.CreateLogger(), + var launcher = CreateLauncher( commandResolver: cmd => cmd == "dotnet" ? "dotnet" : null); using (profilingTelemetry.StartGuestExecuteCommand( @@ -783,6 +793,8 @@ public async Task ProcessGuestLauncher_AnnotatesAmbientGuestProfilingActivity() ["--version"], new DirectoryInfo(tempDirectory.Path), new Dictionary(), + afterLaunchAsync: null, + options: null, CancellationToken.None); Assert.Equal(0, exitCode); @@ -816,9 +828,7 @@ public async Task ProcessGuestLauncher_KillsProcessAndReturnsOnCancellation() // passed to this launcher. The launcher must kill the guest process tree (rather than // leaving it running) and drain output, otherwise pendingRun never completes and the CLI // appears to hang while it waits for the AppHost system to exit. - var launcher = new ProcessGuestLauncher( - "test", - _loggerFactory.CreateLogger()); + var launcher = CreateLauncher(); // Use a long-running cross-platform command. We pick something the OS resolves through PATH // so the launcher's CommandPathResolver succeeds without any fake. @@ -843,6 +853,8 @@ public async Task ProcessGuestLauncher_KillsProcessAndReturnsOnCancellation() args, new DirectoryInfo(Path.GetTempPath()), new Dictionary(), + afterLaunchAsync: null, + options: null, cts.Token); // Give the process a moment to actually start before cancelling so we exercise the @@ -988,9 +1000,9 @@ private sealed class RecordingLauncher : IGuestProcessLauncher string[] args, DirectoryInfo workingDirectory, IDictionary environmentVariables, - CancellationToken cancellationToken, - Func? afterLaunchAsync = null, - GuestLaunchOptions? options = null) + Func? afterLaunchAsync, + GuestLaunchOptions? options, + CancellationToken cancellationToken) { Calls.Add((command, args)); LastCommand = command; diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs index 12717f9b31c..37054cede81 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Aspire.Cli.DotNet; using Aspire.Cli.Projects; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; @@ -12,6 +13,14 @@ namespace Aspire.Cli.Tests.Projects; public class ProcessGuestLauncherTests(ITestOutputHelper outputHelper) { + private static ProcessGuestLauncher CreateLauncher() + => new( + "test", + NullLogger.Instance, + fileLoggerProvider: null, + commandResolver: PathLookupHelper.FindFullPathFromPath, + processExecutionFactory: new ProcessExecutionFactory(NullLogger.Instance)); + [Fact] public async Task LaunchAsync_NoOptions_OnCancellation_ForceKillsProcessTreeAndReturns() { @@ -20,7 +29,7 @@ public async Task LaunchAsync_NoOptions_OnCancellation_ForceKillsProcessTreeAndR // cancellation. This preserves today's tactical behavior for non-Run callers and exercises // the same code path as the existing ProcessGuestLauncher_KillsProcessAndReturnsOnCancellation // test, just with an explicit assertion on the no-options branch. - var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + var launcher = CreateLauncher(); var (command, args) = GetLongRunningCommand(); @@ -30,6 +39,8 @@ public async Task LaunchAsync_NoOptions_OnCancellation_ForceKillsProcessTreeAndR args, new DirectoryInfo(Path.GetTempPath()), new Dictionary(), + afterLaunchAsync: null, + options: null, cts.Token); // Give the OS time to spawn the child before cancelling so we exercise the kill-while-running @@ -55,7 +66,7 @@ public async Task LaunchAsync_WithGracefulServices_GracefulSucceeds_NoTreeKillEs // before anyone calls Expire(). The central shutdown token must NOT be cancelled — this // proves the launcher consumes the token as a deadline (not a trigger) and doesn't burn // the central budget on successful graceful exits. - var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + var launcher = CreateLauncher(); var (command, args) = GetLongRunningCommand(); using var cts = new CancellationTokenSource(); @@ -86,9 +97,9 @@ public async Task LaunchAsync_WithGracefulServices_GracefulSucceeds_NoTreeKillEs args, new DirectoryInfo(Path.GetTempPath()), new Dictionary(), - cts.Token, afterLaunchAsync: null, - options: options); + options: options, + cts.Token); await Task.Delay(500); cts.Cancel(); @@ -106,7 +117,7 @@ public async Task LaunchAsync_WithGracefulServices_BlockingSignalerDoesNotConsum // Regression coverage for DCP's stop-process-tree behavior: the graceful signaler can // deliver the signal quickly and then block until the target exits. The ladder must wait // for process exit in parallel with that signaler instead of awaiting the signaler first. - var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + var launcher = CreateLauncher(); var (command, args) = GetLongRunningCommand(); using var cts = new CancellationTokenSource(); @@ -138,9 +149,9 @@ public async Task LaunchAsync_WithGracefulServices_BlockingSignalerDoesNotConsum args, new DirectoryInfo(Path.GetTempPath()), new Dictionary(), - cts.Token, afterLaunchAsync: null, - options: options); + options: options, + cts.Token); await Task.Delay(500); cts.Cancel(); @@ -164,7 +175,7 @@ public async Task LaunchAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEs // must break the ladder out of WaitForExitAsync and escalate to Kill(entireProcessTree: true) // so the tree dies even when the cooperative path fails. using var workspace = TemporaryWorkspace.Create(outputHelper); - var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + var launcher = CreateLauncher(); var descendantPidFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "descendant.pid")); var (command, args) = await GetProcessTreeCommandAsync(workspace.WorkspaceRoot, descendantPidFile); @@ -192,9 +203,9 @@ public async Task LaunchAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEs args, workspace.WorkspaceRoot, new Dictionary(), - cts.Token, afterLaunchAsync: null, - options: options); + options: options, + cts.Token); var descendantPid = await WaitForPidFileAsync(descendantPidFile); @@ -231,7 +242,7 @@ public async Task LaunchAsync_WithGracefulServices_SignalerThrows_StillEscalates // network blip, anything) must be logged and swallowed so the ladder still escalates to // Kill(entireProcessTree: true). The previous force-kill path swallowed signaler failures // implicitly by not even calling the signaler; the new ladder needs an explicit guarantee. - var launcher = new ProcessGuestLauncher("test", NullLogger.Instance); + var launcher = CreateLauncher(); var (command, args) = GetLongRunningCommand(); using var cts = new CancellationTokenSource(); @@ -252,9 +263,9 @@ public async Task LaunchAsync_WithGracefulServices_SignalerThrows_StillEscalates args, new DirectoryInfo(Path.GetTempPath()), new Dictionary(), - cts.Token, afterLaunchAsync: null, - options: options); + options: options, + cts.Token); await Task.Delay(500); cts.Cancel(); From e9256993d2bd141a52aae8e73144b56e23b5cab8 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 12:56:48 -0700 Subject: [PATCH 55/56] Make remaining new optional args required where default is never used Audit pass over arguments added in this PR: make a new optional param required when no caller relies on its default. - IAppHostServerProject.RunAsync: env/additionalArgs/debug/runControl all required (sole caller passes them); update doc + 2 impls + 2 fakes. - GuestAppHostProject.ExecuteGuestAppHostAsync: appHostLaunchOptions required and cancellationToken moved last (sole caller always passes it). - IsolatedProcess.Kill(bool): drop the default to match the non-optional sibling ProcessExecution.Kill(bool) (zero callers relied on it). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Processes/IsolatedProcess.cs | 8 ++++---- src/Aspire.Cli/Projects/AppHostServerSession.cs | 1 + .../Projects/DotNetBasedAppHostServerProject.cs | 8 ++++---- src/Aspire.Cli/Projects/GuestAppHostProject.cs | 6 +++--- src/Aspire.Cli/Projects/IAppHostServerProject.cs | 12 ++++++------ src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs | 8 ++++---- .../TestServices/FakeFailingAppHostServerProject.cs | 8 ++++---- .../FakeSucceedingAppHostServerProject.cs | 8 ++++---- 8 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/Aspire.Cli/Processes/IsolatedProcess.cs b/src/Aspire.Cli/Processes/IsolatedProcess.cs index 39154d98db7..0765a11c696 100644 --- a/src/Aspire.Cli/Processes/IsolatedProcess.cs +++ b/src/Aspire.Cli/Processes/IsolatedProcess.cs @@ -219,11 +219,11 @@ public Task WaitForExitAsync(CancellationToken cancellationToken = default) => _waitForExitProvider?.Invoke(cancellationToken) ?? Process.WaitForExitAsync(cancellationToken); /// - /// Mirrors . Defaults to - /// because every consumer of this type needs tree-kill semantics - /// (graceful-shutdown failures escalate to tree kill). + /// Mirrors . Every consumer of this type needs tree-kill + /// semantics (graceful-shutdown failures escalate to tree kill), so callers pass + /// explicitly, matching . /// - public void Kill(bool entireProcessTree = true) => Process.Kill(entireProcessTree); + public void Kill(bool entireProcessTree) => Process.Kill(entireProcessTree); /// public ValueTask DisposeAsync() diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index f96d13c09d3..bc96417aa0c 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -196,6 +196,7 @@ public async Task StartAsync() result = await _project.RunAsync( Environment.ProcessId, serverEnvironmentVariables, + additionalArgs: null, debug: _debug, runControl: new AppHostServerRunControl(_isolateConsole, _gracefulShutdownSignaler, _shutdownService)).ConfigureAwait(false); } diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index d87832cf993..c25fb8c0698 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -468,10 +468,10 @@ public async Task PrepareAsync( /// public async Task RunAsync( int hostPid, - IReadOnlyDictionary? environmentVariables = null, - string[]? additionalArgs = null, - bool debug = false, - AppHostServerRunControl? runControl = null) + IReadOnlyDictionary? environmentVariables, + string[]? additionalArgs, + bool debug, + AppHostServerRunControl? runControl) { var assemblyPath = Path.Combine(BuildPath, ProjectDllName); var dotnetExe = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 86ec12bcd01..5abf7943430 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -647,7 +647,7 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() GracefulShutdownSignaler: _gracefulShutdownSignaler, ShutdownService: _shutdownService); (guestExitCode, guestOutput) = await ExecuteGuestAppHostAsync( - appHostFile, directory, environmentVariables, enableHotReload, context.NoBuild, rpcClient, launcher, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, appHostSystemToken, guestLaunchOptions); + appHostFile, directory, environmentVariables, enableHotReload, context.NoBuild, rpcClient, launcher, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, guestLaunchOptions, appHostSystemToken); } // If the user cancelled (Ctrl+C), surface that as cancellation instead of a "guest failed" @@ -1904,8 +1904,8 @@ private async Task InstallDependenciesAsync( IAppHostRpcClient rpcClient, IGuestProcessLauncher launcher, Func? afterAppHostLaunchedAsync, - CancellationToken cancellationToken, - GuestLaunchOptions? appHostLaunchOptions = null) + GuestLaunchOptions? appHostLaunchOptions, + CancellationToken cancellationToken) { await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index acac552dba1..7dd98fccf00 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -113,17 +113,17 @@ Task PrepareAsync( /// Additional command-line arguments. /// Whether to enable debug logging. /// - /// Console-isolation + graceful-shutdown wiring for the spawn. (the - /// default) preserves force-kill-on-cancel semantics for non-Run callers (SDK gen, scaffolding, + /// Console-isolation + graceful-shutdown wiring for the spawn. + /// preserves force-kill-on-cancel semantics for non-Run callers (SDK gen, scaffolding, /// publish, dump). The run path passes a populated . /// /// A task producing the launched server process execution and its captured output. Task RunAsync( int hostPid, - IReadOnlyDictionary? environmentVariables = null, - string[]? additionalArgs = null, - bool debug = false, - AppHostServerRunControl? runControl = null); + IReadOnlyDictionary? environmentVariables, + string[]? additionalArgs, + bool debug, + AppHostServerRunControl? runControl); /// /// Gets a unique identifier path for this AppHost, used for running instance detection. diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index cb56b6c754c..de898a17f70 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -917,10 +917,10 @@ private static string GetRestoreVersion(string packageName, string version, bool /// public async Task RunAsync( int hostPid, - IReadOnlyDictionary? environmentVariables = null, - string[]? additionalArgs = null, - bool debug = false, - AppHostServerRunControl? runControl = null) + IReadOnlyDictionary? environmentVariables, + string[]? additionalArgs, + bool debug, + AppHostServerRunControl? runControl) { var startInfo = CreateStartInfo(hostPid, environmentVariables, additionalArgs, debug); var outputCollector = new OutputCollector(); diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index c280e31d0e0..2cf5ced9f83 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -31,10 +31,10 @@ public Task PrepareAsync( public Task RunAsync( int hostPid, - IReadOnlyDictionary? environmentVariables = null, - string[]? additionalArgs = null, - bool debug = false, - AppHostServerRunControl? runControl = null) => + IReadOnlyDictionary? environmentVariables, + string[]? additionalArgs, + bool debug, + AppHostServerRunControl? runControl) => throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); public void Dispose() diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs index 788a460d730..8e6defbdabe 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs @@ -28,10 +28,10 @@ public Task PrepareAsync( public Task RunAsync( int hostPid, - IReadOnlyDictionary? environmentVariables = null, - string[]? additionalArgs = null, - bool debug = false, - AppHostServerRunControl? runControl = null) => + IReadOnlyDictionary? environmentVariables, + string[]? additionalArgs, + bool debug, + AppHostServerRunControl? runControl) => throw new NotSupportedException("Run should not be invoked when using a fake codegen session."); public void Dispose() From dc2949384c2e9d63b308c7156baf238a1919c760 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Tue, 23 Jun 2026 12:56:59 -0700 Subject: [PATCH 56/56] Fix run cancellation exit code, Windows force-kill escalation, and dispose idempotency Address three review findings: - GuestAppHostProject: Ctrl+C during backchannel connect faulted the completion source, tripping the IsFaulted continuation that sets FailedToDotnetRunAppHost. Guard cancellation so user cancellation surfaces as Cancelled (130) instead of a run failure. - ProcessTreeGracefulShutdownService: the force-kill fallback only tree- killed on Unix. Once graceful shutdown has failed/timed out, tree-kill on Windows too so orphaned descendants (DCP, tsx/node, guest) are not left running. The success-path mop-up still avoids tree-kill. - AppHostServerSession: DisposeAsync disposed _startGate, so a second DisposeAsync threw ObjectDisposedException from WaitAsync before the idempotency guard could observe _disposed. Stop disposing the semaphore (AvailableWaitHandle is never used). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Processes/ProcessTreeGracefulShutdownService.cs | 9 ++++++++- src/Aspire.Cli/Projects/AppHostServerSession.cs | 7 +++++-- src/Aspire.Cli/Projects/GuestAppHostProject.cs | 13 ++++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs b/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs index 3b4fc6bb2d5..61b7829a144 100644 --- a/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs @@ -96,7 +96,14 @@ private async Task StopProcessesAsync( private void ForceKillRemainingProcesses(IEnumerable processes, bool afterTimeout) { - var killEntireProcessTree = !OperatingSystem.IsWindows(); + // On Windows DCP is normally an in-tree descendant of the AppHost, so the success path + // (afterTimeout: false, which only mops up leftover shutdown-handle processes after a + // graceful stop succeeded) must NOT tree-kill — that would tear DCP down mid-cleanup. + // But once graceful shutdown has failed/timed out (afterTimeout: true) the whole point of + // this escalation is a guaranteed teardown, so we tree-kill on Windows too; otherwise the + // root PID dies while orphaned descendants (DCP, tsx/node, the guest) keep running. On Unix + // we always tree-kill. + var killEntireProcessTree = afterTimeout || !OperatingSystem.IsWindows(); foreach (var process in processes.Distinct()) { diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index bc96417aa0c..21c0e9c6805 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -40,7 +40,11 @@ internal sealed class AppHostServerSession : IAppHostServerSession private readonly bool _isolateConsole; // Serializes StartAsync against DisposeAsync so a concurrent dispose cannot orphan a - // just-spawned process (see StartAsync for the ordering guarantee). + // just-spawned process (see StartAsync for the ordering guarantee). Never disposed: the + // idempotency guard in DisposeAsync reads _disposed only AFTER acquiring this gate, so + // disposing it would make a second DisposeAsync throw ObjectDisposedException from WaitAsync + // before it could observe that the first dispose already ran. We never touch + // AvailableWaitHandle, so SemaphoreSlim needs no disposal here. private readonly SemaphoreSlim _startGate = new(1, 1); private bool _startInvoked; private bool _disposed; @@ -430,7 +434,6 @@ public async ValueTask DisposeAsync() _stopCts.Dispose(); (_project as IDisposable)?.Dispose(); _activity.Dispose(); - _startGate.Dispose(); } private static void ObserveFaultedTask(Task task) diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 5abf7943430..cd52052a747 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -1261,7 +1261,7 @@ private async Task StartBackchannelConnectionAsync( // unreliable for processes the BCL did not itself start) goes through the // IsolatedProcess wrapper's GetExitCodeProcess-backed accessors instead. // See https://github.com/dotnet/runtime/issues/45003. - catch (SocketException ex) when (serverSession.HasServerExited == true) + catch (SocketException ex) when (serverSession.HasServerExited == true && !cancellationToken.IsCancellationRequested) { var exitCode = serverSession.TryGetServerExitCode() ?? -1; _logger.LogError("AppHost server process has exited with code {ExitCode}. Unable to connect to backchannel at {SocketPath}", exitCode, socketPath); @@ -1297,6 +1297,17 @@ private async Task StartBackchannelConnectionAsync( await Task.Delay(50, cancellationToken).ConfigureAwait(false); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // User cancellation (Ctrl+C) is tearing down the run; appHostSystemToken is linked + // to it. Leave the backchannel completion source pending rather than faulting it: + // faulting here would trip the IsFaulted continuation in RunAppHostAsync, which sets + // internalFaultCode = FailedToDotnetRunAppHost and would surface a run *failure* + // instead of the normal Cancelled (130). The outer run loop observes the same token + // and returns cancellation on its own. + _logger.LogDebug("Backchannel connection to AppHost server cancelled during shutdown."); + return; + } catch (Exception ex) { _logger.LogError(ex, "Failed to connect to AppHost server backchannel");