Skip to content

Commit 8d037fb

Browse files
Copilotjkotasstephentoub
authored
Avoid throwing exceptions in Process.KillTree during process enumeration (#123865)
Fixes #121279 --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: jkotas <[email protected]> Co-authored-by: stephentoub <[email protected]>
1 parent 4433efd commit 8d037fb

File tree

3 files changed

+169
-19
lines changed

3 files changed

+169
-19
lines changed

src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Win32.cs

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -328,13 +328,65 @@ private bool WaitForInputIdleCore(int milliseconds)
328328
/// </remarks>
329329
private bool IsParentOf(Process possibleChild)
330330
{
331-
try
331+
// Use non-throwing helpers to avoid first-chance exceptions during enumeration.
332+
// This is critical for performance when a debugger is attached.
333+
if (!TryGetStartTime(out DateTime myStartTime) ||
334+
!possibleChild.TryGetStartTime(out DateTime childStartTime) ||
335+
!possibleChild.TryGetParentProcessId(out int childParentId))
332336
{
333-
return StartTime < possibleChild.StartTime && Id == possibleChild.ParentProcessId;
337+
return false;
334338
}
335-
catch (Exception e) when (IsProcessInvalidException(e))
339+
340+
return myStartTime < childStartTime && Id == childParentId;
341+
}
342+
343+
/// <summary>
344+
/// Try to get the process start time without throwing exceptions.
345+
/// </summary>
346+
private bool TryGetStartTime(out DateTime startTime)
347+
{
348+
startTime = default;
349+
using (SafeProcessHandle handle = GetProcessHandle(Interop.Advapi32.ProcessOptions.PROCESS_QUERY_LIMITED_INFORMATION, false))
336350
{
337-
return false;
351+
if (handle.IsInvalid)
352+
{
353+
return false;
354+
}
355+
356+
ProcessThreadTimes processTimes = new ProcessThreadTimes();
357+
if (!Interop.Kernel32.GetProcessTimes(handle,
358+
out processTimes._create, out processTimes._exit,
359+
out processTimes._kernel, out processTimes._user))
360+
{
361+
return false;
362+
}
363+
364+
startTime = processTimes.StartTime;
365+
return true;
366+
}
367+
}
368+
369+
/// <summary>
370+
/// Try to get the parent process ID without throwing exceptions.
371+
/// </summary>
372+
private unsafe bool TryGetParentProcessId(out int parentProcessId)
373+
{
374+
parentProcessId = 0;
375+
using (SafeProcessHandle handle = GetProcessHandle(Interop.Advapi32.ProcessOptions.PROCESS_QUERY_LIMITED_INFORMATION, false))
376+
{
377+
if (handle.IsInvalid)
378+
{
379+
return false;
380+
}
381+
382+
Interop.NtDll.PROCESS_BASIC_INFORMATION info;
383+
if (Interop.NtDll.NtQueryInformationProcess(handle, Interop.NtDll.ProcessBasicInformation, &info, (uint)sizeof(Interop.NtDll.PROCESS_BASIC_INFORMATION), out _) != 0)
384+
{
385+
return false;
386+
}
387+
388+
parentProcessId = (int)info.InheritedFromUniqueProcessId;
389+
return true;
338390
}
339391
}
340392

@@ -359,14 +411,20 @@ private unsafe int ParentProcessId
359411

360412
private bool Equals(Process process)
361413
{
362-
try
414+
// Check IDs first since they're cheap to compare and will fail most of the time.
415+
if (Id != process.Id)
363416
{
364-
return Id == process.Id && StartTime == process.StartTime;
417+
return false;
365418
}
366-
catch (Exception e) when (IsProcessInvalidException(e))
419+
420+
// Use non-throwing helper to avoid first-chance exceptions during enumeration.
421+
if (!TryGetStartTime(out DateTime myStartTime) ||
422+
!process.TryGetStartTime(out DateTime otherStartTime))
367423
{
368424
return false;
369425
}
426+
427+
return myStartTime == otherStartTime;
370428
}
371429

372430
private List<Exception>? KillTree()

src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessManager.Windows.cs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ static unsafe ProcessManager()
236236
}
237237
}
238238

239-
public static SafeProcessHandle OpenProcess(int processId, int access, bool throwIfExited)
239+
public static SafeProcessHandle OpenProcess(int processId, int access, bool throwOnError)
240240
{
241241
SafeProcessHandle processHandle = Interop.Kernel32.OpenProcess(access, false, processId);
242242
int result = Marshal.GetLastWin32Error();
@@ -247,24 +247,21 @@ public static SafeProcessHandle OpenProcess(int processId, int access, bool thro
247247

248248
processHandle.Dispose();
249249

250+
if (!throwOnError)
251+
{
252+
return SafeProcessHandle.InvalidHandle;
253+
}
254+
250255
if (processId == 0)
251256
{
252257
throw new Win32Exception(Interop.Errors.ERROR_ACCESS_DENIED);
253258
}
254259

255-
// If the handle is invalid because the process has exited, only throw an exception if throwIfExited is true.
256-
// Assume the process is still running if the error was ERROR_ACCESS_DENIED for better performance
257-
if (result != Interop.Errors.ERROR_ACCESS_DENIED && !IsProcessRunning(processId))
260+
if (!IsProcessRunning(processId))
258261
{
259-
if (throwIfExited)
260-
{
261-
throw new InvalidOperationException(SR.Format(SR.ProcessHasExited, processId.ToString()));
262-
}
263-
else
264-
{
265-
return SafeProcessHandle.InvalidHandle;
266-
}
262+
throw new InvalidOperationException(SR.Format(SR.ProcessHasExited, processId.ToString()));
267263
}
264+
268265
throw new Win32Exception(result);
269266
}
270267

src/libraries/System.Diagnostics.Process/tests/ProcessTests.Windows.cs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using System.ComponentModel;
66
using System.IO;
77
using System.Runtime.InteropServices;
8+
using System.Threading;
9+
using Microsoft.DotNet.RemoteExecutor;
810
using Microsoft.DotNet.XUnitExtensions;
911
using Xunit;
1012

@@ -55,5 +57,98 @@ private static unsafe void ReEnableCtrlCHandlerIfNeeded(PosixSignal signal)
5557
}
5658
}
5759
}
60+
61+
[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
62+
public void Kill_EntireProcessTree_MinimalExceptions()
63+
{
64+
// This test validates that Kill(true) doesn't throw excessive exceptions internally
65+
// during process enumeration, which causes severe performance degradation with debugger attached.
66+
// See https://github.com/dotnet/runtime/issues/121279
67+
68+
int exceptionCount = 0;
69+
EventHandler<System.Runtime.ExceptionServices.FirstChanceExceptionEventArgs> handler =
70+
(sender, e) => Interlocked.Increment(ref exceptionCount);
71+
72+
AppDomain.CurrentDomain.FirstChanceException += handler;
73+
74+
try
75+
{
76+
// Create a process tree based on the repro from the issue:
77+
// Main process spawns child1, child1 spawns child2
78+
RemoteInvokeHandle handle = RemoteExecutor.Invoke(CreateProcessTreeAndWait);
79+
Process parentProcess = handle.Process;
80+
81+
try
82+
{
83+
// Wait a bit to ensure the process tree is established
84+
Thread.Sleep(1500);
85+
86+
// Reset the counter before killing
87+
exceptionCount = 0;
88+
89+
// Kill the entire process tree
90+
parentProcess.Kill(entireProcessTree: true);
91+
92+
// Wait for process to exit
93+
Assert.True(parentProcess.WaitForExit(Helpers.PassingTestTimeoutMilliseconds));
94+
95+
// The fix should ensure that we don't throw exceptions for each system process
96+
// that we can't access during enumeration. Allow up to 5 exceptions for edge cases
97+
// (e.g., processes that exit during enumeration, rare error conditions)
98+
Assert.True(exceptionCount <= 5,
99+
$"Expected no more than 5 exceptions during Kill(true), but got {exceptionCount}");
100+
}
101+
finally
102+
{
103+
try
104+
{
105+
if (!parentProcess.HasExited)
106+
{
107+
parentProcess.Kill();
108+
}
109+
handle.Dispose();
110+
}
111+
catch
112+
{
113+
// Best effort cleanup
114+
}
115+
}
116+
}
117+
finally
118+
{
119+
AppDomain.CurrentDomain.FirstChanceException -= handler;
120+
}
121+
}
122+
123+
private static int CreateProcessTreeAndWait()
124+
{
125+
// This mimics the repro code from the issue
126+
// Create child1 which will create child2
127+
ProcessStartInfo psi = new ProcessStartInfo
128+
{
129+
FileName = RemoteExecutor.HostRunner,
130+
Arguments = $"exec \"{RemoteExecutor.Path}\" {typeof(ProcessTests).Assembly.Location} {nameof(SleepForever)}",
131+
UseShellExecute = false,
132+
RedirectStandardOutput = true,
133+
RedirectStandardError = true,
134+
};
135+
136+
using (Process child1 = Process.Start(psi))
137+
{
138+
// Wait a bit to ensure child1 is running
139+
Thread.Sleep(500);
140+
141+
// Keep this process alive
142+
Thread.Sleep(Timeout.Infinite);
143+
}
144+
145+
return RemoteExecutor.SuccessExitCode;
146+
}
147+
148+
private static int SleepForever()
149+
{
150+
Thread.Sleep(Timeout.Infinite);
151+
return RemoteExecutor.SuccessExitCode;
152+
}
58153
}
59154
}

0 commit comments

Comments
 (0)