// 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.Bundles;
using Aspire.Cli.Layout;
using Aspire.Cli.Processes;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.DotNet;
/// <summary>
/// The single <see cref="IProcessExecution"/> implementation. Wraps an <see cref="IsolatedProcess"/>
/// for isolated-console, kill-on-parent-exit, and ordinary redirected subprocesses. The child is
/// spawned lazily on <see cref="IProcessExecution.StartAsync"/> so callers that build an execution but never start it (e.g.
/// the extension-host launch path, which reads <see cref="Arguments"/> /
/// <see cref="EnvironmentVariables"/> and returns before starting) don't orphan a process.
/// </summary>
internal sealed class ProcessExecution : IProcessExecution
{
private static readonly TimeSpan s_drainIdleTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan s_drainPollInterval = TimeSpan.FromMilliseconds(100);
private readonly IsolatedProcessStartInfo _startInfo;
private readonly string _fileName;
private readonly IReadOnlyList<string> _arguments;
private readonly IReadOnlyDictionary<string, string?> _environment;
private readonly ILogger _logger;
private readonly ProcessInvocationOptions _options;
private readonly IEnvironment _hostEnvironment;
private readonly ILayoutDiscovery? _layoutDiscovery;
private readonly IBundleService? _bundleService;
private readonly CliExecutionContext? _executionContext;
private IsolatedProcess? _process;
// The detached Unix launcher is DCP from the selected bundle version. Keep that lease alive
// after DCP reports the child PID so cleanup cannot remove the bundle before the child CLI
// reaches Program.Main and acquires its own lease from the handoff environment.
private IDisposable? _detachedUnixLauncherLease;
private long _lastActivityTimestamp = Stopwatch.GetTimestamp();
private readonly TaskCompletionSource _startCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _lifecycleState = (int)LifecycleState.NotStarted;
internal ProcessExecution(
IsolatedProcessStartInfo startInfo,
string fileName,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string?> environment,
ILogger logger,
ProcessInvocationOptions options,
IEnvironment hostEnvironment,
ILayoutDiscovery? layoutDiscovery = null,
IBundleService? bundleService = null,
CliExecutionContext? executionContext = null)
{
_startInfo = startInfo;
_fileName = fileName;
_arguments = arguments;
_environment = environment;
_logger = logger;
_options = options;
_hostEnvironment = hostEnvironment;
_layoutDiscovery = layoutDiscovery;
_bundleService = bundleService;
_executionContext = executionContext;
}
/// <inheritdoc />
public string FileName => _fileName;
/// <inheritdoc />
public IReadOnlyList<string> Arguments => _arguments;
/// <inheritdoc />
public IReadOnlyDictionary<string, string?> EnvironmentVariables => _environment;
/// <inheritdoc />
public int ProcessId => Process.Id;
/// <inheritdoc />
public bool HasExited => Process.HasExited;
/// <inheritdoc />
public int ExitCode => Process.ExitCode;
/// <inheritdoc />
public DateTimeOffset? StartTime => Process.StartTime;
private IsolatedProcess Process =>
Volatile.Read(ref _process)
?? throw new InvalidOperationException($"{nameof(ProcessExecution)} has not been started. Call {nameof(StartAsync)} first.");
/// <inheritdoc />
public async Task<bool> StartAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var previousState = (LifecycleState)Interlocked.CompareExchange(ref _lifecycleState, (int)LifecycleState.Starting, (int)LifecycleState.NotStarted);
if (previousState != LifecycleState.NotStarted)
{
throw previousState == LifecycleState.Disposed
? new ObjectDisposedException(nameof(ProcessExecution))
: new InvalidOperationException($"{nameof(ProcessExecution)} has already been started.");
}
IsolatedProcess? process = null;
IDisposable? detachedUnixLauncherLease = null;
try
{
detachedUnixLauncherLease = await ResolveDetachedUnixLauncherAsync(cancellationToken).ConfigureAwait(false);
// Match the real Process API ordering: start the child, publish the process object so
// ProcessId is valid for callbacks, then begin asynchronous stdout/stderr reads.
// IsolatedProcess.StartAsync throws on spawn failure, so a successful return always
// means the child is running — there is no false-on-failure case to model.
// Process.Start() returning false is not applicable when UseShellExecute=false.
process = new IsolatedProcess(_startInfo);
process.OutputDataReceived += OnOutputLine;
process.ErrorDataReceived += OnErrorLine;
await process.StartAsync(cancellationToken).ConfigureAwait(false);
Volatile.Write(ref _process, process);
process.BeginOutputReadLine();
process.BeginErrorReadLine();
_detachedUnixLauncherLease = detachedUnixLauncherLease;
detachedUnixLauncherLease = null;
Volatile.Write(ref _lifecycleState, (int)LifecycleState.Started);
}
catch
{
Volatile.Write(ref _lifecycleState, (int)LifecycleState.Disposed);
if (ReferenceEquals(Volatile.Read(ref _process), process))
{
Volatile.Write(ref _process, null);
}
if (process is not null)
{
await process.DisposeAsync().ConfigureAwait(false);
}
throw;
}
finally
{
detachedUnixLauncherLease?.Dispose();
_startCompletion.TrySetResult();
}
_logger.LogDebug("{FileName}({ProcessId}) started in {WorkingDirectory}", _fileName, process.Id, _startInfo.WorkingDirectory);
return true;
}
private async Task<IDisposable?> ResolveDetachedUnixLauncherAsync(CancellationToken cancellationToken)
{
if (!_options.Detached || OperatingSystem.IsWindows() || _startInfo.DetachedUnixLauncherPath is not null)
{
return null;
}
if (_layoutDiscovery is null || _bundleService is null || _executionContext is null)
{
throw new InvalidOperationException("Detached Unix process launch requires Aspire layout services.");
}
var dcpExecutable = await DcpExecutableResolver.TryGetDcpExecutableAsync(
_layoutDiscovery,
_bundleService,
_executionContext,
"dcp-fork-process",
cancellationToken).ConfigureAwait(false);
if (dcpExecutable is null)
{
throw new InvalidOperationException("Could not find DCP executable in the Aspire layout.");
}
try
{
if (dcpExecutable.LayoutLease is not null)
{
var environment = _startInfo.Environment
.Where(static kvp => kvp.Value is not null)
.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value!, ProcessEnvironment.Comparer);
dcpExecutable.LayoutLease.AddEnvironment(environment);
foreach (var (key, value) in environment)
{
_startInfo.Environment[key] = value;
}
}
_startInfo.DetachedUnixLauncherPath = dcpExecutable.ExecutablePath;
_logger.LogDebug("Launching detached child process through DCP fork-process: {DcpPath}", dcpExecutable.ExecutablePath);
return dcpExecutable;
}
catch
{
dcpExecutable.Dispose();
throw;
}
}
/// <inheritdoc />
public async Task<int> WaitForExitAsync(CancellationToken cancellationToken)
{
var process = Process;
_logger.LogDebug("{FileName}({ProcessId}) waiting for exit", _fileName, process.Id);
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
_logger.LogDebug("{FileName}({ProcessId}) wait was canceled, stopping it", _fileName, process.Id);
await ShutdownOnCancelAsync(process.Process).ConfigureAwait(false);
// The child has now been signalled/killed by the coordinator. Drain trailing stdout/stderr
// before propagating the cancellation so callers that observe output — or that swallow the
// 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);
DisposeDetachedUnixLauncherLease();
throw;
}
_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);
DisposeDetachedUnixLauncherLease();
return process.ExitCode;
}
/// <summary>
/// The single decision point this execution routes through when its child must be torn down on
/// cancellation. Both branches run the same <see cref="ShutdownLadderAsync"/>: with a signaler for
/// the graceful ladder (the <c>aspire run</c> path) or without one for the best-effort force-kill
/// fallback (non-Run callers).
/// </summary>
/// <remarks>
/// The graceful-vs-force decision is command-level and all-or-nothing: it keys off
/// <see cref="IGracefulShutdownWindow.IsEnabled"/> (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 <see cref="IGracefulShutdownWindow.BeginGracefulWindow"/>, 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.
/// </remarks>
private Task ShutdownOnCancelAsync(Process process)
{
var signaler = _options.GracefulShutdownSignaler;
var gracefulShutdownWindow = _options.ShutdownService;
if (signaler is not null && gracefulShutdownWindow is { IsEnabled: true })
{
// Start the central clock so the ladder's wait is bounded even when teardown was triggered
// by disposal (e.g. normal aspire run completion) rather than a user signal. Idempotent —
// if a user Ctrl+C already armed the window this is a no-op.
gracefulShutdownWindow.BeginGracefulWindow();
return ShutdownLadderAsync(process, signaler, gracefulShutdownWindow.GracefulShutdownToken);
}
return ShutdownLadderAsync(process, signaler: null, gracefulToken: CancellationToken.None);
}
/// <summary>
/// Shuts down the child, choosing the graceful ladder or the force-kill fallback based on whether
/// <paramref name="signaler"/> is supplied. Graceful mode (signaler present — <c>aspire run</c>)
/// 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.
/// </summary>
/// <remarks>
/// Whoever triggers shutdown (<see cref="ConsoleCancellationManager.Cancel"/>) owns the central
/// clock; this consumes <paramref name="gracefulToken"/> but never owns timing.
/// </remarks>
private async Task ShutdownLadderAsync(Process process, IProcessTreeGracefulShutdownSignaler? signaler, CancellationToken gracefulToken)
{
if (signaler is null)
{
// Force mode: no graceful budget. Best-effort courtesy SIGTERM (Unix) then hard-kill.
ForceKillChild(process);
return;
}
// Phase 1: fire-and-forget the graceful signal so its 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
{
// Phase 2: wait for exit with the FULL graceful budget. When the apphost exits,
// the signaler task observes the same exit and completes shortly after. Whoever
// triggered shutdown (CCM.Cancel) owns the timing of `gracefulToken`.
try
{
await process.WaitForExitAsync(gracefulToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Graceful budget expired; fall through to kill.
}
if (process.HasExited)
{
return;
}
// Phase 3: ALWAYS tree-kill on escalation, regardless of OS. Even when the graceful
// signal returned cleanly, descendants may still be alive — e.g. on Windows tsx wraps
// node and swallows Ctrl+C/Ctrl+Break, leaving the child node and any further
// descendants running after the tsx shell exits. Skipping tree-kill would orphan them.
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
// Process exited between HasExited check and Kill — nothing to do.
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to kill {FileName} (pid {Pid}).", _fileName, 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));
}
}
finally
{
// 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);
}
}
}
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
{
if (process.HasExited)
{
_logger.LogDebug("{FileName} process {ProcessId} already exited.", _fileName, process.Id);
return;
}
if (!_hostEnvironment.IsWindows())
{
ProcessSignaler.RequestGracefulShutdown(process.Id, expectedStartTime: null, _logger);
if (process.HasExited)
{
return;
}
}
_logger.LogDebug(
"Sending kill to {FileName} process {ProcessId} (entireProcessTree={EntireProcessTree}).",
_fileName,
process.Id,
entireProcessTree);
process.Kill(entireProcessTree);
}
catch (InvalidOperationException ex)
{
_logger.LogDebug(
ex,
"{FileName} process exited before termination could complete (entireProcessTree={EntireProcessTree}).",
_fileName,
entireProcessTree);
}
catch (Exception ex)
{
_logger.LogDebug(
ex,
"Failed to terminate {FileName} process (entireProcessTree={EntireProcessTree}).",
_fileName,
entireProcessTree);
}
}
private async Task InvokeSignalerAsync(IProcessTreeGracefulShutdownSignaler signaler, int pid, CancellationToken gracefulToken)
{
try
{
// startTime is 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);
}
}
private static int GetSafePid(Process process)
{
try
{
return process.Id;
}
catch (Exception)
{
return -1;
}
}
/// <inheritdoc />
public void Kill(bool entireProcessTree) => Process.Kill(entireProcessTree);
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
while (true)
{
switch ((LifecycleState)Volatile.Read(ref _lifecycleState))
{
case LifecycleState.NotStarted:
if (Interlocked.CompareExchange(ref _lifecycleState, (int)LifecycleState.Disposed, (int)LifecycleState.NotStarted) == (int)LifecycleState.NotStarted)
{
return;
}
break;
case LifecycleState.Starting:
// StartAsync owns the DCP resolution and process publication handoff. Wait until
// it either publishes the process/lease or cleans up so disposal cannot miss them.
await _startCompletion.Task.ConfigureAwait(false);
break;
case LifecycleState.Started:
if (Interlocked.CompareExchange(ref _lifecycleState, (int)LifecycleState.Disposed, (int)LifecycleState.Started) == (int)LifecycleState.Started)
{
await DisposeStartedProcessAsync().ConfigureAwait(false);
return;
}
break;
case LifecycleState.Disposed:
return;
}
}
}
private async Task DisposeStartedProcessAsync()
{
// 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 releases its Process-like resources but does
// NOT terminate the process — so without this kill the child would be orphaned. Owning
// "kill if still alive on dispose" here keeps that responsibility off every consumer.
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch
{
// Best effort: the process may have exited between the check and the kill, or be
// unkillable. The drain/handle release below still runs.
}
await DrainOutputAsync(process, CancellationToken.None).ConfigureAwait(false);
try
{
await process.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "{FileName} IsolatedProcess dispose threw", _fileName);
}
finally
{
DisposeDetachedUnixLauncherLease();
}
}
private void DisposeDetachedUnixLauncherLease()
{
Interlocked.Exchange(ref _detachedUnixLauncherLease, null)?.Dispose();
}
private enum LifecycleState
{
NotStarted,
Starting,
Started,
Disposed
}
private void OnOutputLine(IsolatedProcess sender, string line)
{
// RecordActivity brackets the callback 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 DrainOutputAsync(IsolatedProcess process, CancellationToken cancellationToken)
{
var drained = Task.WhenAll(process.StandardOutputClosed, process.StandardErrorClosed);
while (true)
{
if (drained.IsCompleted)
{
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;
}
// 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 leave teardown to DisposeAsync so this method never closes streams while callbacks
// may still be processing data.
if (Stopwatch.GetElapsedTime(Interlocked.Read(ref _lastActivityTimestamp)) >= s_drainIdleTimeout)
{
_logger.LogWarning("{FileName}({ProcessId}) stdout/stderr pumps did not drain within idle timeout after exit", _fileName, process.Id);
return;
}
try
{
await Task.Delay(s_drainPollInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
}
}
private void RecordActivity() => Interlocked.Exchange(ref _lastActivityTimestamp, Stopwatch.GetTimestamp());
}