Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public int ProcessId
/// <summary>
/// Sets a value indicating whether the process has been terminated due to timeout or cancellation.
/// </summary>
private bool Canceled
internal bool Canceled
{
set => GetWaitState()._canceled = value;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public int ProcessId
/// <summary>
/// Gets or sets a value indicating whether the process has been terminated due to timeout or cancellation.
/// </summary>
private bool Canceled { get; set; }
internal bool Canceled { get; set; }

protected override bool ReleaseHandle()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ public ProcessExitStatus WaitForExitOrKillOnTimeout(TimeSpan timeout)
/// <para>
/// When the cancellation token is canceled, this method stops waiting and throws <see cref="OperationCanceledException"/>.
/// The process is NOT killed and continues running. If you want to kill the process on cancellation,
/// use <see cref="WaitForExitOrKillOnCancellationAsync"/> instead.
/// use <see cref="WaitForExitOrKillOnCancellationAsync(CancellationToken)"/> instead.
/// </para>
/// <para>On Unix, it's impossible to obtain the exit status of a non-child process.</para>
/// </remarks>
Expand Down Expand Up @@ -419,7 +419,13 @@ public async Task<ProcessExitStatus> WaitForExitAsync(CancellationToken cancella
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
public async Task<ProcessExitStatus> WaitForExitOrKillOnCancellationAsync(CancellationToken cancellationToken)
public Task<ProcessExitStatus> WaitForExitOrKillOnCancellationAsync(CancellationToken cancellationToken)
=> WaitForExitOrKillOnCancellationAsync(cancellationToken, kill: null);

[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
internal async Task<ProcessExitStatus> WaitForExitOrKillOnCancellationAsync(CancellationToken cancellationToken, Action? kill)
{
Validate();

Expand Down Expand Up @@ -448,18 +454,26 @@ public async Task<ProcessExitStatus> WaitForExitOrKillOnCancellationAsync(Cancel
ctr = cancellationToken.UnsafeRegister(
static state =>
{
var (handle, tcs) = ((SafeProcessHandle, TaskCompletionSource<bool>))state!;
(SafeProcessHandle handle, TaskCompletionSource<bool> tcs, Action? kill) =
((SafeProcessHandle, TaskCompletionSource<bool>, Action?))state!;
try
{
handle.Canceled = true;
handle.SignalCore(PosixSignal.SIGKILL);
if (kill is null)
{
handle.SignalCore(PosixSignal.SIGKILL);
}
else
{
kill();
}
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
},
(this, tcs));
(this, tcs, kill));
}

await tcs.Task.ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@
<value>The KillOnParentExit property cannot be used with UseShellExecute.</value>
</data>
<data name="CantSetRedirectForSafeProcessHandleStart" xml:space="preserve">
<value>The RedirectStandardInput, RedirectStandardOutput, and RedirectStandardError properties cannot be used by SafeProcessHandle.Start or Process.StartAndForget. Use the StandardInputHandle, StandardOutputHandle, and StandardErrorHandle properties.</value>
<value>The RedirectStandardInput, RedirectStandardOutput, and RedirectStandardError properties cannot be used by this method. Use the StandardInputHandle, StandardOutputHandle, and StandardErrorHandle properties instead.</value>
</data>
<data name="StartDetachedNotCompatible" xml:space="preserve">
<value>The StartDetached property cannot be used with UseShellExecute set to true.</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Collections.Generic;
using System.IO;
using System.Runtime.ExceptionServices;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -90,7 +91,7 @@ public static int StartAndForget(string fileName, IEnumerable<string>? arguments
/// <param name="timeout">
/// The maximum amount of time to wait for the process to exit.
/// When <see langword="null"/>, waits indefinitely.
/// If the process does not exit within the specified timeout, it is killed.
/// If the process does not exit within the specified timeout, it and its descendants are killed.
/// </param>
/// <returns>The exit status of the process.</returns>
/// <exception cref="ArgumentNullException"><paramref name="startInfo"/> is <see langword="null"/>.</exception>
Expand All @@ -106,11 +107,17 @@ public static ProcessExitStatus Run(ProcessStartInfo startInfo, TimeSpan? timeou

ThrowIfUseShellExecute(startInfo, nameof(Run));

using SafeProcessHandle processHandle = SafeProcessHandle.Start(startInfo);
startInfo.ThrowIfInvalid(out bool anyRedirection, out _);
if (anyRedirection)
{
throw new InvalidOperationException(SR.CantSetRedirectForSafeProcessHandleStart);
}

using Process process = Start(startInfo)!;

return timeout.HasValue
? processHandle.WaitForExitOrKillOnTimeout(timeout.Value)
: processHandle.WaitForExit();
? process.WaitForExitOrKillEntireProcessTreeOnTimeout(timeout.Value)
: process.WaitForExitStatus();
}

/// <summary>
Expand All @@ -129,7 +136,7 @@ public static ProcessExitStatus Run(ProcessStartInfo startInfo, TimeSpan? timeou
/// <param name="timeout">
/// The maximum amount of time to wait for the process to exit.
/// When <see langword="null"/>, waits indefinitely.
/// If the process does not exit within the specified timeout, it is killed.
/// If the process does not exit within the specified timeout, it and its descendants are killed.
/// </param>
/// <returns>The exit status of the process.</returns>
/// <exception cref="ArgumentNullException"><paramref name="fileName"/> is <see langword="null"/>.</exception>
Expand All @@ -155,7 +162,7 @@ public static ProcessExitStatus Run(string fileName, IEnumerable<string>? argume
/// <param name="startInfo">The <see cref="ProcessStartInfo"/> that contains the information used to start the process.</param>
/// <param name="cancellationToken">
/// A token to cancel the asynchronous operation.
/// If the token is canceled, the process is killed.
/// If the token is canceled, the process and its descendants are killed.
/// </param>
/// <returns>A task that represents the asynchronous operation. The value of the task contains the exit status of the process.</returns>
/// <exception cref="ArgumentNullException"><paramref name="startInfo"/> is <see langword="null"/>.</exception>
Expand All @@ -171,9 +178,14 @@ public static async Task<ProcessExitStatus> RunAsync(ProcessStartInfo startInfo,

ThrowIfUseShellExecute(startInfo, nameof(RunAsync));

using SafeProcessHandle processHandle = SafeProcessHandle.Start(startInfo);
startInfo.ThrowIfInvalid(out bool anyRedirection, out _);
if (anyRedirection)
{
throw new InvalidOperationException(SR.CantSetRedirectForSafeProcessHandleStart);
Comment thread
adamsitnik marked this conversation as resolved.
Resolved
}

return await processHandle.WaitForExitOrKillOnCancellationAsync(cancellationToken).ConfigureAwait(false);
using Process process = Start(startInfo)!;
return await process.WaitForExitOrKillEntireProcessTreeOnCancellationAsync(cancellationToken).ConfigureAwait(false);
}

/// <summary>
Expand All @@ -191,7 +203,7 @@ public static async Task<ProcessExitStatus> RunAsync(ProcessStartInfo startInfo,
/// </param>
/// <param name="cancellationToken">
/// A token to cancel the asynchronous operation.
/// If the token is canceled, the process is killed.
/// If the token is canceled, the process and its descendants are killed.
/// </param>
/// <returns>A task that represents the asynchronous operation. The value of the task contains the exit status of the process.</returns>
/// <exception cref="ArgumentNullException"><paramref name="fileName"/> is <see langword="null"/>.</exception>
Expand Down Expand Up @@ -219,7 +231,7 @@ public static async Task<ProcessExitStatus> RunAsync(string fileName, IEnumerabl
/// <param name="timeout">
/// The maximum amount of time to wait for the process to exit.
/// When <see langword="null"/>, waits indefinitely.
/// If the process does not exit within the specified timeout, it is killed.
/// If the process does not exit within the specified timeout, it and its descendants are killed.
/// </param>
/// <returns>The captured text output and exit status of the process.</returns>
/// <exception cref="ArgumentNullException"><paramref name="startInfo"/> is <see langword="null"/>.</exception>
Expand Down Expand Up @@ -249,7 +261,8 @@ public static ProcessTextOutput RunAndCaptureText(ProcessStartInfo startInfo, Ti
}
catch
{
try { process.Kill(); } catch { }
// Cleanup must not replace the original capture exception.
try { process.TryKillEntireTree(); } catch { }
throw;
}

Expand All @@ -259,11 +272,11 @@ public static ProcessTextOutput RunAndCaptureText(ProcessStartInfo startInfo, Ti
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTimestamp);
TimeSpan remaining = timeout.Value - elapsed;
remaining = remaining >= TimeSpan.Zero ? remaining : TimeSpan.Zero;
exitStatus = process.SafeHandle.WaitForExitOrKillOnTimeout(remaining);
exitStatus = process.WaitForExitOrKillEntireProcessTreeOnTimeout(remaining);
}
else
{
exitStatus = process.SafeHandle.WaitForExit();
exitStatus = process.WaitForExitStatus();
}

return new ProcessTextOutput(exitStatus, standardOutput, standardError, process.Id);
Expand All @@ -281,7 +294,7 @@ public static ProcessTextOutput RunAndCaptureText(ProcessStartInfo startInfo, Ti
/// <param name="timeout">
/// The maximum amount of time to wait for the process to exit.
/// When <see langword="null"/>, waits indefinitely.
/// If the process does not exit within the specified timeout, it is killed.
/// If the process does not exit within the specified timeout, it and its descendants are killed.
/// </param>
/// <returns>The captured text output and exit status of the process.</returns>
/// <exception cref="ArgumentNullException"><paramref name="fileName"/> is <see langword="null"/>.</exception>
Expand All @@ -299,7 +312,7 @@ public static ProcessTextOutput RunAndCaptureText(string fileName, IEnumerable<s
/// <param name="startInfo">The <see cref="ProcessStartInfo"/> that contains the information used to start the process.</param>
/// <param name="cancellationToken">
/// A token to cancel the asynchronous operation.
/// If the token is canceled, the process is killed.
/// If the token is canceled, the process and its descendants are killed.
/// </param>
/// <returns>A task that represents the asynchronous operation. The value of the task contains the captured text output and exit status of the process.</returns>
/// <exception cref="ArgumentNullException"><paramref name="startInfo"/> is <see langword="null"/>.</exception>
Expand Down Expand Up @@ -327,11 +340,12 @@ public static async Task<ProcessTextOutput> RunAndCaptureTextAsync(ProcessStartI
}
catch
{
try { process.Kill(); } catch { }
// Cleanup must not replace the original capture exception.
try { process.TryKillEntireTree(); } catch { }
throw;
}

ProcessExitStatus exitStatus = await process.SafeHandle.WaitForExitOrKillOnCancellationAsync(cancellationToken).ConfigureAwait(false);
ProcessExitStatus exitStatus = await process.WaitForExitOrKillEntireProcessTreeOnCancellationAsync(cancellationToken).ConfigureAwait(false);

return new ProcessTextOutput(exitStatus, standardOutput, standardError, process.Id);
}
Expand All @@ -347,7 +361,7 @@ public static async Task<ProcessTextOutput> RunAndCaptureTextAsync(ProcessStartI
/// </param>
/// <param name="cancellationToken">
/// A token to cancel the asynchronous operation.
/// If the token is canceled, the process is killed.
/// If the token is canceled, the process and its descendants are killed.
/// </param>
/// <returns>A task that represents the asynchronous operation. The value of the task contains the captured text output and exit status of the process.</returns>
/// <exception cref="ArgumentNullException"><paramref name="fileName"/> is <see langword="null"/>.</exception>
Expand All @@ -374,6 +388,45 @@ private static ProcessStartInfo CreateStartInfoForCapture(string fileName, IEnum
return startInfo;
}

[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private ProcessExitStatus WaitForExitOrKillEntireProcessTreeOnTimeout(TimeSpan timeout)
{
SafeProcessHandle safeProcessHandle = _processHandle!;
if (safeProcessHandle.TryWaitForExit(timeout, out ProcessExitStatus? exitStatus))
Comment thread
adamsitnik marked this conversation as resolved.
Resolved
{
return exitStatus;
}

safeProcessHandle.Canceled = true;
TryKillEntireTree();

return safeProcessHandle.WaitForExit();
}

[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private Task<ProcessExitStatus> WaitForExitOrKillEntireProcessTreeOnCancellationAsync(CancellationToken cancellationToken)
=> _processHandle!.WaitForExitOrKillOnCancellationAsync(cancellationToken, cancellationToken.CanBeCanceled ? TryKillEntireTree : null);

[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
private void TryKillEntireTree()
{
try
{
Kill(entireProcessTree: true);

@jkotas jkotas Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Kill(entireProcessTree: true) is a ton on code. This change will add about ~400kB to a simple NAOT compiled app that just starts process using the simple APIs that this PR is changing (on Windows). Is this the right tradeoff?

@adamsitnik adamsitnik Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Kill(entireProcessTree: true) is a ton on code. This change will add about ~400kB to a simple NAOT compiled app that just starts process using the simple APIs that this PR is changing (on Windows).

As of today, it is. With #126273, we could make it trivial, especially here where we know that we are about to start a process and we know whether we may need to kill entire tree or not (timeout/cancelable token was provided). This is what some of our 1P customers do (I keep an eye on code that was not able to remove all custom p/invokes after migrating to the new APIs).

Is this the right tradeoff?

We are going to increase the size on disk, but on the other hand we are going to protect some of our customers from hard to diagnose issues. I hate regressing any kind of benchmarks, but here I think it's the right thing to do. Especially since we can eliminate this regression (in a follow up PR) and make the whole operation faster and atomic (at the cost of putting all processes into a Job).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is what some of our 1P customers do

We should be also asking how many are not doing this and would be actually broken by this change. This change can cut both ways - both on functionality and/or on performance.

Are there example in other ecosystems where simple process APIs try to kill the whole process tree on failure?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We should be also asking how many are not doing this and would be actually broken by this change. This change can cut both ways - both on functionality and/or on performance.

I think that 99.9% would benefit and only 0.1% could be broken for scenarios like spawning background compiler process for performance purposes.

Are there example in other ecosystems where simple process APIs try to kill the whole process tree on failure?

I've used the Office Copilot Researcher to answer your quesiton:

image

@jkotas which leads me to a conclusion that this most likely should be controlled via a new, optional bool parameter:

ProcessExitStatus> RunAsync(
    string fileName, IEnumerable<string>? arguments = null,
    bool silent = false,
+   bool entireProcessTree = false,
    CancellationToken ct = default)

@jkotas jkotas Oct 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

which leads me to a conclusion that this most likely should be controlled via a new, optional bool parameter:

Yes, it sounds like that to me as well. It is unfortunate that we have made silent a bool - it would be nicer if it is an enum.

If we do this, we should look at trimming the implementation to be leaner.

}
catch
{
// In case killing the process tree fails, attempt to Kill the root.
Kill();
}
}

private static void ThrowIfUseShellExecute(ProcessStartInfo startInfo, string methodName)
{
if (startInfo.UseShellExecute)
Expand Down
16 changes: 13 additions & 3 deletions src/libraries/System.Diagnostics.Process/tests/ProcessTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2842,16 +2842,26 @@ await Helpers.RetryWithBackoff(() =>
}
}

[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
public async Task Kill_EntireProcessTree_True_EntireTreeTerminated()
[ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
[InlineData(false)]
[InlineData(true)]
public async Task Kill_EntireProcessTree_True_EntireTreeTerminated(bool getById)
{
IReadOnlyList<Process> tree = CreateProcessTree();

try
{
Process parentProcess = tree.First();

parentProcess.Kill(entireProcessTree: true);
if (getById)
{
using Process fetchedProcess = Process.GetProcessById(parentProcess.Id);
fetchedProcess.Kill(entireProcessTree: true);
}
else
{
parentProcess.Kill(entireProcessTree: true);
}

await Helpers.RetryWithBackoff(() =>
{
Expand Down
Loading
Loading