From 9896a2eae12e812e77c84fbf07a7cebb586c6f76 Mon Sep 17 00:00:00 2001 From: Sebastian Solnica Date: Fri, 5 Apr 2024 08:07:50 +0200 Subject: [PATCH] --terminate-job-on-exit (#62) + refactoring --- procgov-tests/FunctionalTests.cs | 30 +++++ procgov-tests/ProcessGovernorUnitTests.cs | 128 ---------------------- procgov-tests/UnitTests.cs | 125 +++++++++++++++++++++ procgov-tests/procgov-tests.csproj | 2 +- procgov/ProcessModule.cs | 6 + procgov/Program.cs | 81 ++++++++------ procgov/SessionSettings.cs | 2 +- procgov/Win32Job.cs | 8 +- procgov/Win32JobModule.cs | 20 ++-- 9 files changed, 225 insertions(+), 177 deletions(-) create mode 100644 procgov-tests/FunctionalTests.cs delete mode 100644 procgov-tests/ProcessGovernorUnitTests.cs create mode 100644 procgov-tests/UnitTests.cs diff --git a/procgov-tests/FunctionalTests.cs b/procgov-tests/FunctionalTests.cs new file mode 100644 index 0000000..dc64912 --- /dev/null +++ b/procgov-tests/FunctionalTests.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; +using System.ComponentModel; +using System.Threading; + +namespace ProcessGovernor.Tests; + +[TestFixture] +public class FunctionTests +{ + [Test] + public void ProcessStartFailureTest() + { + var session = new SessionSettings(); + var exception = Assert.Catch(() => + { + ProcessModule.StartProcessUnderDebuggerAndAssignToJobObject(["____wrong-executable.exe"], session); + }); + Assert.That(exception?.NativeErrorCode, Is.EqualTo(2)); + } + + [Test] + public void ProcessExitCodeForwardingTest() + { + var session = new SessionSettings(); + var job = ProcessModule.StartProcessAndAssignToJobObject(["cmd.exe", "/c", "exit 5"], session); + Win32JobModule.WaitForTheJobToComplete(job, CancellationToken.None); + var exitCode = ProcessModule.GetProcessExitCode(job.FirstProcessHandle!); + Assert.That(exitCode, Is.EqualTo(5)); + } +} diff --git a/procgov-tests/ProcessGovernorUnitTests.cs b/procgov-tests/ProcessGovernorUnitTests.cs deleted file mode 100644 index b09a0fd..0000000 --- a/procgov-tests/ProcessGovernorUnitTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; - -namespace ProcessGovernor.Tests -{ - - [TestFixture] - public class ProcessGovernorUnitTests - { - [Test] - public void CalculateAffinityMaskFromCpuCountTest() - { - Assert.That(Program.CalculateAffinityMaskFromCpuCount(1), Is.EqualTo(0x1UL)); - Assert.That(Program.CalculateAffinityMaskFromCpuCount(2), Is.EqualTo(0x3UL)); - Assert.That(Program.CalculateAffinityMaskFromCpuCount(4), Is.EqualTo(0xfUL)); - Assert.That(Program.CalculateAffinityMaskFromCpuCount(9), Is.EqualTo(0x1ffUL)); - Assert.That(Program.CalculateAffinityMaskFromCpuCount(64), Is.EqualTo(0xffffffffffffffffUL)); - } - - [Test] - public void LoadCustomEnvironmentVariablesTest() - { - var envVarsFile = Path.GetTempFileName(); - try { - using (var writer = new StreamWriter(envVarsFile, false)) { - writer.WriteLine("TEST=TESTVAL"); - writer.WriteLine(" TEST2 = TEST VAL2 "); - } - - var session = new SessionSettings(); - Program.LoadCustomEnvironmentVariables(session, envVarsFile); - Assert.That(session.AdditionalEnvironmentVars, Is.EquivalentTo(new Dictionary(StringComparer.OrdinalIgnoreCase) { - { "TEST", "TESTVAL" }, - { "TEST2", "TEST VAL2" } - })); - - using (var writer = new StreamWriter(envVarsFile, false)) { - writer.WriteLine(" = TEST VAL2 "); - } - - Assert.Throws(() => { - Program.LoadCustomEnvironmentVariables(session, envVarsFile); - }); - } finally { - if (File.Exists(envVarsFile)) { - File.Delete(envVarsFile); - } - } - } - - [Test] - public void ParseMemoryStringTest() - { - Assert.That(Program.ParseMemoryString("2K"), Is.EqualTo(2 * 1024u)); - Assert.That(Program.ParseMemoryString("3M"), Is.EqualTo(3 * 1024u * 1024u)); - Assert.That(Program.ParseMemoryString("3G"), Is.EqualTo(3 * 1024u * 1024u * 1024u)); - } - - [Test] - public void ParseTimeStringToMillisecondsTest() - { - Assert.That(Program.ParseTimeStringToMilliseconds("10"), Is.EqualTo(10u)); - Assert.That(Program.ParseTimeStringToMilliseconds("10ms"), Is.EqualTo(10u)); - Assert.That(Program.ParseTimeStringToMilliseconds("10s"), Is.EqualTo(10000u)); - Assert.That(Program.ParseTimeStringToMilliseconds("10m"), Is.EqualTo(600000u)); - Assert.That(Program.ParseTimeStringToMilliseconds("10h"), Is.EqualTo(36000000u)); - Assert.Throws(() => Program.ParseTimeStringToMilliseconds("sdfms")); - } - - [Test] - public void PrepareDebuggerCommandStringTest() - { - var session = new SessionSettings() { - CpuAffinityMask = 0x2, - MaxProcessMemory = 1024 * 1024, - MaxJobMemory = 2048 * 1024, - ProcessUserTimeLimitInMilliseconds = 500, - JobUserTimeLimitInMilliseconds = 1000, - ClockTimeLimitInMilliseconds = 2000, - CpuMaxRate = 90, - MaxBandwidth = 100, - MinWorkingSetSize = 1024, - MaxWorkingSetSize = 1024 * 1024, - NumaNode = 1, - Privileges = ["SeDebugPrivilege", "SeShutdownPrivilege"], - PropagateOnChildProcesses = true, - SpawnNewConsoleWindow = true - }; - session.AdditionalEnvironmentVars.Add("TEST", "TESTVAL"); - session.AdditionalEnvironmentVars.Add("TEST2", "TESTVAL2"); - - var appImageExe = Path.GetFileName(@"C:\temp\test.exe"); - var debugger = Program.PrepareDebuggerCommandString(session, appImageExe, true); - - var envFilePath = Program.GetAppEnvironmentFilePath(appImageExe); - Assert.That(File.Exists(envFilePath), Is.True); - - try { - - var txt = File.ReadAllText(envFilePath); - Assert.That(txt, Is.EqualTo("TEST=TESTVAL\r\nTEST2=TESTVAL2\r\n")); - - var expectedCmdLine = - $"\"{Environment.GetCommandLineArgs()[0]}\" --nogui --debugger --env=\"{envFilePath}\" --cpu=0x2 --maxmem=1048576 " + - "--maxjobmem=2097152 --maxws=1048576 --minws=1024 --node=1 --cpurate=90 --bandwidth=100 --recursive " + - "--timeout=2000 --process-utime=500 --job-utime=1000 --enable-privileges=SeDebugPrivilege,SeShutdownPrivilege --nowait"; - - Assert.That(debugger, Is.EqualTo(expectedCmdLine)); - } finally { - File.Delete(envFilePath); - } - } - - [Test] - public void StartProcessTest() - { - var session = new SessionSettings(); - var exception = Assert.Catch(() => - { - ProcessModule.StartProcessUnderDebuggerAndAssignToJobObject(new[] { "____wrong-executable.exe" }, session); - }); - Assert.That(exception?.NativeErrorCode, Is.EqualTo(2)); - } - } -} diff --git a/procgov-tests/UnitTests.cs b/procgov-tests/UnitTests.cs new file mode 100644 index 0000000..7499f9d --- /dev/null +++ b/procgov-tests/UnitTests.cs @@ -0,0 +1,125 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; + +namespace ProcessGovernor.Tests; + +[TestFixture] +public class UnitTests +{ + [Test] + public void CalculateAffinityMaskFromCpuCountTest() + { + Assert.That(Program.CalculateAffinityMaskFromCpuCount(1), Is.EqualTo(0x1UL)); + Assert.That(Program.CalculateAffinityMaskFromCpuCount(2), Is.EqualTo(0x3UL)); + Assert.That(Program.CalculateAffinityMaskFromCpuCount(4), Is.EqualTo(0xfUL)); + Assert.That(Program.CalculateAffinityMaskFromCpuCount(9), Is.EqualTo(0x1ffUL)); + Assert.That(Program.CalculateAffinityMaskFromCpuCount(64), Is.EqualTo(0xffffffffffffffffUL)); + } + + [Test] + public void LoadCustomEnvironmentVariablesTest() + { + var envVarsFile = Path.GetTempFileName(); + try + { + using (var writer = new StreamWriter(envVarsFile, false)) + { + writer.WriteLine("TEST=TESTVAL"); + writer.WriteLine(" TEST2 = TEST VAL2 "); + } + + var session = new SessionSettings(); + Program.LoadCustomEnvironmentVariables(session, envVarsFile); + Assert.That(session.AdditionalEnvironmentVars, Is.EquivalentTo(new Dictionary(StringComparer.OrdinalIgnoreCase) { + { "TEST", "TESTVAL" }, + { "TEST2", "TEST VAL2" } + })); + + using (var writer = new StreamWriter(envVarsFile, false)) + { + writer.WriteLine(" = TEST VAL2 "); + } + + Assert.Throws(() => + { + Program.LoadCustomEnvironmentVariables(session, envVarsFile); + }); + } + finally + { + if (File.Exists(envVarsFile)) + { + File.Delete(envVarsFile); + } + } + } + + [Test] + public void ParseMemoryStringTest() + { + Assert.That(Program.ParseMemoryString("2K"), Is.EqualTo(2 * 1024u)); + Assert.That(Program.ParseMemoryString("3M"), Is.EqualTo(3 * 1024u * 1024u)); + Assert.That(Program.ParseMemoryString("3G"), Is.EqualTo(3 * 1024u * 1024u * 1024u)); + } + + [Test] + public void ParseTimeStringToMillisecondsTest() + { + Assert.That(Program.ParseTimeStringToMilliseconds("10"), Is.EqualTo(10u)); + Assert.That(Program.ParseTimeStringToMilliseconds("10ms"), Is.EqualTo(10u)); + Assert.That(Program.ParseTimeStringToMilliseconds("10s"), Is.EqualTo(10000u)); + Assert.That(Program.ParseTimeStringToMilliseconds("10m"), Is.EqualTo(600000u)); + Assert.That(Program.ParseTimeStringToMilliseconds("10h"), Is.EqualTo(36000000u)); + Assert.Throws(() => Program.ParseTimeStringToMilliseconds("sdfms")); + } + + [Test] + public void PrepareDebuggerCommandStringTest() + { + var session = new SessionSettings() + { + CpuAffinityMask = 0x2, + MaxProcessMemory = 1024 * 1024, + MaxJobMemory = 2048 * 1024, + ProcessUserTimeLimitInMilliseconds = 500, + JobUserTimeLimitInMilliseconds = 1000, + ClockTimeLimitInMilliseconds = 2000, + CpuMaxRate = 90, + MaxBandwidth = 100, + MinWorkingSetSize = 1024, + MaxWorkingSetSize = 1024 * 1024, + NumaNode = 1, + Privileges = ["SeDebugPrivilege", "SeShutdownPrivilege"], + PropagateOnChildProcesses = true, + SpawnNewConsoleWindow = true + }; + session.AdditionalEnvironmentVars.Add("TEST", "TESTVAL"); + session.AdditionalEnvironmentVars.Add("TEST2", "TESTVAL2"); + + var appImageExe = Path.GetFileName(@"C:\temp\test.exe"); + var debugger = Program.PrepareDebuggerCommandString(session, appImageExe, true); + + var envFilePath = Program.GetAppEnvironmentFilePath(appImageExe); + Assert.That(File.Exists(envFilePath), Is.True); + + try + { + + var txt = File.ReadAllText(envFilePath); + Assert.That(txt, Is.EqualTo("TEST=TESTVAL\r\nTEST2=TESTVAL2\r\n")); + + var expectedCmdLine = + $"\"{Environment.GetCommandLineArgs()[0]}\" --nogui --debugger --env=\"{envFilePath}\" --cpu=0x2 --maxmem=1048576 " + + "--maxjobmem=2097152 --maxws=1048576 --minws=1024 --node=1 --cpurate=90 --bandwidth=100 --recursive " + + "--timeout=2000 --process-utime=500 --job-utime=1000 --enable-privileges=SeDebugPrivilege,SeShutdownPrivilege --nowait"; + + Assert.That(debugger, Is.EqualTo(expectedCmdLine)); + } + finally + { + File.Delete(envFilePath); + } + } +} diff --git a/procgov-tests/procgov-tests.csproj b/procgov-tests/procgov-tests.csproj index 84bfc66..d8849dd 100644 --- a/procgov-tests/procgov-tests.csproj +++ b/procgov-tests/procgov-tests.csproj @@ -2,7 +2,7 @@ net8.0-windows - procgov_tests + ProcessGovernor.Tests enable false diff --git a/procgov/ProcessModule.cs b/procgov/ProcessModule.cs index 141b75d..0a9ac2f 100644 --- a/procgov/ProcessModule.cs +++ b/procgov/ProcessModule.cs @@ -349,4 +349,10 @@ private static string GetNewJobName() return envEntries.ToString(); } + + public static uint GetProcessExitCode(SafeHandle processHandle) + { + PInvoke.GetExitCodeProcess(processHandle, out var exitCode); + return exitCode; + } } \ No newline at end of file diff --git a/procgov/Program.cs b/procgov/Program.cs index cf01d78..5c30328 100644 --- a/procgov/Program.cs +++ b/procgov/Program.cs @@ -26,7 +26,8 @@ public static class Program public static int Main(string[] args) { var procargs = new List(); - bool showhelp = false, nogui = false, debug = false, quiet = false, nowait = false; + bool showhelp = false, nogui = false, debug = false, quiet = false, nowait = false, + terminateJobOnExit = false; int[] pids = Array.Empty(); var registryOperation = RegistryOperation.NONE; @@ -35,41 +36,39 @@ public static int Main(string[] args) var p = new OptionSet() { { "m|maxmem=", "Max committed memory usage in bytes (accepted suffixes: K, M, or G).", - v => { session.MaxProcessMemory = ParseMemoryString(v); } }, + v => session.MaxProcessMemory = ParseMemoryString(v) }, { "maxjobmem=", "Max committed memory usage for all the processes in the job (accepted suffixes: K, M, or G).", - v => { session.MaxJobMemory = ParseMemoryString(v); } }, + v => session.MaxJobMemory = ParseMemoryString(v) }, { "maxws=", "Max working set size in bytes (accepted suffixes: K, M, or G). Must be set with minws.", - v => { session.MaxWorkingSetSize = ParseMemoryString(v); } }, + v => session.MaxWorkingSetSize = ParseMemoryString(v) }, { "minws=", "Min working set size in bytes (accepted suffixes: K, M, or G). Must be set with maxws.", - v => { session.MinWorkingSetSize = ParseMemoryString(v); } }, + v => session.MinWorkingSetSize = ParseMemoryString(v) }, { "env=", "A text file with environment variables (each line in form: VAR=VAL).", v => LoadCustomEnvironmentVariables(session, v) }, { "n|node=", "The preferred NUMA node for the process.", v => session.NumaNode = ushort.Parse(v) }, { "c|cpu=", "If in hex (starts with 0x) it is treated as an affinity mask, otherwise it is a number of CPU cores assigned to your app. " + "If you also provide the NUMA node, this setting will apply only to this node.", - v => { - if (v.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) { - session.CpuAffinityMask = ulong.Parse(v[2..], NumberStyles.HexNumber); - } else { - session.CpuAffinityMask = CalculateAffinityMaskFromCpuCount(int.Parse(v)); - } - }}, + v => session.CpuAffinityMask = v switch { + var s when s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) => ulong.Parse(s[2..], NumberStyles.HexNumber), + var s => CalculateAffinityMaskFromCpuCount(int.Parse(s)) + } + }, { "e|cpurate=", "The maximum CPU rate in % for the process. If you also set the affinity, " + "the rate will apply only to the selected CPU cores. (Windows 8.1+)", - v => { session.CpuMaxRate = ParseCpuRate(v); } }, + v => session.CpuMaxRate = ParseCpuRate(v) }, { "b|bandwidth=", "The maximum bandwidth (in bytes) for the process outgoing network traffic" + " (accepted suffixes: K, M, or G). (Windows 10+)", - v => { session.MaxBandwidth = ParseByteLength(v); } }, + v => session.MaxBandwidth = ParseByteLength(v) }, { "r|recursive", "Apply limits to child processes too (will wait for all processes to finish).", - v => { session.PropagateOnChildProcesses = v != null; } }, - { "newconsole", "Start the process in a new console window.", v => { session.SpawnNewConsoleWindow = v != null; } }, + v => session.PropagateOnChildProcesses = v != null }, + { "newconsole", "Start the process in a new console window.", v => session.SpawnNewConsoleWindow = v != null }, { "nogui", "Hide Process Governor console window (set always when installed as debugger).", - v => { nogui = v != null; } }, + v => nogui = v != null }, { "p|pid=", "Apply limits on an already running process (or processes)", v => pids = ParsePids(v) }, { "install", "Install procgov as a debugger for a specific process using Image File Executions. " + "DO NOT USE this option if the process you want to control starts child instances of itself (for example, Chrome).", - v => { registryOperation = RegistryOperation.INSTALL; } }, + v => registryOperation = RegistryOperation.INSTALL }, { "t|timeout=", "Kill the process (with -r, also all its children) if it does not finish within the specified time. " + "Add suffix to define the time unit. Valid suffixes are: ms, s, m, h.", v => session.ClockTimeLimitInMilliseconds = ParseTimeStringToMilliseconds(v) }, @@ -79,10 +78,12 @@ public static int Main(string[] args) { "job-utime=", "Kill the process (with -r, also all its children) if the total user-mode execution " + "time exceed the specified value. Add suffix to define the time unit. Valid suffixes are: ms, s, m, h.", v => session.JobUserTimeLimitInMilliseconds = ParseTimeStringToMilliseconds(v) }, - { "uninstall", "Uninstall procgov for a specific process.", v => { registryOperation = RegistryOperation.UNINSTALL; } }, + { "uninstall", "Uninstall procgov for a specific process.", v => registryOperation = RegistryOperation.UNINSTALL }, { "enable-privileges=", "Enables the specified privileges in the remote process. You may specify multiple privileges " + "by splitting them with commas, for example, 'SeDebugPrivilege,SeLockMemoryPrivilege'", v => session.Privileges = v.Split(',', StringSplitOptions.RemoveEmptyEntries) }, + { "terminate-job-on-exit", "Terminates the job (and all its processes) when you stop procgov with Ctrl + C.", + v => terminateJobOnExit = true }, { "debugger", "Internal - do not use.", v => debug = v != null }, { "q|quiet", "Do not show procgov messages.", v => quiet = v != null }, @@ -121,6 +122,12 @@ public static int Main(string[] args) showhelp = true; } + if (terminateJobOnExit && nowait) + { + Console.Error.WriteLine("ERROR: --terminate-job-on-exit and --nowait cannot be used together."); + return 0xff; + } + if (session.MaxWorkingSetSize != session.MinWorkingSetSize && Math.Min(session.MaxWorkingSetSize, session.MinWorkingSetSize) == 0) { Console.Error.WriteLine("ERROR: minws and maxws must be set together and be greater than 0."); @@ -171,30 +178,42 @@ public static int Main(string[] args) ShowLimits(session); } - var job = session switch + using var job = session switch { _ when debug => ProcessModule.StartProcessUnderDebuggerAndAssignToJobObject(procargs, session), _ when pids.Length == 1 => ProcessModule.AssignProcessToJobObject(pids[0], session), _ when pids.Length > 1 => ProcessModule.AssignProcessesToJobObject(pids, session), _ => ProcessModule.StartProcessAndAssignToJobObject(procargs, session) }; - try + + if (nowait) + { + return 0; + } + + if (!quiet) { - if (!quiet) + if (terminateJobOnExit) { - if (!nowait) - { - Console.WriteLine("Press Ctrl-C to end execution without terminating the process."); - Console.WriteLine(); - } + Console.WriteLine("Press Ctrl-C to end execution and terminate the job."); } - - return nowait ? 0 : Win32JobModule.WaitForTheJobToComplete(job, cts.Token); + else + { + Console.WriteLine("Press Ctrl-C to end execution without terminating the process."); + } + Console.WriteLine(); } - finally + + Win32JobModule.WaitForTheJobToComplete(job, cts.Token); + var exitCode = job.FirstProcessHandle is { } h && !h.IsInvalid ? + ProcessModule.GetProcessExitCode(h) : 0; + + if (cts.Token.IsCancellationRequested && terminateJobOnExit) { - job.Dispose(); + Win32JobModule.TerminateJob(job, exitCode); } + + return (int)exitCode; } catch (Win32Exception ex) { diff --git a/procgov/SessionSettings.cs b/procgov/SessionSettings.cs index 6c33295..4525f46 100644 --- a/procgov/SessionSettings.cs +++ b/procgov/SessionSettings.cs @@ -31,7 +31,7 @@ public sealed class SessionSettings public bool PropagateOnChildProcesses { get; set; } - public string[] Privileges { get; set; } = new string[0]; + public string[] Privileges { get; set; } = []; public Dictionary AdditionalEnvironmentVars => additionalEnvironmentVars; } diff --git a/procgov/Win32Job.cs b/procgov/Win32Job.cs index 793ed3f..4c93db9 100644 --- a/procgov/Win32Job.cs +++ b/procgov/Win32Job.cs @@ -2,21 +2,19 @@ namespace ProcessGovernor; -public record Win32Job(SafeHandle JobHandle, string JobName, SafeHandle? ProcessHandle = null, long ClockTimeLimitInMilliseconds = 0L) : IDisposable +public record Win32Job(SafeHandle JobHandle, string JobName, SafeHandle? FirstProcessHandle = null, long ClockTimeLimitInMilliseconds = 0L) : IDisposable { private readonly DateTime startTimeUtc = DateTime.UtcNow; public bool IsTimedOut => ClockTimeLimitInMilliseconds > 0 && DateTime.UtcNow.Subtract(startTimeUtc).TotalMilliseconds >= ClockTimeLimitInMilliseconds; - // When we are monitoring only a specific process, we will wait for its termination. Otherwise, - // we will wait for the job object to be signaled. - public SafeHandle WaitHandle => ProcessHandle ?? JobHandle; + public SafeHandle Handle => JobHandle; public void Dispose() { JobHandle.Dispose(); - if (ProcessHandle is { } h && !h.IsInvalid) + if (FirstProcessHandle is { } h && !h.IsInvalid) { h.Dispose(); } diff --git a/procgov/Win32JobModule.cs b/procgov/Win32JobModule.cs index 48883f6..773ef30 100644 --- a/procgov/Win32JobModule.cs +++ b/procgov/Win32JobModule.cs @@ -267,20 +267,15 @@ static unsafe void SetMaxBandwith(Win32Job job, SessionSettings session) } } - public static unsafe int WaitForTheJobToComplete(Win32Job job, CancellationToken ct) + public static unsafe void WaitForTheJobToComplete(Win32Job job, CancellationToken ct) { while (!ct.IsCancellationRequested) { - switch (PInvoke.WaitForSingleObject(job.WaitHandle, 200 /* ms */)) + switch (PInvoke.WaitForSingleObject(job.Handle, 200 /* ms */)) { case WAIT_EVENT.WAIT_OBJECT_0: logger.TraceInformation("Job or process got signaled."); - if (job.ProcessHandle is { } h && !h.IsInvalid) - { - PInvoke.GetExitCodeProcess(h, out var exitCode); - return (int)exitCode; - } - return 0; + return; case WAIT_EVENT.WAIT_FAILED: throw new Win32Exception(); default: @@ -293,13 +288,13 @@ public static unsafe int WaitForTheJobToComplete(Win32Job job, CancellationToken if (jobBasicAcctInfo.ActiveProcesses == 0) { logger.TraceInformation("No active processes in the job - terminating."); - return 0; + return; } else if (job.IsTimedOut) { logger.TraceInformation("Clock time limit passed - terminating."); PInvoke.TerminateJobObject(job.JobHandle, 1); - return 0; + return; } else { @@ -307,7 +302,10 @@ public static unsafe int WaitForTheJobToComplete(Win32Job job, CancellationToken } } } + } - return 0; + public static void TerminateJob(Win32Job job, uint exitCode) + { + PInvoke.TerminateJobObject(job.JobHandle, exitCode); } }