File: BrowserLogsPipeBrowserProcessLauncher.Windows.cs
Web Access
Project: src\src\Aspire.Hosting.Browsers\Aspire.Hosting.Browsers.csproj (Aspire.Hosting.Browsers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only
 
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
 
namespace Aspire.Hosting;
 
internal static partial class BrowserLogsPipeBrowserProcessLauncher
{
    private static BrowserLogsPipeBrowserProcess StartWindows(string executablePath, IReadOnlyList<string> browserArguments)
    {
        // Parent writes CDP commands to appToBrowser; the browser reads the client end. Parent reads responses/events
        // from browserToApp; the browser writes the client end. AnonymousPipeServerStream makes the client handles
        // inheritable, but CreateWindowsProcess below restricts inheritance to only those two handles.
        var appToBrowser = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable);
        var browserToApp = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
        SafeWaitHandle? jobHandle = null;
        SafeWaitHandle? processHandle = null;
        try
        {
            var browserReadHandle = appToBrowser.GetClientHandleAsString();
            var browserWriteHandle = browserToApp.GetClientHandleAsString();
            var arguments = CreatePipeArguments(browserArguments);
            // Chromium expects the child read handle first and the child write handle second. These are raw Win32 handle
            // values, not file descriptor numbers, and Chromium opens them before starting DevTools pipe IO.
            arguments.Add($"--remote-debugging-io-pipes={browserReadHandle},{browserWriteHandle}");
 
            var inheritedHandles = new[]
            {
                new IntPtr(long.Parse(browserReadHandle, CultureInfo.InvariantCulture)),
                new IntPtr(long.Parse(browserWriteHandle, CultureInfo.InvariantCulture))
            };
 
            var processInfo = CreateWindowsProcess(executablePath, arguments, inheritedHandles);
            processHandle = new SafeWaitHandle(processInfo.ProcessHandle, ownsHandle: true);
            CloseWindowsHandle(processInfo.ThreadHandle);
            // A Windows job with KILL_ON_JOB_CLOSE gives pipe-created browsers the same "owned by the AppHost"
            // behavior even if the AppHost process exits before managed cleanup can run. Assigning can fail when a
            // parent job forbids nested jobs, so this remains best-effort and normal DisposeAsync cleanup is still
            // the primary path.
            jobHandle = TryCreateKillOnCloseJob(processHandle);
 
            appToBrowser.DisposeLocalCopyOfClientHandle();
            browserToApp.DisposeLocalCopyOfClientHandle();
 
            var processTask = WaitForWindowsProcessAsync(processHandle);
            return new BrowserLogsPipeBrowserProcess(
                processInfo.ProcessId,
                browserToApp,
                appToBrowser,
                processTask,
                new WindowsProcessLifetime(processInfo.ProcessId, processHandle, jobHandle, processTask));
        }
        catch
        {
            jobHandle?.Dispose();
            processHandle?.Dispose();
            appToBrowser.Dispose();
            browserToApp.Dispose();
            throw;
        }
    }
 
    private static WindowsProcessInfo CreateWindowsProcess(string executablePath, IReadOnlyList<string> arguments, IntPtr[] inheritedHandles)
    {
        var commandLine = Marshal.StringToHGlobalUni(BuildWindowsCommandLine(executablePath, arguments));
        var attributeListSize = UIntPtr.Zero;
        // STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_HANDLE_LIST lets us turn on handle inheritance while limiting it to the
        // two CDP pipe handles. That avoids the broad "all inheritable handles leak into Chromium" behavior of plain
        // CreateProcess(..., inheritHandles: true).
        _ = InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeListSize);
        var attributeList = Marshal.AllocHGlobal((nint)attributeListSize.ToUInt64());
        var handleList = Marshal.AllocHGlobal(IntPtr.Size * inheritedHandles.Length);
        var startupInfoPointer = IntPtr.Zero;
 
        try
        {
            if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize))
            {
                throw CreateWindowsException("InitializeProcThreadAttributeList");
            }
 
            for (var i = 0; i < inheritedHandles.Length; i++)
            {
                Marshal.WriteIntPtr(handleList, i * IntPtr.Size, inheritedHandles[i]);
            }
 
            if (!UpdateProcThreadAttribute(
                attributeList,
                0,
                s_procThreadAttributeHandleList,
                handleList,
                (UIntPtr)(uint)(IntPtr.Size * inheritedHandles.Length),
                IntPtr.Zero,
                IntPtr.Zero))
            {
                throw CreateWindowsException("UpdateProcThreadAttribute");
            }
 
            var startupInfo = new STARTUPINFOEX
            {
                StartupInfo = new STARTUPINFO
                {
                    cb = Marshal.SizeOf<STARTUPINFOEX>()
                },
                lpAttributeList = attributeList
            };
            startupInfoPointer = Marshal.AllocHGlobal(Marshal.SizeOf<STARTUPINFOEX>());
            Marshal.StructureToPtr(startupInfo, startupInfoPointer, fDeleteOld: false);
 
            if (!CreateProcessW(
                lpApplicationName: IntPtr.Zero,
                lpCommandLine: commandLine,
                lpProcessAttributes: IntPtr.Zero,
                lpThreadAttributes: IntPtr.Zero,
                bInheritHandles: true,
                dwCreationFlags: EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW,
                lpEnvironment: IntPtr.Zero,
                lpCurrentDirectory: IntPtr.Zero,
                lpStartupInfo: startupInfoPointer,
                lpProcessInformation: out var processInformation))
            {
                throw CreateWindowsException("CreateProcessW");
            }
 
            return new WindowsProcessInfo(processInformation.dwProcessId, processInformation.hProcess, processInformation.hThread);
        }
        finally
        {
            DeleteProcThreadAttributeList(attributeList);
            Marshal.FreeHGlobal(startupInfoPointer);
            Marshal.FreeHGlobal(attributeList);
            Marshal.FreeHGlobal(handleList);
            Marshal.FreeHGlobal(commandLine);
        }
    }
 
    internal static string BuildWindowsCommandLine(string executablePath, IReadOnlyList<string> arguments)
    {
        var builder = new StringBuilder();
        AppendWindowsCommandLineArgument(builder, executablePath);
 
        foreach (var argument in arguments)
        {
            builder.Append(' ');
            AppendWindowsCommandLineArgument(builder, argument);
        }
 
        return builder.ToString();
    }
 
    // Adapted from dotnet/runtime PasteArguments.AppendArgument so CreateProcess receives the same argv Chromium expects.
    private static void AppendWindowsCommandLineArgument(StringBuilder builder, string argument)
    {
        if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"'))
        {
            builder.Append(argument);
            return;
        }
 
        builder.Append('"');
 
        var index = 0;
        while (index < argument.Length)
        {
            var character = argument[index++];
            if (character == '\\')
            {
                var backslashCount = 1;
                while (index < argument.Length && argument[index] == '\\')
                {
                    index++;
                    backslashCount++;
                }
 
                if (index == argument.Length)
                {
                    builder.Append('\\', backslashCount * 2);
                }
                else if (argument[index] == '"')
                {
                    builder.Append('\\', backslashCount * 2 + 1);
                    builder.Append('"');
                    index++;
                }
                else
                {
                    builder.Append('\\', backslashCount);
                }
 
                continue;
            }
 
            if (character == '"')
            {
                builder.Append('\\');
                builder.Append('"');
                continue;
            }
 
            builder.Append(character);
        }
 
        builder.Append('"');
    }
 
    private static async Task<BrowserLogsProcessResult> WaitForWindowsProcessAsync(SafeWaitHandle processHandle)
    {
        return await Task.Run(() =>
        {
            var waitResult = WaitForSingleObject(processHandle.DangerousGetHandle(), INFINITE);
            if (waitResult != WAIT_OBJECT_0)
            {
                throw CreateWindowsException("WaitForSingleObject");
            }
 
            if (!GetExitCodeProcess(processHandle.DangerousGetHandle(), out var exitCode))
            {
                throw CreateWindowsException("GetExitCodeProcess");
            }
 
            return new BrowserLogsProcessResult(unchecked((int)exitCode));
        }).ConfigureAwait(false);
    }
 
    private static Win32Exception CreateWindowsException(string operation) =>
        new(Marshal.GetLastWin32Error(), $"Failed to invoke {operation} while starting tracked browser CDP pipe.");
 
    private static void CloseWindowsHandle(IntPtr handle)
    {
        if (handle != IntPtr.Zero && !CloseHandle(handle))
        {
            throw CreateWindowsException("CloseHandle");
        }
    }
 
    private static void TryKillProcessTree(int processId)
    {
        try
        {
            using var process = Process.GetProcessById(processId);
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
            }
        }
        catch (ArgumentException)
        {
        }
        catch (InvalidOperationException)
        {
        }
    }
 
    private static SafeWaitHandle? TryCreateKillOnCloseJob(SafeWaitHandle processHandle)
    {
        var job = CreateJobObjectW(IntPtr.Zero, lpName: null);
        if (job == IntPtr.Zero)
        {
            return null;
        }
 
        var jobHandle = new SafeWaitHandle(job, ownsHandle: true);
        var jobInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
        {
            BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
            {
                LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
            }
        };
        var jobInfoSize = Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
        var jobInfoPointer = Marshal.AllocHGlobal(jobInfoSize);
 
        try
        {
            Marshal.StructureToPtr(jobInfo, jobInfoPointer, fDeleteOld: false);
            if (!SetInformationJobObject(jobHandle.DangerousGetHandle(), JobObjectExtendedLimitInformation, jobInfoPointer, (uint)jobInfoSize) ||
                !AssignProcessToJobObject(jobHandle.DangerousGetHandle(), processHandle.DangerousGetHandle()))
            {
                jobHandle.Dispose();
                return null;
            }
        }
        finally
        {
            Marshal.FreeHGlobal(jobInfoPointer);
        }
 
        return jobHandle;
    }
 
    private readonly record struct WindowsProcessInfo(int ProcessId, IntPtr ProcessHandle, IntPtr ThreadHandle);
 
    private sealed class WindowsProcessLifetime(int processId, SafeWaitHandle processHandle, SafeWaitHandle? jobHandle, Task<BrowserLogsProcessResult> processTask) : IBrowserLogsPipeBrowserProcessLifetime
    {
        public async ValueTask DisposeAsync()
        {
            try
            {
                if (!processTask.IsCompleted)
                {
                    TryKillProcessTree(processId);
                    await processTask.WaitAsync(s_processExitTimeout).ConfigureAwait(false);
                }
            }
            catch (TimeoutException)
            {
                TryKillProcessTree(processId);
                await processTask.ConfigureAwait(false);
            }
            finally
            {
                jobHandle?.Dispose();
                processHandle.Dispose();
            }
        }
    }
 
    private const int CREATE_NO_WINDOW = 0x08000000;
    private const int EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
    private const uint INFINITE = 0xffffffff;
    private const int JobObjectExtendedLimitInformation = 9;
    private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
    private static readonly IntPtr s_procThreadAttributeHandleList = 0x00020002;
    private const uint WAIT_OBJECT_0 = 0x00000000;
 
    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static partial bool CloseHandle(IntPtr hObject);
 
    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static partial bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
 
    [LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
    private static partial IntPtr CreateJobObjectW(IntPtr lpJobAttributes, string? lpName);
 
    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static partial bool CreateProcessW(
        IntPtr lpApplicationName,
        IntPtr lpCommandLine,
        IntPtr lpProcessAttributes,
        IntPtr lpThreadAttributes,
        [MarshalAs(UnmanagedType.Bool)]
        bool bInheritHandles,
        int dwCreationFlags,
        IntPtr lpEnvironment,
        IntPtr lpCurrentDirectory,
        IntPtr lpStartupInfo,
        out PROCESS_INFORMATION lpProcessInformation);
 
    [LibraryImport("kernel32.dll", SetLastError = true)]
    private static partial void DeleteProcThreadAttributeList(IntPtr lpAttributeList);
 
    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static partial bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
 
    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static partial bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref UIntPtr lpSize);
 
    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static partial bool SetInformationJobObject(IntPtr hJob, int jobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
 
    [LibraryImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static partial bool UpdateProcThreadAttribute(
        IntPtr lpAttributeList,
        int dwFlags,
        IntPtr attribute,
        IntPtr lpValue,
        UIntPtr cbSize,
        IntPtr lpPreviousValue,
        IntPtr lpReturnSize);
 
    [LibraryImport("kernel32.dll", SetLastError = true)]
    private static partial uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
 
    [StructLayout(LayoutKind.Sequential)]
    private struct PROCESS_INFORMATION
    {
        public IntPtr hProcess;
        public IntPtr hThread;
        public int dwProcessId;
        public int dwThreadId;
    }
 
    [StructLayout(LayoutKind.Sequential)]
    private struct IO_COUNTERS
    {
        public ulong ReadOperationCount;
        public ulong WriteOperationCount;
        public ulong OtherOperationCount;
        public ulong ReadTransferCount;
        public ulong WriteTransferCount;
        public ulong OtherTransferCount;
    }
 
    [StructLayout(LayoutKind.Sequential)]
    private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
    {
        public long PerProcessUserTimeLimit;
        public long PerJobUserTimeLimit;
        public uint LimitFlags;
        public UIntPtr MinimumWorkingSetSize;
        public UIntPtr MaximumWorkingSetSize;
        public uint ActiveProcessLimit;
        public UIntPtr Affinity;
        public uint PriorityClass;
        public uint SchedulingClass;
    }
 
    [StructLayout(LayoutKind.Sequential)]
    private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
    {
        public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
        public IO_COUNTERS IoInfo;
        public UIntPtr ProcessMemoryLimit;
        public UIntPtr JobMemoryLimit;
        public UIntPtr PeakProcessMemoryUsed;
        public UIntPtr PeakJobMemoryUsed;
    }
 
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    private struct STARTUPINFO
    {
        public int cb;
        public string? lpReserved;
        public string? lpDesktop;
        public string? lpTitle;
        public int dwX;
        public int dwY;
        public int dwXSize;
        public int dwYSize;
        public int dwXCountChars;
        public int dwYCountChars;
        public int dwFillAttribute;
        public int dwFlags;
        public short wShowWindow;
        public short cbReserved2;
        public IntPtr lpReserved2;
        public IntPtr hStdInput;
        public IntPtr hStdOutput;
        public IntPtr hStdError;
    }
 
    [StructLayout(LayoutKind.Sequential)]
    private struct STARTUPINFOEX
    {
        public STARTUPINFO StartupInfo;
        public IntPtr lpAttributeList;
    }
}