diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 1a278e11b0d..b367e299924 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, + ProcessTreeGracefulShutdownService processShutdownService, IDetachedProcessLauncher detachedProcessLauncher, ILogger logger, TimeProvider timeProvider) diff --git a/src/Aspire.Cli/Commands/BaseCommand.cs b/src/Aspire.Cli/Commands/BaseCommand.cs index fca1b4b0bbf..4f8de23fe9c 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; @@ -80,6 +89,12 @@ private async Task HandleCommandAsync(ParseResult parseResult, Cancellation 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/DashboardRunCommand.cs b/src/Aspire.Cli/Commands/DashboardRunCommand.cs index 2e8b791f5d2..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) { @@ -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/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 36528c10469..aa6e6a1ea42 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -77,6 +77,16 @@ 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 + // resources before the central drain budget arms and ladders escalate to forceful kill. + // 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 // error. Keep guest AppHost startup waits alive briefly so those failures are reported instead of hidden. @@ -84,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 @@ -504,9 +516,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. + // 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 @@ -529,8 +542,9 @@ 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. + // 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) diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 19be191f06e..53695daf237 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -33,7 +33,7 @@ namespace Aspire.Cli.Commands.Sdk; internal sealed class SdkDumpCommand : BaseCommand { private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; - private readonly IAppHostServerSessionFactory _appHostServerSessionFactory; + private readonly IAppHostServerSessionFactory _serverSessionFactory; private readonly ILogger _logger; private static readonly Argument s_integrationArgument = new("integrations") @@ -56,13 +56,13 @@ internal sealed class SdkDumpCommand : BaseCommand public SdkDumpCommand( IAppHostServerProjectFactory appHostServerProjectFactory, - IAppHostServerSessionFactory appHostServerSessionFactory, + IAppHostServerSessionFactory serverSessionFactory, ILogger logger, CommonCommandServices services) : base("dump", "Dump ATS capabilities from Aspire integration libraries.", services) { _appHostServerProjectFactory = appHostServerProjectFactory; - _appHostServerSessionFactory = appHostServerSessionFactory; + _serverSessionFactory = serverSessionFactory; _logger = logger; Arguments.Add(s_integrationArgument); @@ -193,10 +193,11 @@ private async Task DumpCapabilitiesAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = _appHostServerSessionFactory.Start( - appHostServerProject, - environmentVariables: null, - debug: false); + 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. + await serverSession.StartAsync(); // Connect and get capabilities var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -291,10 +292,11 @@ private async Task DumpCapabilitiesToDirectoryAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = _appHostServerSessionFactory.Start( - appHostServerProject, - environmentVariables: null, - debug: false); + 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. + 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 0e94ee4cbb5..10ba5300625 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -20,7 +20,7 @@ internal sealed class SdkGenerateCommand : BaseCommand { private readonly ILanguageDiscovery _languageDiscovery; private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; - private readonly IAppHostServerSessionFactory _appHostServerSessionFactory; + private readonly IAppHostServerSessionFactory _serverSessionFactory; private readonly ILogger _logger; private static readonly Argument s_integrationArgument = new("integration") @@ -41,14 +41,14 @@ internal sealed class SdkGenerateCommand : BaseCommand public SdkGenerateCommand( ILanguageDiscovery languageDiscovery, IAppHostServerProjectFactory appHostServerProjectFactory, - IAppHostServerSessionFactory appHostServerSessionFactory, + 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; - _appHostServerSessionFactory = appHostServerSessionFactory; + _serverSessionFactory = serverSessionFactory; _logger = logger; Arguments.Add(s_integrationArgument); @@ -154,10 +154,11 @@ private async Task GenerateSdkAsync( return CliExitCodes.FailedToBuildArtifacts; } - await using var serverSession = _appHostServerSessionFactory.Start( - appHostServerProject, - environmentVariables: null, - debug: false); + 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. + await 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 21daf122592..b6d9a486e3c 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -22,7 +22,7 @@ internal sealed class StopCommand : BaseCommand private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; private readonly ICliHostEnvironment _hostEnvironment; - private readonly ProcessShutdownService _processShutdownService; + private readonly ProcessTreeGracefulShutdownService _processShutdownService; private readonly ProfilingTelemetry _profilingTelemetry; private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", StopCommandStrings.ProjectArgumentDescription); @@ -35,7 +35,7 @@ internal sealed class StopCommand : BaseCommand public StopCommand( AppHostConnectionResolver connectionResolver, ICliHostEnvironment hostEnvironment, - ProcessShutdownService processShutdownService, + ProcessTreeGracefulShutdownService processShutdownService, ILogger logger, ProfilingTelemetry profilingTelemetry, CommonCommandServices services) diff --git a/src/Aspire.Cli/ConsoleCancellationManager.cs b/src/Aspire.Cli/ConsoleCancellationManager.cs index 637492d7d01..ac8a04656c1 100644 --- a/src/Aspire.Cli/ConsoleCancellationManager.cs +++ b/src/Aspire.Cli/ConsoleCancellationManager.cs @@ -9,13 +9,53 @@ 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. +/// 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 +/// . /// -internal sealed class ConsoleCancellationManager : IDisposable +/// +/// +/// 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 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, 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 +/// 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, 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 sources. +/// +/// +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. @@ -24,26 +64,38 @@ internal sealed class ConsoleCancellationManager : IDisposable private const int SigTermExitCode = 143; private readonly CancellationTokenSource _cts = new(); - private readonly TimeSpan _processTerminationTimeout; + 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; - private int _cancelCalled; + // Number of termination signals (Ctrl+C, SIGINT, SIGTERM, SIGQUIT, ProcessExit) received. + // 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 timeout after a termination signal. + /// does not complete within the configured drain budget after a termination signal. /// 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,13 +105,27 @@ internal sealed class ConsoleCancellationManager : IDisposable /// internal void SetLogger(ILogger logger) => Volatile.Write(ref _logger, logger); - public ConsoleCancellationManager(TimeSpan processTerminationTimeout) + public ConsoleCancellationManager(TimeSpan finalDrainBudget) { - _processTerminationTimeout = processTerminationTimeout; + _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; + + // 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 + // calls across the watcher (Phase 1 end), the 2nd-signal branch, and this continuation are safe. + _processTerminationCompletionSource.Task.ContinueWith( + static (_, state) => ((ConsoleCancellationManager)state!).Expire(), + this, + 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). @@ -93,8 +159,95 @@ 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). 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 bool IsEnabled => _gracefulBudget > TimeSpan.Zero; + 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 so the AppHost gets 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; + } + + /// + /// 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) { context.Cancel = true; @@ -113,16 +266,23 @@ 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 forcedTerminationExitCode) + internal void Cancel(int exitCode) { - var signalCount = Interlocked.Increment(ref _cancelCalled); + var signalNumber = Interlocked.Increment(ref _signalCount); - if (signalCount == 1) + if (signalNumber == 1) { - // First signal: request cooperative cancellation and schedule an async timeout - // that will force-terminate if the handler doesn't complete in time. + // 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 @@ -135,52 +295,68 @@ 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); } - else + else if (signalNumber == 2) { - // Second (or subsequent) signal: force immediate termination without waiting. - _logger.LogWarning("Second termination signal received, forcing immediate exit."); - _processTerminationCompletionSource.TrySetResult(forcedTerminationExitCode); + // 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(); } + + // 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 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. - if (Debugger.IsAttached) + // 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(). + BeginGracefulWindow(); + + try { - return; + await Task.Delay(Timeout.InfiniteTimeSpan, _gracefulToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Graceful window expired (budget elapsed or 2nd Ctrl+C); fall through to Phase 2. } + // 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); } } @@ -195,5 +371,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 a26594bcfc4..0ade99e7c90 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -14,6 +14,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; +using Aspire.Cli.Processes; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; @@ -60,6 +61,42 @@ 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; + + /// + /// 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 + /// 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 + /// '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 shutdown ladder (DCP + /// stop-process-tree on Windows, SIGTERM on Unix). When null, the cancellation + /// path uses 's force-kill mode. + /// + public IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler { get; set; } + + /// + /// The central graceful-shutdown window whose + /// bounds the shutdown ladder. When + /// null, the cancellation path uses 's + /// force-kill mode. + /// + public IGracefulShutdownWindow? ShutdownService { get; set; } } internal sealed class DotNetCliRunner( @@ -139,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 @@ -272,6 +312,16 @@ private static ProcessInvocationOptions CreateInstrumentedProcessOptions( StartDebugSession = options.StartDebugSession, 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 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 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, StandardOutputCallback = line => { var lineCount = Interlocked.Increment(ref outputCounters.StdoutLineCount); 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/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 1a1f71ecd09..aff4fd33e12 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecution.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecution.cs @@ -2,231 +2,475 @@ // 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; /// -/// 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(); + // 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; + } - if (!started) + /// + public async Task WaitForExitAsync(CancellationToken cancellationToken) + { + var process = Process; + _logger.LogDebug("{FileName}({ProcessId}) waiting for exit", _fileName, process.Id); + + try { - _logger.LogDebug("{FileName} failed to start with args: {Args}", FileName, string.Join(" ", Arguments)); - return false; + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) + { + _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, stopping it", _fileName, process.Id); - _logger.LogDebug("{FileName}({ProcessId}) started in {WorkingDirectory}", FileName, _process.Id, _process.StartInfo.WorkingDirectory); - RecordForwarderActivity(); + await ShutdownOnCancelAsync(process.Process).ConfigureAwait(false); - // Start stream forwarders - _stdoutForwarder = Task.Run(async () => - { - await ForwardStreamToLoggerAsync( - _process.StandardOutput, - "stdout", - _options.StandardOutputCallback); - }); + // 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); - _stderrForwarder = Task.Run(async () => - { - await ForwardStreamToLoggerAsync( - _process.StandardError, - "stderr", - _options.StandardErrorCallback); - }); + throw; + } - return true; + _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", _fileName, process.Id, process.ExitCode); + + // 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; } - /// - 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 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 + /// (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) { - _logger.LogDebug("{FileName}({ProcessId}) waiting for exit", FileName, _process.Id); + var signaler = _options.GracefulShutdownSignaler; + var gracefulShutdownWindow = _options.ShutdownService; - try + 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 ShutdownLadderAsync(process, signaler, gracefulShutdownWindow.GracefulShutdownToken); + } + + 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 tree-kill the same way 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) { - await _process.WaitForExitAsync(cancellationToken); + // Force mode: no graceful budget. Best-effort courtesy SIGTERM (Unix) then hard-kill. + ForceKillChild(process); + return; } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + + // 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, GetSafePid(process), gracefulToken); + + try { - if (!_process.HasExited) + // 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) { - _logger.LogDebug("{FileName}({ProcessId}) wait was canceled, killing it", FileName, _process.Id); - TryKillProcessTree(); + // Graceful budget expired; fall through to kill. } - throw; - } + if (process.HasExited) + { + return; + } - if (!_process.HasExited) - { - _logger.LogDebug("{FileName}({ProcessId}) has not exited, killing it", FileName, _process.Id); - _process.Kill(false); + // 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, GetSafePid(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, GetSafePid(process)); + } } - else + finally { - _logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", FileName, _process.Id, _process.ExitCode); + // 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); + } + else + { + using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await signalTask.WaitAsync(drainCts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } } + } - // Give the forwarders a fresh idle window to consume any buffered tail output produced right before exit. - RecordForwarderActivity(); - - // 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) + 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 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 { - var forwardersCompleted = Task.WhenAll([_stdoutForwarder, _stderrForwarder]); - if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false)) + if (process.HasExited) + { + _logger.LogDebug("{FileName} process {ProcessId} already exited.", _fileName, process.Id); + return; + } + + if (!OperatingSystem.IsWindows()) { - _logger.LogDebug("{FileName}({ProcessId}) closing stdout/stderr streams after forwarder idle timeout", FileName, _process.Id); - _process.StandardOutput.Close(); - _process.StandardError.Close(); + ProcessSignaler.RequestGracefulShutdown(process.Id, expectedStartTime: null, _logger); - if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false)) + if (process.HasExited) { - _logger.LogWarning("{FileName}({ProcessId}) stream forwarders did not complete within idle timeout after stream close", FileName, _process.Id); + return; } } - } - return _process.ExitCode; + _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); + } } - /// - public void Kill(bool entireProcessTree) + private async Task InvokeSignalerAsync(IProcessTreeGracefulShutdownSignaler signaler, int pid, CancellationToken gracefulToken) { - _process.Kill(entireProcessTree); + try + { + // 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 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( + 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); + } } - /// - public void Dispose() + private static int GetSafePid(Process process) { - _process.Dispose(); + try + { + return process.Id; + } + catch (Exception) + { + return -1; + } } - private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identifier, Action? lineCallback) + /// + public void Kill(bool entireProcessTree) => Process.Kill(entireProcessTree); + + /// + public async ValueTask DisposeAsync() { - _logger.LogDebug( - "{FileName}({ProcessId}) starting to forward {Identifier} stream", - FileName, - _process.Id, - identifier - ); + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + // 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) + { + 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 { - string? line; - while ((line = await reader.ReadLineAsync()) is not null) + if (!process.HasExited) { - RecordForwarderActivity(); - - if (_logger.IsEnabled(LogLevel.Trace)) - { - _logger.LogTrace( - "{FileName}({ProcessId}) {Identifier}: {Line}", - FileName, - _process.Id, - identifier, - line - ); - } - lineCallback?.Invoke(line); - RecordForwarderActivity(); + process.Kill(entireProcessTree: true); } } - catch (ObjectDisposedException) + catch { - // 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); + // 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); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "{FileName} IsolatedProcess dispose threw", _fileName); + } + } + + 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); + } + _options.StandardOutputCallback?.Invoke(line); + RecordActivity(); + } + + private void OnErrorLine(IsolatedProcess sender, string line) + { + RecordActivity(); + if (_logger.IsEnabled(LogLevel.Trace)) + { + _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 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); - } - } + 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 01728837b51..2822bfba571 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.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.Processes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -27,48 +27,109 @@ public IProcessExecution CreateExecution(string fileName, string[] args, IDictio } } - 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) + foreach (var a in args) { - startInfo.EnvironmentVariables.Remove(envVarName); + startInfo.ArgumentList.Add(a); } + // 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) { foreach (var envKvp in env) { - startInfo.EnvironmentVariables[envKvp.Key] = envKvp.Value; + startInfo.Environment[envKvp.Key] = envKvp.Value; } } - foreach (var a in args) + 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 { - startInfo.ArgumentList.Add(a); + 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); } - var process = new Process { StartInfo = startInfo }; - return new ProcessExecution(process, effectiveLogger, options); + // 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". 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) + { + childEnvironment[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, logger, options); } } diff --git a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs index c3ba3d4fcd3..7885b7f5196 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()) { @@ -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,7 +59,7 @@ public IProcessExecution Start( if (!execution.Start()) { - execution.Dispose(); + await execution.DisposeAsync().ConfigureAwait(false); throw new InvalidOperationException($"Failed to start process: {toolPath}"); } 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..5a0de066e1a --- /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/IsolatedProcess.Windows.cs b/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs new file mode 100644 index 00000000000..ae07dad116a --- /dev/null +++ b/src/Aspire.Cli/Processes/IsolatedProcess.Windows.cs @@ -0,0 +1,244 @@ +// 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; +using Microsoft.Win32.SafeHandles; + +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. + + // 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(); + 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(); + + var process = Process.GetProcessById(pi.dwProcessId); + + // pi.hThread is no longer needed; the SafeProcessHandle owns pi.hProcess. + WindowsProcessInterop.CloseHandle(pi.hThread); + + // 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; + var capturedProcessHandle = processHandle; + + ValueTask ExtraDispose() + { + try { capturedStdoutReader.Dispose(); } catch { } + 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; + } + + // 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, + process, + stdoutReader, + stderrReader, + standardOutputHandler, + standardErrorHandler, + ExtraDispose, + exitCodeProvider: GetExitCode, + hasExitedProvider: GetHasExited, + waitForExitProvider: ct => WindowsProcessInterop.WaitForExitAsync(capturedProcessHandle, ct)); + } + 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 { } + // Dispose the SafeProcessHandle if we still own it; if ownership already + // transferred (processHandle == null) the wrapper's ExtraDispose owns it. + processHandle?.Dispose(); + 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..0765a11c696 --- /dev/null +++ b/src/Aspire.Cli/Processes/IsolatedProcess.cs @@ -0,0 +1,508 @@ +// 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 System.Text; +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 + /// on Windows hosts; on non-Windows hosts (Unix process-group + /// semantics cover the equivalent case). + /// + 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 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 + /// 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 + /// (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; + + /// + /// 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(); + 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; +} + +/// +/// Mirrors for a child spawned by . +/// 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. +/// +/// +/// 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 readonly Func? _exitCodeProvider; + private readonly Func? _hasExitedProvider; + private readonly Func? _waitForExitProvider; + private int _disposed; + + private IsolatedProcess( + Process process, + string fileName, + IReadOnlyList arguments, + Task standardOutputClosed, + Task standardErrorClosed, + Func disposeAsync, + Func? exitCodeProvider, + Func? hasExitedProvider, + Func? waitForExitProvider) + { + Process = process; + Id = process.Id; + FileName = fileName; + Arguments = arguments; + StandardOutputClosed = standardOutputClosed; + StandardErrorClosed = standardErrorClosed; + _disposeAsync = disposeAsync; + _exitCodeProvider = exitCodeProvider; + _hasExitedProvider = hasExitedProvider; + _waitForExitProvider = waitForExitProvider; + } + + /// 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 . 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 . 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 + /// 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 . + /// + /// 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) + => _waitForExitProvider?.Invoke(cancellationToken) ?? Process.WaitForExitAsync(cancellationToken); + + /// + /// 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) => 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); + + // 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 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); + } + + /// + /// 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. + /// + /// + /// 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 . + /// + /// + /// 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, + TextReader standardOutput, + TextReader standardError, + Action standardOutputHandler, + Action standardErrorHandler, + Func? extraDispose, + Func? exitCodeProvider = 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. + 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, + exitCodeProvider, + 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. + 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/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/ProcessShutdownService.cs b/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs similarity index 84% rename from src/Aspire.Cli/Processes/ProcessShutdownService.cs rename to src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs index d0089a68d54..61b7829a144 100644 --- a/src/Aspire.Cli/Processes/ProcessShutdownService.cs +++ b/src/Aspire.Cli/Processes/ProcessTreeGracefulShutdownService.cs @@ -11,15 +11,17 @@ 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 ProcessShutdownService( +internal sealed class ProcessTreeGracefulShutdownService( 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); @@ -94,13 +96,14 @@ private async Task StopProcessesAsync( private void ForceKillRemainingProcesses(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(); + // 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()) { @@ -113,6 +116,9 @@ private void ForceKillRemainingProcesses(IEnumerable processes, b logger.LogDebug("Forcing remaining shutdown handle process {Pid} to terminate.", process.Pid); } + // 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); } } @@ -189,7 +195,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/WindowsConsoleProcessJob.cs b/src/Aspire.Cli/Processes/WindowsConsoleProcessJob.cs new file mode 100644 index 00000000000..08224bcb596 --- /dev/null +++ b/src/Aspire.Cli/Processes/WindowsConsoleProcessJob.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.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 — on first isolated spawn via — 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 +{ + // 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); + 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..829863a9ba8 --- /dev/null +++ b/src/Aspire.Cli/Processes/WindowsProcessInterop.cs @@ -0,0 +1,729 @@ +// 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); + + // 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 + // 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); + + // 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; + + // 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.). + // + // 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); + void AddIfUnique(nint handle) + { + 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); + + 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; + } + + /// + /// 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 async Task WaitForExitAsync(SafeProcessHandle processHandle, CancellationToken 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; + } + + // Fast path: already signaled, so skip the thread-pool registration entirely. + if (WaitForSingleObject(processHandle, 0) == WaitObject0) + { + return; + } + + // 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; + + // 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) + { + processHandle.DangerousRelease(); + } + } + } +} diff --git a/src/Aspire.Cli/Profiling/ProfileCaptureService.cs b/src/Aspire.Cli/Profiling/ProfileCaptureService.cs index c07505a214c..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) { @@ -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 5d1f7bd5da0..3e34bb837b8 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -326,7 +326,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 @@ -338,6 +340,12 @@ 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); + // 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 + // 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 // separate TracerProvider instances: // - Azure Monitor provider with filtering (only exports activities with EXTERNAL_TELEMETRY=true) @@ -437,8 +445,17 @@ 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 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()); // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed) builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService>())); @@ -484,6 +501,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(); @@ -543,9 +561,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 => @@ -905,10 +920,28 @@ 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 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()) + { + 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. - using var cancellationManager = new ConsoleCancellationManager(processTerminationTimeout: 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; @@ -1044,6 +1077,14 @@ public static async Task Main(string[] args) // Log exit code for debugging logger.LogInformation("Exit code: {ExitCode}", exitCode); } + catch (OperationCanceledException) + { + // 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) { // Should never get here because RootCommand's handler should catch all exceptions, but log just in case. 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 d553f2ab05e..21c0e9c6805 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.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.Telemetry; using Aspire.Cli.Utils; using Aspire.Hosting; @@ -11,194 +11,428 @@ 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 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 +/// 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 readonly string _authenticationToken; + 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 IProcessTreeGracefulShutdownSignaler? _gracefulShutdownSignaler; + private readonly IGracefulShutdownWindow? _shutdownService; + private readonly bool _isolateConsole; + + // Serializes StartAsync against DisposeAsync so a concurrent dispose cannot orphan a + // 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; - internal AppHostServerSession( - Process serverProcess, - OutputCollector output, - string socketPath, - string authenticationToken, + private IProcessExecution? _execution; + private Task? _runTask; + private string? _socketPath; + private OutputCollector? _output; + private TaskCompletionSource? _completion; + private ProfilingTelemetry.ActivityScope _activity; + private IAppHostRpcClient? _rpcClient; + + public AppHostServerSession( + IAppHostServerProject project, + Dictionary? environmentVariables, + bool debug, ILogger logger, - ProfilingTelemetry.ActivityScope activity = default, - ProfilingTelemetry? profilingTelemetry = null, - IDisposable? projectLifetime = null) + ProfilingTelemetry? profilingTelemetry, + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler, + IGracefulShutdownWindow? shutdownService, + bool isolateConsole, + CancellationToken stopRequested) { - _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(); + _gracefulShutdownSignaler = gracefulShutdownSignaler; + _shutdownService = shutdownService; + _isolateConsole = isolateConsole; + + // 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); } - /// - 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 whether the underlying AppHost server process has exited, or + /// 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 + /// https://github.com/dotnet/runtime/issues/45003. /// - public string AuthenticationToken => _authenticationToken; + public bool? HasServerExited => _execution?.HasExited; /// - /// Starts an AppHost server process with an authentication token injected into the server environment. + /// 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. /// - /// 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. - /// 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) + public int? TryGetServerExitCode() { - var currentPid = Environment.ProcessId; - var serverEnvironmentVariables = environmentVariables is null - ? new Dictionary() - : new Dictionary(environmentVariables); + if (_execution is not { HasExited: true } execution) + { + return null; + } + + try + { + return execution.ExitCode; + } + catch + { + return null; + } + } - var authenticationToken = TokenGenerator.GenerateToken(); - serverEnvironmentVariables[KnownConfigNames.RemoteAppHostToken] = authenticationToken; + /// + /// 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. + /// + public int? ServerProcessId => _execution?.ProcessId; - var activity = profilingTelemetry.StartAppHostServerLifetime(appHostServerProject.GetType().Name); - if (activity.IsRunning) + /// + /// 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 the session has been disposed. + public async Task StartAsync() + { + // 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. + await _startGate.WaitAsync().ConfigureAwait(false); + try { - activity.AddContextToEnvironment(serverEnvironmentVariables); + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_startInvoked) + { + throw new InvalidOperationException("AppHostServerSession has already been started."); + } + + _startInvoked = true; + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _completion = completion; + + var serverEnvironmentVariables = _callerEnvironmentVariables is null + ? new Dictionary() + : new Dictionary(_callerEnvironmentVariables); + serverEnvironmentVariables[KnownConfigNames.RemoteAppHostToken] = _authenticationToken; + + _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); + } + + AppHostServerRunResult result; + try + { + result = await _project.RunAsync( + Environment.ProcessId, + serverEnvironmentVariables, + additionalArgs: null, + debug: _debug, + 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; + } + + // 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; + + // 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); + + _activity.SetProcessId(result.Execution.ProcessId); + + // 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); } - else + finally { - // 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); + _startGate.Release(); } + } - string socketPath; - Process serverProcess; - OutputCollector serverOutput; + /// + /// 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 new SessionNotStartedException()).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 "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) + { try { - (socketPath, serverProcess, serverOutput) = appHostServerProject.Run( - currentPid, - serverEnvironmentVariables, - debug: debug); + completion.TrySetResult(await execution.WaitForExitAsync(stopToken).ConfigureAwait(false)); + } + catch (OperationCanceledException) + { + completion.TrySetResult(execution.HasExited ? execution.ExitCode : -1); } catch (Exception ex) { - activity.SetError(ex.Message); - activity.Dispose(); - (appHostServerProject as IDisposable)?.Dispose(); - throw; + completion.TrySetException(ex); } - - activity.SetProcessId(serverProcess.Id); - activity.SetProcessInvocation(serverProcess.StartInfo.FileName, serverProcess.StartInfo.ArgumentList); - - return new AppHostServerSession( - serverProcess, - serverOutput, - socketPath, - authenticationToken, - logger, - activity, - profilingTelemetry, - appHostServerProject as IDisposable); } - /// + /// + /// 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); if (_rpcClient is not null) { return _rpcClient; } + 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 new SessionNotStartedException()).Task; + // ConnectAsync already retries until the RPC socket is available. Race it against the - // server process lifetime instead of sleeping first, so fast startups connect immediately - // and failed server launches surface as soon as the process exits. + // server-exit signal instead of sleeping first, so fast startups connect immediately and + // failed server launches surface as soon as the process exits. We race _completion rather + // than Process.WaitForExitAsync because on the isolated Windows path the Process is + // GetProcessById-derived and its lifetime getters are unreliable — the completion is + // tripped from the IsolatedProcess wrapper that holds the original CreateProcess handle. using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var connectTask = AppHostRpcClient.ConnectAsync(_socketPath, _authenticationToken, _profilingTelemetry, connectCts.Token); - var processExitTask = _serverProcess.WaitForExitAsync(connectCts.Token); - var completedTask = await Task.WhenAny(connectTask, processExitTask).ConfigureAwait(false); + var connectTask = AppHostRpcClient.ConnectAsync(socketPath, _authenticationToken, _profilingTelemetry, connectCts.Token); + var completedTask = await Task.WhenAny(connectTask, serverExitTask).ConfigureAwait(false); if (completedTask == connectTask) { // Stop the process-exit watcher once the RPC connection wins the race. connectCts.Cancel(); - ObserveFaultedTask(processExitTask); + ObserveFaultedTask(serverExitTask); _rpcClient = await connectTask.ConfigureAwait(false); return _rpcClient; } - await processExitTask.ConfigureAwait(false); + await serverExitTask.ConfigureAwait(false); // Stop the retrying connection attempt once the server has exited, then observe any // cancellation/failure it reports so the losing task cannot raise an unobserved exception. connectCts.Cancel(); ObserveFaultedTask(connectTask); - throw new InvalidOperationException($"AppHost server process exited before the RPC connection could be established. Exit code: {_serverProcess.ExitCode}."); + var exitCode = TryGetServerExitCode(); + throw new InvalidOperationException( + exitCode is { } code + ? $"AppHost server process exited before the RPC connection could be established. Exit code: {code}." + : "AppHost server process exited before the RPC connection could be established."); } - /// public async ValueTask DisposeAsync() { - if (_disposed) + // 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 + { + alreadyDisposed = _disposed; + _disposed = true; + } + finally + { + _startGate.Release(); + } + + if (alreadyDisposed) { return; } - _disposed = true; + // 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. + } + + // 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 runTask.ConfigureAwait(false); + } + catch + { + // DriveAsync never throws (it funnels faults into the completion), but swallow + // defensively so disposal stays best-effort. + } + } - if (_rpcClient != null) + if (_rpcClient is not null) { - await _rpcClient.DisposeAsync(); + await _rpcClient.DisposeAsync().ConfigureAwait(false); _rpcClient = null; } - if (!_serverProcess.HasExited) + // Observe the completion task unconditionally to prevent UnobservedTaskException if + // 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 { - _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 Start caller; swallow during disposal. } } - if (_serverProcess.HasExited) + if (_execution is { HasExited: true } execution) { - _activity.SetProcessExitCode(_serverProcess.ExitCode); + try + { + _activity.SetProcessExitCode(execution.ExitCode); + } + catch + { + // Exit code unreadable (handle closed concurrently) — telemetry is best-effort. + } } - _serverProcess.Dispose(); - _projectLifetime?.Dispose(); + // 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 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. + } + _execution = null; + } + + _stopCts.Dispose(); + (_project as IDisposable)?.Dispose(); _activity.Dispose(); } @@ -210,86 +444,45 @@ private static void ObserveFaultedTask(Task task) TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } + + private sealed class SessionNotStartedException() : InvalidOperationException( + $"{nameof(AppHostServerSession)} has not been started. Call {nameof(StartAsync)} first."); } /// -/// Factory for creating instances. +/// Default that constructs real +/// 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 { - private readonly IAppHostServerProjectFactory _projectFactory; private readonly ILogger _logger; private readonly ProfilingTelemetry _profilingTelemetry; - public AppHostServerSessionFactory( - IAppHostServerProjectFactory projectFactory, - ILogger logger, - ProfilingTelemetry profilingTelemetry) + public AppHostServerSessionFactory(ILogger logger, ProfilingTelemetry profilingTelemetry) { - _projectFactory = projectFactory; _logger = logger; _profilingTelemetry = profilingTelemetry; } - /// - public async Task CreateAsync( - string appHostPath, - string sdkVersion, - IEnumerable integrations, - Dictionary? launchSettingsEnvVars, - bool debug, - CancellationToken cancellationToken) - { - var appHostServerProject = await _projectFactory.CreateAsync(appHostPath, cancellationToken); - - // Prepare the server (create files + build for dev mode, restore packages for prebuilt mode) - AppHostServerPrepareResult prepareResult; - try - { - prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, integrations, cancellationToken: cancellationToken); - } - catch - { - (appHostServerProject as IDisposable)?.Dispose(); - throw; - } - - if (!prepareResult.Success) - { - (appHostServerProject as IDisposable)?.Dispose(); - - return new AppHostServerSessionResult( - Success: false, - Session: null, - BuildOutput: prepareResult.Output, - ChannelName: prepareResult.ChannelName); - } - - var session = AppHostServerSession.Start( - appHostServerProject, - launchSettingsEnvVars, - debug, - _logger, - _profilingTelemetry); - - return new AppHostServerSessionResult( - Success: true, - Session: session, - BuildOutput: prepareResult.Output, - ChannelName: prepareResult.ChannelName); - } - - /// - public IAppHostServerSession Start( + public IAppHostServerSession Create( IAppHostServerProject appHostServerProject, Dictionary? environmentVariables, - bool debug) - { - return AppHostServerSession.Start( + bool debug, + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler, + IGracefulShutdownWindow? shutdownService, + bool isolateConsole, + CancellationToken stopRequested) => + new AppHostServerSession( appHostServerProject, environmentVariables, debug, _logger, - _profilingTelemetry); - } + _profilingTelemetry, + gracefulShutdownSignaler, + shutdownService, + isolateConsole, + stopRequested); } diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 4e2071e5bbf..72e4c7656c2 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,8 @@ internal sealed class DotNetAppHostProject : IAppHostProject private readonly Program.CliLoggingOptions _loggingOptions; private readonly IAppHostInfoResolver _appHostInfoResolver; private readonly IConfigurationService _configurationService; + private readonly IGracefulShutdownWindow _shutdownService; + private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; private readonly CliExecutionContext _executionContext; private static readonly string[] s_detectionPatterns = ["*.csproj", "*.fsproj", "*.vbproj", "apphost.cs"]; @@ -72,6 +75,8 @@ public DotNetAppHostProject( Program.CliLoggingOptions loggingOptions, IAppHostInfoResolver appHostInfoResolver, IConfigurationService configurationService, + IGracefulShutdownWindow shutdownService, + IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, CliExecutionContext executionContext, TimeProvider timeProvider) { @@ -89,6 +94,8 @@ public DotNetAppHostProject( _loggingOptions = loggingOptions; _appHostInfoResolver = appHostInfoResolver; _configurationService = configurationService; + _shutdownService = shutdownService; + _gracefulShutdownSignaler = gracefulShutdownSignaler; _executionContext = executionContext; _timeProvider = timeProvider; _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider, _profilingTelemetry); @@ -340,7 +347,16 @@ 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()), + // 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 the shared ladder's force-kill mode. + IsolateConsole = true, + GracefulShutdownSignaler = _gracefulShutdownSignaler, + ShutdownService = _shutdownService, }; // The backchannel completion source is the contract with RunCommand @@ -405,6 +421,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 561d58dda51..c25fb8c0698 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -37,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; @@ -46,6 +47,7 @@ public DotNetBasedAppHostServerProject( string repoRoot, IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, + IProcessExecutionFactory processExecutionFactory, ILogger logger, string? projectModelPath = null, string? logFilePath = null) @@ -57,6 +59,7 @@ public DotNetBasedAppHostServerProject( _repoRoot = Path.GetFullPath(repoRoot) + Path.DirectorySeparatorChar; _dotNetCliRunner = dotNetCliRunner; _packagingService = packagingService; + _processExecutionFactory = processExecutionFactory; _logger = logger; _logFilePath = logFilePath; @@ -463,15 +466,19 @@ public async Task PrepareAsync( public string GetInstanceIdentifier() => GetProjectFilePath(); /// - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public async Task RunAsync( int hostPid, - IReadOnlyDictionary? environmentVariables = null, - string[]? additionalArgs = null, - bool debug = false) + IReadOnlyDictionary? environmentVariables, + string[]? additionalArgs, + bool debug, + AppHostServerRunControl? runControl) { 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, @@ -534,29 +541,52 @@ public async Task PrepareAsync( startInfo.RedirectStandardOutput = true; startInfo.RedirectStandardError = true; - var process = Process.Start(startInfo)!; - var outputCollector = new OutputCollector(); - process.OutputDataReceived += (sender, e) => + + // 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. + IProcessExecution execution = null!; + + void OnStdout(string line) { - if (e.Data is not null) - { - _logger.LogTrace("AppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data); - outputCollector.AppendOutput(e.Data); - } - }; - process.ErrorDataReceived += (sender, e) => + _logger.LogTrace("AppHostServer({ProcessId}) stdout: {Line}", execution.ProcessId, line); + outputCollector.AppendOutput(line); + } + + void OnStderr(string line) { - if (e.Data is not null) - { - _logger.LogTrace("AppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data); - outputCollector.AppendError(e.Data); - } + _logger.LogTrace("AppHostServer({ProcessId}) stderr: {Line}", execution.ProcessId, line); + outputCollector.AppendError(line); + } + + 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(), }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - return (_socketPath, process, outputCollector); + execution = _processExecutionFactory.CreateExecution(startInfo, options); + + try + { + execution.Start(); + } + catch + { + await execution.DisposeAsync().ConfigureAwait(false); + throw; + } + + return new AppHostServerRunResult(_socketPath, outputCollector, execution); } private static string? FindNuGetConfig(string workingDirectory) diff --git a/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs b/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs index e9ba0c3c641..644f0cc4a4f 100644 --- a/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs +++ b/src/Aspire.Cli/Projects/ExtensionGuestLauncher.cs @@ -33,9 +33,13 @@ public ExtensionGuestLauncher( string[] args, DirectoryInfo workingDirectory, IDictionary environmentVariables, - CancellationToken cancellationToken, - Func? afterLaunchAsync = 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 + // 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 ef2eb553832..e8134474f5e 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; @@ -35,7 +36,6 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly IInteractionService _interactionService; private readonly IAppHostCliBackchannel _backchannel; private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; - private readonly IAppHostServerSessionFactory _appHostServerSessionFactory; private readonly ICertificateService _certificateService; private readonly IDotNetCliRunner _runner; private readonly IPackagingService _packagingService; @@ -48,6 +48,9 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly TimeProvider _timeProvider; private readonly RunningInstanceManager _runningInstanceManager; private readonly ProfilingTelemetry _profilingTelemetry; + private readonly IProcessTreeGracefulShutdownSignaler _gracefulShutdownSignaler; + private readonly IGracefulShutdownWindow _shutdownService; + private readonly IAppHostServerSessionFactory _serverSessionFactory; // Language is always resolved via constructor private readonly LanguageInfo _resolvedLanguage; @@ -58,7 +61,6 @@ public GuestAppHostProject( IInteractionService interactionService, IAppHostCliBackchannel backchannel, IAppHostServerProjectFactory appHostServerProjectFactory, - IAppHostServerSessionFactory appHostServerSessionFactory, ICertificateService certificateService, IDotNetCliRunner runner, IPackagingService packagingService, @@ -69,13 +71,15 @@ public GuestAppHostProject( ILogger logger, FileLoggerProvider fileLoggerProvider, ProfilingTelemetry profilingTelemetry, + IProcessTreeGracefulShutdownSignaler gracefulShutdownSignaler, + IGracefulShutdownWindow shutdownService, + IAppHostServerSessionFactory serverSessionFactory, TimeProvider timeProvider) { _resolvedLanguage = language; _interactionService = interactionService; _backchannel = backchannel; _appHostServerProjectFactory = appHostServerProjectFactory; - _appHostServerSessionFactory = appHostServerSessionFactory; _certificateService = certificateService; _runner = runner; _packagingService = packagingService; @@ -88,6 +92,9 @@ public GuestAppHostProject( _profilingTelemetry = profilingTelemetry; _timeProvider = timeProvider; _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider, _profilingTelemetry); + _gracefulShutdownSignaler = gracefulShutdownSignaler; + _shutdownService = shutdownService; + _serverSessionFactory = serverSessionFactory; } // ═══════════════════════════════════════════════════════════════ @@ -286,10 +293,11 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Aspir } // Step 2: Start the AppHost server temporarily for code generation - await using var serverSession = _appHostServerSessionFactory.Start( - appHostServerProject, - environmentVariables: null, - debug: false); + 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. + await serverSession.StartAsync(); // Step 3: Connect to server var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); @@ -359,6 +367,14 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken _logger.LogDebug("Running {Language} AppHost: {AppHostFile}", DisplayName, appHostFile.FullName); var startProjectContext = Activity.Current?.Context ?? default; + // 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. 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 { // Step 1: Ensure certificates are trusted @@ -374,7 +390,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 @@ -411,6 +427,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( @@ -436,19 +453,34 @@ 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 - IAppHostServerSession 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 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 = _serverSessionFactory.Create( + appHostServerProject, + launchSettingsEnvVars, + context.Debug, + _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; IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverSession = _appHostServerSessionFactory.Start( - appHostServerProject, - launchSettingsEnvVars, - context.Debug); + await serverSession.StartAsync(); + serverCompletion = serverSession.WaitForExitAsync(); + 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 @@ -467,27 +499,18 @@ await GenerateCodeViaRpcAsync( await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); } - catch (Exception) when (serverSession.ServerProcess.HasExited && !cancellationToken.IsCancellationRequested) + catch (Exception) when (serverSession.HasServerExited == true && !cancellationToken.IsCancellationRequested) { // If the helper server exits while we are connecting or making setup RPC calls, // its captured output is the only place the real startup failure may be recorded. - _interactionService.DisplayLines(serverSession.Output.GetLines()); + // serverSession is in an `await using` scope, so returning here disposes it. + _interactionService.DisplayLines(serverSession.Output!.GetLines()); _interactionService.DisplayError("App host exited unexpectedly."); - await serverSession.DisposeAsync(); return CliExitCodes.FailedToDotnetRunAppHost; } - catch - { - // 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 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 @@ -499,27 +522,39 @@ 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. + // 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 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. 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 => { - if (t.IsFaulted && !appHostSystemCts.IsCancellationRequested) + if (t.IsFaulted) { + // The outer OCE catch reads this when the in-flight await throws because we + // cancelled below. + internalFaultCode = CliExitCodes.FailedToDotnetRunAppHost; try { appHostSystemCts.Cancel(); } catch (ObjectDisposedException) { - // RunAsync already returned and disposed the CTS; nothing to do. + // 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. } } }, @@ -540,18 +575,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"); - } - } - + // `await using serverSession` runs the per-process shutdown ladder as we unwind. return installResult; } @@ -609,15 +633,21 @@ 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, 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 + graceful + // shutdown on Windows, matching what the AppHost server already does. + var guestLaunchOptions = new GuestLaunchOptions( + IsolateConsoleForGracefulShutdown: true, + GracefulShutdownSignaler: _gracefulShutdownSignaler, + ShutdownService: _shutdownService); (guestExitCode, guestOutput) = await ExecuteGuestAppHostAsync( - appHostFile, directory, environmentVariables, enableHotReload, context.NoBuild, rpcClient, launcher, StartBackchannelConnectionAfterGuestAppHostLaunchesAsync, appHostSystemToken); + 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" @@ -637,8 +667,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); @@ -667,19 +696,8 @@ 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); - } - } - + // The backchannel exception above causes RunCommand's startup wait to throw, + // tearing down the run via `await using serverSession`/launcher disposal. return guestExitCode; } @@ -687,33 +705,27 @@ 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 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) { - try - { - appHostServerProcess.Kill(entireProcessTree: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error killing AppHost server process"); - } + return 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 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) { @@ -1014,19 +1026,29 @@ 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) - IAppHostServerSession serverSession; + // Linked stop CTS is the only termination trigger we hand to the session. + using var serverStopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + await using var serverSession = _serverSessionFactory.Create( + appHostServerProject, + launchSettingsEnvVars, + context.Debug, + gracefulShutdownSignaler: null, + shutdownService: null, + isolateConsole: false, + serverStopCts.Token); + Task serverCompletion; IAppHostRpcClient rpcClient; using (_profilingTelemetry.StartRunAppHostStartAppHostServer()) { - serverSession = _appHostServerSessionFactory.Start( - appHostServerProject, - launchSettingsEnvVars, - context.Debug); + await serverSession.StartAsync(); + serverCompletion = serverSession.WaitForExitAsync(); try { // 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 @@ -1045,35 +1067,31 @@ await GenerateCodeViaRpcAsync( await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); } - catch (Exception) when (serverSession.ServerProcess.HasExited && !cancellationToken.IsCancellationRequested) + catch (Exception) when (serverSession.HasServerExited == true && !cancellationToken.IsCancellationRequested) { // The publish pipeline waits on the AppHost backchannel independently from this // setup path. If the helper server exits before the guest AppHost can launch, // fault that waiter immediately instead of letting it burn the full connection timeout. + // serverSession is in an `await using` scope, so returning here disposes it. context.BackchannelCompletionSource?.TrySetException( new InvalidOperationException("The app host server exited unexpectedly.")); - _interactionService.DisplayLines(serverSession.Output.GetLines()); + _interactionService.DisplayLines(serverSession.Output!.GetLines()); _interactionService.DisplayError("App host exited unexpectedly."); - await serverSession.DisposeAsync(); return CliExitCodes.FailedToDotnetRunAppHost; } catch (Exception ex) { - // Code generation and runtime-spec lookup happen before the publish guest process - // starts. Surface those setup failures through the same backchannel completion path - // so callers do not keep waiting for a guest AppHost that will never be launched. + // 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); - - // 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; @@ -1088,17 +1106,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; } @@ -1122,7 +1130,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; @@ -1150,35 +1158,15 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() 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, @@ -1236,7 +1224,7 @@ private static void MergeServerOutputIntoContextCollector(AppHostProjectContext /// Starts connecting to the AppHost server's backchannel server. /// private async Task StartBackchannelConnectionAsync( - Process process, + IAppHostServerSession serverSession, string socketPath, TaskCompletionSource backchannelCompletionSource, bool enableHotReload, @@ -1268,10 +1256,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 && !cancellationToken.IsCancellationRequested) { - _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); @@ -1303,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"); @@ -1910,6 +1915,7 @@ private async Task InstallDependenciesAsync( IAppHostRpcClient rpcClient, IGuestProcessLauncher launcher, Func? afterAppHostLaunchedAsync, + GuestLaunchOptions? appHostLaunchOptions, CancellationToken cancellationToken) { await EnsureRuntimeCreatedAsync(directory, rpcClient, cancellationToken); @@ -1920,7 +1926,7 @@ private async Task InstallDependenciesAsync( return (CliExitCodes.FailedToDotnetRunAppHost, new OutputCollector()); } - return await _guestRuntime.RunAsync(appHostFile, directory, environmentVariables, watchMode, launcher, cancellationToken, noBuild: noBuild, afterAppHostLaunchedAsync: afterAppHostLaunchedAsync); + return await _guestRuntime.RunAsync(appHostFile, directory, environmentVariables, watchMode, launcher, cancellationToken, noBuild: noBuild, afterAppHostLaunchedAsync: afterAppHostLaunchedAsync, appHostLaunchOptions: appHostLaunchOptions); } /// diff --git a/src/Aspire.Cli/Projects/GuestRuntime.cs b/src/Aspire.Cli/Projects/GuestRuntime.cs index 3351c656e7f..16e0a171613 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; @@ -86,6 +88,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm args, directory, environmentVariables, + afterLaunchAsync: null, + options: null, cancellationToken); activity.SetProcessExitCode(exitCode); if (exitCode != 0) @@ -124,6 +128,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm args, directory, environmentVariables, + afterLaunchAsync: null, + options: null, cancellationToken); activity.SetProcessExitCode(exitCode); if (exitCode != 0) @@ -145,6 +151,11 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm /// Cancellation token. /// Whether to skip pre-execution build/check commands. /// 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, @@ -154,7 +165,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm IGuestProcessLauncher launcher, CancellationToken cancellationToken, bool noBuild = false, - Func? afterAppHostLaunchedAsync = null) + Func? afterAppHostLaunchedAsync = null, + GuestLaunchOptions? appHostLaunchOptions = null) { var useWatchCommand = watchMode && _spec.WatchExecute is not null; var commandSpec = useWatchCommand @@ -174,7 +186,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm 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); } /// @@ -237,7 +249,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm _logger.LogDebug("Launching pre-execution command: {Command} {Args}", commandSpec.Command, string.Join(" ", args)); using var activity = _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) { @@ -258,7 +270,8 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm string phase, IGuestProcessLauncher launcher, CancellationToken cancellationToken, - Func? afterLaunchAsync = null) + Func? afterLaunchAsync = null, + GuestLaunchOptions? launchOptions = null) { var args = ReplacePlaceholders(commandSpec.Args, appHostFile, directory, additionalArgs); @@ -266,7 +279,7 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm _logger.LogDebug("Launching: {Command} {Args}", commandSpec.Command, string.Join(" ", args)); using var activity = _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, afterLaunchAsync: afterLaunchAsync, options: launchOptions, cancellationToken); activity.SetProcessExitCode(exitCode); if (exitCode != 0) { @@ -317,7 +330,14 @@ private async Task EnsureMigrationFilesExistAsync(DirectoryInfo directory, Cance /// /// Creates the default process-based launcher for this runtime. /// - public ProcessGuestLauncher CreateDefaultLauncher() => new(_spec.Language, _logger, _commandResolver, _fileLoggerProvider); + 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/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs index 216dccd42a8..7dd98fccf00 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.cs @@ -1,8 +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.DotNet; +using Aspire.Cli.Processes; using Aspire.Cli.Utils; namespace Aspire.Cli.Projects; @@ -20,6 +21,52 @@ internal sealed record AppHostServerPrepareResult( string? ChannelName = null, bool NeedsCodeGeneration = false); +/// +/// Result of — a launched AppHost server process plus the +/// captured output. +/// +/// RPC socket the server is publishing on. +/// Captured stdout/stderr for failure display. +/// +/// 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. +/// +internal sealed record AppHostServerRunResult( + string SocketPath, + OutputCollector OutputCollector, + IProcessExecution Execution); + +/// +/// 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 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. +/// +/// +/// 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. /// This abstraction allows for different implementations: @@ -54,16 +101,29 @@ 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. /// Whether to enable debug logging. - /// The socket path, server process, and an output collector for stdout/stderr. - (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + /// + /// 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); + 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/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs index 08f708b80b6..aa345d7d149 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs @@ -1,89 +1,95 @@ // 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.Utils; namespace Aspire.Cli.Projects; /// -/// Represents a running AppHost server session. -/// Manages server lifecycle and provides RPC client access. +/// 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. /// internal interface IAppHostServerSession : IAsyncDisposable { /// - /// Gets the socket path for RPC communication. + /// Gets the authentication token injected into the server environment. Available before + /// so callers can plumb it into the guest AppHost environment. /// - string SocketPath { get; } + string AuthenticationToken { get; } /// - /// Gets the server process. + /// Gets the RPC socket path, or if has not + /// been called (or threw before the process was published). /// - Process ServerProcess { get; } + string? SocketPath { get; } /// - /// Gets the output collector for server stdout/stderr. + /// 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; } + OutputCollector? Output { get; } /// - /// Gets the authentication token for the server session. + /// Gets whether the underlying AppHost server process has exited, or + /// if has not been called (or threw before the process was published). /// - string AuthenticationToken { get; } + 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(); /// - /// Gets an RPC client connected to this session. + /// Launches the AppHost server process and wires lifecycle observation. The returned 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. /// Task GetRpcClientAsync(CancellationToken cancellationToken); } /// -/// Factory for building and starting AppHost server sessions. +/// Creates instances. Production wires this to construct real +/// instances; tests inject a factory that returns a fake session. /// 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); - - /// - /// Starts a server session from an already-prepared server project. + /// Creates an unstarted session for the already-prepared . + /// The caller drives it via , + /// , then disposal. Cancelling + /// (or disposing the session) terminates the server process. /// - /// The prepared server project to start. - /// Optional environment variables for the server process. - /// Whether to enable debug logging. - /// The started server session. - IAppHostServerSession Start( + /// + /// 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); + bool debug, + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler, + IGracefulShutdownWindow? shutdownService, + bool isolateConsole, + CancellationToken stopRequested); } - -/// -/// 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..c8a8c056a98 100644 --- a/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs +++ b/src/Aspire.Cli/Projects/IGuestProcessLauncher.cs @@ -1,10 +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; 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 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. +/// +/// +/// 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 window. Its +/// bounds both the graceful-signal call and the post-signal wait-for-exit, so a 2nd Ctrl+C +/// (which calls ) +/// interrupts both immediately and the ladder escalates to Kill(entireProcessTree: true). +/// +internal sealed record GuestLaunchOptions( + bool IsolateConsoleForGracefulShutdown = false, + IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler = null, + IGracefulShutdownWindow? ShutdownService = null); + /// /// Strategy for launching a guest language process. /// @@ -18,6 +51,7 @@ internal interface IGuestProcessLauncher string[] args, DirectoryInfo workingDirectory, IDictionary environmentVariables, - CancellationToken cancellationToken, - Func? afterLaunchAsync = null); + Func? afterLaunchAsync, + GuestLaunchOptions? options, + CancellationToken cancellationToken); } diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 627863da8f8..de898a17f70 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -44,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; @@ -66,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( @@ -77,6 +79,7 @@ public PrebuiltAppHostServer( IDotNetSdkInstaller sdkInstaller, IPackagingService packagingService, CliExecutionContext executionContext, + IProcessExecutionFactory processExecutionFactory, ILogger logger, BundleLayoutLease? layoutLease = null) { @@ -88,6 +91,7 @@ public PrebuiltAppHostServer( _sdkInstaller = sdkInstaller; _packagingService = packagingService; _executionContext = executionContext; + _processExecutionFactory = processExecutionFactory; _logger = logger; _layoutLease = layoutLease; @@ -911,48 +915,68 @@ 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 async Task RunAsync( int hostPid, - IReadOnlyDictionary? environmentVariables = null, - string[]? additionalArgs = null, - bool debug = false) + IReadOnlyDictionary? environmentVariables, + string[]? additionalArgs, + bool debug, + AppHostServerRunControl? runControl) { var startInfo = CreateStartInfo(hostPid, environmentVariables, additionalArgs, debug); + var outputCollector = new OutputCollector(); - var process = Process.Start(startInfo)!; + // 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. + IProcessExecution execution = null!; - var outputCollector = new OutputCollector(); - process.OutputDataReceived += (_, e) => + void OnStdout(string line) { - 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); - } - }; - process.ErrorDataReceived += (_, e) => + // 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}", execution.ProcessId, line); + outputCollector.AppendOutput(line); + } + + void OnStderr(string line) { - 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); - } + // 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}", execution.ProcessId, line); + outputCollector.AppendError(line); + } + + var options = new ProcessInvocationOptions + { + StandardOutputCallback = OnStdout, + StandardErrorCallback = OnStderr, + IsolateConsole = runControl?.IsolateConsole ?? false, + GracefulShutdownSignaler = runControl?.GracefulShutdownSignaler, + ShutdownService = runControl?.ShutdownService, + KillEntireProcessTreeOnCancel = !OperatingSystem.IsWindows(), }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + execution = _processExecutionFactory.CreateExecution(startInfo, options); + + try + { + execution.Start(); + } + catch + { + await execution.DisposeAsync().ConfigureAwait(false); + throw; + } - return (_socketPath, process, outputCollector); + 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 75672a3af34..3fd1b23cc32 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.DotNet; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; @@ -18,8 +19,14 @@ internal sealed class ProcessGuestLauncher : IGuestProcessLauncher private readonly ILogger _logger; private readonly FileLoggerProvider? _fileLoggerProvider; private readonly Func _commandResolver; - - public ProcessGuestLauncher(string language, ILogger logger, Func commandResolver, FileLoggerProvider? fileLoggerProvider = null) + private readonly IProcessExecutionFactory _processExecutionFactory; + + public ProcessGuestLauncher( + string language, + ILogger logger, + FileLoggerProvider? fileLoggerProvider, + Func commandResolver, + IProcessExecutionFactory processExecutionFactory) { ArgumentNullException.ThrowIfNull(commandResolver); @@ -27,6 +34,10 @@ public ProcessGuestLauncher(string language, ILogger logger, Func LaunchAsync( @@ -34,8 +45,9 @@ public ProcessGuestLauncher(string language, ILogger logger, Func environmentVariables, - CancellationToken cancellationToken, - Func? afterLaunchAsync = null) + Func? afterLaunchAsync, + GuestLaunchOptions? options, + CancellationToken cancellationToken) { var activity = GetCurrentProfilingActivity(); AddEvent(activity, ProfilingTelemetry.Events.GuestProcessResolveStart); @@ -55,147 +67,129 @@ public ProcessGuestLauncher(string language, ILogger logger, Func + // 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) { - if (e.Data is null) + var pid = execution.ProcessId; + 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); + } - process.ErrorDataReceived += (sender, e) => + void HandleStderrLine(string line) { - if (e.Data is null) + var pid = execution.ProcessId; + if (Interlocked.Exchange(ref firstStderrSeen, 1) == 0) { - // ProcessDataReceivedEventArgs.Data is null when the redirected stderr stream closes. - stderrCompleted.TrySetResult(); + AddEvent(activity, ProfilingTelemetry.Events.GuestFirstStderr, TelemetryConstants.Tags.ProcessPid, pid); } - else - { - if (Interlocked.Exchange(ref firstStderrSeen, 1) == 0) - { - AddEvent(activity, ProfilingTelemetry.Events.GuestFirstStderr, TelemetryConstants.Tags.ProcessPid, process.Id); - } - _logger.LogTrace("{Language}({ProcessId}) stderr: {Line}", _language, process.Id, e.Data); - outputCollector.AppendError(e.Data); - } + _logger.LogTrace("{Language}({ProcessId}) stderr: {Line}", _language, pid, line); + outputCollector.AppendError(line); + } + + // 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 + { + FileName = resolvedCommandPath, + WorkingDirectory = workingDirectory.FullName, }; - 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) + foreach (var arg in args) { - await afterLaunchAsync().ConfigureAwait(false); + startInfo.ArgumentList.Add(arg); } - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + foreach (var (key, value) in effectiveEnvironmentVariables) + { + startInfo.Environment[key] = value; + } - try + var invocationOptions = new ProcessInvocationOptions { - var waitForExitTask = process.WaitForExitAsync(cancellationToken); + StandardOutputCallback = HandleStdoutLine, + StandardErrorCallback = HandleStderrLine, + // 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, + // 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, + }; - using var _ = cancellationToken.Register(() => - _logger.LogInformation("Cancellation requested while waiting for {Language} guest process {ProcessId} to exit", _language, process.Id)); + AddEvent(activity, ProfilingTelemetry.Events.GuestProcessStart); - await waitForExitTask.ConfigureAwait(false); - } - catch (OperationCanceledException) + execution = _processExecutionFactory.CreateExecution(startInfo, invocationOptions); + + try { - // 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) - { - _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); - } - } - else + execution.Start(); + + _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) { - _logger.LogDebug("{Language} guest process {ProcessId} already exited before kill", _language, process.Id); + await afterLaunchAsync().ConfigureAwait(false); } - _logger.LogDebug("Waiting for {Language} guest process {ProcessId} to exit after kill", _language, process.Id); - await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); - } + int finalExitCode; + try + { + using var _ = cancellationToken.Register(() => + _logger.LogInformation("Cancellation requested while waiting for {Language} guest process {ProcessId} to exit", _language, execution.ProcessId)); - _logger.LogDebug("{Language} guest process {ProcessId} exited with code {ExitCode}", _language, process.Id, process.ExitCode); + // 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. 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; + } - activity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); - AddEvent(activity, ProfilingTelemetry.Events.GuestProcessExited, TelemetryConstants.Tags.ProcessExitCode, process.ExitCode); + _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(stdoutCompleted.Task, stderrCompleted.Task))) + return (finalExitCode, outputCollector); + } + finally { - 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); + // 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); } - - return (process.ExitCode, outputCollector); } private static Activity? GetCurrentProfilingActivity() @@ -222,22 +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/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 129b31c963f..0394c0a4cb3 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -39,7 +39,7 @@ internal sealed class ScaffoldingService : IScaffoldingService }; private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; - private readonly IAppHostServerSessionFactory _appHostServerSessionFactory; + private readonly IAppHostServerSessionFactory _serverSessionFactory; private readonly ILanguageDiscovery _languageDiscovery; private readonly IInteractionService _interactionService; private readonly ILogger _logger; @@ -48,7 +48,7 @@ internal sealed class ScaffoldingService : IScaffoldingService public ScaffoldingService( IAppHostServerProjectFactory appHostServerProjectFactory, - IAppHostServerSessionFactory appHostServerSessionFactory, + IAppHostServerSessionFactory serverSessionFactory, ILanguageDiscovery languageDiscovery, IInteractionService interactionService, ILogger logger, @@ -56,7 +56,7 @@ public ScaffoldingService( ProfilingTelemetry profilingTelemetry) { _appHostServerProjectFactory = appHostServerProjectFactory; - _appHostServerSessionFactory = appHostServerSessionFactory; + _serverSessionFactory = serverSessionFactory; _languageDiscovery = languageDiscovery; _interactionService = interactionService; _logger = logger; @@ -146,10 +146,11 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can } // Step 2: Start the server temporarily for scaffolding and code generation - await using var serverSession = _appHostServerSessionFactory.Start( - appHostServerProject, - environmentVariables: null, - debug: false); + 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. + await serverSession.StartAsync(); // Step 3: Connect to server and get scaffold templates via RPC var rpcClient = await serverSession.GetRpcClientAsync(cancellationToken); diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionChecker.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionChecker.cs index 1add9bbd694..9c4d3d76536 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 43cff7f2c3f..bf2b3bd5cc2 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(processTerminationTimeout: 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/Commands/AppHostLauncherTests.cs b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs index dc59556ae18..fe346524d48 100644 --- a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs @@ -719,12 +719,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 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/Commands/DashboardRunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs index 9fa83fc1227..4fe92e51033 100644 --- a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs @@ -307,7 +307,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", diff --git a/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs index 42fd8c2f764..ba621c95a09 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/ConsoleCancellationManagerTests.cs b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs index 8555e99c39c..f3fbad4bb79 100644 --- a/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs +++ b/tests/Aspire.Cli.Tests/ConsoleCancellationManagerTests.cs @@ -1,12 +1,21 @@ // 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 ConfigureForCommand_NegativeBudget_Throws() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(5)); + + Assert.Throws(() => manager.ConfigureForCommand(TimeSpan.FromMilliseconds(-1))); + } + [Fact] public void FirstSignal_RequestsCancellation() { @@ -33,55 +42,122 @@ public void FirstSignal_TokenIsCancelled() } [Fact] - public async Task SecondSignal_ForcesImmediateTermination() + public async Task FirstSignal_DefaultZeroBudget_ExpiresGracefulImmediately() { using var manager = new ConsoleCancellationManager(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 manager.GracefulShutdownToken.WaitUntilCancelledAsync().DefaultTimeout(); + Assert.True(manager.GracefulShutdownToken.IsCancellationRequested); + } + + [Fact] + 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), so the timing this test asserts + // never happens. Surface that as a real skip rather than a silent pass. + if (Debugger.IsAttached) + { + Assert.Skip("Graceful CancelAfter timing is disabled while a debugger is attached."); + } + + 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 = Stopwatch.StartNew(); manager.Cancel(130); - var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); - Assert.Equal(130, exitCode); + // Wait for the budget to elapse. + 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. + 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 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)); - // No handler set, so ForceTerminationAfterTimeoutAsync should complete quickly - manager.Cancel(143); + // Set a handler that never completes; 2nd signal should ONLY collapse graceful (not force exit). + manager.SetStartedHandler(new TaskCompletionSource().Task); - var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); - Assert.Equal(143, exitCode); + manager.Cancel(130); + + // 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(manager.GracefulShutdownToken.IsCancellationRequested); + + // 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 FirstSignal_HandlerCompletesWithinTimeout_DoesNotForceTermination() + public async Task AdditionalSignals_AfterSecond_AreIgnored() + { + using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); + // Long graceful and drain budgets so neither elapses during the test window. + manager.ConfigureForCommand(TimeSpan.FromSeconds(30)); + + // 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); + + // 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] + public async Task FirstSignal_HandlerCompletesWithinDrainBudget_DoesNotForceTermination() { using var manager = new ConsoleCancellationManager(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)); - // Set a handler that never completes + // Set a handler that never completes. manager.SetStartedHandler(new TaskCompletionSource().Task); manager.Cancel(143); @@ -90,39 +166,75 @@ public async Task FirstSignal_HandlerExceedsTimeout_ForcesTermination() Assert.Equal(143, exitCode); } + [Fact] + public async Task FirstSignal_WithNoHandler_ForcesTerminationAfterDrainBudget() + { + 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); + + var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); + Assert.Equal(143, exitCode); + } + + [Fact] + public async Task GracefulBudgetElapses_ThenDrainBudgetElapses_FiresProcessTermination() + { + using var manager = new ConsoleCancellationManager(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(manager.GracefulShutdownToken.IsCancellationRequested); + } + [Fact] public void Cancel_IsNonBlocking() { using var manager = new ConsoleCancellationManager(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(); + var sw = 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 async Task ProcessTermination_FiresGracefulExpiration_LaddersUnblock() { using var manager = new ConsoleCancellationManager(TimeSpan.FromSeconds(30)); - // Set a handler that never completes - manager.SetStartedHandler(new TaskCompletionSource().Task); - - // Third signal should not throw or cause issues - manager.Cancel(130); - manager.Cancel(130); - manager.Cancel(130); - - var exitCode = await manager.ProcessTerminationCompletionSource.Task.DefaultTimeout(); - Assert.Equal(130, exitCode); + // 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), gracefulToken); + 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] @@ -131,7 +243,7 @@ public void Dispose_AllowsSubsequentCancelWithoutException() var manager = new ConsoleCancellationManager(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); } @@ -142,7 +254,143 @@ public void Token_RemainsAccessibleAfterDispose() 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); } + + [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), so the timing this test asserts never + // happens. Surface that as a real skip rather than a silent pass. + if (Debugger.IsAttached) + { + Assert.Skip("Graceful CancelAfter timing is disabled while a debugger is attached."); + } + + 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() + { + // 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) + { + Assert.Skip("Graceful CancelAfter timing is disabled while a debugger is attached."); + } + + 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 4be3de77739..f449acbb9ca 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -2547,8 +2547,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 934a8fc5851..6f3f31ee44f 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessExecutionTests.cs @@ -6,9 +6,12 @@ using System.Text; 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; +using static Aspire.Cli.Tests.TestServices.ProcessTestHelpers; namespace Aspire.Cli.Tests.DotNet; @@ -23,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(); @@ -43,9 +41,8 @@ public async Task WaitForExitAsync_AllowsForwardersToDrainBeforeClosingStreams() }); var isFirstLine = true; - using var execution = new ProcessExecution( - process, - NullLogger.Instance, + await using var execution = CreateExecution( + scriptFile, new ProcessInvocationOptions { StandardOutputCallback = line => @@ -89,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(); @@ -108,9 +100,8 @@ public async Task WaitForExitAsync_AllowsBufferedTailOutputAfterLongIdlePeriod() releaseCallback.SetResult(); }); - using var execution = new ProcessExecution( - process, - NullLogger.Instance, + await using var execution = CreateExecution( + scriptFile, new ProcessInvocationOptions { StandardOutputCallback = line => @@ -154,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, + await using var execution = CreateExecution( + scriptFile, new ProcessInvocationOptions()); Assert.True(execution.Start()); @@ -171,9 +156,99 @@ 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] + [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 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. + var signaler = new RecordingGracefulSignaler(onSignal: pid => + { + TryKillProcess(pid); + return Task.FromResult(true); + }); + + await 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.GracefulShutdownToken.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 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. + var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var signaler = new RecordingGracefulSignaler(onSignal: _ => + { + signaled.TrySetResult(); + return Task.FromResult(true); + }); + + await 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 TestGracefulShutdownWindow(); + // Model the run path: graceful shutdown is enabled so the coordinator runs the ladder. + var signaler = new RecordingGracefulSignaler(onSignal: _ => + throw new InvalidOperationException("simulated DCP failure")); + + await 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) @@ -313,4 +388,38 @@ private static ProcessStartInfo CreateStartInfo(FileInfo scriptFile) ArgumentList = { scriptFile.FullName } }; } + + private static IProcessExecution CreateExecution( + FileInfo scriptFile, + ProcessInvocationOptions options) + { + var factory = new ProcessExecutionFactory(NullLogger.Instance); + var startInfo = CreateStartInfo(scriptFile); + + 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, + 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. + return CreateExecution( + scriptFile, + new ProcessInvocationOptions + { + IsolateConsole = isolateConsole, + GracefulShutdownSignaler = signaler, + ShutdownService = shutdownService + }); + } + } 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); + } +} 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/ProcessShutdownServiceTests.cs b/tests/Aspire.Cli.Tests/Processes/ProcessTreeGracefulShutdownServiceTests.cs similarity index 95% rename from tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs rename to tests/Aspire.Cli.Tests/Processes/ProcessTreeGracefulShutdownServiceTests.cs index d155e7e6423..0663ad8e495 100644 --- a/tests/Aspire.Cli.Tests/Processes/ProcessShutdownServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Processes/ProcessTreeGracefulShutdownServiceTests.cs @@ -15,7 +15,7 @@ namespace Aspire.Cli.Tests.Processes; -public class ProcessShutdownServiceTests(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", - ProcessShutdownService.FormatDcpProcessStartTime(startTime) + ProcessTreeGracefulShutdownService.FormatDcpProcessStartTime(startTime) ], capturedArguments); } @@ -143,7 +143,7 @@ public async Task StopAppHostAsync_CleansUpCliProcessWithoutWaitingForItAsSucces } } - private static ProcessShutdownService CreateService( + private static ProcessTreeGracefulShutdownService CreateService( TemporaryWorkspace workspace, string dcpDirectory, TestProcessExecutionFactory executionFactory, @@ -159,12 +159,12 @@ private static ProcessShutdownService CreateService( Path.Combine(workspace.WorkspaceRoot.FullName, "test.log"), identityChannel: "local"); - return new ProcessShutdownService( + 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/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/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 31b8a47d7ef..f204ded9a72 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 b6f448f27b9..8c09367453d 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -4,8 +4,10 @@ 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; @@ -17,7 +19,6 @@ using Aspire.Tests; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Projects; @@ -27,22 +28,18 @@ public class AppHostServerSessionTests(ITestOutputHelper outputHelper) [Fact] public async Task Start_DoesNotMutateCallerEnvironmentVariables() { - // Arrange var project = new RecordingAppHostServerProject(); var environmentVariables = new Dictionary { ["EXISTING_VALUE"] = "present" }; - using var profilingTelemetry = new ProfilingTelemetry(CreateConfiguration()); - var factory = CreateSessionFactory(profilingTelemetry); - // Act - await using var session = factory.Start( + await using var session = CreateSession( project, - environmentVariables, - debug: false); + CancellationToken.None, + environmentVariables: environmentVariables); + await session.StartAsync(); - // Assert Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken)); @@ -63,12 +60,13 @@ public async Task Start_PropagatesProfilingContextToServerEnvironment() (ProfilingTelemetry.EnvironmentVariables.Enabled, "true"), (ProfilingTelemetry.EnvironmentVariables.SessionId, "session-1"))); using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource); - var factory = CreateSessionFactory(profilingTelemetry); - await using var session = factory.Start( + await using var session = CreateSession( project, - environmentVariables, - debug: false); + CancellationToken.None, + environmentVariables: environmentVariables, + profilingTelemetry: profilingTelemetry); + await session.StartAsync(); Assert.Equal("present", environmentVariables["EXISTING_VALUE"]); Assert.False(environmentVariables.ContainsKey(KnownConfigNames.RemoteAppHostToken)); @@ -97,15 +95,15 @@ public async Task Start_DoesNotLeaveServerProcessActivityAmbient() (ProfilingTelemetry.EnvironmentVariables.Enabled, "true"), (ProfilingTelemetry.EnvironmentVariables.SessionId, "session-1"))); using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource); - var factory = CreateSessionFactory(profilingTelemetry); using var parentActivity = parentSource.StartActivity("aspire/cli/run"); Assert.NotNull(parentActivity); - await using var session = factory.Start( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false); + CancellationToken.None, + profilingTelemetry: profilingTelemetry); + await session.StartAsync(); Assert.Same(parentActivity, Activity.Current); @@ -114,22 +112,37 @@ public async Task Start_DoesNotLeaveServerProcessActivityAmbient() } [Fact] - public async Task GetRpcClientAsync_WhenServerExitsBeforeSocketIsAvailable_FailsWithoutWaitingForConnectionTimeout() + public void AuthenticationToken_IsAvailableBeforeStart() { var project = new RecordingAppHostServerProject(); + var session = CreateSession( + project, + CancellationToken.None); + + Assert.False(string.IsNullOrEmpty(session.AuthenticationToken)); + } - using var loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper)); - using var profilingTelemetry = new ProfilingTelemetry(CreateConfiguration()); - var factory = CreateSessionFactory(profilingTelemetry, loggerFactory.CreateLogger()); + [Fact] + public async Task GetRpcClientAsync_WhenServerExitsBeforeSocketIsAvailable_FailsWithoutWaitingForConnectionTimeout() + { + // RecordingAppHostServerProject spawns `dotnet --version`, which exits almost immediately + // with exit code 0 against a socket path ("test.sock") that never hosts an RPC server. + // The connection race in GetRpcClientAsync should observe the server-exit signal and fail + // fast rather than burning the full connection-retry timeout. + var project = new RecordingAppHostServerProject(); - await using var session = factory.Start( + await using var session = CreateSession( project, - environmentVariables: null, - debug: false); + CancellationToken.None); + 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. - 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( @@ -142,29 +155,290 @@ public async Task GetRpcClientAsync_WhenServerExitsBeforeSocketIsAvailable_Fails } [Fact] - public async Task CreateAsync_DisposesProjectWhenPrepareFails() + public void SessionState_BeforeStart_IsNull() { - var project = new FakeFailingAppHostServerProject(Directory.GetCurrentDirectory()); - var projectFactory = new TestAppHostServerProjectFactory + var project = new RecordingAppHostServerProject(); + var session = CreateSession( + project, + CancellationToken.None); + + Assert.Null(session.SocketPath); + Assert.Null(session.Output); + Assert.Null(session.ServerProcessId); + } + + [Fact] + public async Task Start_CalledTwice_Throws() + { + var project = new RecordingAppHostServerProject(); + await using var session = CreateSession( + project, + CancellationToken.None); + + await session.StartAsync(); + + await Assert.ThrowsAsync(session.StartAsync); + } + + [Fact] + public async Task Start_StopRequested_KillsProcessAndCompletesTask() + { + var project = new LongRunningAppHostServerProject(); + using var stopCts = new CancellationTokenSource(); + await using var session = CreateSession( + project, + stopCts.Token); + + await session.StartAsync(); + var completion = session.WaitForExitAsync(); + + // Process should be running before we ask the session to stop. + Assert.False(completion.IsCompleted); + 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.HasServerExited); + Assert.Equal(session.TryGetServerExitCode(), exitCode); + } + + [Fact] + 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 + // 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 TestGracefulShutdownWindow(); + + // Model the run path: graceful shutdown is enabled so the session routes through the ladder. + + var signaler = new RecordingGracefulSignaler(onSignal: pid => { - CreateAsyncCallback = (_, _) => Task.FromResult(project) - }; - using var profilingTelemetry = new ProfilingTelemetry(CreateConfiguration()); - var sessionFactory = new AppHostServerSessionFactory( - projectFactory, - NullLogger.Instance, - profilingTelemetry); - - var result = await sessionFactory.CreateAsync( - project.AppDirectoryPath, - "13.4.0", - [], - launchSettingsEnvVars: null, - debug: false, + 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 = CreateSession( + project, + stopCts.Token, + gracefulShutdownSignaler: signaler, + shutdownService: shutdownService); + + await session.StartAsync(); + var completion = session.WaitForExitAsync(); + Assert.False(completion.IsCompleted); + var serverPid = session.ServerProcessId!.Value; + + stopCts.Cancel(); + + await completion.WaitAsync(TimeSpan.FromSeconds(30)); + + 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(). + Assert.False(shutdownService.GracefulShutdownToken.IsCancellationRequested); + } + + [Fact] + 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 + // 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 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. + + 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 = CreateSession( + project, + stopCts.Token, + gracefulShutdownSignaler: signaler, + shutdownService: shutdownService); + + await session.StartAsync(); + var completion = session.WaitForExitAsync(); + 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)); + + shutdownService.Expire(); + + await completion.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.True(session.HasServerExited); + } + + [Fact] + 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 + // 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 TestGracefulShutdownWindow(); + + // Model the run path: graceful shutdown is enabled so the session routes through the ladder. + + var signaler = new RecordingGracefulSignaler(onSignal: _ => + throw new InvalidOperationException("simulated DCP failure")); + + await using var session = CreateSession( + project, + stopCts.Token, + gracefulShutdownSignaler: signaler, + shutdownService: shutdownService); + + await session.StartAsync(); + var completion = session.WaitForExitAsync(); + Assert.False(completion.IsCompleted); + + stopCts.Cancel(); + shutdownService.Expire(); + + await completion.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.True(session.HasServerExited); + Assert.Single(signaler.Pids); + } + + [Fact] + public async Task DisposeAsync_WithUnconfiguredGracefulService_ForceKillsWithoutSignaling() + { + // 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 TestGracefulShutdownWindow { IsEnabled = false }; + var signaler = new RecordingGracefulSignaler(); + + var session = CreateSession( + project, + stopCts.Token, + gracefulShutdownSignaler: signaler, + shutdownService: shutdownService); + + await session.StartAsync(); + var completion = session.WaitForExitAsync(); + Assert.False(completion.IsCompleted); + 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: + // 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 Start_ProcessExitsNaturally_CompletionReturnsExitCode() + { + var project = new RecordingAppHostServerProject(); + await using var session = CreateSession( + project, CancellationToken.None); - Assert.False(result.Success); + await session.StartAsync(); + var exitCode = await session.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task Start_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 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 never call WaitForExitAsync. + 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 = CreateSession( + project, + CancellationToken.None); + + await Assert.ThrowsAsync(session.StartAsync); + + await session.DisposeAsync(); + } } [Fact] @@ -200,6 +474,58 @@ 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; + 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 IConfiguration CreateConfiguration(params (string Key, string? Value)[] values) { return new ConfigurationBuilder() @@ -207,14 +533,30 @@ private static IConfiguration CreateConfiguration(params (string Key, string? Va .Build(); } - private static AppHostServerSessionFactory CreateSessionFactory(ProfilingTelemetry profilingTelemetry, ILogger? logger = null) - { - var projectFactory = CreateAppHostServerProjectFactory(); - return new AppHostServerSessionFactory( - projectFactory, - logger ?? NullLogger.Instance, - profilingTelemetry); - } + // 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() { @@ -233,6 +575,7 @@ private static AppHostServerProjectFactory CreateAppHostServerProjectFactory() nugetService, new TestDotNetSdkInstaller(), executionContext, + new TestProcessExecutionFactory(), NullLoggerFactory.Instance); } @@ -242,7 +585,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; @@ -254,25 +597,108 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, - bool debug = false) + bool debug = false, + AppHostServerRunControl? runControl = null) { ReceivedEnvironmentVariables = environmentVariables is null ? null : new Dictionary(environmentVariables); - var process = Process.Start(new ProcessStartInfo("dotnet", "--version") + var startInfo = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add("--version"); + + var execution = CreateServerExecution(startInfo, runControl); + execution.Start(); + + StartedExecution = execution; + return Task.FromResult(new AppHostServerRunResult( + SocketPath: "test.sock", + OutputCollector: new OutputCollector(), + Execution: execution)); + } + } + + 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 Task RunAsync( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = 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", new[] { "/c", "pause" }) + : ("sleep", new[] { "60" }); + + var startInfo = new ProcessStartInfo(fileName) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false - })!; + }; + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg); + } + + var execution = CreateServerExecution(startInfo, runControl); + execution.Start(); - StartedProcess = process; - return ("test.sock", process, new OutputCollector()); + return Task.FromResult(new AppHostServerRunResult( + SocketPath: "test.sock", + OutputCollector: new OutputCollector(), + Execution: execution)); } } + + 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 Task RunAsync( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = false, + AppHostServerRunControl? runControl = 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 f3f9d904989..e53557cb50a 100644 --- a/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs @@ -40,6 +40,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/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/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index e4813aae17d..fd842e1ef0e 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.Resources; using Aspire.Cli.Telemetry; @@ -1003,12 +1004,12 @@ await File.WriteAllTextAsync(configPath, $$""" Task.FromResult(new FakeSucceedingAppHostServerProject(appPath)) }; - var sessionFactory = new TestAppHostServerSessionFactory(); + IAppHostServerSessionFactory sessionFactory = new FakeAppHostServerSessionFactory(); var project = CreateGuestAppHostProject( interactionService: interactionService, appHostServerProjectFactory: factory, - appHostServerSessionFactory: sessionFactory); + serverSessionFactory: sessionFactory); var context = new UpdatePackagesContext { @@ -1087,12 +1088,12 @@ await File.WriteAllTextAsync(configPath, $$""" Task.FromResult(new FakeSucceedingAppHostServerProject(appPath)) }; - var sessionFactory = new TestAppHostServerSessionFactory(); + IAppHostServerSessionFactory sessionFactory = new FakeAppHostServerSessionFactory(); var project = CreateGuestAppHostProject( interactionService: interactionService, appHostServerProjectFactory: factory, - appHostServerSessionFactory: sessionFactory); + serverSessionFactory: sessionFactory); var context = new UpdatePackagesContext { @@ -1154,7 +1155,7 @@ private GuestAppHostProject CreateGuestAppHostProject( TestInteractionService? interactionService = null, string identityChannel = "local", TestAppHostServerProjectFactory? appHostServerProjectFactory = null, - IAppHostServerSessionFactory? appHostServerSessionFactory = null, + IAppHostServerSessionFactory? serverSessionFactory = null, bool identityOverridden = false) { var language = new LanguageInfo( @@ -1172,12 +1173,18 @@ private GuestAppHostProject CreateGuestAppHostProject( logFilePath: logFilePath, identityOverridden: identityOverridden); + // 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, interactionService: interactionService ?? new TestInteractionService(), backchannel: new TestAppHostBackchannel(), appHostServerProjectFactory: appHostServerProjectFactory ?? new TestAppHostServerProjectFactory(), - appHostServerSessionFactory: appHostServerSessionFactory ?? new TestAppHostServerSessionFactory(), certificateService: new TestCertificateService(), runner: new TestDotNetCliRunner(), packagingService: new TestPackagingService(), @@ -1188,7 +1195,16 @@ private GuestAppHostProject CreateGuestAppHostProject( logger: NullLogger.Instance, fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()), profilingTelemetry: _profilingTelemetry, + gracefulShutdownSignaler: new NoOpGracefulSignaler(), + shutdownService: shutdownWindow, + serverSessionFactory: serverSessionFactory ?? new FakeAppHostServerSessionFactory(), timeProvider: TimeProvider.System); } + 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 704c371ed99..6645ff57189 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,17 +726,17 @@ public async Task ProcessGuestLauncher_WritesOutputToLogFile() { using var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()); - var launcher = new ProcessGuestLauncher( - "test", - _loggerFactory.CreateLogger(), - commandResolver: cmd => cmd == "dotnet" ? "dotnet" : null, - fileLoggerProvider: fileLoggerProvider); + var launcher = CreateLauncher( + fileLoggerProvider: fileLoggerProvider, + commandResolver: cmd => cmd == "dotnet" ? "dotnet" : null); var (exitCode, output) = await launcher.LaunchAsync( "dotnet", ["--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,10 +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(), - PathLookupHelper.FindFullPathFromPath); + 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. @@ -844,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 @@ -989,8 +1000,9 @@ private sealed class RecordingLauncher : IGuestProcessLauncher string[] args, DirectoryInfo workingDirectory, IDictionary environmentVariables, - CancellationToken cancellationToken, - Func? afterLaunchAsync = null) + Func? afterLaunchAsync, + GuestLaunchOptions? options, + CancellationToken cancellationToken) { Calls.Add((command, args)); LastCommand = command; diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 324325cc1d3..e7b6bd4143e 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -232,17 +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(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace, appPath: appHostDirectory.FullName); var workingDirectory = Assert.IsType( typeof(PrebuiltAppHostServer) @@ -276,23 +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(), - 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,23 +884,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, - NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace, packagingService: packagingService, executionContext: executionContext); var sources = await server.GetNuGetSourcesAsync("daily", packageSourceOverride: null, CancellationToken.None); @@ -955,7 +914,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(), @@ -963,26 +950,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, @@ -1017,24 +996,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, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + return CreatePrebuiltAppHostServer(workspace, packagingService: packagingService, executionContext: executionContext); } private static async Task InvokeTryCreateTemporaryNuGetConfigAsync( @@ -1078,17 +1040,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(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace); var channel = server.ResolveRequestedChannel(); @@ -1100,17 +1052,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(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace); var workingDirectory = GetWorkingDirectory(server); @@ -1759,22 +1701,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(), - NullLogger.Instance); + var server = CreatePrebuiltAppHostServer( + workspace, + dotNetCliRunner: dotNetCliRunner, + packagingService: packagingService); var workingDirectory = GetWorkingDirectory(server); try @@ -1903,22 +1833,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(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer(workspace, dotNetCliRunner: dotNetCliRunner); var workingDirectory = GetWorkingDirectory(server); try @@ -2005,16 +1920,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, - NullLogger.Instance); + var server = CreatePrebuiltAppHostServer( + workspace, + appPath: projectDirectory.FullName, + layout: layout, + packagingService: packagingService, + executionContext: executionContext, + nugetService: nugetService); var workingDirectory = GetWorkingDirectory(server); try @@ -2486,17 +2398,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(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + return CreatePrebuiltAppHostServer(workspace, layout: layout, dotNetCliRunner: dotNetCliRunner); } private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory ExecutionFactory) CreatePackageReferenceServer(TemporaryWorkspace workspace) @@ -2517,16 +2419,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(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var server = CreatePrebuiltAppHostServer( + workspace, + layout: layout, + packagingService: packagingService, + nugetService: nugetService); return (server, executionFactory); } @@ -2663,16 +2560,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, - NullLogger.Instance); + var server = CreatePrebuiltAppHostServer( + workspace, + layout: layout, + executionContext: executionContext, + nugetService: nugetService); var startInfo = server.CreateStartInfo(123); diff --git a/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs new file mode 100644 index 00000000000..37054cede81 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/ProcessGuestLauncherTests.cs @@ -0,0 +1,363 @@ +// 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.Projects; +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; + +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() + { + // 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 = CreateLauncher(); + + var (command, args) = GetLongRunningCommand(); + + using var cts = new CancellationTokenSource(); + var launchTask = launcher.LaunchAsync( + command, + 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 + // 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 = CreateLauncher(); + var (command, args) = GetLongRunningCommand(); + + using var cts = new CancellationTokenSource(); + using var shutdownService = new TestGracefulShutdownWindow(); + // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. + 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(), + afterLaunchAsync: null, + options: options, + cts.Token); + + 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.GracefulShutdownToken.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 = CreateLauncher(); + var (command, args) = GetLongRunningCommand(); + + using var cts = new CancellationTokenSource(); + using var shutdownService = new TestGracefulShutdownWindow(); + // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. + 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(), + afterLaunchAsync: null, + options: options, + cts.Token); + + 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.GracefulShutdownToken.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() + { + // 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. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var launcher = CreateLauncher(); + 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 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. + + 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, + workspace.WorkspaceRoot, + new Dictionary(), + afterLaunchAsync: null, + options: options, + cts.Token); + + var descendantPid = await WaitForPidFileAsync(descendantPidFile); + + try + { + 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(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] + 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 = CreateLauncher(); + var (command, args) = GetLongRunningCommand(); + + using var cts = new CancellationTokenSource(); + using var shutdownService = new TestGracefulShutdownWindow(); + + // Model the run path: graceful shutdown is enabled so the launcher routes through the ladder. + + 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(), + afterLaunchAsync: null, + options: options, + cts.Token); + + 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 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) + { + try + { + // 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. + } + } + + await Task.Delay(50); + pidFile.Refresh(); + } + + throw new TimeoutException($"Timed out waiting for descendant pid file '{pidFile.FullName}'."); + } +} diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs index 38496aed449..65fc0010f8d 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -7,10 +7,8 @@ using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; -using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; -using System.Diagnostics; namespace Aspire.Cli.Tests.Scaffolding; @@ -87,7 +85,7 @@ public async Task ScaffoldAsync_PassesPackageSourceOverrideToPrepareAsync() { CreateAsyncCallback = (_, _) => Task.FromResult(appHostServerProject) }, - appHostServerSessionFactory: new TestAppHostServerSessionFactory(), + serverSessionFactory: new FakeAppHostServerSessionFactory(), languageDiscovery: new TestLanguageDiscovery(language), interactionService: new TestInteractionService(), logger: NullLogger.Instance, @@ -119,7 +117,7 @@ private static ScaffoldingService CreateScaffoldingService(TemporaryWorkspace wo { return new ScaffoldingService( appHostServerProjectFactory: new TestAppHostServerProjectFactory(), - appHostServerSessionFactory: new TestAppHostServerSessionFactory(), + serverSessionFactory: new FakeAppHostServerSessionFactory(), languageDiscovery: new TestLanguageDiscovery(s_testLanguage), interactionService: new TestInteractionService(), logger: NullLogger.Instance, @@ -146,11 +144,12 @@ public Task PrepareAsync( return Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); } - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public Task RunAsync( int hostPid, IReadOnlyDictionary? environmentVariables = null, string[]? additionalArgs = null, - bool debug = false) => + bool debug = false, + AppHostServerRunControl? runControl = null) => throw new NotSupportedException("Run should not be invoked when PrepareAsync fails."); } } diff --git a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs index 6cacf4e4bc0..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(processTerminationTimeout: 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/FakeAppHostServerSession.cs b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs index 1a5a1caf42e..132d3bf7bc4 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeAppHostServerSession.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.Commands.Sdk; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Utils; using Aspire.TypeSystem; @@ -16,19 +16,47 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class FakeAppHostServerSession : IAppHostServerSession { private readonly FakeAppHostRpcClient _rpcClient = new(); + private readonly TaskCompletionSource _exit = new(); - public string SocketPath => "fake-socket"; + public string AuthenticationToken { get; } = "fake-token"; - public Process ServerProcess { get; } = Process.GetCurrentProcess(); + public string? SocketPath { get; } = "fake.sock"; - public OutputCollector Output { get; } = new(); + public OutputCollector? Output { get; } = new(); - public string AuthenticationToken => "fake-token"; + 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; + } +} + +/// +/// Fake that hands back a +/// without building or launching a real AppHost server. +/// +internal sealed class FakeAppHostServerSessionFactory : IAppHostServerSessionFactory +{ + public IAppHostServerSession Create( + IAppHostServerProject appHostServerProject, + Dictionary? environmentVariables, + bool debug, + IProcessTreeGracefulShutdownSignaler? gracefulShutdownSignaler, + IGracefulShutdownWindow? shutdownService, + bool isolateConsole, + CancellationToken stopRequested) + => new FakeAppHostServerSession(); } /// diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs index 3bf4fa884d4..2cf5ced9f83 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeFailingAppHostServerProject.cs @@ -1,10 +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.Projects; -using Aspire.Cli.Utils; namespace Aspire.Cli.Tests.TestServices; @@ -31,11 +29,12 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => Task.FromResult(new AppHostServerPrepareResult(Success: false, Output: null)); - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public Task RunAsync( int hostPid, - IReadOnlyDictionary? environmentVariables = null, - string[]? additionalArgs = null, - bool debug = false) => + 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 fafd6481a41..8e6defbdabe 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakeSucceedingAppHostServerProject.cs @@ -1,17 +1,16 @@ // 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.Projects; -using Aspire.Cli.Utils; namespace Aspire.Cli.Tests.TestServices; /// /// whose returns success. -/// Used with a fake that bypasses -/// so is never called. +/// Used with a fake codegen session ( via an injected +/// ) that bypasses , +/// so is never called. /// internal sealed class FakeSucceedingAppHostServerProject(string appDirectoryPath) : IAppHostServerProject, IDisposable { @@ -27,12 +26,13 @@ public Task PrepareAsync( CancellationToken cancellationToken = default) => Task.FromResult(new AppHostServerPrepareResult(Success: true, Output: null)); - public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + public Task RunAsync( int hostPid, - IReadOnlyDictionary? environmentVariables = null, - string[]? additionalArgs = null, - bool debug = false) => - throw new NotSupportedException("Run should not be invoked when using a fake session starter."); + 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() { 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/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); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs deleted file mode 100644 index 86d2b9d6d6f..00000000000 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostServerSessionFactory.cs +++ /dev/null @@ -1,47 +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 -/// from and a from . -/// -internal sealed class TestAppHostServerSessionFactory : IAppHostServerSessionFactory -{ - public Func?, bool, IAppHostServerSession>? StartCallback { get; set; } - - 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)); - } - - public IAppHostServerSession Start( - IAppHostServerProject appHostServerProject, - Dictionary? environmentVariables, - bool debug) - { - if (StartCallback is { } callback) - { - return callback(appHostServerProject, environmentVariables, debug); - } - - return new FakeAppHostServerSession(); - } -} 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..eb1ca2dabd0 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(); + + // 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); + + return CreateExecution(startInfo.FileName, args, env, workingDirectory, options); + } } internal sealed class TestProcessExecution : IProcessExecution @@ -210,10 +233,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 3583c313c28..0edef3fbb56 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -159,7 +159,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(); services.AddSingleton(options.ScaffoldingServiceFactory); services.AddSingleton(); - services.AddSingleton(options.AppHostServerSessionFactory); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(options.LanguageServiceFactory); services.AddSingleton(); @@ -169,7 +169,19 @@ 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 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()); + // 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( + finalDrainBudget: TimeSpan.FromSeconds(5))); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(options.BundlePayloadProviderFactory); services.AddSingleton(options.BundleServiceFactory); services.AddSingleton(); @@ -221,7 +233,6 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(); services.AddSingleton(options.ApiDocsIndexServiceFactory); - services.AddSingleton(new ConsoleCancellationManager(processTerminationTimeout: Timeout.InfiniteTimeSpan)); services.AddSingleton(); services.AddTransient(); services.AddTransient(); @@ -496,12 +507,12 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser public Func ScaffoldingServiceFactory { get; set; } = (IServiceProvider serviceProvider) => { var appHostServerProjectFactory = serviceProvider.GetRequiredService(); - var appHostServerSessionFactory = 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, appHostServerSessionFactory, languageDiscovery, interactionService, logger, executionContext, serviceProvider.GetRequiredService()); + return new ScaffoldingService(appHostServerProjectFactory, serverSessionFactory, languageDiscovery, interactionService, logger, executionContext, serviceProvider.GetRequiredService()); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => @@ -653,11 +664,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(); 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; + } }