Skip to content

Process: SafeChildProcessHandle and option bag #123380

Description

@adamsitnik

Background and motivation

System.Diagnostics.Process has plenty of design, usability, performance and reliablity issues. The API can not be 100% fixed without breaking changes, so I would like to introduce a set of new types related to process execution.

The foundation of the new process APIs is a low-level API that represents SafeHandle for child processes. It allow users to start processes with fine-grained control over STD input/output/error, environment, working directory, and other parameters that can be implemented on every platform supported by .NET.

API Proposal

The first part of the proposal is an option bag type that represents process start options. It's used by the low-level process creation API. It will be also used by higher-level APIs (proposed in #123959) that provide more user-friendly abstractions over typical process scenarios (execute, discard output, get output lines etc). Those high level APIs are currently WIP and will be proposed later.

namespace System.INeedName;

public sealed class ProcessStartOptions
{
    public string FileName { get; }
    public IList<string> Arguments { get; } // if it was List, users  could use .AddRange
    public IDictionary<string, string?> Environment { get; }
    public DirectoryInfo? WorkingDirectory { get; set; }

    public IList<SafeHandle> InheritedHandles { get; } // NEW (https://github.com/dotnet/runtime/issues/13943)
    public bool KillOnParentDeath { get; set; } // NEW (https://github.com/dotnet/runtime/issues/101985)
    public bool CreateNewProcessGroup { get; set; } // NEW for Unix, so far we had it only for Windows

    public ProcessStartOptions(string fileName);

    /// <summary>
    /// Resolves the given file name to an absolute path and creates a new ProcessStartOptions instance.
    /// </summary>
    public static ProcessStartOptions ResolvePath(string fileName); // NEW
}

The second part of the proposal is a low-level API that allows starting a child process with the given options and STD input/output/error handles.

The most important aspects:

  • WaitForExit and WaitForExitAsync do not depend on reading STD OUT/ERR until EOF (this is very error prone, see #51277).
  • Every new process inherits only STD IN/OUT/ERR. Users can specify additional handles to inherit via an allow-list.
namespace Microsoft.Win32.SafeHandles; // should we still follow that convention?

public class SafeChildProcessHandle : SafeHandle
{
    public int ProcessId { get; }

    public SafeChildProcessHandle(IntPtr existingHandle, int processId, bool ownsHandle);

    public static SafeChildProcessHandle Open(int processId);

    public static SafeChildProcessHandle Start(ProcessStartOptions options, SafeFileHandle? input, SafeFileHandle? output, SafeFileHandle? error); // NEW (https://github.com/dotnet/runtime/issues/28838)
    public static SafeChildProcessHandle StartSuspended(ProcessStartOptions options, SafeFileHandle? input, SafeFileHandle? output, SafeFileHandle? error); // NEW (https://github.com/dotnet/runtime/issues/94127)

    public bool Kill(bool entireProcessGroup = false);
    public void Resume(); // NEW (https://github.com/dotnet/runtime/issues/94127)

    public void SendSignal(PosixSignal signal, bool toEntireProcessGroup = false);  // NEW (https://github.com/dotnet/runtime/issues/59746)

    public ProcessExitStatus WaitForExit();
    public bool TryWaitForExit(TimeSpan timeout, out ProcessExitStatus exitStatus);
    public ProcessExitStatus WaitForExitOrKillOnTimeout(TimeSpan timeout);

    public Task<ProcessExitStatus> WaitForExitAsync(CancellationToken cancellationToken = default)
    public Task<ProcessExitStatus> WaitForExitOrKillOnCancellationAsync(CancellationToken cancellationToken)
}

We need an ability to send SIGKILL and SIGSTOP which so far were not part of the PosixSignal enum:

namespace System.Runtime.InteropServices;

public enum PosixSignal
{
    SIGTSTP = -10, // polite request to stop
+   SIGKILL = -11, // force termination
+   SIGSTOP = -12 // unconditional stop
}

And exit status to express processes that were signaled and/or terminated due to timeout or cancellation:

namespace System.INeedName;

public readonly struct ProcessExitStatus : IEquatable<ProcessExitStatus>
{
    internal ProcessExitStatus(int exitCode, bool cancelled, PosixSignal? signal = null);

    public int ExitCode { get; }
    public PosixSignal? Signal { get; }

    /// <summary>
    /// Gets a value indicating whether the process has been terminated due to timeout or cancellation.
    /// </summary>
    public bool Canceled { get; }

    public bool Equals(ProcessExitStatus other);
    public override bool Equals([NotNullWhen(true)] object? obj);
    public static bool operator ==(ProcessExitStatus left, ProcessExitStatus right);
    public static bool operator !=(ProcessExitStatus left, ProcessExitStatus right);
    public override int GetHashCode();
}

API Usage

This example demonstrates piping output from one process to another using anonymous pipes, providing empty input, redirecting to file and discarding error:

File.CreateAnonymousPipe(out SafeFileHandle readPipe, out SafeFileHandle writePipe);

using (readPipe)
using (writePipe)
{
    ProcessStartOptions producer = new("cmd")
    {
        Arguments = { "/c", "echo hello world & echo test line & echo another test" }
    };
    ProcessStartOptions consumer = new("findstr")
    {
        Arguments = { "test" }
    };

    // Start producer with output redirected to the write end of the pipe, no input and discarding error.
    using SafeChildProcessHandle producerHandle = SafeChildProcessHandle.Start(producer, input: null, output: writePipe, error: null);

    // Start consumer with input from the read end of the pipe, writing output to file and discarding error.
    using SafeFileHandle outputHandle = File.OpenHandle("output.txt", FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
    using SafeProcessHandle consumerHandle = SafeProcessHandle.Start(consumer, readPipe, outputHandle, error: null);

    // Wait for both processes to complete
    await producerHandle.WaitForExitAsync();
    await consumerHandle.WaitForExitAsync();
}

Alternative Designs

Moving InheritedHandles out of the option bag

InheritedHandles provides a list of additional handles to be inherited by the child process. Each of these handles implements IDisposable, so it affects the lifetime management of the option bag. Since providing such handles is a niche scenario that we need for completness, we could move it out of the option bag and into the Start method:

public sealed class ProcessStartOptions
{
-   public IList<SafeHandle> InheritedHandles { get; } 
}

public class SafeChildProcessHandle : SafeHandle
{
-   public static SafeChildProcessHandle Start(ProcessStartOptions options, SafeFileHandle? input, SafeFileHandle? output, SafeFileHandle? error);
+   public static SafeChildProcessHandle Start(ProcessStartOptions options, SafeFileHandle? input, SafeFileHandle? output, SafeFileHandle? error, IList<SafeHandle>? inheritedHandles = null);
}

It would allow the users to cache the option bag (with a resolved exe path for example) without worrying about the lifetime of the inherited handles.

Risks

To be 100% safe from accidental handle inheritance, both old and new APIs need to share the global reader-writer lock on Windows. See the details for more:

Details

From https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute:

PROC_THREAD_ATTRIBUTE_HANDLE_LIST: "These handles must be created as inheritable handles and must not include pseudo handles".

So even if we want to provide an explicit list of handles to inherit, we still need to create them as inheritable. It's fine as long as everyone uses PROC_THREAD_ATTRIBUTE_HANDLE_LIST to spawn processes, but if some code spawns a process without it, the inheritable handles may be inherited by accident. And if we introduce the new Process API, there is nothing that would stop the users from using the old API to spawn processes without PROC_THREAD_ATTRIBUTE_HANDLE_LIST.

To be 100% safe without breaking changes, the new and old API need to share the global reader-writer lock.

Due to explicit executable path resolution, if CreateProcess sys-call is one day changed to handle path resolution in a different way for security reasons, the managed layer will need to be updated as well.

Activity

  1. self-assigned this
    on Jan 20, 2026
  2. removed
    untriagedNew issue has not been triaged by the area owner
    on Jan 20, 2026
  3. dotnet-policy-service commented on Jan 20, 2026

    @dotnet-policy-service
    Contributor

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

  4. added this to the 11.0.0 milestone on Jan 20, 2026
  5. tmds commented on Jan 21, 2026

    @tmds
    Member

    WaitForExit and WaitForExitAsync do not depend on reading STD OUT/ERR until EOF (this is very error prone, see #51277).

    When a process produces more output than the size of the pipe, wait for exit depends on reading that output because the writing process will block when the pipe is full (which causes it not to exit).

    SafeFileHandle? input, SafeFileHandle? output, SafeFileHandle? error, IList? inheritedHandles = null

    On Linux/Unix, sometimes applications use conventions about the file descriptors that are passed. For example, systemd starts passing additional file descriptors at 3 (see https://www.freedesktop.org/software/systemd/man/latest/sd_listen_fds.html). This API doesn't support this.

    I want to make explicit it doesn't. I don't mean to say it must.

  6. tmds commented on Jan 21, 2026

    @tmds
    Member

    Task WaitForExitAsync(CancellationToken cancellationToken = default)

    Combining waiting for the process exit, returning the exit code and not depending it on EOF make a good API.

    I've implemented the same behavior in a RemoteProces.GetExitCodeAsync for Tmds.Ssh. That method also actively discards the output so that the process may complete. In this case SafeChildProcessHandle doesn't concern itself with the output streams, so killing the child when the CancellationToken triggers is a good way to go about it.

  7. tmds commented on Jan 21, 2026

    @tmds
    Member

    so killing the child when the CancellationToken triggers is a good way to go about it.

    Or you might leave it to the user when the method throws OperationCanceledException. The user can then call Kill and may call WaitForExitAsync once more.

  8. tmds commented on Jan 21, 2026

    @tmds
    Member

    Task WaitForExitAsync(CancellationToken cancellationToken = default)

    Adding a separate timeout arg can help encourage users to set a timeout (esp when they'd need to create a linked CancellationToken).

  9. adamsitnik commented on Jan 21, 2026

    @adamsitnik
    MemberAuthor

    This API doesn't support this.

    Yes, the handles are inherited with their current values. The reason why I don't allow to provide new value like with dup2 on Linux is that Windows does not support it.

    The DuplicateHandle sys-call just returns the new value.

    The exact code that I am using:

    https://github.com/adamsitnik/ProcessPlayground/blob/3191f94dde3a6c6da3a64857c8341b74e4eaee73/Library/SafeChildProcessHandle.Windows.cs#L276-L289

  10. jkotas commented on Jan 21, 2026

    @jkotas
    Member

    The reason why I don't allow to provide new value like with dup2 on Linux is that Windows does not support it.

    The typical pattern of passing the extra handles on Windows vs. Unix seems to be different. On Windows, one cannot depend on fixed handle value and the current handle value must be passed to the child process via some channel (e.g. via the command line). On Unix, it is common to depend on the fixed handle value, we have seen multiple instances of that.

    Emulating the Windows pattern on Unix does not make the API super useful on Unix. Should we have Unix-specific API to support the typical Unix pattern?

  11. KalleOlaviNiemitalo commented on Jan 21, 2026

    @KalleOlaviNiemitalo

    On Windows, the Universal C Runtime Library (UCRT) has a convention for mapping file descriptor numbers to handles, via the WORD cbReserved2 and LPBYTE lpReserved2 members of STARTUPINFOW. This is implemented in exec\spawnv.cpp and lowio\ioinit.cpp. So if the .NET Runtime implemented the same convention, then it would be possible for a .NET program to start a C program as a child process and pass a specific handle to it as file descriptor 3, for example. However, further API design would be needed if you wanted .NET programs to be able to receive this kind of information from their parent processes.

  12. dmitry-azaraev commented on Jan 22, 2026

    @dmitry-azaraev

    UCRT is interesting thing, but this doesnt make any sense. Valid Windows program may never links to UCRT. E.g. it may have sense only if whole windows kernel say "hey, now additionally to file handles i know about file descriptors and there is documented way how to pass them". But file descriptors currently on Windows is completely C library thing, and all current Windows programs do openfdhandle and get "dynamic" fd when they really need it, or they skip use of C library completely in favor of native calls, because they already have handle in the hands.

  13. 14 remaining items

  14. added
    api-approvedAPI was approved in API review, it can be implemented
    on Feb 10, 2026
  15. tmds commented on Feb 11, 2026

    @tmds
    Member

    SIGKILL = -11,

    I'm so confused 😄 https://www.youtube.com/watch?v=Fow7iUaKrq4

  16. tmds commented on Feb 19, 2026

    @tmds
    Member

    Some late additional feedback:

    public ProcessExitStatus WaitForExitOrKillOnTimeout(TimeSpan timeout);
    public Task WaitForExitOrKillOnCancellationAsync(CancellationToken cancellationToken);

    Perhaps these could have been left out, and leave the killing to the user based on how the other methods return?

    Does this kill the entire process group when CreateNewProcessGroup = true.

    public bool Kill();
    public bool KillProcessGroup();

    Perhaps a single Kill method might be enough if CreateNewProcessGroup = true implies Kill is for the process group.

  17. adamsitnik commented on Feb 26, 2026

    @adamsitnik
    MemberAuthor

    Perhaps these could have been left out, and leave the killing to the user based on how the other methods return?

    The reason I added them is that I've seen most of the code bases implementing it on their own. So I just want to provide in box (and ensure correctness in edge cases like timeout reached -> attempt to kill -> process already exited).

    Does this kill the entire process group when CreateNewProcessGroup = true.

    Kill maps to kill(pid, SIGKILL), KillProcessGroup maps to kill(-pid, SIGKILL).

  18. tmds commented on Feb 26, 2026

    @tmds
    Member

    Kill maps to kill(pid, SIGKILL), KillProcessGroup maps to `kill(-pid, SIGKILL).

    Users might end up accidentally calling Kill when they mean to call KillProcessGroup.
    It could have been an option to leave out KillProcessGroup so that they can only call Kill.

    If a user would really like to only kill the direct child, they could still call Signal(PosixSignal.SIGKILL).

    public bool Kill();

    Why does this return bool?

    I assume the use of this method to be:

    process.Kill();
    process.WaitForExit();

    And if you specifically like to know if the process was terminated by the kill:

    process.Kill();
    var status = process.WaitForExit();
    bool wasKilled = status.Signal == PosixSignal.SIGKILL;

    public void Signal(PosixSignal signal);
    public void SignalProcessGroup(PosixSignal signal);

    Perhaps these should return a bool so that they can return false when the process/process group no longer exist.

  19. adamsitnik commented on Feb 26, 2026

    @adamsitnik
    MemberAuthor

    Users might end up accidentally calling Kill when they mean to call KillProcessGroup. It could have been an option to leave out KillProcessGroup so that they can only call Kill.

    If a user would really like to only kill the direct child, they could still call Signal(PosixSignal.SIGKILL).

    Historically in .NET, we had Process.Kill that kills the direct child. Then we introduced Process.Kill(bool entireProcessTree) that walks entire process tree and kills each process individually (it's bad for many reasons). The idea of KillProcessGroup is to kill the entire process group with a single sys-call.

    Why does this return bool?

    To let users identify scenarios where the process has already exited without throwing an exception. Imagine:

    if (!handle.TryWaitForExit(TimeSpan.FromSeconds(3))
    {
        // the process exits here
        handle.Kill(); // there is nothing to kill
    }

    On Unix, we check errno and return false on ESRCH

    And if you specifically like to know if the process was terminated by the kill:

    It works, but only on Unix. On Windows, there is no way to detect if the process was killed or not (the sys-call for termination requires the caller to provide exit code in explicit way)

    Perhaps these should return a bool so that they can return false when the process/process group no longer exist.

    If everything goes right, this API will be shipped as part of preview 3. If it's clear that it would benefit our users and can be implemented on Windows too, I am more than happy to make it return bool.

  20. tmds commented on Feb 26, 2026

    @tmds
    Member

    The idea of KillProcessGroup is to kill the entire process group with a single sys-call.

    My question is whether it would make sense to remove KillProcessGroup and have Kill kill the process group when CreateNewProcessGroup = true.

    This avoids accidentally calling Kill when you meant to call KillProcessGroup.

    To let users identify scenarios where the process has already exited without throwing an exception.

    Process.Kill has void return and when the process has already terminated it doesn't throw.

    This API could do the same. It avoids returning a value the user thinks he should act on.

    It works, but only on Unix. On Windows, there is no way to detect if the process was killed or not (the sys-call for termination requires the caller to provide exit code in explicit way)

    Also on Windows the exit code will indicate the application terminated in an unexpected fashion.

    If it's clear that it would benefit our users and can be implemented on Windows too, I am more than happy to make it return bool.

    The user code that calls Signal/SignalProcessGroup should handle the case when the process (group) is no longer there.

    It's not intuitive this is done through an exception which should be caught by the user.

  21. adamsitnik commented on Feb 27, 2026

    @adamsitnik
    MemberAuthor

    My question is whether it would make sense to remove KillProcessGroup and have Kill kill the process group when CreateNewProcessGroup = true.

    Thanks, now I get it. Can we guarantee there are no valid scenarios where users would like to kill the single process instead of a group when CreateNewProcessGroup = true?

    And there is one more scenario: we are going to allow the users to open existing processes by pid. So for example, when for some reason they decided to spawn the process on their own, they can use our APIs to work with it. In such scenario, we don't know whether the process is a leader of it's own group (for example, on Windows, we can't fetch dwCreationFlags of an exiting process).

    SafeProcessHandle handle = SafeProcessHandle.Open(myPid);
    handle.Kill(); // is it a leader?

    This API could do the same. It avoids returning a value the user thinks he should act on.

    It returns this value on purpose, so the users can differentiate a process that exited before it got killed. Others are free to ignore it, they same way they can ignore HashSet.Add result.

    It's not intuitive this is done through an exception which should be caught by the user.

    I am not against changing Signal to return bool, based on #59746 I simply had not enough data to identify this requirement.

  22. tmds commented on May 8, 2026

    @tmds
    Member

    @adamsitnik can this be closed? or does it still track something?

  23. adamsitnik commented on May 8, 2026

    @adamsitnik
    MemberAuthor

    @adamsitnik can this be closed? or does it still track something?

    There is one API I still need to implement:

    public static SafeProcessHandle Open(int processId);

    I am most likely going to do it in one PR with #101582

  24. locked and limited conversation to collaborators on Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions