Skip to content

feat: support File.OpenHandle and RandomAccess in MockFileSystem - #1546

Open
Mpdreamz wants to merge 4 commits into
TestableIO:mainfrom
Mpdreamz:feat/mock-open-handle
Open

Mpdreamz wants to merge 4 commits into
TestableIO:mainfrom
Mpdreamz:feat/mock-open-handle

Conversation

@Mpdreamz

Copy link
Copy Markdown

MockFileSystem throws for File.OpenHandle and for every member of RandomAccess since #1542, and the SafeFileHandle overloads of IFile throw NotImplementedException. FileStream.New(handle) does not throw, but passes handle.ToString() on as a path, so it fails with FileNotFoundException. Code that uses handles therefore cannot be tested against the mock. This adds that support, along the lines of what Testably.Abstractions shipped in 7.1.0, adapted to this mock's model.

How it works

SafeFileHandle is sealed and wraps an operating system handle, so the mock cannot create one a real system call would accept. File.OpenHandle hands out a handle with a synthetic value instead (well above any real handle, unique across instances) and an internal registry on MockFileSystem remembers which file it stands for. The handle refers to the MockFileData it opened rather than to its path, so it keeps working after the file is deleted, as it does on a real file system.

  • File.OpenHandle opens like MockFileStream does: missing file or directory, CreateNew on an existing file, a directory at the path, a read-only file opened for writing. It takes a file share through FileHandles, so it conflicts with streams exactly as a stream would. Arguments are validated in the runtime's order (FileStreamHelpers.ValidateArguments), so a call with several invalid arguments reports the same one.
  • Closing. Because the class is sealed, the mock is not told when a handle is disposed. GetFile, FileExists and the All* enumerations release closed handles before answering: their file share goes, and FileOptions.DeleteOnClose is applied if the same file is still at that path. The sweep runs under the lock on the files, so a thread never misses a handle it closed itself. It costs nothing while no handle is open. The registry holds handles weakly, so a handle that is dropped without being disposed is released once it has been collected, as a real one is by its finalizer.
  • RandomAccess reads and writes that file at an offset. Writes replace the contents rather than changing them in place, so an open MockFileStream sees them, and they update the file times. GetLength works through a write-only handle, FlushToDisk validates the handle, and SetLength through a read-only handle throws IOException on Unix (EINVAL) and UnauthorizedAccessException on Windows.
  • The SafeFileHandle overloads of IFile read and write the attributes, times and Unix mode of the open file.
  • FileStream.New(handle, ...) wraps the file the handle holds open, as FileStream does. It does not open the path again, takes no share of its own, does not apply the handle's FileMode a second time, and closes the handle when it is disposed. It is limited by the handle's own access. It throws ObjectDisposedException once the handle is closed. It validates bufferSize, and rejects an isAsync that differs from the handle's. Targets without FEATURE_RANDOM_ACCESS keep the previous behaviour.
  • A behaviour change on net6+: FileStream.New with a handle that did not come from the mock now throws ArgumentException. Before, it opened a file named after handle.ToString().
  • A handle from another MockFileSystem, or from the real file system, throws ArgumentException. An accessor that is not a MockFileSystem still gets the NotSupportedException (with an updated message).

There is no public API change: the registry is internal and the members already existed.

Validated against the real file system

Besides the new unit tests, I ran 69 scenarios through both new FileSystem() and new MockFileSystem() and compared the outcome of each: the return value, or the exception type and parameter name. They cover argument validation, open modes, reads, writes, gather/scatter, lengths, access checks, closed and null handles, cancellation, file shares, DeleteOnClose (including through directory enumeration), handles on deleted files, streams built on handles and the handle overloads. I also ran a two-thread stress test of the handle lifetime (200,000 iterations, no misses). On macOS 65 scenarios match. The other 4 are listed below.

Known limitations

  • File.Move in this mock copies the MockFileData, so a handle does not follow a file that is moved while it is open.
  • The contents are a single array, so an offset beyond Array.MaxLength throws IOException. For an offset that overflows, the runtime throws ArgumentOutOfRangeException instead.
  • A MockFileStream refreshes from the shared contents on reads, not on writes. A stream that writes after a RandomAccess.Write to the same file can therefore overwrite it, as two streams on one file already can in this mock.
  • SafeFileHandle.IsAsync asks the operating system, so it is false for a mock handle even with FileOptions.Asynchronous. The mock's own isAsync checks use the options the handle was opened with.
  • Sharing follows the mock's existing Windows-style rules on every platform. A handle opened with FileShare.Read blocks a writer, just as a MockFileStream does, while on Unix only FileShare.None is enforced.

Found along the way, not changed here

MockFile.Open(path, mode, access, share) ignores share and always opens with FileShare.Read, see MockFile.OpenInternal. The new tests use FileStream.New, which passes the share on. Happy to fix that separately.

Verification

In Release on net8.0, net9.0 and net10.0 (macOS):

  • TestingHelpers.Tests: all pass (77 new).
  • Wrappers, API and parity tests: all pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TymoqrVYgZwdNSper5ZZB1

mpdreamz and others added 2 commits September 30, 2026 15:19
`MockFileSystem` threw for `File.OpenHandle`, every `IRandomAccess` member
and the `SafeFileHandle` overloads of `IFile`, and `FileStream.New(handle)`
used `handle.ToString()` as a path. Code that uses handles could not be
tested against the mock.

- `File.OpenHandle` returns a `SafeFileHandle` with a synthetic value that
  the mock resolves to the `MockFileData` it opened. The handle refers to
  that file, not its path, so it keeps working after the file is deleted.
  It takes a file share like a stream does, and releases it (and applies
  `FileOptions.DeleteOnClose`) once the handle is closed or collected.
- `RandomAccess` reads and writes that file at an offset. Writes replace
  the contents, so an open `MockFileStream` sees them.
- The `SafeFileHandle` overloads of `IFile` read and write the attributes,
  times and Unix mode of the open file.
- `FileStream.New(handle, ...)` wraps the open file like `FileStream` does:
  no new share, no `FileMode` applied again, and disposing it closes the
  handle.
- Arguments are validated in the runtime's order, and the outcomes were
  compared scenario by scenario against the real file system.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TymoqrVYgZwdNSper5ZZB1
- The closed-handle sweep runs under the lock on the files. Before, a
  thread that closed a handle could skip it while another thread was
  sweeping, and still hit its own share lock or find its DeleteOnClose
  file.
- `AllPaths`, `AllFiles` and `AllDirectories` sweep too, so a closed
  DeleteOnClose file no longer shows up in `Directory.GetFiles` or stops
  a non-recursive `Directory.Delete`.
- A stream on a handle is limited by the handle's own access, and throws
  `ObjectDisposedException` once the handle is closed, as `FileStream`
  does. `bufferSize` is validated, `isAsync` must match the handle, and
  the overloads without it take it from the handle.
- `Create` and `Truncate` on an existing empty file update the write time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TymoqrVYgZwdNSper5ZZB1
Copilot AI balanced review requested due to automatic review settings September 30, 2026 13:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Handle registry concurrency, sharing, option handling, and closed-handle flushing have unresolved correctness issues.

Review effort: Balanced
Findings: 4 Medium severity

Open (4)
What changed in this PR

Adds mock support for file handles, RandomAccess, handle-backed streams, and handle-based metadata operations.

Changes:

  • Adds synthetic handle registration and lifecycle management.
  • Implements random-access and handle-backed stream operations.
  • Adds broad behavioral test coverage and supporting exceptions/resources.
File Description
MockRandomAccessTests.cs Tests random-access and handle-backed streams.
MockFileOpenHandleTests.cs Tests handle opening, lifecycle, and metadata.
Resources.resx Adds handle-related error messages.
MockSafeFileHandles.cs Implements synthetic handle registry.
MockRandomAccess.cs Implements random-access operations.
MockFileSystem.cs Integrates handle cleanup.
MockFileStreamFactory.cs Creates streams from mock handles.
MockFileStream.cs Adds handle-backed stream behavior.
MockFile.cs Implements handle APIs and metadata overloads.
CommonExceptions.cs Adds exception factories.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +430 to +433
// A stream on a handle writes to the file the handle holds open, wherever its path now points.
if (adoptedHandle != null || mockFileDataAccessor.FileExists(path))
{
var mockFileData = mockFileDataAccessor.GetFile(path);
var mockFileData = adoptedHandle != null ? fileData : mockFileDataAccessor.GetFile(path);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 67e514f. Flush, Flush(bool) and FlushAsync now throw ObjectDisposedException once the handle is closed, and disposing the stream still succeeds. I checked both against the real file system: FileStream behaves the same way.

/// <summary>
/// The handles handed out by <see cref="MockFile.OpenHandle"/>. Not serialized, since they stand for open files.
/// </summary>
internal MockSafeFileHandles SafeFileHandles => safeFileHandles ??= new MockSafeFileHandles(this);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 67e514f. The registry is now published with LazyInitializer.EnsureInitialized, so threads that make their first call at the same time share one registry. There's a test that opens 16 handles in parallel on a fresh file system, 20 times over, and then resolves each of them.

FileOptions.DeleteOnClose |
FileOptions.SequentialScan |
FileOptions.Encrypted |
(FileOptions)0x20000000; // NoBuffering

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right, the real File.OpenHandle accepts BackupOrRestore (I checked on .NET 10). I added it to the mask in 67e514f, with a test. The mask is the only place the check happens, so this also covers the second location you mentioned.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

A correction to my reply above: the runtime accepts BackupOrRestore only from .NET 9. .NET 8 still rejects it, which I found when running the same check in the sibling library's tests, since those also run against the real file system. As of d69dd99 the mock accepts it on net9.0 and later and rejects it on net8.0 and earlier, with a test for each.

}

var shareGuid = Guid.NewGuid();
mockFileDataAccessor.FileHandles.AddHandle(path, shareGuid, access, share);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

True, but this was already the case before this PR: MockFileStream registers its share in FileHandles the same way, and File.Delete/File.Move only look at MockFileData.AllowedFileShare. So a stream opened without FileShare.Delete doesn't block a delete today either. Making delete and move respect open shares would change how existing streams behave too, so I'd rather do that in a separate PR, and I'm happy to if you want it.

mpdreamz and others added 2 commits September 30, 2026 17:13
- `Flush`, `Flush(bool)` and `FlushAsync` on a stream whose handle has
  been closed throw `ObjectDisposedException`, as `FileStream` does;
  disposing it still succeeds.
- The registry is published with `LazyInitializer`, so threads that
  open their first handle at the same time share one registry.
- `OpenHandle` accepts `BackupOrRestore` (0x02000000), which the
  runtime's options check allows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TymoqrVYgZwdNSper5ZZB1
.NET 8's options check rejects `FileOptions` 0x02000000; .NET 9 and later
accept it. The mock now does the same on each target.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TymoqrVYgZwdNSper5ZZB1

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants