Skip to content

Avoid throwing exceptions in Process.KillTree during process enumeration#123865

Merged
jkotas merged 9 commits intomainfrom
copilot/fix-killtree-exception-handling
Feb 4, 2026
Merged

Avoid throwing exceptions in Process.KillTree during process enumeration#123865
jkotas merged 9 commits intomainfrom
copilot/fix-killtree-exception-handling

Conversation

Copy link
Contributor

Copilot AI commented Feb 2, 2026

Fix Process.KillTree excessive exception throwing on Windows

Problem Analysis

  • Process.KillTree throws and catches exceptions for every process it cannot open (e.g., system processes)
  • This causes severe performance issues when a debugger is attached (~23s vs 0.17s)
  • The exceptions were thrown during process enumeration in multiple places

Implementation

  • Modify ProcessManager.OpenProcess to return invalid handle for any error when throwOnError is false
  • Add TryGetStartTime helper that gets start time without throwing exceptions
  • Add TryGetParentProcessId helper that gets parent process ID without throwing exceptions
  • Update IsParentOf to use non-throwing helpers instead of properties that throw
  • Update Equals to use non-throwing helpers instead of StartTime property
  • Optimize Equals to check process ID first before gathering start times
  • Add test for minimal exception behavior

Changes Made

ProcessManager.Windows.cs

  • When throwOnError is false, return SafeProcessHandle.InvalidHandle for any error immediately

Process.Win32.cs

  • Added TryGetStartTime(out DateTime startTime) - gets process start time without throwing
  • Added TryGetParentProcessId(out int parentProcessId) - gets parent process ID without throwing
  • Updated IsParentOf to use the new non-throwing helpers
  • Updated Equals to check process ID first (cheap), then gather start times only if IDs match

Test

Test Kill_EntireProcessTree_MinimalExceptions validates that no more than 5 exceptions are thrown during Kill(true).

Test Results

All Kill-related tests pass including the new minimal exceptions test for both admin and non-admin users.

Original prompt

This section details on the original issue you should resolve

<issue_title>Process.KillTree throws a lot of exceptions internally</issue_title> <issue_description>I'm seeing a situation in microsoft/testfx repo where Process.KillTree` appears to take so long.

Image

In the above case, the process itself has already exited before Kill(true) was called. Based on the code comment here:

// If the process has exited, the handle is invalid.
if (handle.IsInvalid)
return null;

I'm really surprised this condition isn't hit and we return immediately.
I guess it's not a supported scenario to kill whole tree if the parent process already exited?

Note: I haven't tried to create a minimal repro yet.</issue_description>

<agent_instructions>Avoid throwing and catching exceptions internally inside Process.KillTree implementation on Windows Focus on exceptions thrown and caught during process enumeration where the bulk of the exceptions is thrown and caught today in the typical case. It is ok to have thrown and catch exceptions in rare cases to keep the code maintainable.</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@Youssef1313 Note that even if the process itself hasn't exited, something is still going on.

And seems somehow related to whether or not debugger is attached?

Run the following with debugger attached in VS:

using System.Diagnostics;

if (args.Length == 1 && args[0] == "1")
{
    var processStartInfo = new ProcessStartInfo(Process.GetCurrentProcess().MainModule!.FileName)
    {
        Arguments = "2",
        UseShellExecute = false,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
    };

    Process.Start(processStartInfo);

    while (true)
    {
        Thread.Sleep(10000);
    }
}
else if (args.Length == 1 && args[0] == "2")
{
    while (true)
    {
        Thread.Sleep(10000);
    }
}

var processStartInfo1 = new ProcessStartInfo(Process.GetCurrentProcess().MainModule!.FileName)
{
    Arguments = "1",
    UseShellExecute = false,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
};

var process = Process.Start(processStartInfo1);
Thread.Sleep(1000);
var timestamp = Stopwatch.GetTimestamp();
process.Kill(true);
Console.WriteLine(Stopwatch.GetElapsedTime(timestamp));

prints ~ 00:00:23.6890791

If I run without debugger, I get ~ 00:00:00.1698470.

23seconds seems like A LOT.</comment_new>
<comment_new>@Youssef1313
Ping @dotnet/area-system-diagnostics-process. I hit this from time to time.</comment_new>
<comment_new>@jkotas

Run the following with debugger attached in VS

I doubt there is much we can do about this in the .NET libraries. It is getting stuck in the OS.</comment_new>
<comment_new>@Youssef1313
@jkotas Why is it very different when the debugger is attached though?</comment_new>
<comment_new>@jkotas
My guess is that Windows has to coordinate the killing of the process between the debuggee and the debugger. The process cannot just disappear. There may be ways in VS debugger to make it better.</comment_new>
<comment_new>@Youssef1313

My guess is that Windows has to coordinate the killing of the process between the debuggee and the debugger. The process cannot just disappear. There may be ways in VS debugger to make it better.

To the extent that it takes 23 seconds when debugger is attached and less than 0.2 seconds when it's not attached? 23 seconds feels to me extremely long and unreasonable.

This is currently slowing me down a lot when debugging our integration tests in microsoft/testfx repo.</comment_new>
<comment_new>@jkotas
Changing area to diagnostics-coreclr. It is a debugger issue, not a System.Diagnostics.Process issue.</comment_new>
<comment_new>@hoyosjs
I can't seem to repro this - I consistently get sub-second times debugging or not. </comment_new>
<comment_new>@Youssef1313
In case it matters, I'm running on Windows and from Visual Studio.

csproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

C#:

using System.Diagnostics;

if (args.Length == 1 && args[0] == "1")
{
    var processStartInfo = new ProcessStartInfo(Process.GetCurrentProcess().MainModule!.FileName)
    {
        Arguments = "2",
        UseShellExecute = false,
        RedirectStandardOutput = true,
      ...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes dotnet/runtime#121279

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

@dotnet-policy-service
Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-system-diagnostics-process
See info in area-owners.md if you want to be subscribed.

Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix exceptions thrown by Process.KillTree Avoid throwing exceptions in Process.KillTree during process enumeration Feb 2, 2026
Copilot AI requested a review from jkotas February 2, 2026 03:53
@jkotas
Copy link
Member

jkotas commented Feb 2, 2026

@copilot Refactor the repro at #121279 (comment) into a test. Add code that checks number of exceptions thrown and caught internally inside Kill(true). You can use AppDomain.CurrentDomain.FirstChanceException handler that increments a counter for that. The test should validate that there are no more than 5 exceptions thrown and caught.

Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Feb 2, 2026

@copilot Refactor the repro at #121279 (comment) into a test. Add code that checks number of exceptions thrown and caught internally inside `Kill(...

Added test Kill_EntireProcessTree_MinimalExceptions based on the repro code. The test creates a process tree, uses AppDomain.CurrentDomain.FirstChanceException to count exceptions during Kill(true), and validates that no more than 5 exceptions are thrown. Commit: 5b1fbcb

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
@jkotas
Copy link
Member

jkotas commented Feb 2, 2026

@copilot The test you have added passes only when run as admin. When it is run as regular user, it fails with:

      System.Diagnostics.Tests.ProcessTests.Kill_EntireProcessTree_MinimalExceptions [FAIL]
        Expected no more than 5 exceptions during Kill(true), but got 166
        Stack Trace:
          C:\runtime\src\libraries\System.Diagnostics.Process\tests\ProcessTests.Windows.cs(98,0): at System.Diagnostics.Tests.ProcessTests.Kill_EntireProcessTree_MinimalExceptions()
             at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack target, Void** arguments, ObjectHandleOnStack sig, BOOL isConstructor, ObjectHandleOnStack result)
          C:\runtime\src\libraries\System.Private.CoreLib\src\System\Reflection\RuntimeMethodInfo.cs(134,0): at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)

Please fix that.

…nError is false

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Feb 2, 2026

@copilot, go ahead and just return an invalid handle for any error rather than throwing when the parameter is false. You can also rename it then to throwOnError. I assume the only time...

Simplified to return invalid handle for any error when throwOnError is false. Removed the IsProcessAccessError helper.

Regarding other OSes: The Unix implementation (Process.Unix.cs) uses direct syscalls and already handles ESRCH (process no longer exists) gracefully without exceptions - no changes needed there.

The only callers passing false are:

  • Process.Win32.cs line 376: in KillTree to get the process handle for enumeration
  • Process.Windows.cs line 94: in Kill() for similar enumeration

Both are enumeration scenarios where we want to skip inaccessible processes. Commit: 1aac39f

@stephentoub
Copy link
Member

The Unix implementation (Process.Unix.cs) uses direct syscalls and already handles ESRCH (process no longer exists) gracefully without exceptions - no changes needed there.

@copilot does it throw for other errors?

Copy link
Contributor Author

Copilot AI commented Feb 2, 2026

The Unix implementation (Process.Unix.cs) uses direct syscalls and already handles ESRCH (process no longer exists) gracefully without exceptions - no changes needed there.

@copilot

Yes, the Unix implementation does throw Win32Exception for errors other than ESRCH. Looking at lines 117-120 and 131-134 in Process.Unix.cs:

if (error != Interop.Error.ESRCH)
{
    (exceptions ??= new List<Exception>()).Add(new Win32Exception());
}

It creates and adds exceptions to the list for any error that isn't ESRCH. These would include errors like EPERM (no permission) or EINVAL (invalid signal).

However, unlike Windows, this doesn't happen during process enumeration - it happens during the actual kill syscalls. The GetChildProcesses() method (line 124) reads from /proc filesystem and doesn't appear to throw for inaccessible processes. So the Unix pattern is different: enumeration is exception-free, but the kill operations can throw for non-ESRCH errors.

@stephentoub stephentoub marked this pull request as ready for review February 3, 2026 02:43
@stephentoub stephentoub enabled auto-merge (squash) February 3, 2026 02:46
@jkotas jkotas disabled auto-merge February 3, 2026 05:27
@jkotas jkotas added the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Feb 3, 2026
@jkotas
Copy link
Member

jkotas commented Feb 3, 2026

@copilot The new test still fails when run as non-admin, with over 100 exceptions thrown and caught internally.

The exceptions are thrown at stacktraces like:

     at System.Environment.get_StackTrace() in C:\runtime\src\libraries\System.Private.CoreLib\src\System\Environment.cs:line 239
     at System.Diagnostics.Tests.ProcessTests.<>c__DisplayClass185_0.<Kill_EntireProcessTree_MinimalExceptions>b__0(Object sender, FirstChanceExceptionEventArgs e) in C:\runtime\src\libraries\System.Diagnostics.Process\tests\ProcessTests.Windows.cs:line 70
     at System.Diagnostics.Process.GetProcessTimes() in C:\runtime\src\libraries\System.Diagnostics.Process\src\System\Diagnostics\Process.Windows.cs:line 703
     at System.Diagnostics.Process.get_StartTimeCore() in C:\runtime\src\libraries\System.Diagnostics.Process\src\System\Diagnostics\Process.Windows.cs:line 241
     at System.Diagnostics.Process.get_StartTime() in C:\runtime\src\libraries\System.Diagnostics.Process\src\System\Diagnostics\Process.cs:line 205
     at System.Diagnostics.Process.IsParentOf(Process possibleChild) in C:\runtime\src\libraries\System.Diagnostics.Process\src\System\Diagnostics\Process.Win32.cs:line 333
     at System.Diagnostics.Process.GetChildProcesses(Process[] processes) in C:\runtime\src\libraries\System.Diagnostics.Process\src\System\Diagnostics\Process.NonUap.cs:line 86
     at System.Diagnostics.Process.IsSelfOrDescendantOf(Process processOfInterest) in C:\runtime\src\libraries\System.Diagnostics.Process\src\System\Diagnostics\Process.NonUap.cs:line 49
     at System.Diagnostics.Process.Kill(Boolean entireProcessTree) in C:\runtime\src\libraries\System.Diagnostics.Process\src\System\Diagnostics\Process.NonUap.cs:line 25
     at System.Diagnostics.Tests.ProcessTests.Kill_EntireProcessTree_MinimalExceptions() in C:\runtime\src\libraries\System.Diagnostics.Process\tests\ProcessTests.Windows.cs:line 90

… avoid first-chance exceptions during enumeration

Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Feb 3, 2026

@copilot The new test still fails when run as non-admin, with over 100 exceptions thrown and caught internally.

The exceptions are thrown at stacktraces like:
...

Fixed by adding TryGetStartTime and TryGetParentProcessId helper methods that don't throw exceptions. Updated IsParentOf and Equals to use these helpers instead of the property accessors that were throwing exceptions during enumeration.

The previous fix only addressed exceptions from OpenProcess, but the IsParentOf method was still throwing exceptions when accessing StartTime and ParentProcessId properties on processes we can't query. Commit: 7485838

Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
Copilot AI requested a review from jkotas February 3, 2026 15:46
@jkotas jkotas removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Feb 4, 2026
Copy link
Member

@jkotas jkotas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have validated locally that there is no excessive number of exceptions thrown anymore (even when running without admin privileges).

@jkotas jkotas merged commit 8d037fb into main Feb 4, 2026
84 of 91 checks passed
@jkotas jkotas deleted the copilot/fix-killtree-exception-handling branch February 4, 2026 22:48
lewing pushed a commit to lewing/runtime that referenced this pull request Feb 9, 2026
…ion (dotnet#123865)

Fixes dotnet#121279

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants