| File: Processes\WindowsConsoleProcessJob.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.Runtime.InteropServices; using System.Runtime.Versioning; using Microsoft.Win32.SafeHandles; namespace Aspire.Cli.Processes; /// <summary> /// Owns a Windows job object that is used as the crash-time safety net for interactive /// children spawned by <see cref="IsolatedProcess"/>. The job is created /// once per CLI process — on first isolated spawn via <see cref="Shared"/> — and held for /// the CLI's entire lifetime. /// </summary> /// <remarks> /// <para> /// The job is configured with <c>JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE</c> so that when the /// last handle to the job is released — which happens automatically when the parent CLI /// process exits, even via SIGKILL / power loss — the OS kernel terminates every process /// currently assigned to the job. This is the only reliable way to prevent orphaned guest /// AppHosts (which live in their own console group via <c>CREATE_NEW_CONSOLE</c>) from /// surviving a parent crash on Windows. The Unix equivalent (process-group reparenting /// to <c>init</c>) provides no equivalent kill behavior, but on Unix the parent's normal /// SIGINT/SIGTERM signalling reaches the whole process group, so no safety net is needed. /// </para> /// <para> /// The job is also configured with <c>JOB_OBJECT_LIMIT_BREAKAWAY_OK</c> so that DCP (and /// anything else that needs to outlive the CLI for its own cleanup) can opt out by spawning /// itself with <c>CREATE_BREAKAWAY_FROM_JOB</c>. Our job only grants permission to break /// away — the breakaway itself is the responsibility of the spawning code. /// </para> /// <para> /// IMPORTANT: do NOT dispose this service during the normal shutdown ladder. The graceful /// shutdown path is responsible for cooperatively terminating its children; closing the /// job handle while they are still alive would convert clean shutdown into a hard kill. /// Let the OS close the handle on process exit; the kill-on-parent-exit behavior is exactly the /// crash-safety net we want and is harmless when the children have already exited. /// </para> /// </remarks> [SupportedOSPlatform("windows")] internal sealed class WindowsConsoleProcessJob : IDisposable { // The job is a process-wide singleton: its configuration never varies, and the OS closes // the handle on process exit (firing KILL_ON_JOB_CLOSE on any still-assigned children). // Created lazily on the first isolated spawn that needs it so non-Run invocations — and // every non-Windows host — never allocate the kernel object. Threading a job instance // through the spawn callers was the alternative; an on-demand singleton removes that // plumbing because there is only ever one correct job to use. private static readonly Lazy<WindowsConsoleProcessJob> s_shared = new(static () => new WindowsConsoleProcessJob()); private readonly SafeFileHandle _jobHandle; private int _disposed; /// <summary> /// The process-wide job, created on first access. Callers that opt into parent-lifetime /// cleanup use this instead of receiving a job instance, so they cannot forget to supply one. /// Intentionally never disposed in production: the OS closes the handle at process exit, /// which is exactly the crash-safety net we want. /// </summary> public static WindowsConsoleProcessJob Shared => s_shared.Value; public WindowsConsoleProcessJob() { _jobHandle = WindowsProcessInterop.CreateJobObjectW(nint.Zero, null); if (_jobHandle.IsInvalid) { throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create CLI kill-on-parent-exit job object"); } try { // BREAKAWAY_OK is required so DCP can fork itself with CREATE_BREAKAWAY_FROM_JOB // and survive the CLI exiting; KILL_ON_JOB_CLOSE catches everything else. var info = new WindowsProcessInterop.JOBOBJECT_EXTENDED_LIMIT_INFORMATION { BasicLimitInformation = { LimitFlags = WindowsProcessInterop.JobObjectLimitKillOnJobClose | WindowsProcessInterop.JobObjectLimitBreakawayOk, }, }; var infoSize = Marshal.SizeOf<WindowsProcessInterop.JOBOBJECT_EXTENDED_LIMIT_INFORMATION>(); var infoPtr = Marshal.AllocHGlobal(infoSize); try { Marshal.StructureToPtr(info, infoPtr, fDeleteOld: false); if (!WindowsProcessInterop.SetInformationJobObject( _jobHandle, WindowsProcessInterop.JobObjectInfoClass.ExtendedLimitInformation, infoPtr, (uint)infoSize)) { throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to configure CLI kill-on-parent-exit job object limits"); } } finally { Marshal.FreeHGlobal(infoPtr); } } catch { // Constructor failure must not leave the partial job alive; closing the handle // would also fire KILL_ON_JOB_CLOSE harmlessly (zero processes assigned yet) but // mainly we just don't want to leak a kernel object. _jobHandle.Dispose(); throw; } } /// <summary> /// The job handle. Pass directly to <see cref="WindowsProcessInterop.SpawnProcess"/> so the spawn /// primitive can assign the child to this job atomically at creation via /// <c>PROC_THREAD_ATTRIBUTE_JOB_LIST</c>. /// </summary> public SafeFileHandle Handle => _jobHandle; public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } // Closing the job's last handle fires KILL_ON_JOB_CLOSE on any still-assigned processes. // This is intentional as a final safety net for tests and abnormal shutdown — production // disposal happens at process exit via the OS, so any process that actually reaches this // call is either a test fixture tearing down or a misbehaving consumer; either way killing // the stragglers is the correct outcome. _jobHandle.Dispose(); } }