Skip to content

Implement Process.ReadAllText and ReadAllBytes with platform-specific multiplexing#126807

Draft
Copilot wants to merge 14 commits intomainfrom
copilot/implement-readalltext-and-readallbytes
Draft

Implement Process.ReadAllText and ReadAllBytes with platform-specific multiplexing#126807
Copilot wants to merge 14 commits intomainfrom
copilot/implement-readalltext-and-readallbytes

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 12, 2026

Description

Implements Process.ReadAllText and Process.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 throw InvalidOperationException if either is missing. The methods also require that neither StandardOutput nor StandardError has been accessed in sync mode (StreamReadMode.Undefined required), preventing silent data loss from StreamReader-buffered data.

using Process process = Process.Start(startInfo);
(string stdout, string stderr) = process.ReadAllText(timeout: TimeSpan.FromSeconds(30));
(byte[] stdoutBytes, byte[] stderrBytes) = process.ReadAllBytes();

Windows — Overlapped IO

  • ReadPipes is private static unsafe void with all resources (pins, overlapped, events) declared at method top and cleaned up in a single finally block — ensures no leaks if any allocation throws
  • NativeOverlapped* with ManualResetEvent per pipe
  • ReadFile (overlapped) → WaitHandle.WaitAnyGetOverlappedResult loop
  • CancelIoEx on timeout, GetOverlappedResult(bWait: true) to observe completion before freeing OVERLAPPED
  • AllocateOverlapped uses NativeMemory.AllocZeroed and only sets the meaningful EventHandle field — no redundant zero assignments
  • Uses is not null pattern matching syntax throughout

Unix — poll(2)

  • Interop.Sys.Poll on stdout/stderr file descriptors
  • RandomAccess.Read once poll reports data ready
  • EINTR handling with deadline recalculation
  • Poll errors translated via Interop.Sys.ConvertErrorPalToPlatform(error) for proper errno in Win32Exception
  • ReadPipes is a regular private static void (non-nullable SafeFileHandle parameters)

Common (Process.Multiplexing.cs)

  • ValidateReadAllState: validates state (disposed, both streams redirected, async mode conflict, sync mode conflict) and sets sync mode — called from ReadAllText and ReadAllBytes before renting arrays from the pool. Requires StreamReadMode.Undefined for both stdout and stderr to prevent calling ReadAll* after StandardOutput/StandardError was accessed (StreamReader may have buffered data that handle-based reads would miss)
  • ReadPipesToBuffers: obtains handles with DangerousAddRef/DangerousRelease protection, uses ToTimeoutMilliseconds for proper timeout validation, and delegates to platform ReadPipes
  • ReadAllBytes: calls ValidateReadAllState, rents from ArrayPool, calls ReadPipesToBuffers, copies results to owned arrays
  • ReadAllText: calls ValidateReadAllState, rents from ArrayPool, calls ReadPipesToBuffers, decodes directly from rented buffers via Encoding.GetString(byte[], int, int) (avoids extra buffer copy)
  • GetSafeFileHandleFromStreamReader: extracts handle from FileStream (Windows) or AnonymousPipeClientStream (Unix), returns out SafeHandle owner for reference counting; throws UnreachableException for unexpected stream types
  • Buffer growth via RentLargerBuffer (2x, pool-backed, capped at Array.MaxLength; throws OutOfMemoryException if the buffer is already at Array.MaxLength to prevent infinite loop)

Tests (25 cases)

  • Validation: no streams, only stdout redirected, only stderr redirected, async mode, sync mode (4 cases via Theory), disposed
  • Both streams, stdout-only output, stderr-only output
  • Large output (100KB, triggers buffer growth)
  • Empty output
  • Interleaved output (100 iterations alternating stdout/stderr to stress multiplexing)
  • Binary data with null bytes (1024 bytes with embedded nulls for ReadAllBytes)
  • Timeout on blocking process (single and both streams)

Code review findings addressed

  • Fixed missing else in timeout test that caused ReadAllText to always run regardless of bytes parameter
  • Fixed integer overflow in RentLargerBuffer where buffer.Length * 2 could overflow for large buffers — now uses Math.Min((long)buffer.Length * 2, Array.MaxLength)
  • Fixed RentLargerBuffer infinite loop when buffer.Length == Array.MaxLength — now throws OutOfMemoryException
  • Fixed unsafe timeout cast by using existing ToTimeoutMilliseconds() helper which properly validates the TimeSpan range
  • Added StreamReadMode.syncMode guard in ValidateReadAllState — prevents calling ReadAll* after StandardOutput/StandardError was accessed (data may have been buffered by StreamReader)
  • Renamed ReadAllText_ReadsBothOutputAndErrorReadAll_ReadsBothOutputAndError since the test exercises both ReadAllText and ReadAllBytes
  • Changed GetSafeFileHandleFromStreamReader fallback exception from InvalidOperationException to UnreachableException (we control stream creation)
  • Added ReadAll_ThrowsWhenOutputOrErrorIsInSyncMode test (4 cases via Theory)
  • Replaced StreamBody with Dummy in empty-output test to avoid garbage in test output
  • Interleaved output test uses exact string equality (Assert.Equal) instead of Assert.Contains

Copilot AI and others added 4 commits April 12, 2026 07:31
…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>
Copilot AI review requested due to automatic review settings April 12, 2026 09:31
Copilot AI review requested due to automatic review settings April 12, 2026 09:31
@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.

@adamsitnik adamsitnik added the NO-REVIEW Experimental/testing PR, do NOT review it label Apr 12, 2026
Copy link
Copy Markdown
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

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

@copilot please address my feedback

Comment on lines +82 to +88
bool hasOutput = _standardOutput is not null;
bool hasError = _standardError is not null;

if (!hasOutput && !hasError)
{
throw new InvalidOperationException(SR.CantGetStandardOut);
}
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.

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

Suggested change
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);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +128 to +134
byte[] outputResult = outputBytesRead > 0
? outputBuffer.AsSpan(0, outputBytesRead).ToArray()
: Array.Empty<byte>();

byte[] errorResult = errorBytesRead > 0
? errorBuffer.AsSpan(0, errorBytesRead).ToArray()
: Array.Empty<byte>();
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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +150 to +162
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);
}
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.

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.

Suggested change
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);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 0986690. GetSafeFileHandleFromStreamReader now takes out SafeHandle owner. The caller in ReadPipesToBuffers calls DangerousAddRef before ReadPipes and DangerousRelease in the finally block.

Comment on lines +167 to +177
/// <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);
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.

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).

Suggested change
/// <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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
Copilot AI requested review from Copilot and removed request for Copilot April 12, 2026 10:44
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 12, 2026

@copilot please address my feedback

All feedback addressed in 0986690. Summary of changes:

  1. Both stdout and stderr must be redirected (strict validation with separate error messages)
  2. ReadPipesToBuffers helper shared between ReadAllText and ReadAllBytes (avoids extra buffer copy in ReadAllText)
  3. GetSafeFileHandleFromStreamReader returns out SafeHandle owner; caller uses DangerousAddRef/DangerousRelease
  4. Removed partial method declaration — ReadPipes is a regular static method per platform with non-nullable parameters
  5. All tests updated to redirect both streams; added 2 new validation tests (17/17 pass)

Copilot AI requested a review from adamsitnik April 12, 2026 10:46
Copy link
Copy Markdown
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

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

@copilot address my feedback

…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>
Copilot AI requested review from Copilot and removed request for Copilot April 12, 2026 11:36
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 12, 2026

@copilot address my feedback

All feedback addressed in df0e65aValidateReadAllState helper extracted, Windows ReadPipes made fully unsafe with proper cleanup ordering, is not null syntax. 17/17 tests pass.

Copilot AI requested a review from adamsitnik April 12, 2026 11:38
Comment on lines +42 to +43
outputOverlapped = AllocateOverlapped(outputEvent);
errorOverlapped = AllocateOverlapped(errorEvent);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can't the overlapped's be allocated on the stack?

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.

I know it looks like an ordinary synchronous code, but in reality we issue an async read with ReadFile. The issuing thread should be still alive, but after debugging an AV for an hour that was caused by non-existing callback getting invoked on a ThreadPool thread I am not 100% sure and I prefer to allocate dedicated memory to be 100% sure.

adamsitnik and others added 2 commits April 13, 2026 14:33
…RentLargerBuffer overflow

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/34214748-db27-499b-88ef-23a1af15515f

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot April 13, 2026 12:56
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 13, 2026

@copilot please invoke the code-review skill and post your analysis/results as a comment on this PR

🤖 Copilot Code Review — PR #126807

Note

This review was AI/Copilot-generated using multi-model analysis (Claude Sonnet 4, Gemini 2.5 Pro).

Holistic Assessment

Motivation: The PR implements approved API surface (ReadAllText/ReadAllBytes) from #125838 that solves a real and well-documented deadlock problem when reading both stdout and stderr.

Approach: Platform-specific multiplexing (overlapped IO on Windows, poll(2) on Unix) is the correct approach for single-threaded synchronous draining of two pipes.

Summary: ⚠️ Needs Human Review. Three bugs were found and fixed in 474a484, but some items warrant maintainer judgment.


Detailed Findings

❌ Test logic error — missing else in timeout test (FIXED in 474a484)

ProcessMultiplexingTests.cs line 140: The else keyword was missing, causing ReadAllText timeout to always be tested regardless of the bytes parameter. Fixed.

❌ Integer overflow in RentLargerBuffer (FIXED in 474a484)

Process.Multiplexing.cs line 226: buffer.Length * 2 overflows int when buffer.Length > int.MaxValue / 2. This caused ArgumentOutOfRangeException in the large output tests. Fixed by using (int)Math.Min((long)buffer.Length * 2, Array.MaxLength).

❌ Unsafe timeout cast (FIXED in 474a484)

Process.Multiplexing.cs line 164: (int)timeout.Value.TotalMilliseconds can overflow for large TimeSpan values and doesn't validate negative values. Fixed by using the existing ToTimeoutMilliseconds() helper which validates range properly.

✅ API shape matches approved proposal

The ref assembly signatures exactly match the approved shape from the api-approved comment by @bartonjs on #125838. Tuple element names are correctly PascalCased (StandardOutput, StandardError).

✅ Encoding fallback is consistent with existing code

Both agents flagged GetStandardOutputEncoding() being used as fallback for stderr encoding. However, this matches the existing pattern in Process.cs line 1312 where _standardError is also created with GetStandardOutputEncoding(). There is no GetStandardErrorEncoding() method. Not a bug.

✅ Handle lifetime protection is correct

DangerousAddRef/DangerousRelease pattern in ReadPipesToBuffers correctly prevents handle disposal during I/O. The GetSafeFileHandleFromStreamReader correctly returns the owner handle for ref counting.

✅ Windows unsafe resource cleanup is sound

All six resources (2 pins, 2 overlapped, 2 events) are declared at method top and cleaned up in the finally block. MemoryHandle.Dispose() is idempotent, so the double-dispose scenario during buffer resize is safe.

⚠️ Unix: POLLNVAL not handled

Process.Multiplexing.Unix.cs line 93: The poll event check only looks for POLLIN | POLLHUP | POLLERR. If POLLNVAL is set (invalid fd), the loop would spin. In practice this shouldn't occur since fds are protected by DangerousAddRef, but adding POLLNVAL to the check (treating it as EOF) would be more defensive. Low risk — maintainer judgment.

⚠️ AllocateOverlapped redundantly initializes zero fields

Process.Multiplexing.Windows.cs lines 138-141: After NativeMemory.AllocZeroed, setting InternalHigh, InternalLow, OffsetHigh, OffsetLow to zero is redundant. Only the EventHandle assignment is needed. Minor readability nit.

@github-actions
Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #126807

Note

This review was generated by GitHub Copilot using multi-model analysis (Claude Opus 4.6 primary, Claude Sonnet 4.5 secondary). A GPT-5.3-Codex sub-agent was launched but did not complete within the 10-minute window.

Holistic Assessment

Motivation: Well-justified. The deadlock problem when reading both stdout and stderr simultaneously is a real pain point. The approved API in #125838 clearly documents the need, and this PR implements two of the approved methods (ReadAllText, ReadAllBytes).

Approach: The platform-specific multiplexing approach (poll on Unix, overlapped IO on Windows) is the right solution. Single-threaded synchronous multiplexing avoids thread pool pressure and is consistent with the synchronous API shape.

Summary: ⚠️ Needs Changes. One confirmed bug in the Unix error handling path passes PAL-mapped error codes instead of native errno values to Win32Exception, producing garbage error messages. The ref assembly ordering should also be fixed. The Windows overlapped IO implementation and overall resource management are well-done.


Detailed Findings

❌ Unix Error Path — Wrong Error Code Passed to Win32Exception

File: Process.Multiplexing.Unix.cs:81

throw new Win32Exception((int)error);

The error variable is of type Interop.Error, whose enum values are PAL-mapped constants starting at 0x10001 (e.g., EINTR = 0x1001B). These are not native errno values. The comment in Interop.Errors.cs:17 explicitly warns: "Casting to or from int is suspect."

Passing (int)error (value ≥ 65537) to Win32Exception will produce a meaningless error message like "Unknown error 65563" because StrError() won't recognize the PAL constant.

The previous commit (99ef166) had the correct code: throw Interop.GetExceptionForIoErrno(new Interop.ErrorInfo(error)), but the "fix the build" commit (70df3b1) changed it because Interop.IOErrors.cs isn't included in the csproj.

Fix (preferred — matches the component's established pattern of using Win32Exception with native errno):

throw new Win32Exception(Interop.Sys.ConvertErrorPalToPlatform(error));

ConvertErrorPalToPlatform is available since Interop.Errors.cs is already included in the csproj. This converts the PAL enum back to the platform-native errno, which Win32Exception correctly maps to an error message via strerror().

Alternative — add Interop.IOErrors.cs to the csproj (Unix ItemGroup) and restore the original code:

throw Interop.GetExceptionForIoErrno(new Interop.ErrorInfo(error));

⚠️ Ref Assembly Member Ordering

File: ref/System.Diagnostics.Process.cs:188-189

The new methods are placed between ToString() and WaitForExit():

public override string ToString() { throw null; }
public (string StandardOutput, string StandardError) ReadAllText(...) { throw null; }
public (byte[] StandardOutput, byte[] StandardError) ReadAllBytes(...) { throw null; }
public void WaitForExit() { }

Per dotnet/runtime convention, ref assembly members should be in alphabetical order. ReadAllBytes and ReadAllText should be placed before Refresh() / Start* / ToString(). Additionally, ReadAllBytes should appear before ReadAllText.

✅ API Approval Verified

The PR implements ReadAllText and ReadAllBytes from the API approved in issue #125838 (labeled api-approved by @bartonjs on 2026-03-24). The method signatures match the approved shape exactly:

public (string StandardOutput, string StandardError) ReadAllText(TimeSpan? timeout = default);
public (byte[] StandardOutput, byte[] StandardError) ReadAllBytes(TimeSpan? timeout = default);

This is a partial implementation of the larger approved API surface (which includes async variants, ReadAllLines, RunAndCaptureText, etc.). This is appropriate for incremental delivery.

✅ Windows Overlapped IO — Correctly Implemented

The Windows implementation is well-structured:

  • NativeOverlapped* allocated via NativeMemory.AllocZeroed and freed in finally — no leaks
  • SetLowOrderBit correctly prevents I/O completion port queuing per MSDN documentation
  • CancelIoEx + GetOverlappedResult(bWait: true) for timeout cancellation correctly observes completion before freeing OVERLAPPED
  • Buffer growth only occurs after GetOverlappedResult confirms the previous IO completed, so pin disposal is safe
  • The ReadFile return value check (!= 0) is correct since the interop returns int, not bool — this was the actual build fix

✅ Unix Poll Implementation — Correctly Structured

  • EINTR handling with deadline recalculation on next loop iteration is correct and well-commented
  • RandomAccess.Read with fileOffset: 0 is correct for pipes — per the .NET 11 docs, the offset is ignored for non-seekable handles
  • Poll event mask (POLLIN | POLLHUP | POLLERR) correctly detects data, hangup, and error conditions

✅ Resource Management — Thorough

  • ArrayPool buffers rented/returned in try/finally in both ReadAllText and ReadAllBytes
  • DangerousAddRef/DangerousRelease correctly protects the underlying stream handles during reads
  • Windows: pins, overlapped structs, and events all declared before try and cleaned up in finally
  • Non-owning SafeFileHandle in GetSafeFileHandleFromStreamReader is safe because the owner handle is ref-counted

✅ Validation Logic — Correct

  • ValidateReadAllState checks disposed, both streams redirected, and no async mode conflict
  • Sets SyncMode to prevent subsequent async operations
  • Requiring both stdout and stderr redirected (per @adamsitnik's feedback) prevents silent data loss

💡 Test Coverage — Adequate but Could Be Expanded (Follow-up)

The 17 test cases cover the key scenarios well. For a future follow-up, consider:

  • Interleaved output: Rapid alternation between stdout and stderr to stress multiplexing
  • Exact buffer boundary: Output of exactly 4096 bytes (initial buffer size) to test the totalBytesRead == currentBuffer.Length boundary
  • Binary data with null bytes: For ReadAllBytes specifically
  • Very large output: Multi-MB to test several buffer growth cycles

💡 AllocateOverlapped — Redundant Zero-Initialization

File: Process.Multiplexing.Windows.cs:148-155

NativeMemory.AllocZeroed already zeroes all bytes, making the subsequent assignments of InternalHigh, InternalLow, OffsetHigh, OffsetLow to zero redundant. Only the EventHandle assignment is meaningful. This is harmless but slightly noisy — consider removing the redundant lines for clarity.


Models contributing: Claude Opus 4.6 (primary analysis, API approval verification, error code investigation), Claude Sonnet 4.5 (parallel review, overlapped IO correctness verification, SafeHandle analysis).

Generated by Code Review for issue #126807 ·

…mode test

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/f6ca8588-63c1-4cd8-aeaf-48c716a4ea11

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot April 13, 2026 13:12
Copy link
Copy Markdown
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

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

Fix (preferred — matches the component's established pattern of using Win32Exception with native errno):

throw new Win32Exception(Interop.Sys.ConvertErrorPalToPlatform(error));

@copilot do it

Per dotnet/runtime convention, ref assembly members should be in alphabetical order. ReadAllBytes and ReadAllText should be placed before Refresh() / Start* / ToString(). Additionally, ReadAllBytes should appear before ReadAllText.

@copilot reorder the APIs

NativeMemory.AllocZeroed already zeroes all bytes, making the subsequent assignments of InternalHigh, InternalLow, OffsetHigh, OffsetLow to zero redundant. Only the EventHandle assignment is meaningful. This is harmless but slightly noisy — consider removing the redundant lines for clarity.

@copilot address this

  • Interleaved output: Rapid alternation between stdout and stderr to stress multiplexing

@copilot add such test for ReadAllText

  • Binary data with null bytes: For ReadAllBytes specifically

@copilot add such test for ReadAllBytes

[InlineData(false, false)]
public void ReadAll_ThrowsWhenOutputOrErrorIsInSyncMode(bool bytes, bool standardOutput)
{
Process process = CreateProcess(RemotelyInvokable.StreamBody);
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.

@copilot Don't use StreamBody, as it produces garbage in the test output:

  System.Diagnostics.Process.Tests started
  System.Diagnostics.Process.Tests closed
Suggested change
Process process = CreateProcess(RemotelyInvokable.StreamBody);
Process process = CreateProcess(RemotelyInvokable.Dummy);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 1d30ed2. Replaced StreamBody with Dummy.

…rlapped cleanup, new tests, StreamBody->Dummy

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/dac42b05-c591-4e1c-b751-9eadeb376728

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot April 13, 2026 14:08
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 13, 2026

Fix (preferred — matches the component's established pattern of using Win32Exception with native errno):

All 5 items addressed in 1d30ed2:

  1. Unix exception now uses Interop.Sys.ConvertErrorPalToPlatform(error) for proper errno translation
  2. Ref assembly reordered: ReadAllBytes before ReadAllText, both before Refresh()
  3. Removed redundant zero-field assignments in AllocateOverlapped — only EventHandle assignment remains
  4. Added ReadAllText_ReadsInterleavedOutput test (100 iterations alternating stdout/stderr)
  5. Added ReadAllBytes_ReadsBinaryDataWithNullBytes test (1024 bytes with null bytes every 10th position)

@copilot please invoke the code-review skill and post your analysis/results as a comment on this PR

🤖 Copilot Code Review — PR #126807

Note

This review was AI/Copilot-generated using multi-model analysis (Claude Sonnet 4, Gemini 2.5 Pro).

Holistic Assessment

Motivation: Implements approved API surface (ReadAllText/ReadAllBytes) from #125838 that solves the well-documented deadlock problem when reading both stdout and stderr.

Approach: Platform-specific multiplexing (overlapped IO on Windows, poll(2) on Unix) is the correct approach for single-threaded synchronous draining of two pipes.

Summary: ✅ LGTM. All previous code review findings have been addressed. The implementation is solid with proper resource management, handle lifetime protection, and comprehensive test coverage (23 test cases).


Detailed Findings

✅ API shape matches approved proposal

The ref assembly signatures exactly match the approved shape from @bartonjs's API review comment on #125838. Tuple element names are PascalCase (StandardOutput, StandardError). ReadAllBytes precedes ReadAllText alphabetically, both before Refresh().

✅ Encoding fallback is consistent with existing code

GetStandardOutputEncoding() is used as fallback for both stdout and stderr. This matches the existing pattern at Process.cs line 1312 where _standardError is also created with GetStandardOutputEncoding(). There is no GetStandardErrorEncoding() method. Not a bug.

✅ Handle lifetime protection is correct

DangerousAddRef/DangerousRelease pattern in ReadPipesToBuffers correctly prevents handle disposal during I/O.

✅ Windows unsafe resource cleanup is sound

All 6 resources (2 pins, 2 overlapped, 2 events) are declared at method top and cleaned up in finally. AllocateOverlapped now only sets EventHandle after AllocZeroed — no redundant zero assignments.

✅ ReadFile return value handling is correct

QueueRead properly handles immediate success (sets event manually), ERROR_IO_PENDING, ERROR_BROKEN_PIPE/ERROR_HANDLE_EOF (returns false = EOF), and other errors (throws).

✅ Unix poll error handling

Now correctly uses Interop.Sys.ConvertErrorPalToPlatform(error) to translate PAL error codes to platform errno before constructing Win32Exception.

✅ Buffer overflow protection

RentLargerBuffer uses (int)Math.Min((long)buffer.Length * 2, Array.MaxLength) — the long cast prevents int overflow, Math.Min ensures the result fits in int since Array.MaxLength < int.MaxValue.

✅ Sync mode guard prevents data loss

ValidateReadAllState requires StreamReadMode.Undefined for both streams, preventing ReadAll* after StandardOutput/StandardError was accessed (StreamReader may have buffered data).

✅ Test coverage is comprehensive

23 test cases covering validation (disposed, no redirect, single redirect, sync mode, async mode), success paths (both streams, stdout-only, stderr-only, empty, interleaved), edge cases (binary with null bytes, large 100KB output), and timeout behavior.

Copilot AI requested review from Copilot and removed request for Copilot April 13, 2026 14:14
Copy link
Copy Markdown
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

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

@copilot address my feedback


for (int i = 0; i < iterations; i++)
{
Assert.Contains($"out{i} ", standardOutput);
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.

@copilot Build the exact expected string and then perform explicit equality check, don't use Assert.Contains

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9896de0. The test now builds the exact expected strings via StringBuilder and uses Assert.Equal for both stdout and stderr.

/// <summary>
/// Rents a larger buffer from the array pool and copies the existing data to it.
/// </summary>
private static void RentLargerBuffer(ref byte[] buffer, int bytesRead)
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 method will cause endless loop if buffer.Length == Array.MaxLength. Detect such situation and throw an exception. I believe that OutOfMemoryException would be best.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9896de0. RentLargerBuffer now checks buffer.Length >= Array.MaxLength at the top and throws OutOfMemoryException before attempting to rent.

…al in interleaved test

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/560e33ea-a2eb-4f65-a9c9-a2bc3fc32986

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot April 13, 2026 14:27
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 13, 2026

@copilot address my feedback

Both items addressed in 9896de0:

  1. RentLargerBuffer now throws OutOfMemoryException when buffer.Length >= Array.MaxLength
  2. Interleaved output test builds exact expected strings and uses Assert.Equal

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Diagnostics.Process NO-REVIEW Experimental/testing PR, do NOT review it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants