| File: ProcessReaper.cs | Web Access |
| Project: src\sdk\src\Cli\Microsoft.DotNet.Cli.Utils\Microsoft.DotNet.Cli.Utils.csproj (Microsoft.DotNet.Cli.Utils) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Win32.SafeHandles; #if !TARGET_WINDOWS && NET using System.ComponentModel; #endif #if TARGET_WINDOWS using Windows.Win32.System.JobObjects; #endif namespace Microsoft.DotNet.Cli.Utils; /// <summary> /// Responsible for reaping a target process if the current process terminates. /// </summary> /// <remarks> /// <para> /// On Windows, a job object will be used to ensure the termination of the target /// process (and its tree) even if the current process is rudely terminated. /// </para> /// <para> /// On POSIX systems, the reaper will handle SIGTERM and attempt to forward the /// signal to the target process only. /// </para> /// <para> /// The reaper also suppresses SIGINT in the current process to allow the target /// process to handle the signal. /// </para> /// </remarks> internal class ProcessReaper : IDisposable { private readonly Process _process; #if TARGET_WINDOWS private sealed class WindowsProcessReaper : ProcessReaper { public WindowsProcessReaper(Process process) : base(process) { // Ensure Ctrl+C handling is enabled in this process. // // When a parent process (e.g. dotnet-watch) launches us with CREATE_NEW_PROCESS_GROUP, // Ctrl+C handlers are disabled in the new process group. We re-enable them so that // HandleCancelKeyPress fires when the parent sends CTRL_C_EVENT. // This is safe to call unconditionally — it's a no-op if Ctrl+C is already enabled. // // See https://learn.microsoft.com/windows/console/setconsolectrlhandler EnableWindowsCtrlCHandling(); } private unsafe static void EnableWindowsCtrlCHandling() { PInvoke.SetConsoleCtrlHandler(null, false); } private SafeWaitHandle? _job; public override void NotifyProcessStarted() { // Limit the use of job objects to versions of Windows that support nested jobs (i.e. Windows 8/2012 or later). // Ideally, we would check for some new API export or OS feature instead of the OS version, // but nested jobs are transparently implemented with respect to the Job Objects API. // Note: Windows 8.1 and later may report as Windows 8 (see https://docs.microsoft.com/windows/desktop/sysinfo/operating-system-version). // However, for the purpose of this check that is still sufficient. if (Environment.OSVersion.Version.Major > 6 || (Environment.OSVersion.Version.Major == 6 && Environment.OSVersion.Version.Minor >= 2)) { _job = AssignProcessToJobObject((HANDLE)_process.Handle); } } private static SafeWaitHandle? AssignProcessToJobObject(HANDLE process) { HANDLE job = PInvoke.CreateJobObject(null, null); if (job.IsNull) { return null; } if (!SetKillOnJobClose(job, true)) { PInvoke.CloseHandle(job); return null; } if (!PInvoke.AssignProcessToJobObject(job, process)) { PInvoke.CloseHandle(job); return null; } return new(job, ownsHandle: true); } private static unsafe bool SetKillOnJobClose(HANDLE job, bool value) { JOBOBJECT_EXTENDED_LIMIT_INFORMATION information = new(); if (value) { information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; } return PInvoke.SetInformationJobObject( job, JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation, &information, (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); } public override void Dispose() { if (_job is null) { base.Dispose(); return; } // Clear the kill on close flag because the child process terminated successfully // If this fails, then we have no choice but to terminate any remaining processes in the job SetKillOnJobClose((HANDLE)_job.DangerousGetHandle(), false); _job.Dispose(); _job = null; base.Dispose(); } } #endif // The Unix reaper is only relevant to a Unix runtime, which is always a modern .NET // (NET) target. .NET Framework (net472) runs only on Windows and must not compile this // code. TARGET_WINDOWS reflects the build's TargetOS, not the runtime, and is not // defined when net472 is cross-compiled on a non-Windows build - hence "&& NET" here // rather than relying on TARGET_WINDOWS alone. #if !TARGET_WINDOWS && NET private sealed class UnixProcessReaper : ProcessReaper { // Coordinates Dispose (typically the main thread) with HandleProcessExit, // which is raised on the AppDomain.ProcessExit thread when the CLI itself is // shutting down (for example when a SIGTERM handler calls Environment.Exit). // // A System.Threading.Lock is used here rather than a Mutex on purpose. The prior // Mutex based design disposed the mutex in Dispose while a concurrent ProcessExit // callback could still call WaitOne on it, throwing "Cannot access a disposed // object" during shutdown (dotnet/sdk#55096). A managed lock has no disposable // wait handle to race on, and the _shuttingDown flag closes the window so a // ProcessExit callback that starts after Dispose has run simply becomes a no-op. private readonly Lock _shutdownLock = new(); private bool _shuttingDown; public UnixProcessReaper(Process process) : base(process) { AppDomain.CurrentDomain.ProcessExit += HandleProcessExit; } public override void Dispose() { AppDomain.CurrentDomain.ProcessExit -= HandleProcessExit; // If a ProcessExit driven shutdown is already running on another thread, // block here until it finishes so we don't race with the CLR shutdown from // the signal handler. Setting _shuttingDown also makes any ProcessExit // callback that starts after this point a no-op. lock (_shutdownLock) { _shuttingDown = true; } base.Dispose(); } private void HandleProcessExit(object? sender, EventArgs args) { lock (_shutdownLock) { if (_shuttingDown) { // Dispose has already run (or is running on another thread); there // is nothing to do and the process state may already be torn down. return; } _shuttingDown = true; try { // If the target is still running, forward SIGTERM so it can shut // down as well. Signal returns false if the process already exited, // and throws Win32Exception if the signal could not be delivered. if (!_process.WaitForExit(0)) { _process.SafeHandle.Signal(PosixSignal.SIGTERM); } // If SIGTERM was ignored by the target, then we'll still wait. _process.WaitForExit(); Environment.ExitCode = _process.ExitCode; } catch (InvalidOperationException) { // The process hasn't started yet, or no exit code is available; // nothing to signal or wait for. } catch (Win32Exception) { // The signal could not be delivered (for example, insufficient // permissions). Don't wait on a process we couldn't signal. } } } } #endif /// <inheritdoc cref="ProcessReaper(Process)"/> public static ProcessReaper Create(Process process) { #if TARGET_WINDOWS return new WindowsProcessReaper(process); #elif NET return new UnixProcessReaper(process); #else // .NET Framework only runs on Windows, so the Unix reaper is never relevant and // the CsWin32 based Windows reaper is only compiled for Windows TargetOS builds. // This branch is reached only when the net472 target is cross-compiled on a // non-Windows build (not a shipping configuration); fall back to the base reaper, // which still suppresses Ctrl+C so the target process can handle it. return new ProcessReaper(process); #endif } /// <summary> /// Creates a new process reaper. /// </summary> /// <param name="process"> /// The target process to reap if the current process terminates. The process should not yet be started. /// </param> private ProcessReaper(Process process) { _process = process; // The tests need the event handlers registered prior to spawning the child to prevent a race // where the child writes output the test expects before the intermediate dotnet process // has registered the event handlers to handle the signals the tests will generate. Console.CancelKeyPress += HandleCancelKeyPress; } /// <summary> /// Call to notify the reaper that the process has started. /// </summary> public virtual void NotifyProcessStarted() { } public virtual void Dispose() { Console.CancelKeyPress -= HandleCancelKeyPress; } private void HandleCancelKeyPress(object? sender, ConsoleCancelEventArgs e) { // Ignore SIGINT/SIGQUIT so that the process can handle the signal e.Cancel = true; // For WinExe apps (WinForms, WPF, MAUI) that don't respond to Ctrl+C, // CloseMainWindow() posts WM_CLOSE to gracefully shut them down. // For console apps this is a no-op (returns false) since they have no main window. try { _process.CloseMainWindow(); } catch (InvalidOperationException) { // The process hasn't started yet or has already exited; nothing to signal } } }