Implement new Process APIs: Run, ReadAllLines, RunAndCaptureText, StartAndForget, SafeProcessHandle wait methods#126800
Implement new Process APIs: Run, ReadAllLines, RunAndCaptureText, StartAndForget, SafeProcessHandle wait methods#126800
Conversation
|
Tagging subscribers to this area: @dotnet/area-system-diagnostics-process |
…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>
… 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>
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/f2452c88-4c47-4a67-a440-70aa4e58f918 Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
There was a problem hiding this comment.
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
Processinstance/static APIs for output draining (ReadAll*) and execution helpers (Run*,RunAndCaptureText*,StartAndForget). - Added
SafeProcessHandle.Openplus sync/async wait APIs returningProcessExitStatus. - Introduced
ProcessOutputLine/ProcessTextOutputand addedProcessStartInfo.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
| /// <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) | ||
| { |
There was a problem hiding this comment.
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.
| return ReadAllLinesIterator(); | ||
| } | ||
|
|
||
| private IEnumerable<ProcessOutputLine> ReadAllLinesIterator() | ||
| { | ||
| StreamReader stdout = _standardOutput!; | ||
| StreamReader stderr = _standardError!; | ||
|
|
There was a problem hiding this comment.
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.
| 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) | |
| { |
| Task.WaitAll(stdoutTask, stderrTask); | ||
|
|
||
| bool canceled = false; | ||
| if (timeout.HasValue) | ||
| { | ||
| if (!process.WaitForExit(timeout.Value)) | ||
| { | ||
| process.Kill(entireProcessTree: true); | ||
| canceled = true; | ||
| } | ||
| } | ||
|
|
||
| process.WaitForExit(); |
There was a problem hiding this comment.
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).
| 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); |
| startInfo.RedirectStandardOutput = true; | ||
| startInfo.RedirectStandardError = true; | ||
|
|
||
| using Process process = Start(startInfo)!; |
There was a problem hiding this comment.
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.
| using Process process = Start(startInfo)!; | |
| using Process process = Start(startInfo) ?? throw new InvalidOperationException("The process could not be started."); |
| { | ||
| ArgumentNullException.ThrowIfNull(startInfo); | ||
|
|
||
| using Process process = Start(startInfo)!; |
There was a problem hiding this comment.
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.
| using Process process = Start(startInfo)!; | |
| using Process process = Start(startInfo) ?? throw new InvalidOperationException("Process.Start returned null."); |
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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())); | ||
| } |
There was a problem hiding this comment.
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.
| 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); | |
| } |
| catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) | ||
| { | ||
| Kill(); | ||
| return await WaitForExitAsyncCore(CancellationToken.None).ConfigureAwait(false); |
There was a problem hiding this comment.
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).
| return await WaitForExitAsyncCore(CancellationToken.None).ConfigureAwait(false); | |
| ProcessExitStatus exitStatus = await WaitForExitAsyncCore(CancellationToken.None).ConfigureAwait(false); | |
| exitStatus.Canceled = true; | |
| return exitStatus; |
| [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); | ||
| } |
There was a problem hiding this comment.
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().
| ProcessTextOutput output = Process.RunAndCaptureText(psi); | ||
| Assert.Equal(RemoteExecutor.SuccessExitCode, output.ExitStatus.ExitCode); | ||
| Assert.False(output.ExitStatus.Canceled); | ||
| Assert.True(output.ProcessId > 0); |
There was a problem hiding this comment.
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.
| Assert.True(output.ProcessId > 0); | |
| Assert.True(output.ProcessId > 0); | |
| Assert.False(string.IsNullOrEmpty(output.StandardOutput)); | |
| Assert.False(string.IsNullOrEmpty(output.StandardError)); |
Description
Implements the approved new
System.Diagnostics.ProcessAPIs 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 outputProcessTextOutput— captures exit status + stdout + stderr + PID from a completed processProcessStartInfo
KillOnParentExit—[SupportedOSPlatform("windows"), SupportedOSPlatform("linux")]SafeProcessHandle
Open(int processId)— open existing process by PIDWaitForExit(),TryWaitForExit(),WaitForExitOrKillOnTimeout()— sync wait returningProcessExitStatusWaitForExitAsync(),WaitForExitOrKillOnCancellationAsync()— async variantsProcess instance methods (concurrent stdout+stderr draining, no deadlocks)
ReadAllLines()/ReadAllLinesAsync()— multiplexed line-by-line viaTask.WhenAnyReadAllText()/ReadAllTextAsync()— full text captureReadAllBytes()/ReadAllBytesAsync()— raw byte captureProcess static methods
Run()/RunAsync()— start, wait, returnProcessExitStatus; kill on timeout/cancellationRunAndCaptureText()/RunAndCaptureTextAsync()— start, capture output, returnProcessTextOutputStartAndForget()— fire-and-forget, returns PID, cleans up resourcesUsage
Notes:
timeoutparameters onReadAll*methods are present per approved API shape for forward compatibility; actual drain timeout behavior deferred per API review.KillOnParentExitproperty/validation added; native enforcement (Job Objects/prctl) requires follow-up native interop work.