File: Processes\WindowsProcessInterop.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.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using Microsoft.Win32.SafeHandles;
 
namespace Aspire.Cli.Processes;
 
/// <summary>
/// Helpers and Win32 interop declarations shared by Windows process launchers that hand off
/// raw command lines, environment blocks, and STARTUPINFO structures to <c>CreateProcessW</c>.
/// Detached and console-isolated <see cref="IsolatedProcess"/> launches 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 callers from silently drifting
/// apart on something like "this one accidentally lacks
/// <c>CREATE_UNICODE_ENVIRONMENT</c>" or "these struct layouts diverged after a Win32 SDK update".
/// </summary>
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;
 
    /// <summary>
    /// Base creation flags shared by Windows launchers that need explicit handle inheritance
    /// and/or job assignment: 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 is added independently by callers that need console isolation.
    /// </summary>
    public const uint ExplicitHandleCreationFlags =
        CreateUnicodeEnvironment | ExtendedStartupInfoPresent;
 
    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;
 
    // PROC_THREAD_ATTRIBUTE_JOB_LIST — assigns the child to one or more job objects atomically at
    // CreateProcess time (Windows 8 / Server 2012 and later). 
    // See https://learn.microsoft.com/windows/win32/procthread/process-creation-flags and
    // https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute
    public static readonly nint ProcThreadAttributeJobList = (nint)0x0002000D;
 
    // === Structs ===
 
    /// <summary>
    /// 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.
    /// </summary>
    [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;
    }
 
    /// <summary>
    /// PROCESS_INFORMATION — see
    /// https://learn.microsoft.com/windows/win32/api/processthreadsapi/ns-processthreadsapi-process_information.
    /// </summary>
    [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);
 
    // 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);
 
    // IsProcessInJob — reports whether a process is a member of the specified job. Used to verify
    // that a child spawned with PROC_THREAD_ATTRIBUTE_JOB_LIST is associated with the kill-on-close
    // job the instant it is created. See
    // https://learn.microsoft.com/windows/win32/api/jobapi/nf-jobapi-isprocessinjob
    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static partial bool IsProcessInJob(nint processHandle, SafeFileHandle jobHandle, [MarshalAs(UnmanagedType.Bool)] out bool result);
 
    // 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 ===
 
    // https://learn.microsoft.com/windows/win32/api/winnt/ns-winnt-jobobject_basic_limit_information
    public const uint JobObjectLimitBreakawayOk = 0x00000800;
    public const uint JobObjectLimitKillOnJobClose = 0x00002000;
 
    /// <summary>
    /// JOBOBJECTINFOCLASS values consumed by SetInformationJobObject. We currently use only
    /// <see cref="ExtendedLimitInformation"/>; the rest are intentionally omitted until needed.
    /// See https://learn.microsoft.com/windows/win32/api/winnt/ne-winnt-jobobjectinfoclass.
    /// </summary>
    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;
    }
 
    /// <summary>
    /// Per-spawn stdio handle layout — the three handles that will be inherited by the child
    /// via <c>PROC_THREAD_ATTRIBUTE_HANDLE_LIST</c>. A handle of <see langword="nint.Zero" />
    /// 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.
    /// </summary>
    public readonly record struct StdioHandles(nint Stdin, nint Stdout, nint Stderr);
 
    /// <summary>
    /// Spawns a child process with exactly the stdio handles in <paramref name="stdio"/> made
    /// inheritable through <c>PROC_THREAD_ATTRIBUTE_HANDLE_LIST</c>. Console isolation
    /// (<c>CREATE_NEW_CONSOLE</c>) and parent-exit job assignment are independent options so
    /// short-lived helper processes can get the job safety net without creating a new console.
    /// </summary>
    /// <param name="fileName">Full path to the executable to launch.</param>
    /// <param name="arguments">Arguments to pass to the child. Quoted via <see cref="BuildCommandLine"/>.</param>
    /// <param name="workingDirectory">Working directory for the child.</param>
    /// <param name="stdio">
    /// 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
    /// <c>Stdin</c> as <c>nint.Zero</c>).
    /// </param>
    /// <param name="environment">
    /// Optional complete environment for the child. When <see langword="null"/>, the parent's
    /// environment is inherited verbatim (no env block allocated). When non-null, the dictionary
    /// supplies the entire child env block — entries with <see langword="null"/> values are
    /// omitted (matches <see cref="System.Diagnostics.ProcessStartInfo.Environment"/> 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.
    /// </param>
    /// <param name="createNewConsole">
    /// When <see langword="true"/>, launches the child in a new hidden console group. Use this for
    /// AppHost-style processes that need targeted CTRL+C delivery or detached children that must not
    /// share the parent console. Leave it <see langword="false"/> for ordinary background helpers.
    /// </param>
    /// <param name="jobHandle">
    /// Optional parent-lifetime job object. When supplied, the child is assigned to the job
    /// atomically at <c>CreateProcess</c> time via <c>PROC_THREAD_ATTRIBUTE_JOB_LIST</c>, so there is
    /// no window in which the child exists outside the job — even a launcher that dies (or is
    /// SIGKILL'd) the instant after <c>CreateProcess</c> returns cannot leak it, and no child can spawn
    /// a grandchild that escapes the job before the safety net is in place.
    /// Detached launches pass <see langword="null" /> because detached children must outlive
    /// the CLI; parent-owned launches pass the singleton CLI job so children die with a parent crash.
    /// </param>
    /// <returns>
    /// The raw <see cref="PROCESS_INFORMATION"/> from <c>CreateProcessW</c>. The caller owns
    /// <c>hProcess</c> and <c>hThread</c> and must close both once it has extracted whatever
    /// it needs (e.g. obtained a managed <see cref="System.Diagnostics.Process"/> handle via
    /// <see cref="System.Diagnostics.Process.GetProcessById(int)"/>).
    /// </returns>
    [SupportedOSPlatform("windows")]
    public static PROCESS_INFORMATION SpawnProcess(
        string fileName,
        IReadOnlyList<string> arguments,
        string workingDirectory,
        StdioHandles stdio,
        IReadOnlyDictionary<string, string?>? environment,
        bool createNewConsole,
        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. Suppressed-stdio launches legitimately point 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<nint>(3);
        void AddIfUnique(nint handle)
        {
            if (handle != nint.Zero && !inheritable.Contains(handle))
            {
                inheritable.Add(handle);
            }
        }
        AddIfUnique(stdio.Stdin);
        AddIfUnique(stdio.Stdout);
        AddIfUnique(stdio.Stderr);
 
        // The child always needs the stdio handle whitelist; when a parent-lifetime job is supplied it
        // ALSO needs PROC_THREAD_ATTRIBUTE_JOB_LIST so the child is assigned to the job atomically at creation (below).
        var attributeCount = jobHandle is not null ? 2 : 1;
 
        var attrListSize = nint.Zero;
        InitializeProcThreadAttributeList(nint.Zero, attributeCount, 0, ref attrListSize);
 
        var attrList = Marshal.AllocHGlobal(attrListSize);
        try
        {
            if (!InitializeProcThreadAttributeList(attrList, attributeCount, 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);
                var pinnedJobHandles = default(GCHandle);
                var jobHandleRefAdded = false;
 
                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");
                    }
 
                    if (jobHandle is not null)
                    {
                        jobHandle.DangerousAddRef(ref jobHandleRefAdded);
                        var jobHandles = new[] { jobHandle.DangerousGetHandle() };
                        pinnedJobHandles = GCHandle.Alloc(jobHandles, GCHandleType.Pinned);
 
                        if (!UpdateProcThreadAttribute(
                            attrList,
                            0,
                            ProcThreadAttributeJobList,
                            pinnedJobHandles.AddrOfPinnedObject(),
                            (nint)(nint.Size * jobHandles.Length),
                            nint.Zero,
                            nint.Zero))
                        {
                            throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Failed to add job object to process thread attribute list");
                        }
                    }
 
                    var si = new STARTUPINFOEX
                    {
                        cb = Marshal.SizeOf<STARTUPINFOEX>(),
                        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);
 
                    var flags = ExplicitHandleCreationFlags;
                    if (createNewConsole)
                    {
                        flags |= CreateNewConsole;
                    }
 
                    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}");
                        }
 
                        return pi;
                    }
                    finally
                    {
                        if (envBlockHandle != nint.Zero)
                        {
                            Marshal.FreeHGlobal(envBlockHandle);
                        }
                    }
                }
                finally
                {
                    if (pinnedJobHandles.IsAllocated)
                    {
                        pinnedJobHandles.Free();
                    }
 
                    if (jobHandleRefAdded)
                    {
                        jobHandle!.DangerousRelease();
                    }
 
                    pinnedHandles.Free();
                }
            }
            finally
            {
                DeleteProcThreadAttributeList(attrList);
            }
        }
        finally
        {
            Marshal.FreeHGlobal(attrList);
        }
    }
 
    /// <summary>
    /// Builds a Windows command line string with correct quoting rules.
    /// Adapted from dotnet/runtime PasteArguments.AppendArgument.
    /// </summary>
    public static StringBuilder BuildCommandLine(string fileName, IReadOnlyList<string> arguments)
    {
        var sb = new StringBuilder();
 
        sb.Append('"').Append(fileName).Append('"');
 
        foreach (var arg in arguments)
        {
            sb.Append(' ');
            AppendArgument(sb, arg);
        }
 
        return sb;
    }
 
    /// <summary>
    /// Appends a correctly-quoted argument to the command line.
    /// Copied from dotnet/runtime src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs
    /// </summary>
    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('"');
    }
 
    /// <summary>
    /// 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.
    /// </summary>
    /// <param name="environment">
    /// The complete environment for the child. Entries with a <see langword="null"/>
    /// value are omitted (mirrors <see cref="System.Diagnostics.ProcessStartInfo.Environment"/> semantics).
    /// </param>
    [SupportedOSPlatform("windows")]
    public static nint BuildEnvironmentBlock(IReadOnlyDictionary<string, string?> environment)
    {
        var envVars = new SortedDictionary<string, string>(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;
    }
 
    /// <summary>
    /// Asynchronously waits for the process owned by <paramref name="processHandle"/> to exit by
    /// registering a thread-pool wait on the kernel process object.
    /// </summary>
    /// <remarks>
    /// <see cref="System.Diagnostics.Process.WaitForExitAsync(CancellationToken)"/> on a
    /// <see cref="System.Diagnostics.Process.GetProcessById(int)"/> 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 <c>GetExitCodeProcess</c> read still
    /// reports <c>STILL_ACTIVE</c>. This waits on the kept <c>CreateProcess</c> handle — the same
    /// authoritative source <see cref="GetExitCodeProcess"/> and the zero-timeout
    /// <see cref="WaitForSingleObject"/> read from — so when this completes an ExitCode read is
    /// guaranteed to succeed.
    /// </remarks>
    [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();
            }
        }
    }
}