Skip to content

Relax RandomAccess type requirements: make Read/Write methods work with non-seekable files#125512

Open
Copilot wants to merge 20 commits intomainfrom
copilot/relax-randomaccess-requirements
Open

Relax RandomAccess type requirements: make Read/Write methods work with non-seekable files#125512
Copilot wants to merge 20 commits intomainfrom
copilot/relax-randomaccess-requirements

Conversation

Copy link
Contributor

Copilot AI commented Mar 12, 2026

Description

Relaxes System.IO.RandomAccess to support non-seekable handles (pipes, sockets, character devices) by falling back to non-offset syscalls when the file does not support seeking. Updates affected call sites in System.Console and System.Diagnostics.Process to use RandomAccess.Read/Write, and adds comprehensive tests for non-seekable handle scenarios.

Core changes

  • Update RandomAccess validation to allow non-seekable handles for Read*/Write* methods (while keeping seek-only behavior for GetLength/SetLength)
  • Add SystemNative_ReadV/SystemNative_WriteV native exports with EAGAIN/EWOULDBLOCK poll-loop handling and GetAllowedVectorCount capping for IOV_MAX
  • Extract ShouldFallBackToNonOffsetSyscall helper for ENXIO/ESPIPE fallback logic, with consolidated control flow to avoid duplicate syscalls
  • Update XML doc comments with version-qualified behavior: "In .NET 11 and later versions, ..." for new non-seekable support, and restored NotSupportedException docs qualified with "In .NET 10 and earlier versions, ..." for backward compatibility

Console/Process call site updates

  • Replace Interop.Sys.Read/Write with RandomAccess.Read/Write in ConsolePal.Unix.cs, ConsolePal.Wasi.cs, and ConsolePal.Unix.ConsoleStream.cs (with EPIPE handling preserved)
  • Revert ConsolePal.Browser.cs changes (WASM does not support libSystem.Native dynamic linking)
  • Use foreach loop in UpdatedCachedCursorPosition instead of index-based iteration
  • Fix ProcessWaitingTests to use ReadBlock instead of Read to handle partial reads correctly

Tests

  • Add non-seekable handle tests for single/multi-buffer sync/async read/write, cancellation, and partial reads
  • Apply test improvements from PR Make RandomAccess.Read*|Write* methods work with non-seekable files #96711: AssertCanceled helper, AssertExtensions.SequenceEqual, break on read == 0
  • Fill pipe buffer before write cancellation test to avoid flaky sync completion
  • Merge PartialReads sync/async tests into a single [Theory]

Testing

  • All System.IO.FileSystem.Tests pass (9741 tests)
  • System.Console builds for all 8 targets (unix, windows, browser, wasi, android, ios, tvos, default)
  • ProcessWaitingTests pass with ReadBlock fix
Original prompt

This section details on the original issue you should resolve

<issue_title>Relax RandomAccess type requirements, make all Read*|Write* methods work with non-seekable files</issue_title>
<issue_description>In .NET 6 we have introduced new type called RandomAccess that allows for reading and writing to specific file offset.

As of today, all it's Read* and Write* methods throw when given handle points to a non-seekable file like socket or pipe:

ThrowHelper.ThrowNotSupportedException_UnseekableStream();

But it's not a problem for it's internal implementation (used by FileStream):

// The Windows implementation uses ReadFile, which ignores the offset if the handle
// isn't seekable. We do the same manually with PRead vs Read, in order to enable
// the function to be used by FileStream for all the same situations.
int result = handle.CanSeek ?
Interop.Sys.PRead(handle, bufPtr, buffer.Length, fileOffset) :
Interop.Sys.Read(handle, bufPtr, buffer.Length);

And we use it's internal API surface to workaround this limitation is CoreLib (mind the call to RandomAccess.WriteAtOffset (internal), not RandomAccess.Write (public):

RandomAccess.WriteAtOffset(sfh, bytes, 0);

We should relax the public requirements and make RandomAccess work with non-seekable files.

This is going to require:

  • a breaking change doc
  • implementation change (stop throwing exception)
  • test changes (this test shows how to easily create a handle that points to non-seekable pipe)

Everything motioned above should be a single PR. In the same or separate PR, the Unix implementation of overloads that accept multiple buffers should start using readv and writev sys-calls. This should be relatively easy (just search for preadv and pwritev and reuse the patterns)

But it's going to allow to:

<agent_instructions>1. Do exactly what I did in #96711, you can even cherry-pick the git commits if you want.
2. For testing, in RandomAccess_NonSeekable.GetNamedPipeHandlesAsync don't use NamedPipeClientStream or NamedPipeServerStream just use the bran new SafeFileHandle.CreateAnonymousPipe(out readHandle, out writeHandle, bool asyncRead = false, bool asyncWrite = false) API.
3. Search the repo for places where we have SafeFileHandle and could use the RandomAccess API instead of performing direct sys-calls. Use the API there.
4. Make sure you run all the src/libraries/System.Runtime/tests/System.IO.FileSystem.Tests test and they are passing before you send the PR.</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@ Tagging subscribers to this area: @dotnet/area-system-io See info in [area-owners.md](https://github.com/dotnet/runtime/blob/main/docs/area-owners.md) if you want to be subscribed.
Issue Details

In .NET 6 we have introduced new type called RandomAccess that allows for reading and writing to specific file offset.

As of today, all it's Read* and Write* methods throw when given handle points to a non-seekable file like socket or pipe:

ThrowHelper.ThrowNotSupportedException_UnseekableStream();

But it's not a problem for it's internal implementation (used by FileStream):

// The Windows implementation uses ReadFile, which ignores the offset if the handle
// isn't seekable. We do the same manually with PRead vs Read, in order to enable
// the function to be used by FileStream for all the same situations.
int result = handle.CanSeek ?
Interop.Sys.PRead(handle, bufPtr, buffer.Length, fileOffset) :
Interop.Sys.Read(handle, bufPtr, buffer.Length);

And we use it's internal API surface to workaround this limitation is CoreLib (mind the call to RandomAccess.WriteAtOffset (internal), not RandomAccess.Write (public):

RandomAccess.WriteAtOffset(sfh, bytes, 0);

We should relax the public requirements and make RandomAccess work with non-seekable files.

This is going to require:

  • a breaking change doc
  • implementation change (stop throwing exception)
  • test changes (this test shows how to easily create a handle that points to non-seekable pipe)

Everything motioned above should be a single PR. In the same or...


🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

Copilot AI and others added 3 commits March 12, 2026 22:49
…seekable files

- Add native readv/writev sys-call wrappers in pal_io.c and pal_io.h
- Add native entrypoints for ReadV/WriteV in entrypoints.c
- Add managed interop files Interop.ReadV.cs and Interop.WriteV.cs
- Add interop references to System.Private.CoreLib.Shared.projitems
- Modify RandomAccess.cs - relax ValidateInput to allow unseekable handles
- Update XML docs for Read/Write methods (remove NotSupportedException, note offset ignored)
- Modify RandomAccess.Unix.cs - use readv/writev for non-seekable scatter/gather
- Update Base.cs - remove ThrowsNotSupportedExceptionForUnseekableFile test
- Add NonSeekable.cs tests using SafeFileHandle.CreateAnonymousPipe
- Add NonSeekable_AsyncHandles.cs tests
- Update test .csproj to include new test files

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
…d of AnonymousPipeServerStream

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
… consistency

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copy link
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

…ndling

- ReadScatterAtOffset: try PReadV first, fall back to ReadV on ENXIO/ESPIPE
- WriteGatherAtOffset: try PWriteV first, fall back to WriteV on ENXIO/ESPIPE
- SystemNative_ReadV: handle EAGAIN/EWOULDBLOCK with poll loop (like ReadFromNonblocking)
- SystemNative_WriteV: handle EAGAIN/EWOULDBLOCK with poll loop (like WriteToNonblocking)

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 13, 2026

@copilot please address my feedback

All four review items addressed in commit 098316d. All 9741 System.IO.FileSystem.Tests pass.

Copilot AI requested a review from adamsitnik March 13, 2026 08:01
Copy link
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

@copilot use the RandomAccess.Write in following places (respect the current EPIPE handling by swallowing specific exception):

private static unsafe void Write(SafeFileHandle fd, byte* bufPtr, int count)
{
while (count > 0)
{
int bytesWritten = Interop.Sys.Write(fd, bufPtr, count);
if (bytesWritten < 0)
{
Interop.ErrorInfo errorInfo = Interop.Sys.GetLastErrorInfo();
if (errorInfo.Error == Interop.Error.EPIPE)
{
return;
}
else
{
throw Interop.GetIOException(errorInfo);
}
}
count -= bytesWritten;
bufPtr += bytesWritten;
}
}

private static unsafe void Write(SafeFileHandle fd, ReadOnlySpan<byte> buffer, bool mayChangeCursorPosition = true)
{
fixed (byte* p = buffer)
{
byte* bufPtr = p;
int count = buffer.Length;
while (count > 0)
{
int cursorVersion = mayChangeCursorPosition ? Volatile.Read(ref s_cursorVersion) : -1;
int bytesWritten = Interop.Sys.Write(fd, bufPtr, count);
if (bytesWritten < 0)
{
Interop.ErrorInfo errorInfo = Interop.Sys.GetLastErrorInfo();
if (errorInfo.Error == Interop.Error.EPIPE)
{
// Broken pipe... likely due to being redirected to a program
// that ended, so simply pretend we were successful.
return;
}
else if (errorInfo.Error == Interop.Error.EAGAIN) // aka EWOULDBLOCK
{
// May happen if the file handle is configured as non-blocking.
// In that case, we need to wait to be able to write and then
// try again. We poll, but don't actually care about the result,
// only the blocking behavior, and thus ignore any poll errors
// and loop around to do another write (which may correctly fail
// if something else has gone wrong).
Interop.Sys.Poll(fd, Interop.PollEvents.POLLOUT, Timeout.Infinite, out Interop.PollEvents triggered);
continue;
}
else
{
// Something else... fail.
throw Interop.GetExceptionForIoErrno(errorInfo);
}
}
else
{
if (mayChangeCursorPosition)
{
UpdatedCachedCursorPosition(bufPtr, bytesWritten, cursorVersion);
}
}
count -= bytesWritten;
bufPtr += bytesWritten;
}
}
}

use the RandomAccess.Read in following places:

private static unsafe int Read(SafeFileHandle fd, Span<byte> buffer)
{
fixed (byte* bufPtr = buffer)
{
int result = Interop.CheckIo(Interop.Sys.Read(fd, bufPtr, buffer.Length));
Debug.Assert(result <= buffer.Length);
return result;
}
}

private static unsafe int Read(SafeFileHandle fd, Span<byte> buffer)
{
fixed (byte* bufPtr = buffer)
{
int result = Interop.CheckIo(Interop.Sys.Read(fd, bufPtr, buffer.Length));
Debug.Assert(result <= buffer.Length);
return result;
}
}

If it does not break the build, remove related references from the project file (the list below is not complete, please verify it on your own):

<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.Poll.cs"
Link="Common\Interop\Unix\Interop.Poll.cs" />
<Compile Include="$(CommonPath)Interop\Unix\Interop.Poll.Structs.cs"
Link="Common\Interop\Unix\Interop.Poll.Structs.cs" />
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.Read.cs"
Link="Common\Interop\Unix\Interop.Read.cs" />

<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.Poll.cs"
Link="Common\Interop\Unix\Interop.Poll.cs" />
<Compile Include="$(CommonPath)Interop\Unix\Interop.Poll.Structs.cs"
Link="Common\Interop\Unix\Interop.Poll.Structs.cs" />

…ack, use RandomAccess in Console

- Extract NeedsNonOffsetFallback helper method in RandomAccess.Unix.cs
- Improve tests: deduplicate cancellation checks, handle Read returning 0 in loops, use AssertExtensions.SequenceEqual
- Use RandomAccess.Read/Write in ConsolePal.Browser.cs, ConsolePal.Unix.cs, ConsolePal.Wasi.cs
- Remove unused Interop.Read.cs, Interop.Write.cs, Interop.Poll.cs references from System.Console.csproj

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 13, 2026

@copilot please address my feedback

@copilot use the RandomAccess.Write in following places (respect the current EPIPE handling by swallowing specific exception): ...

Done in commit a5d8cb2:

  • ConsolePal.Browser.cs: Replaced Interop.Sys.Write loop with RandomAccess.Write, EPIPE handled via catch (IOException) when (ConvertErrorPlatformToPal(ex.HResult) == EPIPE)
  • ConsolePal.Unix.cs: Read uses RandomAccess.Read, Write uses RandomAccess.Write with EPIPE catch and cursor tracking after the full write. UpdatedCachedCursorPosition now takes ReadOnlySpan<byte> instead of byte*.
  • ConsolePal.Wasi.cs: Both Read and Write use RandomAccess.Read/Write with EPIPE handling
  • Removed Interop.Read.cs, Interop.Write.cs, Interop.Poll.cs, Interop.Poll.Structs.cs from Browser, WASI, and Unix sections of System.Console.csproj

All 9741 System.IO.FileSystem.Tests pass, and System.Console builds for all 8 targets (unix, windows, browser, wasi, android, ios, tvos, and the default).

Copilot AI requested a review from adamsitnik March 13, 2026 09:42
Copy link
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

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copy link
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.

LGTM! @stephentoub the PR is ready for review

child2.Start();
char[] output = new char[6];
child2.StandardOutput.Read(output, 0, output.Length);
child2.StandardOutput.ReadBlock(output, 0, output.Length);
Copy link
Member

Choose a reason for hiding this comment

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

Wasn't this WaitForPeerProcess always wrong, and we were just getting lucky because of implementation detail? i.e. regardless of that debug condition, there's no guarantee Read would always return as much as was requested

ConsolePal.StdInReader.ReadLine(buffer) :
#endif
ConsolePal.Read(_handle, buffer);
RandomAccess.Read(_handle, buffer, fileOffset: 0);
Copy link
Member

Choose a reason for hiding this comment

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

Are there more places we could be using RandomAccess.Read/Write now?

Copy link
Member

Choose a reason for hiding this comment

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

I've searched for Interop.Sys.Read and Interop.Sys.Write and these are all the places where we can use it as of today.

Copy link
Member

Choose a reason for hiding this comment

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

System.IO.Pipes on Windows is next on my TODO list, but it's going to require some non-trivial changes and I believe it should be a separate PR.

Copy link
Member

@adamsitnik adamsitnik Mar 17, 2026

Choose a reason for hiding this comment

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

Console uses raw handles and it often chooses a different sys-call (WriteConsole):

readSuccess = (0 != Interop.Kernel32.ReadFile(hFile, p, buffer.Length, out bytesRead, IntPtr.Zero));

Interop.Kernel32.WriteFile(handle, pBytes, cbytes, out _, IntPtr.Zero);

writeSuccess = (0 != Interop.Kernel32.WriteFile(hFile, p, bytes.Length, out numBytesWritten, IntPtr.Zero));

System.IO.Ports uses SafeFileHandle, but it targets older tfms:

if (_isAsync)
r = Interop.Kernel32.ReadFile(_handle, p + offset, count, IntPtr.Zero, overlapped);
else
r = Interop.Kernel32.ReadFile(_handle, p + offset, count, out numBytesRead, IntPtr.Zero);

<TargetFrameworks>$(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent);$(NetCoreAppMinimum)-windows;$(NetCoreAppMinimum)-unix;$(NetCoreAppMinimum);netstandard2.0;$(NetFrameworkMinimum)</TargetFrameworks>

if (_isAsync)
r = Interop.Kernel32.WriteFile(_handle, p + offset, count, IntPtr.Zero, overlapped);
else
r = Interop.Kernel32.WriteFile(_handle, p + offset, count, out numBytesWritten, IntPtr.Zero);

System.IO.Pipes uses SafePipeHandle (I have a plan how to tackle it, but as I wrote earlier I would perfer it to be a separate PR);

if (Interop.Kernel32.WriteFile(_handle!, p, buffer.Length, out bytesWritten, IntPtr.Zero) == 0)

Copy link
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 the feedback where I've tagged you

child2.Start();
char[] output = new char[6];
child2.StandardOutput.Read(output, 0, output.Length);
child2.StandardOutput.ReadBlock(output, 0, output.Length);
Copy link
Member

Choose a reason for hiding this comment

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

Yes, more or less we were always lucky so far. I wanted to be clear why it was not needed before (to show it's not a bug now)

… calls, rename helper

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 16, 2026

@copilot please address the feedback where I've tagged you

All four items addressed in da4579d:

  • foreach loop in UpdatedCachedCursorPosition
  • Consolidated duplicate ReadV/WriteV calls in scatter/gather methods
  • Renamed NeedsNonOffsetFallbackShouldFallBackToNonOffsetSyscall with doc comment

…d/Write methods

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
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.

Relax RandomAccess type requirements, make all Read*|Write* methods work with non-seekable files

5 participants