using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; namespace BrassAndSigil.Server.Services; /// /// Wraps a Windows Job Object configured with KILL_ON_JOB_CLOSE so that any process /// assigned to it dies the moment our process does -- regardless of *how* we died /// (X-button on the console, Task Manager End Task, parent BSOD, etc.). Without /// this, a Java subprocess can outlive us and keep the server files locked. /// /// On Linux, use systemd's cgroup management instead; the equivalent guarantee /// comes for free when the tool runs as a systemd unit. /// public sealed class WindowsJobObject : IDisposable { private const int JobObjectExtendedLimitInformation = 9; private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(IntPtr hObject); [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 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_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; } private IntPtr _handle; private bool _disposed; public WindowsJobObject() { if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("WindowsJobObject only works on Windows."); _handle = CreateJobObject(IntPtr.Zero, null); if (_handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed"); var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION { BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION { LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE } }; var size = Marshal.SizeOf(info); var ptr = Marshal.AllocHGlobal(size); try { Marshal.StructureToPtr(info, ptr, fDeleteOld: false); if (!SetInformationJobObject(_handle, JobObjectExtendedLimitInformation, ptr, (uint)size)) throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed"); } finally { Marshal.FreeHGlobal(ptr); } } public void AssignProcess(Process process) { if (_disposed) throw new ObjectDisposedException(nameof(WindowsJobObject)); if (!AssignProcessToJobObject(_handle, process.Handle)) throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed"); } public void Dispose() { if (_disposed) return; _disposed = true; if (_handle != IntPtr.Zero) { CloseHandle(_handle); // Closing the last handle triggers KILL_ON_JOB_CLOSE _handle = IntPtr.Zero; } GC.SuppressFinalize(this); } ~WindowsJobObject() => Dispose(); }