Skip to content

Implement new Process APIs: Run, ReadAllLines, RunAndCaptureText, StartAndForget, SafeProcessHandle wait methods#126800

Closed
Copilot wants to merge 4 commits intomainfrom
copilot/add-new-process-apis
Closed

Implement new Process APIs: Run, ReadAllLines, RunAndCaptureText, StartAndForget, SafeProcessHandle wait methods#126800
Copilot wants to merge 4 commits intomainfrom
copilot/add-new-process-apis

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 11, 2026

Description

Implements the approved new System.Diagnostics.Process APIs from the API proposal. These APIs address deadlock-prone output reading, missing process lifecycle management, and lack of convenient run-and-wait patterns.

New types

  • ProcessOutputLine — readonly struct for multiplexed stdout/stderr line output
  • ProcessTextOutput — captures exit status + stdout + stderr + PID from a completed process

ProcessStartInfo

  • KillOnParentExit[SupportedOSPlatform("windows"), SupportedOSPlatform("linux")]

SafeProcessHandle

  • Open(int processId) — open existing process by PID
  • WaitForExit(), TryWaitForExit(), WaitForExitOrKillOnTimeout() — sync wait returning ProcessExitStatus
  • WaitForExitAsync(), WaitForExitOrKillOnCancellationAsync() — async variants

Process instance methods (concurrent stdout+stderr draining, no deadlocks)

  • ReadAllLines() / ReadAllLinesAsync() — multiplexed line-by-line via Task.WhenAny
  • ReadAllText() / ReadAllTextAsync() — full text capture
  • ReadAllBytes() / ReadAllBytesAsync() — raw byte capture

Process static methods

  • Run() / RunAsync() — start, wait, return ProcessExitStatus; kill on timeout/cancellation
  • RunAndCaptureText() / RunAndCaptureTextAsync() — start, capture output, return ProcessTextOutput
  • StartAndForget() — fire-and-forget, returns PID, cleans up resources

Usage

// Deadlock-free output reading
using Process p = Process.Start(new ProcessStartInfo("cmd") { RedirectStandardOutput = true, RedirectStandardError = true });
foreach (ProcessOutputLine line in p.ReadAllLines())
    Console.WriteLine($"[{(line.StandardError ? "ERR" : "OUT")}] {line.Content}");

// One-liner to run and capture
ProcessTextOutput result = Process.RunAndCaptureText("dotnet", ["--version"]);
Console.WriteLine(result.StandardOutput);

// Run with timeout
ProcessExitStatus status = Process.Run(startInfo, timeout: TimeSpan.FromSeconds(30));

Notes:

  • timeout parameters on ReadAll* methods are present per approved API shape for forward compatibility; actual drain timeout behavior deferred per API review.
  • KillOnParentExit property/validation added; native enforcement (Job Objects/prctl) requires follow-up native interop work.

Copilot AI linked an issue Apr 11, 2026 that may be closed by this pull request
Copilot AI requested review from Copilot and removed request for Copilot April 11, 2026 22:05
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

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

…entExit, SafeProcessHandle wait methods, Process.Run/RunAsync/ReadAll/RunAndCaptureText/StartAndForget

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/f2452c88-4c47-4a67-a440-70aa4e58f918

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot April 11, 2026 22:42
… ReadAllLines, ReadAllText, ReadAllBytes, Run, RunAsync, StartAndForget, SafeProcessHandle wait methods

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/f2452c88-4c47-4a67-a440-70aa4e58f918

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot April 11, 2026 22:55
Copilot AI requested review from Copilot and removed request for Copilot April 11, 2026 22:56
Copilot AI changed the title [WIP] Add new APIs to improve process output handling Implement new Process APIs: Run, ReadAllLines, RunAndCaptureText, StartAndForget, SafeProcessHandle wait methods Apr 11, 2026
Copilot AI requested a review from adamsitnik April 11, 2026 23:00
@danmoseley danmoseley requested a review from Copilot April 12, 2026 02:07
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements newly approved System.Diagnostics.Process convenience APIs (run/wait patterns and deadlock-safe stdout/stderr draining), adds supporting DTO types, and extends SafeProcessHandle with open-by-PID and wait helpers.

Changes:

  • Added new Process instance/static APIs for output draining (ReadAll*) and execution helpers (Run*, RunAndCaptureText*, StartAndForget).
  • Added SafeProcessHandle.Open plus sync/async wait APIs returning ProcessExitStatus.
  • Introduced ProcessOutputLine / ProcessTextOutput and added ProcessStartInfo.KillOnParentExit, with accompanying tests and ref updates.
Show a summary per file
File Description
src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.cs Adds the new Process APIs (ReadAll*, Run*, RunAndCaptureText*, StartAndForget).
src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.cs Adds Open + wait APIs to SafeProcessHandle surface.
src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Windows.cs Implements Open/Wait cores for Windows.
src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Unix.cs Implements Open/Wait cores for Unix.
src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.cs Adds KillOnParentExit property with platform annotations.
src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessOutputLine.cs New line DTO for multiplexed stdout/stderr.
src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessTextOutput.cs New result type for captured text + exit status + pid.
src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx Adds new resource string used by ReadAll* error paths.
src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj Includes new source files in the build.
src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs Updates reference surface for all new public APIs/types.
src/libraries/System.Diagnostics.Process/tests/ProcessTests.cs Adds tests for new Process APIs.
src/libraries/System.Diagnostics.Process/tests/SafeProcessHandleTests.cs Adds tests for new SafeProcessHandle APIs.
src/libraries/System.Diagnostics.Process/tests/ProcessStartInfoTests.cs Adds tests for KillOnParentExit.
src/libraries/System.Diagnostics.Process/tests/ProcessExitStatusTests.cs Adds tests for new DTO constructors/guard clauses.

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 11

Comment on lines +1782 to +1793
/// <summary>
/// Reads all output lines from the process's standard output and standard error streams.
/// Both streams are drained concurrently to avoid deadlocks.
/// </summary>
/// <param name="timeout">The maximum amount of time to wait. When <see langword="null"/>, the default timeout is used.</param>
/// <returns>An enumerable of <see cref="ProcessOutputLine"/> representing the lines from both streams.</returns>
/// <exception cref="InvalidOperationException">The process has not been started, or standard output/error are not redirected.</exception>
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[SupportedOSPlatform("maccatalyst")]
public IEnumerable<ProcessOutputLine> ReadAllLines(TimeSpan? timeout = default)
{
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

The timeout parameter is currently unused, but the XML doc states it controls “the maximum amount of time to wait” (and mentions a default timeout). This is a behavior/documentation mismatch; either implement timeout handling or update the docs to clearly state the parameter is reserved and currently has no effect.

Copilot uses AI. Check for mistakes.
Comment on lines +1801 to +1808
return ReadAllLinesIterator();
}

private IEnumerable<ProcessOutputLine> ReadAllLinesIterator()
{
StreamReader stdout = _standardOutput!;
StreamReader stderr = _standardError!;

Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

These new ReadAll* APIs access _standardOutput/_standardError directly, which bypasses the existing StandardOutput/StandardError property guards that set _outputStreamReadMode/_errorStreamReadMode and prevent mixing sync stream reads with BeginOutputReadLine / BeginErrorReadLine. This can allow unsupported mixing patterns that currently throw SR.CantMixSyncAsyncOperation. Consider going through the existing properties (or otherwise updating the read-mode tracking) so the same invariants are enforced.

Suggested change
return ReadAllLinesIterator();
}
private IEnumerable<ProcessOutputLine> ReadAllLinesIterator()
{
StreamReader stdout = _standardOutput!;
StreamReader stderr = _standardError!;
StreamReader stdout = StandardOutput;
StreamReader stderr = StandardError;
return ReadAllLinesIterator(stdout, stderr);
}
private IEnumerable<ProcessOutputLine> ReadAllLinesIterator(StreamReader stdout, StreamReader stderr)
{

Copilot uses AI. Check for mistakes.
Comment on lines +2098 to +2110
Task.WaitAll(stdoutTask, stderrTask);

bool canceled = false;
if (timeout.HasValue)
{
if (!process.WaitForExit(timeout.Value))
{
process.Kill(entireProcessTree: true);
canceled = true;
}
}

process.WaitForExit();
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

RunAndCaptureText waits for ReadToEndAsync on stdout/stderr to complete before enforcing the timeout. Since those reads typically complete only when the process exits, the timeout is effectively ignored and this can hang indefinitely for long-running processes. The timeout logic needs to run concurrently with draining (e.g., wait for process exit with timeout while reads are in progress, then kill + finish draining).

Suggested change
Task.WaitAll(stdoutTask, stderrTask);
bool canceled = false;
if (timeout.HasValue)
{
if (!process.WaitForExit(timeout.Value))
{
process.Kill(entireProcessTree: true);
canceled = true;
}
}
process.WaitForExit();
Task waitForExitTask = process.WaitForExitAsync(CancellationToken.None);
bool canceled = false;
if (timeout.HasValue)
{
Task completedTask = Task.WhenAny(waitForExitTask, Task.Delay(timeout.Value)).GetAwaiter().GetResult();
if (completedTask != waitForExitTask)
{
process.Kill(entireProcessTree: true);
canceled = true;
}
else
{
waitForExitTask.GetAwaiter().GetResult();
}
}
process.WaitForExit();
Task.WaitAll(stdoutTask, stderrTask);

Copilot uses AI. Check for mistakes.
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;

using Process process = Start(startInfo)!;
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

Process.Start(ProcessStartInfo) can legally return null (e.g., Windows ShellExecute/DDE scenarios). Using the null-forgiving operator here can lead to a NullReferenceException later. These helpers should handle the null return (e.g., throw a consistent exception) instead of assuming Start always succeeds.

Suggested change
using Process process = Start(startInfo)!;
using Process process = Start(startInfo) ?? throw new InvalidOperationException("The process could not be started.");

Copilot uses AI. Check for mistakes.
{
ArgumentNullException.ThrowIfNull(startInfo);

using Process process = Start(startInfo)!;
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

Process.Start(ProcessStartInfo) can return null; the null-forgiving operator here makes Run susceptible to NullReferenceException in those cases. Handle the null return with an explicit exception instead of assuming non-null.

Suggested change
using Process process = Start(startInfo)!;
using Process process = Start(startInfo) ?? throw new InvalidOperationException("Process.Start returned null.");

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +81
private static SafeProcessHandle OpenCore(int processId)
{
// On Unix, verify the process exists by sending signal 0.
int killResult = Interop.Sys.Kill(processId, 0);
if (killResult != 0)
{
Interop.ErrorInfo errorInfo = Interop.Sys.GetLastErrorInfo();
if (errorInfo.Error == Interop.Error.ESRCH)
{
throw new ArgumentException(SR.Format(SR.MissingProcess, processId.ToString()));
}
// EPERM means process exists but we don't have permission - still "open" it
}

var holder = new ProcessWaitState.Holder(processId);
var handle = new SafeProcessHandle(processId, holder);
holder.Dispose(); // The DangerousAddRef in the constructor keeps the handle alive.
return handle;
}
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

On Unix, OpenCore disposes the ProcessWaitState.Holder immediately. For non-child processes, that can drop the wait-state refcount to zero, disposing the ManualResetEvent that backs the returned SafeWaitHandle; the SafeProcessHandle then has a wait handle that will never be signaled. Open likely needs to keep the Holder (or otherwise keep the ProcessWaitState alive) for the lifetime of the SafeProcessHandle and release it in ReleaseHandle.

Copilot uses AI. Check for mistakes.
Comment on lines +511 to +517
Interop.Advapi32.ProcessOptions.PROCESS_QUERY_LIMITED_INFORMATION | Interop.Advapi32.ProcessOptions.SYNCHRONIZE | Interop.Advapi32.ProcessOptions.PROCESS_TERMINATE,
false,
processId);
if (handle.IsInvalid)
{
throw new ArgumentException(SR.Format(SR.MissingProcess, processId.ToString()));
}
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

OpenCore treats any failure from OpenProcess as “missing process” and throws ArgumentException, but OpenProcess can fail for other reasons (e.g., access denied). This loses the real error and is misleading. Also, requesting PROCESS_TERMINATE here is stricter than required for waiting/querying and can cause Open to fail unnecessarily. Consider opening with the minimum required rights and throwing an exception that preserves the Win32 error for non-ESRCH/not-running cases.

Suggested change
Interop.Advapi32.ProcessOptions.PROCESS_QUERY_LIMITED_INFORMATION | Interop.Advapi32.ProcessOptions.SYNCHRONIZE | Interop.Advapi32.ProcessOptions.PROCESS_TERMINATE,
false,
processId);
if (handle.IsInvalid)
{
throw new ArgumentException(SR.Format(SR.MissingProcess, processId.ToString()));
}
Interop.Advapi32.ProcessOptions.PROCESS_QUERY_LIMITED_INFORMATION | Interop.Advapi32.ProcessOptions.SYNCHRONIZE,
false,
processId);
if (handle.IsInvalid)
{
int errorCode = Marshal.GetLastWin32Error();
if (errorCode == 87) // ERROR_INVALID_PARAMETER
{
throw new ArgumentException(SR.Format(SR.MissingProcess, processId.ToString()));
}
throw new Win32Exception(errorCode);
}

Copilot uses AI. Check for mistakes.
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
Kill();
return await WaitForExitAsyncCore(CancellationToken.None).ConfigureAwait(false);
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

WaitForExitOrKillOnTimeout / WaitForExitOrKillOnCancellationAsync kill the process when the timeout/cancellation triggers, but the returned ProcessExitStatus will always have Canceled == false because WaitForExitCore/WaitForExitAsyncCore hardcode canceled: false. This makes it impossible for callers to know the kill path was taken. Consider setting Canceled = true when the method performs the kill (consistent with the new Process.Run* APIs).

Suggested change
return await WaitForExitAsyncCore(CancellationToken.None).ConfigureAwait(false);
ProcessExitStatus exitStatus = await WaitForExitAsyncCore(CancellationToken.None).ConfigureAwait(false);
exitStatus.Canceled = true;
return exitStatus;

Copilot uses AI. Check for mistakes.
Comment on lines +1489 to +1502
[Fact]
public void KillOnParentExit_DefaultIsFalse()
{
var psi = new ProcessStartInfo();
Assert.False(psi.KillOnParentExit);
}

[Fact]
public void KillOnParentExit_SetAndGet()
{
var psi = new ProcessStartInfo();
psi.KillOnParentExit = true;
Assert.True(psi.KillOnParentExit);
}
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

KillOnParentExit is annotated [SupportedOSPlatform("windows")] and [SupportedOSPlatform("linux")]. Accessing it in an unguarded [Fact] will trigger CA1416 platform-analyzer warnings (often treated as errors in dotnet/runtime tests) on other target platforms. Mark these tests as platform-specific (Windows|Linux) or guard the property access with OperatingSystem.IsWindows()/IsLinux().

Copilot uses AI. Check for mistakes.
ProcessTextOutput output = Process.RunAndCaptureText(psi);
Assert.Equal(RemoteExecutor.SuccessExitCode, output.ExitStatus.ExitCode);
Assert.False(output.ExitStatus.Canceled);
Assert.True(output.ProcessId > 0);
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

This test is named RunAndCaptureText_CapturesOutput but it doesn't assert that stdout/stderr were actually captured (only exit status and PID). It should validate the captured text to ensure the API is exercising the intended behavior and to prevent regressions in the output-draining/capture logic.

Suggested change
Assert.True(output.ProcessId > 0);
Assert.True(output.ProcessId > 0);
Assert.False(string.IsNullOrEmpty(output.StandardOutput));
Assert.False(string.IsNullOrEmpty(output.StandardError));

Copilot uses AI. Check for mistakes.
@adamsitnik adamsitnik closed this Apr 12, 2026
@adamsitnik adamsitnik deleted the copilot/add-new-process-apis branch April 12, 2026 06:50
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.

New Process APIs

3 participants