Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
01bee22
feat: add mock `MemoryMappedFile` support in a new companion package
vbreuss Jul 11, 2026
8df2b79
fix: align mock MemoryMappedFile view semantics with the BCL
vbreuss Jul 11, 2026
e166b12
Fix review issues
vbreuss Jul 11, 2026
4ffab6e
docs: add companion-library page for MemoryMappedFiles
vbreuss Jul 11, 2026
2b3bba8
Add MemoryMappedFiles to build pipeline
vbreuss Jul 11, 2026
80b9388
fix: align MemoryMappedFiles mock with BCL semantics from review find…
vbreuss Jul 12, 2026
9f4fd3e
fix: close remaining BCL-parity gaps found in the second review round
vbreuss Jul 12, 2026
1fef0cf
fix: address Sonar and Codacy findings
vbreuss Jul 12, 2026
4aee798
fix: suppress conflicting S927 on Seek
vbreuss Jul 12, 2026
6cf36c5
fix: resolve MemoryMappedFiles CI failures on all platforms
vbreuss Jul 12, 2026
bb8992c
fix: close BCL-parity gaps in the MemoryMappedFiles mock found by review
vbreuss Jul 12, 2026
1ab25e8
fix: address Sonar findings from the latest analysis
vbreuss Jul 12, 2026
c000c94
fix: suppress S3011 on the netstandard reference-check reflection
vbreuss Jul 12, 2026
eed8fb5
fix: address review findings in the MemoryMappedFiles mock
vbreuss Jul 12, 2026
f2c6553
fix: address review findings in FileStreamMock and the MemoryMappedFi…
vbreuss Jul 12, 2026
2aadf79
fix: address review findings in the write-range tracking and the Memo…
vbreuss Jul 12, 2026
ee6c7e9
fix: do not track pending write ranges for rejected writes
vbreuss Jul 13, 2026
1585271
perf: publish only the pending write ranges when flushing a file stream
vbreuss Jul 13, 2026
d0163b2
perf: coalesce copy-on-write view reads over consecutive non-privatiz…
vbreuss Jul 13, 2026
6b56b30
refactor: align the duplicated helper implementations across the comp…
vbreuss Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ Testably.Abstractions is a feature-complete testing helper for the `System.IO.Ab
- Mock file system with identical behavior to real file system
- Cross-platform testing (Linux, macOS, Windows simulation)
- Advanced scenarios: multiple drives, FileSystemWatcher, SafeFileHandles
- Companion projects for Compression and AccessControl
- Companion projects for Compression, AccessControl and MemoryMappedFiles
- Time and Random system abstractions

### Architecture
- **Source/**: Main library code with 6 projects
- **Source/**: Main library code with 7 projects
- **Tests/**: Comprehensive test suite with 13,134+ tests
- **Docs/**: Docusaurus documentation site (published to docs.testably.org)
- **Pipeline/**: Nuke build system with .NET 8.0
Expand Down Expand Up @@ -62,7 +62,7 @@ dotnet test --no-build
### Package Commands
**Package Time: ~3 seconds (set timeout: 60s)**
```bash
# Create NuGet packages (6 packages total)
# Create NuGet packages (7 packages total)
export PATH="./.nuke/temp/dotnet-unix:$PATH"
dotnet pack --no-build --configuration Release
```
Expand Down Expand Up @@ -120,7 +120,7 @@ dotnet pack --no-build --configuration Release
### Expected Outputs
- **Build Success**: "Build succeeded with X warning(s)"
- **Test Success**: "Test summary: total: 26699, failed: 0, succeeded: 19355, skipped: 7333"
- **Package Success**: 6 NuGet packages created in Release configuration
- **Package Success**: 7 NuGet packages created in Release configuration

## Project Structure Guide

Expand All @@ -131,6 +131,7 @@ dotnet pack --no-build --configuration Release
- `Testably.Abstractions.FileSystem.Interface`: File system interfaces
- `Testably.Abstractions.Compression`: Zip file support
- `Testably.Abstractions.AccessControl`: ACL support
- `Testably.Abstractions.MemoryMappedFiles`: Memory-mapped file support

### Test Projects (Tests/)
- `Testably.Abstractions.Tests`: Main test suite (~20,000+ tests)
Expand Down
32 changes: 32 additions & 0 deletions Docs/Nuget/MemoryMappedFiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Testably.Abstractions.MemoryMappedFiles

[![Nuget](https://img.shields.io/nuget/v/Testably.Abstractions.MemoryMappedFiles)](https://www.nuget.org/packages/Testably.Abstractions.MemoryMappedFiles)
[![Build](https://github.com/Testably/Testably.Abstractions/actions/workflows/build.yml/badge.svg)](https://github.com/Testably/Testably.Abstractions/actions/workflows/build.yml)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Testably_Testably.Abstractions&branch=main&metric=alert_status)](https://sonarcloud.io/summary/overall?id=Testably_Testably.Abstractions&branch=main)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Testably_Testably.Abstractions&branch=main&metric=coverage)](https://sonarcloud.io/summary/overall?id=Testably_Testably.Abstractions&branch=main)

Memory-mapped file extensions for [`Testably.Abstractions`](https://www.nuget.org/packages/Testably.Abstractions) - adds the methods from `System.IO.MemoryMappedFiles.MemoryMappedFile` to `IFileSystem`, so memory-mapped-file code can be tested against the in-memory `MockFileSystem`.

```ps
dotnet add package Testably.Abstractions.MemoryMappedFiles
```

```csharp
IFileSystem fileSystem; // injected

using IMemoryMappedFile mappedFile = fileSystem.MemoryMappedFile
.CreateFromFile("data.bin");

using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor();
accessor.Write(0, 42);
int value = accessor.ReadInt32(0);
```

The abstraction is exposed as the extension property `fileSystem.MemoryMappedFile` (note: no `()`), returning an `IMemoryMappedFileFactory`. On `RealFileSystem` every call forwards to the underlying base class library (BCL) implementation; on `MockFileSystem` the views are built directly over the in-memory file bytes.

Some parts of the BCL surface are intentionally not abstracted, because they have no meaningful in-memory equivalent:

- `CreateNew`, `CreateOrOpen` and `OpenExisting` operate on operating-system shared memory (named or anonymous) rather than a file. They forward normally on `RealFileSystem`, but throw `NotSupportedException` on `MockFileSystem`.
- The `SafeMemoryMappedFileHandle` / `SafeMemoryMappedViewHandle` handle and pointer APIs, as well as the `SafeFileHandle`-based `CreateFromFile` overload, are not exposed at all (mirroring how the `SafeFileHandle`-based `FileStream` construction is excluded elsewhere).

On `MockFileSystem` the reads and writes, their capacity handling and the thrown exceptions mirror the real `MemoryMappedViewAccessor` / `MemoryMappedViewStream`. Two details tied to operating-system memory mapping are intentionally simplified: a view created without an explicit size has a `Capacity` of exactly the remaining bytes (the real file system rounds up to the system page size), and `PointerOffset` is always `0`.
5 changes: 3 additions & 2 deletions Docs/pages/docs/companion-libraries/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ title: Companion libraries

# Companion libraries

`Testably.Abstractions` ships two optional NuGet packages that extend `IFileSystem` with capabilities the BCL keeps in separate namespaces.
`Testably.Abstractions` ships three optional NuGet packages that extend `IFileSystem` with capabilities the base class library (BCL) keeps in separate namespaces.

| Package | Adds to `IFileSystem` |
|------------------------------------------|--------------------------------------------------------------------|
| `Testably.Abstractions.Compression` | `ZipFile` and `ZipArchive` extension properties |
| `Testably.Abstractions.AccessControl` | `GetAccessControl` / `SetAccessControl` on files and directories |
| `Testably.Abstractions.MemoryMappedFiles`| `MemoryMappedFile` extension property |

Both packages target the `IFileSystem` interface, so they work transparently against either `RealFileSystem` or `MockFileSystem`.
All packages target the `IFileSystem` interface, so they work transparently against either `RealFileSystem` or `MockFileSystem`.
115 changes: 115 additions & 0 deletions Docs/pages/docs/companion-libraries/memory-mapped-files.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
sidebar_position: 3
title: Memory-mapped files
---

# Testably.Abstractions.MemoryMappedFiles

[![NuGet](https://img.shields.io/nuget/v/Testably.Abstractions.MemoryMappedFiles?label=NuGet&logo=nuget)](https://www.nuget.org/packages/Testably.Abstractions.MemoryMappedFiles)

Wraps `System.IO.MemoryMappedFiles.MemoryMappedFile` as a `MemoryMappedFile` extension property on `IFileSystem`, so memory-mapped-file code can be tested against the in-memory `MockFileSystem`.

```powershell
dotnet add package Testably.Abstractions.MemoryMappedFiles
```

## Why this package exists

`System.IO.MemoryMappedFiles.MemoryMappedFile.CreateFromFile` needs a real path (or a real `FileStream`) on disk - it cannot map a file that only exists inside a `MockFileSystem`. Without the companion package, any production code that touches `MemoryMappedFile` is effectively untestable against the mock: your test either falls back to writing real temp files or the memory-mapped path stays uncovered.

```csharp
IFileSystem fileSystem = //...

using IMemoryMappedFile mappedFile = fileSystem.MemoryMappedFile
.CreateFromFile("data.bin");

using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor();
accessor.Write(0, 42);
int value = accessor.ReadInt32(0);
```

The same code runs unchanged against `RealFileSystem` and `MockFileSystem`.

### Production uses the real base class library (BCL) implementation

The abstraction isn't just a uniform façade. When `IFileSystem` is a `RealFileSystem`, every call forwards to the underlying BCL `MemoryMappedFile`, so production keeps the native memory-mapping semantics unchanged. Only when `IFileSystem` is a `MockFileSystem` are the views built directly over the in-memory file bytes:

```csharp
// Inside MemoryMappedFileFactory.CreateFromFile:
if (extensibility.TryGetWrappedInstance(out FileStream? realStream))
{
// real → BCL MemoryMappedFile over the real FileStream
return new MemoryMappedFileWrapper(FileSystem,
MemoryMappedFile.CreateFromFile(realStream, ...), backingStream);
}

// mock → view over the in-memory file bytes
return new MemoryMappedFileMock(FileSystem, stream, capacity, access, ownsStream);
```

## Creating a memory-mapped file

`fileSystem.MemoryMappedFile` exposes the `CreateFromFile` overloads of `System.IO.MemoryMappedFiles.MemoryMappedFile`:

```csharp
using IMemoryMappedFile fromPath = fileSystem.MemoryMappedFile
.CreateFromFile("data.bin", FileMode.Open, mapName: null, capacity: 0,
MemoryMappedFileAccess.ReadWrite);
```

The `FileStream`-based overload is exposed with a `FileSystemStream` instead of a `FileStream`, so it works against both the real and the mocked file system:

```csharp
using FileSystemStream stream =
fileSystem.FileStream.New("data.bin", FileMode.Open, FileAccess.ReadWrite);
using IMemoryMappedFile mappedFile = fileSystem.MemoryMappedFile.CreateFromFile(
stream, mapName: null, capacity: 0, MemoryMappedFileAccess.ReadWrite,
HandleInheritability.None, leaveOpen: true);
```

## Views

An `IMemoryMappedFile` creates two kinds of views over its bytes, mirroring the BCL:

```csharp
// Random-access, typed reads and writes:
using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 100);
accessor.Write(0, 1234567);
int roundtrip = accessor.ReadInt32(0);

// A Stream over a window of the mapped bytes:
using MemoryMappedFileSystemViewStream stream = mappedFile.CreateViewStream(0, 50);
stream.Write([1, 2, 3, 4], 0, 4);
```

`IMemoryMappedViewAccessor` mirrors `MemoryMappedViewAccessor`: the primitive `Read*`/`Write` overloads, the generic `Read<T>`/`Write<T>` for structs, `ReadArray<T>`/`WriteArray<T>`, plus `Capacity`, `CanRead`, `CanWrite`, `PointerOffset` and `Flush`.

`MemoryMappedFileSystemViewStream` derives from `UnmanagedMemoryStream` - the same base class as the real `MemoryMappedViewStream` - and re-declares the `Capacity` member on top of the stream members (the base is not backed by unmanaged memory), plus the `PointerOffset` member specific to `MemoryMappedViewStream`.

## Not supported on the mock

Some parts of the BCL surface operate on operating-system shared memory rather than a file, so they have no meaningful in-memory equivalent. They forward normally on `RealFileSystem`, but throw `NotSupportedException` on `MockFileSystem`:

- `CreateNew`
- `CreateOrOpen` *(Windows-only)*
- `OpenExisting` *(Windows-only)*
- A non-null `mapName` in `CreateFromFile` (named mappings are operating-system shared-memory objects)

The `SafeMemoryMappedFileHandle` / `SafeMemoryMappedViewHandle` handle and pointer APIs, as well as the `SafeFileHandle`-based `CreateFromFile` overload, are not exposed at all - mirroring how the `SafeFileHandle`-based `FileStream` construction is excluded elsewhere. Since no unmanaged memory backs a `MemoryMappedFileSystemViewStream`, the members that only exist on its `UnmanagedMemoryStream` base throw `ObjectDisposedException`: the unsafe `PositionPointer`, and `Capacity` when accessed through a reference typed as `UnmanagedMemoryStream` instead of `MemoryMappedFileSystemViewStream`.

## Behaviour on the mock

On `MockFileSystem` the reads and writes, the capacity handling and the thrown exceptions mirror the real `MemoryMappedViewAccessor` / `MemoryMappedViewStream`, including:

- **Default view access is `ReadWrite`** - creating a writable view over a read-only mapping throws `UnauthorizedAccessException`, exactly as the BCL does.
- **The file and its views have independent lifetimes** - a view stays usable after the `IMemoryMappedFile` is disposed.
- **`CopyOnWrite` views privatize pages on write** - writes are neither persisted to the underlying file nor visible to other views, while pages the view has not written keep reflecting later changes made through other views, matching the lazy page privatization (4096-byte granularity) of real copy-on-write views.

Some details tied to operating-system memory mapping are intentionally simplified:

- A view created without an explicit size has a `Capacity` of exactly the remaining bytes (the real file system rounds up to the system page size), and `PointerOffset` is always `0`.
- The views of the mock read and write through the backing stream instead of mapped memory. Writes are immediately visible to all views of the same mapping, but reach the underlying file (and other open streams of it) only when a view is flushed or disposed, or earlier when the backing stream persists its pending writes on a reposition - the real operating system makes them visible to other readers immediately. For the same reason, when the stream passed to `CreateFromFile` with `leaveOpen: true` is disposed while views are still open, operations on those views throw `ObjectDisposedException` (disposing them stays safe), whereas real views keep operating on the mapped pages without the file handle.
- The mock does not track the open mappings of a file, so truncating the backing stream (for example via `SetLength` on a stream passed with `leaveOpen: true`) while views are open succeeds, and reads of the truncated range afterwards return zeros. The real operating system rejects such a truncation (on Windows with an `IOException` about an open user-mapped section).
- The mocked file system stores the complete file content in memory, which limits the size of a memory-mapped file to 2 GB. Growing a file beyond that throws a `NotSupportedException`, whereas the real file system creates sparse multi-gigabyte mappings.
- Exception types and messages of the mock follow the Windows behaviour of the BCL, regardless of the operating system the tests run on and of the simulated operating system of the `MockFileSystem`. On Linux and macOS the real file system can throw different exception types for the same invalid call (for example when creating a view with execute access or growing a read-only mapping).
- Structs in `Read<T>`/`Write<T>`/`ReadArray<T>`/`WriteArray<T>` are sized by their managed layout (`Unsafe.SizeOf<T>`), matching the BCL on .NET (Core). The real `MemoryMappedViewAccessor` of the .NET Framework uses the marshalled size instead, so on .NET Framework the mock and the real accessor disagree for structs whose marshalled size differs from the managed one (for example a struct containing a `bool`: 1 byte managed, 4 bytes marshalled).
3 changes: 2 additions & 1 deletion Docs/pages/docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Optional companion packages:
```powershell
dotnet add package Testably.Abstractions.Compression
dotnet add package Testably.Abstractions.AccessControl
dotnet add package Testably.Abstractions.MemoryMappedFiles
```

## 2. Register the abstractions
Expand Down Expand Up @@ -71,4 +72,4 @@ That's it - your code now runs against an in-memory file system in tests, and ag
- [File system](./file-system) - `IFileSystem`, `MockFileSystem`, drives, watcher, statistics, strategies.
- [Time system](./time-system) - `ITimeSystem`, time providers, timers, auto-advance, notifications.
- [Random system](./random-system) - `IRandomSystem` and deterministic generators.
- [Companion libraries](./companion-libraries) - Compression and AccessControl.
- [Companion libraries](./companion-libraries) - Compression, AccessControl and MemoryMappedFiles.
1 change: 1 addition & 0 deletions Pipeline/Build.Compile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ partial class Build
Solution.Testably_Abstractions_Testing,
Solution.Testably_Abstractions_AccessControl,
Solution.Testably_Abstractions_Compression,
Solution.Testably_Abstractions_MemoryMappedFiles,
];

CoreProjects =
Expand Down
4 changes: 4 additions & 0 deletions Pipeline/Build.MutationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ await gitHubClient.Issue.Comment.Update("Testably", "Testably.Abstractions",
Solution.Testably_Abstractions_Compression,
[Solution.Tests.Testably_Abstractions_Compression_Tests]
},
{
Solution.Testably_Abstractions_MemoryMappedFiles,
[Solution.Tests.Testably_Abstractions_MemoryMappedFiles_Tests]
},
{
Solution.Core.Testably_Abstractions, [
Solution.Tests.Testably_Abstractions_Testing_Tests,
Expand Down
3 changes: 2 additions & 1 deletion Pipeline/Build.UnitTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ partial class Build
Solution.Tests.Testably_Abstractions_Parity_Tests,
Solution.Tests.Testably_Abstractions_Tests,
Solution.Tests.Testably_Abstractions_Testing_Tests,
Solution.Tests.Testably_Abstractions_AccessControl_Tests,
Solution.Tests.Testably_Abstractions_Compression_Tests,
Solution.Tests.Testably_Abstractions_AccessControl_Tests
Solution.Tests.Testably_Abstractions_MemoryMappedFiles_Tests,
];

Target UnitTests => _ => _
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Then register the implementations in your DI container - see [Getting Started](h
| `Testably.Abstractions.Testing` | `MockFileSystem`, `MockTimeSystem`, `MockRandomSystem` |
| `Testably.Abstractions.Compression` | Zip / `ZipArchive` extension methods on `IFileSystem` |
| `Testably.Abstractions.AccessControl`| `GetAccessControl` / `SetAccessControl` on files and directories |
| `Testably.Abstractions.MemoryMappedFiles` | `MemoryMappedFile` support on `IFileSystem` |

## Already on TestableIO?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ public static IFileSystemExtensibility GetExtensibilityOrThrow(this IFileInfo fi
?? throw new NotSupportedException(
$"{fileInfo.GetType()} does not support IFileSystemExtensibility.");

public static IFileSystemExtensibility GetExtensibilityOrThrow(this FileSystemStream fileStream)
/// <summary>
/// Retrieves the <see cref="IFileSystemExtensibility" /> from the <paramref name="fileStream" />
/// or throws a <see cref="NotSupportedException" /> if it is not supported.
/// </summary>
public static IFileSystemExtensibility GetExtensibilityOrThrow(
this FileSystemStream fileStream)
=> fileStream as IFileSystemExtensibility
?? throw new NotSupportedException(
$"{fileStream.GetType()} does not support IFileSystemExtensibility.");
Expand Down
10 changes: 9 additions & 1 deletion Source/Testably.Abstractions.Compression/Internal/Execute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,15 @@ public static async Task<T> WhenRealFileSystemAsync<T>(IFileSystem fileSystem,
? await onRealFileSystem()
: onMockFileSystem();

private static bool IsRealFileSystem(IFileSystem fileSystem)
/// <summary>
/// Returns <see langword="true" /> when the <paramref name="fileSystem" /> is the real file
/// system (and therefore has an underlying operating-system file system to delegate to).
/// </summary>
/// <remarks>
/// Uses the same side-effect-free type-name check as the other companion packages; probing
/// via a factory would register a phantom call in the statistics of the mocked file system.
/// </remarks>
public static bool IsRealFileSystem(this IFileSystem fileSystem)
=> string.Equals(fileSystem.GetType().Name, "RealFileSystem",
StringComparison.Ordinal);
}
Loading
Loading