Skip to content

feat: add mock support for OpenHandle and IRandomAccess - #1082

Merged
vbreuss merged 15 commits into
Testably:mainfrom
Mpdreamz:feature/random-access-mock
Sep 25, 2026
Merged

vbreuss merged 15 commits into
Testably:mainfrom
Mpdreamz:feature/random-access-mock

Conversation

@Mpdreamz

@Mpdreamz Mpdreamz commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Step 3 of the split: mock support for the IFile.OpenHandle and IRandomAccess abstractions added in #1081.

Stacked on #1081 — the diff shows that branch's commit too, and shrinks to this one commit once #1081 merges. It will need rebasing onto the released Testably.Abstractions.Interface once you have cut the pre-release; the ProjectReference switch in here is the placeholder for that and should be reverted to a PackageReference at the version bump.

How the mock handles a sealed SafeFileHandle

SafeFileHandle is sealed and wraps an OS handle, so the mock cannot produce one a real syscall would accept. It hands out a handle whose value is synthetic and remembers which file that value stands for — the same indirection ISafeFileHandleStrategy already provides for handles created elsewhere, in the other direction.

MockSafeFileHandleRegistry resolves handles it created and falls back to the configured ISafeFileHandleStrategy for everything else, so existing strategy-based code is unaffected. Handle values start at 0x4000_0000, far above any plausible file descriptor, so a mock handle mistakenly passed to a real syscall fails with an invalid-handle error rather than addressing an unrelated file.

Behaviour, each covered by a test that runs against both the real and the mocked file system:

behaviour
Identity a handle resolves to the file it was opened on, not to its name, so it survives a rename and does not follow the path
Backing store a handle and a FileSystemStream on the same path observe the same content
File share handles take part in the same FileShare bookkeeping as streams
Offsets positionless; reads past the end return a short read, writes past the end grow the file and zero-fill the gap
SetLength growth zero-fills
Closing a closed handle throws ObjectDisposedException; its share lock is released; FileOptions.DeleteOnClose deletes once the last handle to the file closes
Access reading a write-only handle or writing a read-only one throws UnauthorizedAccessException; GetLength works on either, being metadata
FlushToDisk the in-memory storage has no write-back cache, so there is nothing to flush; the call still resolves the handle and is counted in Statistics

Because a sealed SafeFileHandle gives no disposal callback, closure is noticed on the next access to MockFileSystem.Storage, which every file system operation goes through. When no handle is open that is a single volatile bool read.

Addressing the review on #1081

Handles resolved by path rather than by opened identity — correct, and fixed. I measured it against the BCL first: open a handle, File.Move the file, read → the original content still comes back. The registry now stores the container the handle was opened on and resolves through that; only foreign handles, which carry nothing but a path, are looked up by name. HandleIdentityTests covers rename and the case where another file later takes the old path.

FileMode.Append handles overwrite instead of appending — the premise does not hold everywhere, so this is fixed only where it is real. Measured on macOS: File.OpenHandle(path, FileMode.Append, FileAccess.Write) then RandomAccess.Write(handle, [9], 0) over [1,2,3,4] yields [9,2,3,4] — the offset is honoured, which is what the mock already did. Linux is the exception: its pwrite(2) appends when the descriptor carries O_APPEND, whatever offset is passed, contrary to POSIX. The mock now reproduces that under Execute.IsLinux, and AppendTests asserts both behaviours, so your Linux CI is the arbiter if I have this the wrong way round.

Every closed handle value retained indefinitely — fixed, and the storage is gone rather than capped. Values are issued sequentially and never reused, so a value inside the issued range that is no longer registered is a closed handle; the set it needed has been deleted.

DeleteOnClose removes shared storage before the last handle closes — fixed. A deletion whose file still has open handles stays pending and is applied when the last one closes.

DeleteOnClose omitted from delete-access bookkeeping — not applied, and I think the suggestion is wrong here. FileHandle.GrantAccess treats deleteAccess as "this is a delete operation", requiring every other handle to have been opened with exactly FileShare.Delete on Windows. Passing it for an ordinary open regressed sharing; DeleteOnClose is a property of the close, not of the open. Happy to be corrected if it was meant differently.

Two things I did not fix, both pre-existing

  • File share locks are keyed by path, in InMemoryStorage._fileHandles, so they do not move with a renamed file and a handle permitting delete sharing does not let the simulated Windows file system rename or delete the file underneath it. The three affected tests skip the Windows-simulating variant and say why. Real Windows allows this, so it is a genuine (small) divergence — worth its own issue rather than a drive-by change in here.
  • FileStreamFactoryMock re-opens the path behind a handle, whereas a real FileStream adopts the handle it is given. A handle therefore has to permit FileShare.ReadWrite before a mocked stream can be layered on it, which OpenHandleStreamTests notes.

Extensions reaching the registration

You asked whether extension packages could get at the handle registration, with MemoryMappedFile in mind. Nothing public is exposed yet — MockSafeFileHandleRegistry is internal, and I would rather agree the shape with you than guess. The natural options are a public resolution method on MockFileSystem, or promoting the registry itself. Say which you prefer and I will add it here or in a follow-up.

Verification

Run in Release, which is what puts the real file system into the test matrix, on macOS with the .NET 8, 9 and 10 SDKs: Testably.Abstractions.Tests 12575 on net8.0, 13054 on net9.0 and 13084 on net10.0; Testing.Tests 1313; MemoryMappedFiles.Tests 665; parity 26; API 30. All pass.

Earlier revisions of this description reported Debug numbers, which silently leave the real file system out of the matrix — that is how the DeleteOnClose and SetLength divergences your CI found got past me.

Happy to rework or reshape any of this — just say what you would prefer.

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

One or more issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 2 Medium severity

Open (3)
What changed in this PR

Adds mock support for IFile.OpenHandle and IRandomAccess, including synthetic handle tracking, sharing, offsets, disposal, and parity coverage.

Changes:

  • Adds feature-gated interfaces and real/mock implementations.
  • Adds handle lifecycle, statistics, and DeleteOnClose support.
  • Adds tests, parity checks, and API baselines.
File Description
Tests/​Testably.Abstractions.Tests/​Testably.Abstractions.Tests.csproj Updated as part of this pull request.
Tests/​Testably.Abstractions.Tests/​FileSystem/​RandomAccess/​WriteTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Tests/​FileSystem/​RandomAccess/​Tests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Tests/​FileSystem/​RandomAccess/​ReadTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Tests/​FileSystem/​RandomAccess/​LengthTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Tests/​FileSystem/​RandomAccess/​HandleIdentityTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Tests/​FileSystem/​RandomAccess/​AppendTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Tests/​FileSystem/​FileStreamFactory/​OpenHandleStreamTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Tests/​FileSystem/​File/​SafeFileHandleTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Tests/​FileSystem/​File/​OpenHandleTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Testing.Tests/​Testably.Abstractions.Testing.Tests.csproj Updated as part of this pull request.
Tests/​Testably.Abstractions.Testing.Tests/​Statistics/​FileSystem/​FileStatisticsTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Parity.Tests/​TestHelpers/​Parity.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Parity.Tests/​ParityTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Parity.Tests/​Net9ParityTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Parity.Tests/​Net8ParityTests.cs Updated as part of this pull request.
Tests/​Testably.Abstractions.Parity.Tests/​Net10ParityTests.cs Updated as part of this pull request.
Tests/​Helpers/​Testably.Abstractions.TestHelpers/​Testably.Abstractions.TestHelpers.csproj Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Core.Api.Tests/​Expected/​Testably.Abstractions.FileSystem.Interface_net9.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Core.Api.Tests/​Expected/​Testably.Abstractions.FileSystem.Interface_net8.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Core.Api.Tests/​Expected/​Testably.Abstractions.FileSystem.Interface_net6.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Core.Api.Tests/​Expected/​Testably.Abstractions.FileSystem.Interface_net10.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Api.Tests/​Expected/​Testably.Abstractions.Testing_net9.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Api.Tests/​Expected/​Testably.Abstractions.Testing_net8.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Api.Tests/​Expected/​Testably.Abstractions.Testing_net6.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Api.Tests/​Expected/​Testably.Abstractions.Testing_net10.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Api.Tests/​Expected/​Testably.Abstractions_net9.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Api.Tests/​Expected/​Testably.Abstractions_net8.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Api.Tests/​Expected/​Testably.Abstractions_net6.0.txt Updated as part of this pull request.
Tests/​Api/​Testably.Abstractions.Api.Tests/​Expected/​Testably.Abstractions_net10.0.txt Updated as part of this pull request.
Source/​Testably.Abstractions/​RealFileSystem.cs Updated as part of this pull request.
Source/​Testably.Abstractions/​FileSystem/​RandomAccessWrapper.cs Updated as part of this pull request.
Source/​Testably.Abstractions/​FileSystem/​FileWrapper.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​Testably.Abstractions.Testing.csproj Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​Statistics/​IFileSystemStatistics.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​Statistics/​FileSystemStatistics.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​MockFileSystem.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​Helpers/​FileModeHelper.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​Helpers/​ExceptionFactory.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​FileSystem/​RandomAccessMock.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​FileSystem/​MockSafeFileHandleRegistry.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​FileSystem/​FileStreamMock.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​FileSystem/​FileStreamFactoryMock.cs Updated as part of this pull request.
Source/​Testably.Abstractions.Testing/​FileSystem/​FileMock.cs Updated as part of this pull request.
Source/​Testably.Abstractions.FileSystem.Interface/​IRandomAccess.cs Updated as part of this pull request.
Source/​Testably.Abstractions.FileSystem.Interface/​IFileSystem.cs Updated as part of this pull request.
Source/​Testably.Abstractions.FileSystem.Interface/​IFile.cs Updated as part of this pull request.
Feature.Flags.props Updated as part of this pull request.

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

Comment thread Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs Outdated
Comment thread Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs Outdated
Comment thread Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs Outdated
@Mpdreamz

Copy link
Copy Markdown
Contributor Author

Pushed fixes for all three findings.

Concurrent writes could lose each other — real, and my own notes had called for a per-file gate that I then never implemented. Read-modify-write sequences are now serialised per file, SetLength included, since RandomAccess explicitly permits concurrent writes at distinct offsets. There is a test that drives two parallel write loops over disjoint ranges and checks both survive.

ReadBytes materialised the whole tail — fixed; reads now copy only what the destination asks for, scatter reads included, so a one-byte read at the start of a large file no longer allocates the file.

DeleteOnClose followed the stale path after a rename — fixed properly rather than documented away, which is what I had done before. IStorage gained a container-to-location lookup, so the deletion follows the file; two tests cover a renamed file being deleted under its new name, and a replacement at the old path being left alone.

I also fixed the two findings Copilot raised on #1083 that are really about code introduced here: a disposed or null handle from outside the mock now throws before reaching ISafeFileHandleStrategy, and a missing mapped file is named in the exception instead of reporting an empty path.

While fixing the last of these I found a bug in my own first attempt: deferring the deletion to the last handle silently dropped the intent when another handle was open, so the file was never deleted at all. It is now held as a pending deletion and applied when the last handle closes.

Two things raised separately, per your note on #1081: #1084 / #1085 for FileShare.Delete being compared by equality, and #1086 / #1087 for FileStreamFactoryMock re-opening the path behind a handle rather than adopting it. Once #1085 lands, the three tests here that skip the Windows-simulating variant should be able to stop skipping.

10502 tests pass; Testing.Tests is clean apart from the two pre-existing macOS access-time failures.

@Mpdreamz
Mpdreamz force-pushed the feature/random-access-mock branch from 9b4bf6d to 6556d04 Compare September 20, 2026 14:19
@vbreuss

vbreuss commented Sep 20, 2026

Copy link
Copy Markdown
Member

@Mpdreamz : The first PR is merged and released as 10.4.0-pre.1. You should now be able to rebase this branch onto main, update the package versions and reset the build scope in Build.cs back to "Default".

Mpdreamz and others added 4 commits September 20, 2026 19:39
Implements the abstractions added alongside for `MockFileSystem`, so the
`SafeFileHandle` surface can be exercised against the mock.

`SafeFileHandle` is sealed and wraps an operating system handle, so the
mock cannot create one a real syscall would accept. It hands out a handle
with a synthetic value and remembers which file that value stands for —
the same indirection `ISafeFileHandleStrategy` already provides for
handles created elsewhere, which continues to work unchanged.

A handle resolves to the file it was opened on rather than to its name,
so it keeps working across a rename and does not start referring to a
file later created under the original path. File share locks are released
and `FileOptions.DeleteOnClose` applies when the last handle to a file
closes, noticed on the next file system operation because a sealed
`SafeFileHandle` gives no disposal callback.

Tests run against both the real and the mocked file system, covering
every member that takes a handle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Serialise read-modify-write sequences per file. `RandomAccess` permits
  concurrent writes at distinct offsets, and rebuilding the whole buffer
  without a gate let one of them discard the other.
- Copy only what the destination asks for. Reading a single byte at the
  start of a large file materialised the entire tail first.
- Follow the file rather than the path when applying
  `FileOptions.DeleteOnClose`, so a rename while the handle is open
  deletes the right file and leaves a replacement at the old path alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A handle the `MockFileSystem` did not create is resolved by the
registered `ISafeFileHandleStrategy`, but was otherwise unchecked: a
disposed or null handle still reached the strategy, where the real
implementation throws.

Also name the file in the exception when the mapped path no longer
exists, instead of reporting an empty path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows the interface change: one flag now limits the random-access
surface that needs more than .NET 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebased onto `main` now that the interface ships in 10.4.0-pre.1:

- the project references added while the interface was unreleased go back
  to package references,
- `Directory.Packages.props` moves to `[10.4.0-pre.1,10.5.0)`,
- `BuildScope` returns to `Default`.

Two of the tests that skipped the Windows-simulating variant now say why
more precisely: moving a file held open by a handle needs
`ignoreFileShare`, which is inert on Windows (Testably#1086), and two openers
that both permit `FileShare.ReadWrite | FileShare.Delete` are refused
(Testably#1090).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Mpdreamz
Mpdreamz force-pushed the feature/random-access-mock branch from 6556d04 to 40d6e6f Compare September 20, 2026 17:50
@Mpdreamz

Copy link
Copy Markdown
Contributor Author

Thanks for the release. All three steps done:

  • rebased onto main, so only the four mock commits remain,
  • the project references I had added while the interface was unreleased are back to package references, and Directory.Packages.props now points at [10.4.0-pre.1,10.5.0),
  • BuildScope is back to Default.

MemoryMappedFiles.Tests passes again — 532 of 532 — which was the project failing with the TypeLoadException on #1081, so the intermediate state is fully closed.

Locally: Testably.Abstractions.Tests 10530 pass, MemoryMappedFiles.Tests 532 pass, parity 26 pass, API suites pass, and Testing.Tests is clean apart from the two macOS access-time failures that also fail on main. I have the matching 10.0.401 SDK installed now, so this was built and run against the same SDK as CI.

One follow-on. I said on this PR that once #1085 landed the three tests skipping the Windows-simulating variant should stop skipping. I tried it, and it was only half right — deletion is fixed, but two other things still block them, so I have kept the skips with precise reasons instead of a guess:

Once #1091 is in, the DeleteOnClose_ShouldDeleteOnlyWhenTheLastHandleIsClosed skip can go; the rename ones wait on whatever you decide for ignoreFileShare.

#1083 is rebased on top of this one and is green too.

Drops the summaries on private and internal members that restate the
member name, the duplicated rationale on `IStorage.GetLocation` in favour
of `<inheritdoc />`, and the prose repeated at both ends of the handle
sweep.

What stays is the handful of decisions the code cannot show on its own:
why synthetic handle values start where they do, why `deleteAccess` is
not what `FileOptions.DeleteOnClose` means, why a closed handle is
noticed late, why writes are serialised, and the Linux `pwrite(2)`
deviation for append handles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Mpdreamz
Mpdreamz force-pushed the feature/random-access-mock branch from 08e00be to 81eb0c6 Compare September 20, 2026 18:10
@Mpdreamz

Copy link
Copy Markdown
Contributor Author

Swept the code comments here after your note on #1091, so this is a pure comment change — no behaviour touched, and the suites are unchanged.

Roughly 80 lines gone:

  • summaries on private and internal members that only restated the member name,
  • the sealed-handle rationale that I had written at both ends of the handle sweep, now in one place,
  • the IStorage.GetLocation documentation copied onto InMemoryStorage, now <inheritdoc /> like its neighbours,
  • class-level summaries on the new test classes, since none of the existing test classes have them.

Sixteen lines remain, all explaining something the code cannot: why synthetic handle values start at 0x4000_0000, why deleteAccess is not what FileOptions.DeleteOnClose means, why a closed handle is noticed on the next operation, why writes are serialised, and the Linux pwrite(2) deviation for append handles. Say the word and I will cut further.

Testably.Abstractions.Tests 10530 pass, MemoryMappedFiles.Tests 532 pass, API and parity pass, Testing.Tests clean apart from the two pre-existing macOS access-time failures.

Running in Release, which is what adds the real file system to the test
matrix, showed two places where the mock had drifted from it on Unix.

`FileOptions.DeleteOnClose` unlinks the name that was opened, as soon as
that handle closes: it does not wait for other handles, a file renamed
since then survives, and a replacement under the old name does not. The
delete-on-last-close behaviour that follows the file across a rename is
Windows', and is now gated on that.

`RandomAccess.SetLength` on a read-only handle fails with `EINVAL` from
`ftruncate`, reported as an `IOException`, where Windows denies access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Mpdreamz

Copy link
Copy Markdown
Contributor Author

Your CI caught two real divergences, and finding out why my local runs missed them is the more useful half of this.

Why I missed them. FileSystemTestsAttribute only adds the real file system to the matrix in Release:

#if DEBUG
    if (Settings.RealFileSystemTests == Settings.TestSettingStatus.AlwaysEnabled)
#else
    if (Settings.RealFileSystemTests != Settings.TestSettingStatus.AlwaysDisabled)
#endif

I had been running Debug throughout, so every "passes against both the real and the mocked file system" I have written on these PRs was, locally, the mock only. Running -c Release reproduces all four failures on this machine immediately. I have also installed the .NET 8 and 9 SDKs, so the other target frameworks are no longer compile-only for me either.

FileOptions.DeleteOnClose. The real behaviour on Unix is to unlink the name that was opened, as soon as that handle closes: it does not wait for other handles, a file renamed since then survives, and a replacement under the old name is removed. Delete-on-last-close that follows the file across a rename is Windows' behaviour, and is now gated on it.

That is worth flagging because it reverses a Copilot finding on this PR, which asked me to make the deletion follow the container rather than the path. That is right on Windows and wrong on Unix, and I implemented it without checking the real file system — the mock's original path-based behaviour was closer to correct than my "fix". The three tests now assert the Unix semantics directly.

RandomAccess.SetLength on a read-only handle. ftruncate fails with EINVAL, surfaced as an IOException with Invalid argument : '<path>'; Windows denies access. The mock now does both, and the test is split.

Release, on this machine, all green: Testably.Abstractions.Tests 12575 on net8.0, 13054 on net9.0, 13084 on net10.0; Testing.Tests 1313; MemoryMappedFiles.Tests 665; parity 26; API 30. The two macOS access-time failures I had been reporting as pre-existing were a Debug-only artefact and do not occur here either.

#1083 is rebased on top and verified the same way.

I had the mock append on Linux, on the strength of the `pwrite(2)` note
that `O_APPEND` overrides the offset. The Linux CI run disagrees: the
offset is honoured there exactly as it is on Windows and macOS, so the
special case and the platform-split tests are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Mpdreamz

Copy link
Copy Markdown
Contributor Author

Fixed — one test was behind both failures, and it was mine.

Write_OnAppendHandle_ShouldAppend_OnLinux asserted that a write through an append handle ignores the offset and appends. I wrote that from the pwrite(2) note that O_APPEND overrides the offset, having measured only macOS. Your Linux runner says otherwise: the offset is honoured there exactly as on Windows and macOS. So the Execute.IsLinux special case in the mock is gone and the platform-split tests are replaced by three that assert offset semantics everywhere.

That is the second time on this PR that a platform difference I took from documentation rather than measurement turned out not to exist, after the FlushToDisk framework gating. I have stopped special-casing on anything I have not actually run.

The Windows job on #1083 was collateral: it was cancelled by fail-fast when ubuntu failed, not a failure of its own — the run's job list shows only ubuntu failing.

Release, on macOS: Testably.Abstractions.Tests 12570 on net8.0, 13049 on net9.0, 13079 on net10.0, Testing.Tests 1313, all passing. Linux and Windows remain your CI's to confirm, which is exactly how this one surfaced.

@vbreuss vbreuss left a comment

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.

Thanks @Mpdreamz for this PR. I had a look myself and also let Claude review it locally.

With this change, the documentation is no longer up to date:
1. safe-file-handles.mdx now contradicts the feature

"Because the mock has no kernel handle, you have to tell it how to translate handles into mock paths."
"install a custom strategy as soon as your code under test reaches for SafeFileHandle"

A handle obtained from File.OpenHandle needs no strategy at all now - that is the headline improvement in this branch, and the page says the opposite.

2. the statistics table is missing RandomAccess

The table is introduced as "one property per IFileSystem sub-property" and now has a hole.

3. there is no RandomAccess documentation page

Would you add/fix documentation as part of this PR or do you prefer a follow-up?

Comment thread Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs Outdated
Comment thread Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj Outdated
Comment thread Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs Outdated
Comment thread Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs Outdated
Comment thread Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs Outdated
Comment thread Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs Outdated
Comment thread Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs Outdated
- `safe-file-handles.mdx` opened by saying the mock has no handle to give
  you and sending you to `ISafeFileHandleStrategy`, which this change
  makes untrue. It now leads with `IFile.OpenHandle`, says what the
  handle carries and how `FileOptions.DeleteOnClose` differs between Unix
  and Windows, and keeps the strategy for handles from elsewhere.
- The statistics table gains `RandomAccess`, which it introduces as one
  row per `IFileSystem` sub-property.
- A new page for `RandomAccess` itself: positionless reads and writes,
  what each framework offers, the behaviour worth knowing, and why
  `FlushToDisk` is worth asserting against the mock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Mpdreamz

Copy link
Copy Markdown
Contributor Author

All three, in this PR — the documentation belongs with the feature, and you are right that it currently contradicts it.

1. safe-file-handles.mdx. Rewritten. It now opens with IFile.OpenHandle and the fact that a handle from the abstraction needs no strategy, then keeps ISafeFileHandleStrategy for the case it is still needed: a handle from platform invocation or a library, which the mock cannot resolve on its own. The reference implementation and its example stay, with the example no longer reaching for the P/Invoke helper.

It also records two things this branch settled against the real file system, both of which would otherwise be surprises: a handle resolves to the file it was opened on, so reads and writes survive a rename and do not follow the name; and FileOptions.DeleteOnClose differs by platform — Unix unlinks the opened name as soon as that handle closes, Windows waits for the last handle and follows the file across a rename.

2. Statistics table. RandomAccess added, so the "one property per IFileSystem sub-property" claim holds again.

3. A RandomAccess page. New: positionless reads and writes, a table of what each framework offers (GetLength/Read/Write from .NET 6, SetLength and FlushToDisk from .NET 8), the behaviour worth knowing — short reads, growth with zero-fill, GetLength working on a write-only handle, FileMode.Append still honouring the offset — and a section on FlushToDisk against the mock, where the point is that the call is recorded so a test can assert a durability barrier was requested.

I moved the safe-file-handles.mdx change here from #1083, which is where I had wrongly put it; #1083 is rebased and now carries only test changes.

Two notes on what I did not invent: the FlushToDisk example uses the stats.X.Methods / Contains(m => m.Name == ...) idiom from the existing statistics page rather than an assertion I had not checked, and every behavioural claim on the new page is one the tests in this branch cover.

Release on macOS: Testably.Abstractions.Tests 13079 on net10.0 and Testing.Tests 1313, all passing.

@vbreuss

vbreuss commented Sep 22, 2026

Copy link
Copy Markdown
Member

All three, in this PR — the documentation belongs with the feature, and you are right that it currently contradicts it.

@Mpdreamz : You only reacted to the review summary comment, but not on the review comments themselves. Did you overlook them?

The only remaining difference was whitespace before `/>`, left over from
switching these references to projects and back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mpdreamz and others added 4 commits September 25, 2026 21:43
…dle`

`FileModeHelper.GetFileContainer` now holds what both did twice: a
missing file for `Open`/`Truncate`, creating it otherwise, a directory at
the path, `CreateNew` on an existing file and a read-only file opened for
writing. `FileStreamMock` calls `FileModeHelper` directly and loses the
forwarding method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Handle lifetime
- Closed handles are no longer released from the `MockFileSystem.Storage`
  getter. `InMemoryStorage` calls `ReleaseClosedHandles()` explicitly
  where a closed handle becomes observable: `GetContainer`,
  `EnumerateLocations` and `TryGetFileAccess`.
- The sweep no longer throws. A delete-on-close that fails (the parent
  is gone, a directory took the name) is ignored, as the OS ignores it.
- On Windows a pending delete waits until nothing holds the file,
  streams included, instead of being dropped when a stream still has it
  open.
- The Windows branch no longer follows a renamed file: moving a file a
  handle holds open is not possible in the mock on Windows yet (Testably#1086),
  so that behaviour could not be observed. `IStorage.GetLocation(
  IStorageContainer)` goes with it.
- The registry holds handles weakly, so one that is dropped without
  `Dispose()` releases its share lock once it is collected, as a real
  handle does when it is finalized.

`OpenHandle`
- Arguments are validated in the order of the runtime's
  `FileStreamHelpers.ValidateArguments`: path, then the enum ranges,
  options, `preallocationSize`, the mode/access combination, and finally
  preallocation on a non-creating mode or without write access.
- `Map` and the handle lookup guard against `null`, and a closed foreign
  handle is rejected on every path.

`RandomAccessMock`
- Writes go through `IStorageContainer.WriteRange`, so an open
  `FileStreamMock` gets a range update and a vetoing interception sees
  the content unchanged.
- An offset beyond what the content can hold throws `IOException`
  instead of overflowing.
- `FileMode` is no longer threaded through; `GetContainerForResize` is
  behind `FEATURE_RANDOMACCESS_FLUSHTODISK`; the buffers are recorded in
  the statistics like the rest of the mock records them.

Also: `Entry` is a record, `IsKnown` and the redundant `NullContainer`
check are gone, `ExceptionFactory` additions are in alphabetical order,
the redundant `AllHandleOverloads_ShouldAgreeWithThePathOverloads` test
is removed and the skip reasons point at Testably#1086 again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`IRandomAccess` joins the data-driven list in `StatisticsTests`, with a
registration test for every member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows no longer claims that a delete-on-close follows a renamed file,
and the page says when a closed or dropped handle is noticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Mpdreamz

Copy link
Copy Markdown
Contributor Author

@vbreuss You're right, I had overlooked them, sorry. Every inline comment has an answer now, and the fixes are in five new commits on top of what you already reviewed (no rebase, so the earlier history is unchanged):

  • chore: the three test project files are identical to main again.
  • refactor: the open checks shared by FileStreamMock and OpenHandle moved into FileModeHelper.GetFileContainer.
  • fix: the review findings on the handle registry and RandomAccessMock. The Storage getter no longer has a side effect, the sweep does not throw, a pending Windows delete waits for streams, OpenHandle validates in the runtime's order, and writes go through WriteRange. All the failing tests you and Claude wrote are included and pass.
  • test: IRandomAccess is in the statistics coverage list.
  • docs: the handle page no longer claims a delete-on-close follows a renamed file, and it says when a closed handle is noticed.

Verified in Release on net10.0 (macOS): Testably.Abstractions.Testing.Tests 1333/1333, and API, parity and MemoryMappedFiles.Tests pass. Testably.Abstractions.Tests passes too, except the new DeleteOnClose_OnWindows_ShouldDeleteOnlyWhenTheLastHandleIsClosed, which needs the #1091 fix that this branch's base predates. Against current main it passes (13139/13139); I can rebase if you'd rather have that on the branch itself.

Mpdreamz pushed a commit to Mpdreamz/Testably.Abstractions that referenced this pull request Sep 25, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `(T1, ReadOnlySpan<T2>, T3)` registration overload was compiled only
with `FEATURE_FILE_SPAN` (.NET 9+), so on .NET 6 and 8 the buffers bound
to the plain generic overload, which cannot take a span. It is a generic
helper with nothing file-specific about it, so it now needs only
`FEATURE_SPAN`, like its counterpart in the test helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mpdreamz pushed a commit to Mpdreamz/Testably.Abstractions that referenced this pull request Sep 25, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mergify

mergify Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

This pull request does not currently match the merge queue conditions, so it cannot be queued from here. The box comes back if it matches again.

@vbreuss
vbreuss merged commit 015a573 into Testably:main Sep 25, 2026
13 checks passed
@vbreuss

vbreuss commented Sep 25, 2026

Copy link
Copy Markdown
Member

Thanks, @Mpdreamz, this is a great addition to the library 🥳

@github-actions

Copy link
Copy Markdown

This is addressed in release v7.1.0.

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.

3 participants