// 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;
namespace Aspire.Cli.Processes;
/// <summary>
/// Describes process launch options that are not fully covered by <see cref="ProcessStartInfo"/>
/// on the target frameworks the CLI currently supports.
/// </summary>
internal sealed class IsolatedProcessStartInfo
{
private Dictionary<string, string?>? _environment;
/// <summary>Required. Executable path or PATH-resolved name.</summary>
public required string FileName { get; init; }
/// <summary>Required. Working directory for the child.</summary>
public required string WorkingDirectory { get; init; }
/// <summary>
/// The argument list. Mirrors <see cref="ProcessStartInfo.ArgumentList"/> — each entry
/// is one logical argument, and the launcher handles per-OS quoting.
/// </summary>
public Collection<string> ArgumentList { get; } = new();
/// <summary>
/// Environment variables for the child. Lazily pre-populated with the current process's
/// environment on first access — mirrors <see cref="ProcessStartInfo.Environment"/>.
/// Add an entry to overlay, set an entry's value to <see langword="null"/> to remove it
/// from the inherited block. Untouched entries are inherited verbatim.
/// </summary>
public IDictionary<string, string?> Environment => _environment ??= LoadParentEnvironment();
/// <summary>
/// When <see langword="true"/>, the child should be terminated when the parent exits.
/// This mirrors the .NET 11 ProcessStartInfo.KillOnParentExit shape so the custom Windows
/// job-object implementation can be replaced by the platform implementation later.
/// </summary>
public bool KillOnParentExit { get; init; }
/// <summary>
/// When <see langword="true"/> (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 <see langword="false"/> the child
/// is spawned via an ordinary redirected <see cref="Process.Start(ProcessStartInfo)"/> unless
/// <see cref="KillOnParentExit"/> or <see cref="Detached"/> requires the Windows interop launcher.
/// On Unix this flag does not affect process creation.
/// </summary>
public bool IsolateConsole { get; init; } = true;
/// <summary>
/// When <see langword="true"/>, launch the child outside the current process group/session on Unix.
/// </summary>
public bool Detached { get; init; }
/// <summary>
/// DCP executable used to create detached Unix process groups until <see cref="ProcessStartInfo"/>
/// exposes this directly.
/// </summary>
public string? DetachedUnixLauncherPath { get; set; }
/// <summary>
/// Returns true when the caller has read or modified <see cref="Environment"/>. 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.
/// </summary>
internal bool HasCustomEnvironment => _environment is not null;
/// <summary>
/// Internal accessor returning the environment dictionary in the read-only shape the
/// Windows spawn primitive consumes. Returns <see langword="null"/> when the caller never
/// touched <see cref="Environment"/>, signalling the spawn path to inherit the parent env
/// verbatim (no allocation of an env block).
/// </summary>
internal IReadOnlyDictionary<string, string?>? GetEnvironmentForSpawn()
=> _environment;
/// <summary>
/// Initializes <see cref="Environment"/> as an empty block and returns it, skipping the
/// lazy parent-environment snapshot that <see cref="Environment"/> 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 <see cref="HasCustomEnvironment"/>
/// so the spawn uses this explicit block rather than re-inheriting the parent.
/// </summary>
internal IDictionary<string, string?> UseEmptyEnvironment()
=> _environment = new Dictionary<string, string?>(ProcessEnvironment.Comparer);
private static Dictionary<string, string?> 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.
return ProcessEnvironment.LoadParentEnvironment();
}
}
/// <summary>
/// Mirrors the subset of <see cref="System.Diagnostics.Process"/> the CLI needs while compensating
/// for process launch features missing from the target framework.
/// </summary>
internal sealed partial class IsolatedProcess : IAsyncDisposable
{
private readonly IsolatedProcessStartInfo _startInfo;
private Func<ValueTask> _disposeAsync = () => ValueTask.CompletedTask;
private Func<int>? _exitCodeProvider;
private Func<bool>? _hasExitedProvider;
private Func<CancellationToken, Task>? _waitForExitProvider;
private Process? _process;
private int? _id;
private DateTimeOffset? _startTime;
private StartedProcess? _startedProcess;
private readonly TaskCompletionSource _startCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _lifecycleState = (int)LifecycleState.NotStarted;
private int _outputReadState;
private int _errorReadState;
public IsolatedProcess(IsolatedProcessStartInfo startInfo)
{
ArgumentNullException.ThrowIfNull(startInfo);
_startInfo = startInfo;
FileName = startInfo.FileName;
Arguments = startInfo.ArgumentList.ToArray();
StandardOutputClosed = Task.CompletedTask;
StandardErrorClosed = Task.CompletedTask;
}
/// <summary>Raised for each line read from stdout.</summary>
public event Action<IsolatedProcess, string>? OutputDataReceived;
/// <summary>Raised for each line read from stderr.</summary>
public event Action<IsolatedProcess, string>? ErrorDataReceived;
/// <summary>The underlying <see cref="System.Diagnostics.Process"/> for escape-hatch scenarios.</summary>
public Process Process => _process ?? throw new InvalidOperationException($"{nameof(IsolatedProcess)} has not been started.");
/// <summary>
/// The child's process id. Captured eagerly at spawn time because <see cref="Process.Id"/>
/// throws once the underlying handle is closed, which can race a fast-exiting child.
/// </summary>
public int Id => _id ?? throw new InvalidOperationException($"{nameof(IsolatedProcess)} has not been started.");
/// <summary>
/// The original executable path. <see cref="ProcessStartInfo.FileName"/> on a
/// <see cref="System.Diagnostics.Process"/> obtained from <see cref="Process.GetProcessById(int)"/>
/// is empty, so callers (telemetry, error messages) read it from here.
/// </summary>
public string FileName { get; }
/// <summary>The original argument list. Same rationale as <see cref="FileName"/>.</summary>
public IReadOnlyList<string> Arguments { get; private set; }
/// <summary>
/// Mirrors <see cref="Process.HasExited"/>. The Windows spawn path overrides this with a
/// <c>WaitForSingleObject(handle, 0)</c> check against the kept <c>SafeProcessHandle</c>
/// because <see cref="Process.HasExited"/> on a <see cref="Process.GetProcessById(int)"/>
/// instance is unreliable for processes the managed Process didn't itself start.
/// </summary>
public bool HasExited => _hasExitedProvider?.Invoke() ?? Process.HasExited;
/// <summary>
/// Mirrors <see cref="Process.ExitCode"/>. The Windows spawn path overrides this with a
/// <c>GetExitCodeProcess</c> call against the kept <c>SafeProcessHandle</c>; without the
/// override <see cref="Process.ExitCode"/> throws <see cref="InvalidOperationException"/>
/// for processes obtained via <see cref="Process.GetProcessById(int)"/> on Windows.
/// See https://github.com/dotnet/runtime/issues/45003.
/// </summary>
public int ExitCode => _exitCodeProvider?.Invoke() ?? Process.ExitCode;
public DateTimeOffset? StartTime => _startTime;
/// <summary>
/// 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.
/// </summary>
public Task StandardOutputClosed { get; private set; }
/// <summary>Stderr counterpart of <see cref="StandardOutputClosed"/>.</summary>
public Task StandardErrorClosed { get; private set; }
/// <summary>
/// Starts asynchronous stdout line reads, matching <see cref="Process.BeginOutputReadLine"/>.
/// </summary>
public void BeginOutputReadLine()
{
var startedProcess = GetStartedProcessForAsyncRead();
if (Interlocked.CompareExchange(ref _outputReadState, 1, 0) != 0)
{
throw new InvalidOperationException($"{nameof(BeginOutputReadLine)} has already been called.");
}
var outputTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
StandardOutputClosed = outputTcs.Task;
var outputPump = ProcessPump.Start(startedProcess.StandardOutput, line => OutputDataReceived?.Invoke(this, line));
_ = ForwardPumpAsync(outputPump.Completion, outputTcs);
}
/// <summary>
/// Starts asynchronous stderr line reads, matching <see cref="Process.BeginErrorReadLine"/>.
/// </summary>
public void BeginErrorReadLine()
{
var startedProcess = GetStartedProcessForAsyncRead();
if (Interlocked.CompareExchange(ref _errorReadState, 1, 0) != 0)
{
throw new InvalidOperationException($"{nameof(BeginErrorReadLine)} has already been called.");
}
var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
StandardErrorClosed = errorTcs.Task;
var errorPump = ProcessPump.Start(startedProcess.StandardError, line => ErrorDataReceived?.Invoke(this, line));
_ = ForwardPumpAsync(errorPump.Completion, errorTcs);
}
private StartedProcess GetStartedProcessForAsyncRead()
{
return (LifecycleState)Volatile.Read(ref _lifecycleState) switch
{
LifecycleState.Started => _startedProcess
?? throw new InvalidOperationException($"{nameof(IsolatedProcess)} has not been started."),
LifecycleState.Disposed => throw new ObjectDisposedException(nameof(IsolatedProcess)),
_ => throw new InvalidOperationException($"{nameof(IsolatedProcess)} has not been started.")
};
}
/// <summary>Mirrors <see cref="Process.WaitForExitAsync(CancellationToken)"/>.</summary>
/// <remarks>
/// The Windows spawn path overrides this to wait on the kept <c>CreateProcess</c> handle rather
/// than the <see cref="Process.GetProcessById(int)"/> instance, whose
/// <see cref="Process.WaitForExitAsync(CancellationToken)"/> can complete before the kernel marks
/// the process exited — which would make an immediately-following <see cref="ExitCode"/> read
/// throw. Routing the wait through the same kept handle as <see cref="ExitCode"/> /
/// <see cref="HasExited"/> keeps them consistent. See https://github.com/dotnet/runtime/issues/45003.
/// </remarks>
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
=> _waitForExitProvider?.Invoke(cancellationToken) ?? Process.WaitForExitAsync(cancellationToken);
/// <summary>
/// Mirrors <see cref="Process.Kill(bool)"/>.
/// </summary>
public void Kill(bool entireProcessTree) => Process.Kill(entireProcessTree);
/// <inheritdoc />
public 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 _disposeAsync();
}
break;
case LifecycleState.Starting:
// StartAsync publishes _disposeAsync only after the child resources are wrapped.
// Wait for that handoff so concurrent disposal cannot permanently miss them.
return new ValueTask(DisposeAfterStartCompletesAsync());
case LifecycleState.Started:
// 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.CompareExchange(ref _lifecycleState, (int)LifecycleState.Disposed, (int)LifecycleState.Started) == (int)LifecycleState.Started)
{
return _disposeAsync();
}
break;
case LifecycleState.Disposed:
return ValueTask.CompletedTask;
}
}
}
/// <summary>
/// Creates and starts an <see cref="IsolatedProcess"/> from <paramref name="startInfo"/>.
/// </summary>
/// <param name="startInfo">Process launch parameters.</param>
/// <param name="cancellationToken">Cancellation token for asynchronous launch work.</param>
public static async Task<IsolatedProcess> StartAsync(
IsolatedProcessStartInfo startInfo,
CancellationToken cancellationToken)
{
var process = new IsolatedProcess(startInfo);
await process.StartAsync(cancellationToken).ConfigureAwait(false);
return process;
}
/// <summary>
/// Mirrors <see cref="Process.Start()"/> plus the launch knobs that require platform shims on the current target framework.
/// </summary>
/// <param name="cancellationToken">Cancellation token for asynchronous launch work.</param>
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(IsolatedProcess))
: new InvalidOperationException($"{nameof(IsolatedProcess)} has already been started.");
}
try
{
StartedProcess startedProcess;
if (_startInfo.Detached && !OperatingSystem.IsWindows())
{
startedProcess = await StartDetachedUnixAsync(_startInfo, cancellationToken).ConfigureAwait(false);
}
// Windows parent-exit protection requires the suspended-create / assign / resume ceremony in
// StartWindows. Route protected helpers through that path even when they do not need a
// graceful CTRL+C console group; otherwise use the ordinary redirected Process.Start shape.
else if (OperatingSystem.IsWindows() && (_startInfo.IsolateConsole || _startInfo.KillOnParentExit || _startInfo.Detached))
{
startedProcess = StartWindows(_startInfo);
}
else
{
startedProcess = StartRedirected(_startInfo);
}
InitializeStartedProcess(startedProcess);
Volatile.Write(ref _lifecycleState, (int)LifecycleState.Started);
return true;
}
catch
{
Volatile.Write(ref _lifecycleState, (int)LifecycleState.Disposed);
await _disposeAsync().ConfigureAwait(false);
throw;
}
finally
{
_startCompletion.TrySetResult();
}
}
private async Task DisposeAfterStartCompletesAsync()
{
await _startCompletion.Task.ConfigureAwait(false);
await DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// Cross-platform redirected spawn: a thin <see cref="Process.Start(ProcessStartInfo)"/> wrapper.
/// </summary>
/// <param name="startInfo">Process launch parameters.</param>
private static StartedProcess StartRedirected(IsolatedProcessStartInfo startInfo)
{
var psi = new ProcessStartInfo
{
FileName = startInfo.FileName,
WorkingDirectory = startInfo.WorkingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
// Guest processes never consume input from the CLI. Redirect and close stdin so tools
// such as package-manager lifecycle scripts observe EOF instead of inheriting the TTY
// and blocking indefinitely. See https://github.com/microsoft/aspire/issues/16791.
RedirectStandardInput = true,
// Pin encodings so process output decoding is stable 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.
ProcessEnvironment.ApplyTo(psi, startInfo.GetEnvironmentForSpawn());
var process = Process.Start(psi)
?? throw new InvalidOperationException($"Failed to start child process: {startInfo.FileName}");
process.StandardInput.Close();
return new StartedProcess(
process,
process.StandardOutput,
process.StandardError,
ExtraDispose: null);
}
/// <summary>
/// Applies the already-started process and platform-specific handle/readers to this wrapper.
/// </summary>
private void InitializeStartedProcess(StartedProcess startedProcess)
{
_process = startedProcess.Process;
_id = startedProcess.ProcessId ?? startedProcess.Process.Id;
Arguments = _startInfo.ArgumentList.ToArray();
_exitCodeProvider = startedProcess.ExitCodeProvider;
_hasExitedProvider = startedProcess.HasExitedProvider;
_waitForExitProvider = startedProcess.WaitForExitProvider;
_startTime = startedProcess.UseProvidedStartTime ? startedProcess.StartTime : GetStartTime(startedProcess.Process);
_startedProcess = startedProcess;
async ValueTask DisposeAsync()
{
if (startedProcess.ExtraDispose is not null)
{
try { await startedProcess.ExtraDispose().ConfigureAwait(false); } catch { }
}
try
{
startedProcess.Process.Dispose();
}
catch
{
// Best effort.
}
}
_disposeAsync = DisposeAsync;
}
private sealed record StartedProcess(
Process Process,
TextReader StandardOutput,
TextReader StandardError,
Func<ValueTask>? ExtraDispose,
Func<int>? ExitCodeProvider = null,
Func<bool>? HasExitedProvider = null,
Func<CancellationToken, Task>? WaitForExitProvider = null,
DateTimeOffset? StartTime = null,
bool UseProvidedStartTime = false,
int? ProcessId = null);
private enum LifecycleState
{
NotStarted,
Starting,
Started,
Disposed
}
private static DateTimeOffset? GetStartTime(Process process)
{
try
{
return ProcessStartTimeHelper.TryGetProcessStartTime(process.Id) ?? new DateTimeOffset(process.StartTime);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
return null;
}
}
/// <summary>
/// Bridges a <see cref="ProcessPump.Completion"/> task into a
/// <see cref="TaskCompletionSource"/> the wrapper exposes. Preserves fault/cancel
/// semantics so consumers of <see cref="StandardOutputClosed"/> see the same outcome
/// the pump produced.
/// </summary>
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);
}
}
/// <summary>
/// Gives <see cref="IsolatedProcess"/> a Process-like line-output surface when platform
/// launch shims own the stdio pipes.
/// </summary>
/// <remarks>
/// 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 <see cref="Completion"/> task after the pump finishes draining.
/// </remarks>
private sealed class ProcessPump
{
private ProcessPump(Task completion)
{
Completion = completion;
}
/// <summary>Completes (or faults) when the underlying reader hits EOF.</summary>
public Task Completion { get; }
/// <summary>
/// Starts a pump that reads lines from <paramref name="reader"/> and invokes
/// <paramref name="onLine"/> for each non-null line. The pump runs on a background
/// task and stops when the reader returns null (EOF) or throws.
/// </summary>
public static ProcessPump Start(TextReader reader, Action<string> onLine)
{
var completion = Task.Run(() => RunAsync(reader, onLine));
return new ProcessPump(completion);
}
private static async Task RunAsync(TextReader reader, Action<string> 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();
}
}
}
}