File: Processes\IsolatedProcess.Windows.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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
{
    /// <summary>
    /// Windows implementation. Opens NUL for stdin and anonymous pipes for stdout/stderr,
    /// then delegates to <see cref="WindowsProcessInterop.SpawnProcess"/> for the actual
    /// <c>CreateProcessW</c> ceremony. Console isolation and parent-exit protection are
    /// independent: when <see cref="IsolatedProcessStartInfo.KillOnParentExit"/> is set, the spawn
    /// primitive assigns the child to the kill-on-close job atomically at creation
    /// (<c>PROC_THREAD_ATTRIBUTE_JOB_LIST</c>) even if the child does not need a new console group.
    /// </summary>
    /// <remarks>
    /// When the child is not detached, this launcher consumes 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.
    /// </remarks>
    [SupportedOSPlatform("windows")]
    private static StartedProcess StartWindows(IsolatedProcessStartInfo startInfo)
    {
        if (startInfo.Detached)
        {
            return StartWindowsSuppressed(startInfo);
        }
 
        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();
 
            // Parent-exit protection is independent from console isolation. A child can need a fresh
            // console group for targeted CTRL+C without also needing the kill-on-close job, and detached
            // children intentionally omit the job so they survive the launching CLI.
            var jobHandle = startInfo.KillOnParentExit ? WindowsConsoleProcessJob.Shared.Handle : null;
            var pi = WindowsProcessInterop.SpawnProcess(
                startInfo.FileName,
                startInfo.ArgumentList,
                startInfo.WorkingDirectory,
                stdio,
                environment,
                createNewConsole: startInfo.IsolateConsole,
                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(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);
                pi.hThread = nint.Zero;
 
                // 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);
 
                // The Process wrapper owns drain orchestration, but these 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 ?? throw new InvalidOperationException("Windows process handle was not initialized.");
 
                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 the
                    // override path stays live for the whole disposal window.
                    try { capturedProcessHandle.Dispose(); } catch { }
                    return ValueTask.CompletedTask;
                }
 
                // 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 new StartedProcess(
                    process,
                    stdoutReader,
                    stderrReader,
                    ExtraDispose: ExtraDispose,
                    ExitCodeProvider: () => GetExitCode(capturedProcessHandle),
                    HasExitedProvider: () => GetHasExited(capturedProcessHandle),
                    WaitForExitProvider: ct => WaitForProcessHandleExitAsync(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;
        }
    }
 
    [SupportedOSPlatform("windows")]
    private static StartedProcess StartWindowsSuppressed(IsolatedProcessStartInfo startInfo)
    {
        using var nulHandle = WindowsProcessInterop.CreateFileW(
            "NUL",
            WindowsProcessInterop.GenericWrite,
            WindowsProcessInterop.FileShareWrite,
            nint.Zero,
            WindowsProcessInterop.OpenExisting,
            0,
            nint.Zero);
 
        if (nulHandle.IsInvalid)
        {
            throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to open NUL device");
        }
 
        if (!WindowsProcessInterop.SetHandleInformation(nulHandle, WindowsProcessInterop.HandleFlagInherit, WindowsProcessInterop.HandleFlagInherit))
        {
            throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to set NUL handle inheritance");
        }
 
        var nulRawHandle = nulHandle.DangerousGetHandle();
        var stdio = new WindowsProcessInterop.StdioHandles(
            Stdin: nint.Zero,
            Stdout: nulRawHandle,
            Stderr: nulRawHandle);
 
        var pi = WindowsProcessInterop.SpawnProcess(
            startInfo.FileName,
            startInfo.ArgumentList,
            startInfo.WorkingDirectory,
            stdio,
            startInfo.GetEnvironmentForSpawn(),
            createNewConsole: startInfo.IsolateConsole,
            jobHandle: startInfo.KillOnParentExit ? WindowsConsoleProcessJob.Shared.Handle : null);
 
        SafeProcessHandle? processHandle = new(pi.hProcess, ownsHandle: true);
        try
        {
            var process = Process.GetProcessById(pi.dwProcessId);
            WindowsProcessInterop.CloseHandle(pi.hThread);
            pi.hThread = nint.Zero;
 
            var capturedProcessHandle = processHandle ?? throw new InvalidOperationException("Windows process handle was not initialized.");
 
            ValueTask ExtraDispose()
            {
                try { capturedProcessHandle.Dispose(); } catch { }
                return ValueTask.CompletedTask;
            }
 
            return new StartedProcess(
                process,
                TextReader.Null,
                TextReader.Null,
                ExtraDispose: ExtraDispose,
                ExitCodeProvider: () => GetExitCode(capturedProcessHandle),
                HasExitedProvider: () => GetHasExited(capturedProcessHandle),
                WaitForExitProvider: ct => WaitForProcessHandleExitAsync(capturedProcessHandle, ct));
        }
        catch
        {
            try { WindowsProcessInterop.TerminateProcess(pi.hProcess, 1); } catch { }
            if (pi.hThread != nint.Zero)
            {
                try { WindowsProcessInterop.CloseHandle(pi.hThread); } catch { }
            }
            processHandle?.Dispose();
            throw;
        }
    }
 
    [SupportedOSPlatform("windows")]
    private static bool GetHasExited(SafeProcessHandle processHandle)
    {
        ThrowIfDisposed(processHandle, nameof(HasExited));
 
        var waitResult = WindowsProcessInterop.WaitForSingleObject(processHandle, 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}"),
        };
    }
 
    [SupportedOSPlatform("windows")]
    private static int GetExitCode(SafeProcessHandle processHandle)
    {
        ThrowIfDisposed(processHandle, nameof(ExitCode));
 
        // Disambiguate STILL_ACTIVE (259) from a real 259 exit code via a zero-timeout wait.
        var waitResult = WindowsProcessInterop.WaitForSingleObject(processHandle, 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(processHandle, out var exitCode))
        {
            throw new Win32Exception(Marshal.GetLastWin32Error(), "GetExitCodeProcess failed while reading IsolatedProcess.ExitCode");
        }
 
        return unchecked((int)exitCode);
    }
 
    [SupportedOSPlatform("windows")]
    private static Task WaitForProcessHandleExitAsync(SafeProcessHandle processHandle, CancellationToken cancellationToken)
    {
        ThrowIfDisposed(processHandle, nameof(WaitForExitAsync));
        return WindowsProcessInterop.WaitForExitAsync(processHandle, cancellationToken);
    }
 
    private static void ThrowIfDisposed(SafeProcessHandle processHandle, string memberName)
    {
        if (processHandle.IsClosed || processHandle.IsInvalid)
        {
            throw new InvalidOperationException($"Cannot read {memberName} after the IsolatedProcess has been disposed.");
        }
    }
}