Implement Process.ReadAllText and ReadAllBytes with platform-specific multiplexing#126807
Implement Process.ReadAllText and ReadAllBytes with platform-specific multiplexing#126807
Conversation
…plexing Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/45ece5ba-166f-4418-887f-3ecaba54615e Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
…le fixes needed) Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/45ece5ba-166f-4418-887f-3ecaba54615e Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
…ead of GetAwaiter().GetResult() Replace the async Task-based Windows implementation with proper Win32 overlapped IO: - Allocate NativeOverlapped* with ManualResetEvent for each pipe - Use Interop.Kernel32.ReadFile with overlapped structs - Use WaitHandle.WaitAny for synchronous multiplexing - Use GetOverlappedResult to retrieve bytes read - Use CancelIoEx for timeout cancellation - Add System.Threading.Overlapped project reference - Add ReadFile, GetOverlappedResult, CancelIoEx interop references - Fix nullable warning in Unix implementation Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/8af96aef-e52b-459d-a945-e1159e8be19b Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
…in32Exception Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/8af96aef-e52b-459d-a945-e1159e8be19b Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
|
Tagging subscribers to this area: @dotnet/area-system-diagnostics-process |
adamsitnik
left a comment
There was a problem hiding this comment.
@copilot please address my feedback
| bool hasOutput = _standardOutput is not null; | ||
| bool hasError = _standardError is not null; | ||
|
|
||
| if (!hasOutput && !hasError) | ||
| { | ||
| throw new InvalidOperationException(SR.CantGetStandardOut); | ||
| } |
There was a problem hiding this comment.
After thinking about it for a while, I believe we should always require both std out and error to be redirected. Otherwise it could lead to a situation where the user forgets to redirect standard error, calls the new method, gets an empty error and thinks that nothing was written to standard error. While it could be written, but simply not read.
@copilot please make the verification more strict and update all called methods in a way they can assume that both std out and err will be always provided
| bool hasOutput = _standardOutput is not null; | |
| bool hasError = _standardError is not null; | |
| if (!hasOutput && !hasError) | |
| { | |
| throw new InvalidOperationException(SR.CantGetStandardOut); | |
| } | |
| if (_standardOutput is null) | |
| { | |
| throw new InvalidOperationException(SR.CantGetStandardOut); | |
| } | |
| else if (_standardError is null) | |
| { | |
| throw new InvalidOperationException(SR.CantGetStandardError); | |
| } |
There was a problem hiding this comment.
Done in 0986690. Both _standardOutput and _standardError are now required — separate checks throw InvalidOperationException with the appropriate message if either is null. All downstream methods (ReadPipes on both platforms) now take non-nullable SafeFileHandle parameters. Added two new test cases (ReadAllBytes_ThrowsWhenOnlyOutputRedirected, ReadAllBytes_ThrowsWhenOnlyErrorRedirected) and updated all existing tests to always redirect both streams.
| byte[] outputResult = outputBytesRead > 0 | ||
| ? outputBuffer.AsSpan(0, outputBytesRead).ToArray() | ||
| : Array.Empty<byte>(); | ||
|
|
||
| byte[] errorResult = errorBytesRead > 0 | ||
| ? errorBuffer.AsSpan(0, errorBytesRead).ToArray() | ||
| : Array.Empty<byte>(); |
There was a problem hiding this comment.
Create a new helper method that does everything that ReadAllBytes currently does except of creating a copy of the buffer. Then call this helper from ReadAllText in order to avoid creating a copy of the buffer when we just want to decode the text. And of course use this helper from ReadAllBytes as well.
There was a problem hiding this comment.
Done in 0986690. Extracted ReadPipesToBuffers which handles validation, handle extraction, DangerousAddRef/DangerousRelease, and calls ReadPipes. Both ReadAllText and ReadAllBytes call it. ReadAllText now decodes directly from the rented buffers via Encoding.GetString(byte[], int, int) — no intermediate .ToArray() copy.
| private static SafeFileHandle GetSafeFileHandleFromStreamReader(StreamReader reader) | ||
| { | ||
| Stream baseStream = reader.BaseStream; | ||
|
|
||
| if (baseStream is FileStream fileStream) | ||
| { | ||
| return fileStream.SafeFileHandle; | ||
| } | ||
|
|
||
| if (baseStream is System.IO.Pipes.AnonymousPipeClientStream pipeStream) | ||
| { | ||
| return new SafeFileHandle(pipeStream.SafePipeHandle.DangerousGetHandle(), ownsHandle: false); | ||
| } |
There was a problem hiding this comment.
Populate the owner and in the method that calls this helper call owner.DagnerousAddRef and accordingly owner.DangerousRelease before and after calling ReadPipes. This will allow us to avoid user disposing the handle while we are using it.
| private static SafeFileHandle GetSafeFileHandleFromStreamReader(StreamReader reader) | |
| { | |
| Stream baseStream = reader.BaseStream; | |
| if (baseStream is FileStream fileStream) | |
| { | |
| return fileStream.SafeFileHandle; | |
| } | |
| if (baseStream is System.IO.Pipes.AnonymousPipeClientStream pipeStream) | |
| { | |
| return new SafeFileHandle(pipeStream.SafePipeHandle.DangerousGetHandle(), ownsHandle: false); | |
| } | |
| private static SafeFileHandle GetSafeFileHandleFromStreamReader(StreamReader reader, out SafeHandle owner) | |
| { | |
| Stream baseStream = reader.BaseStream; | |
| if (baseStream is FileStream fileStream) | |
| { | |
| owner = fileStream.SafeFileHandle; | |
| return fileStream.SafeFileHandle; | |
| } | |
| if (baseStream is System.IO.Pipes.AnonymousPipeClientStream pipeStream) | |
| { | |
| owner = pipeStream.SafePipeHandle; | |
| return new SafeFileHandle(pipeStream.SafePipeHandle.DangerousGetHandle(), ownsHandle: false); | |
| } |
There was a problem hiding this comment.
Done in 0986690. GetSafeFileHandleFromStreamReader now takes out SafeHandle owner. The caller in ReadPipesToBuffers calls DangerousAddRef before ReadPipes and DangerousRelease in the finally block.
| /// <summary> | ||
| /// Reads from one or both standard output and standard error pipes using platform-specific multiplexing. | ||
| /// </summary> | ||
| private static partial void ReadPipes( | ||
| SafeFileHandle? outputHandle, | ||
| SafeFileHandle? errorHandle, | ||
| int timeoutMs, | ||
| ref byte[] outputBuffer, | ||
| ref int outputBytesRead, | ||
| ref byte[] errorBuffer, | ||
| ref int errorBytesRead); |
There was a problem hiding this comment.
As I wrote in the requirements, there is no need to use partial method here as we target only Windows and Unix platforms and the right method will be used anyway (form .Windows.cs or .Unix.cs file).
| /// <summary> | |
| /// Reads from one or both standard output and standard error pipes using platform-specific multiplexing. | |
| /// </summary> | |
| private static partial void ReadPipes( | |
| SafeFileHandle? outputHandle, | |
| SafeFileHandle? errorHandle, | |
| int timeoutMs, | |
| ref byte[] outputBuffer, | |
| ref int outputBytesRead, | |
| ref byte[] errorBuffer, | |
| ref int errorBytesRead); |
There was a problem hiding this comment.
Done in 0986690. Removed the partial method declaration. ReadPipes is now a regular private static void in each platform file.
…elper method, DangerousAddRef/Release, remove partial Changes: 1. Require both stdout and stderr to be redirected (throw if either is missing) 2. Create ReadPipesToBuffers helper shared between ReadAllText and ReadAllBytes (ReadAllText avoids extra buffer copy by decoding from rented buffers directly) 3. GetSafeFileHandleFromStreamReader now returns an owner SafeHandle; caller uses DangerousAddRef/DangerousRelease to prevent handle disposal during reads 4. Remove partial method declaration; ReadPipes is a regular static method in each platform file (non-nullable parameters since both handles always provided) 5. Update tests: all tests redirect both stdout and stderr; add two new tests for missing-stream validation Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/cd917b13-f344-47c5-ac1e-cfb2c2da34f6 Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
All feedback addressed in 0986690. Summary of changes:
|
adamsitnik
left a comment
There was a problem hiding this comment.
@copilot address my feedback
| CheckDisposed(); | ||
|
|
||
| if (_standardOutput is null) | ||
| { | ||
| throw new InvalidOperationException(SR.CantGetStandardOut); | ||
| } | ||
|
|
||
| if (_standardError is null) | ||
| { | ||
| throw new InvalidOperationException(SR.CantGetStandardError); | ||
| } | ||
|
|
||
| if (_outputStreamReadMode == StreamReadMode.AsyncMode) | ||
| { | ||
| throw new InvalidOperationException(SR.CantMixSyncAsyncOperation); | ||
| } | ||
|
|
||
| if (_errorStreamReadMode == StreamReadMode.AsyncMode) | ||
| { | ||
| throw new InvalidOperationException(SR.CantMixSyncAsyncOperation); | ||
| } |
There was a problem hiding this comment.
Move the validation logic to a helper method and call it from ReadAllBytes and ReadAllText before renting the arrays from the pool.
There was a problem hiding this comment.
Done in df0e65a. Extracted ValidateReadAllState() which is called from both ReadAllText and ReadAllBytes before renting arrays from the pool. ReadPipesToBuffers no longer contains any validation logic.
| } | ||
| finally | ||
| { | ||
| if (outputOverlapped != null) |
There was a problem hiding this comment.
nit: prefer is null and is not null syntax
| if (outputOverlapped != null) | |
| if (outputOverlapped is not null) |
| NativeOverlapped* outputOverlapped = null; | ||
| NativeOverlapped* errorOverlapped = null; | ||
|
|
||
| EventWaitHandle outputEvent = new(initialState: false, EventResetMode.ManualReset); |
There was a problem hiding this comment.
this can throw (for example OutOfMemoryException), in such case outputPin won't be diposed.
Make whole method unsafe, remove the unsafe block from here and make sure that the pins, overlapped and events always get cleaned up.
There was a problem hiding this comment.
Done in df0e65a. The method is now private static unsafe void ReadPipes(...). All resources (pins, overlapped, events) are declared and initialized to defaults at the top, allocated inside a single try block, and cleaned up in the finally — so any exception at any allocation point will properly dispose previously allocated resources.
…indows ReadPipes fully unsafe with proper cleanup, use is not null Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/451cf437-2de0-409a-937a-0031c3fa69d4 Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
| outputOverlapped = AllocateOverlapped(outputEvent); | ||
| errorOverlapped = AllocateOverlapped(errorEvent); |
There was a problem hiding this comment.
Can't the overlapped's be allocated on the stack?
Description
Implements
Process.ReadAllTextandProcess.ReadAllBytes— new APIs (approved in #125838) that drain both stdout and stderr without deadlocking, using single-threaded synchronous multiplexing. Both standard output and standard error must be redirected; the methods throwInvalidOperationExceptionif either is missing.Windows — Overlapped IO
ReadPipesisprivate static unsafe voidwith all resources (pins, overlapped, events) declared at method top and cleaned up in a singlefinallyblock — ensures no leaks if any allocation throwsNativeOverlapped*withManualResetEventper pipeReadFile(overlapped) →WaitHandle.WaitAny→GetOverlappedResultloopCancelIoExon timeout,GetOverlappedResult(bWait: true)to observe completion before freeing OVERLAPPEDis not nullpattern matching syntax throughoutUnix — poll(2)
Interop.Sys.Pollon stdout/stderr file descriptorsRandomAccess.Readonce poll reports data readyReadPipesis a regularprivate static void(non-nullableSafeFileHandleparameters)Common (Process.Multiplexing.cs)
ValidateReadAllState: validates state (disposed, both streams redirected, async mode conflict) and sets sync mode — called fromReadAllTextandReadAllBytesbefore renting arrays from the poolReadPipesToBuffers: obtains handles withDangerousAddRef/DangerousReleaseprotection and delegates to platformReadPipesReadAllBytes: callsValidateReadAllState, rents fromArrayPool, callsReadPipesToBuffers, copies results to owned arraysReadAllText: callsValidateReadAllState, rents fromArrayPool, callsReadPipesToBuffers, decodes directly from rented buffers viaEncoding.GetString(byte[], int, int)(avoids extra buffer copy)GetSafeFileHandleFromStreamReader: extracts handle fromFileStream(Windows) orAnonymousPipeClientStream(Unix), returnsout SafeHandle ownerfor reference countingRentLargerBuffer(2x, pool-backed)Tests (17 cases)