diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 05601f8d2..4fa35cbb0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 @@ -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 ``` @@ -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 @@ -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) diff --git a/Docs/Nuget/MemoryMappedFiles.md b/Docs/Nuget/MemoryMappedFiles.md new file mode 100644 index 000000000..17d3fcad9 --- /dev/null +++ b/Docs/Nuget/MemoryMappedFiles.md @@ -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`. diff --git a/Docs/pages/docs/companion-libraries/index.mdx b/Docs/pages/docs/companion-libraries/index.mdx index 25e72cbfb..d7d74f674 100644 --- a/Docs/pages/docs/companion-libraries/index.mdx +++ b/Docs/pages/docs/companion-libraries/index.mdx @@ -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`. diff --git a/Docs/pages/docs/companion-libraries/memory-mapped-files.mdx b/Docs/pages/docs/companion-libraries/memory-mapped-files.mdx new file mode 100644 index 000000000..3277605a4 --- /dev/null +++ b/Docs/pages/docs/companion-libraries/memory-mapped-files.mdx @@ -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`/`Write` for structs, `ReadArray`/`WriteArray`, 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`/`Write`/`ReadArray`/`WriteArray` are sized by their managed layout (`Unsafe.SizeOf`), 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). diff --git a/Docs/pages/docs/getting-started.mdx b/Docs/pages/docs/getting-started.mdx index ec51b184b..0f37a61ac 100644 --- a/Docs/pages/docs/getting-started.mdx +++ b/Docs/pages/docs/getting-started.mdx @@ -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 @@ -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. diff --git a/Pipeline/Build.Compile.cs b/Pipeline/Build.Compile.cs index 5402aa6c2..9664c646e 100644 --- a/Pipeline/Build.Compile.cs +++ b/Pipeline/Build.Compile.cs @@ -36,6 +36,7 @@ partial class Build Solution.Testably_Abstractions_Testing, Solution.Testably_Abstractions_AccessControl, Solution.Testably_Abstractions_Compression, + Solution.Testably_Abstractions_MemoryMappedFiles, ]; CoreProjects = diff --git a/Pipeline/Build.MutationTests.cs b/Pipeline/Build.MutationTests.cs index a8fd79a57..8391f6a93 100644 --- a/Pipeline/Build.MutationTests.cs +++ b/Pipeline/Build.MutationTests.cs @@ -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, diff --git a/Pipeline/Build.UnitTest.cs b/Pipeline/Build.UnitTest.cs index a80bc4811..24c7cb03f 100644 --- a/Pipeline/Build.UnitTest.cs +++ b/Pipeline/Build.UnitTest.cs @@ -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 => _ => _ diff --git a/README.md b/README.md index 9f42a2fd1..fb30fcd1f 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/Source/Testably.Abstractions.AccessControl/AccessControlHelpers.cs b/Source/Testably.Abstractions.AccessControl/AccessControlHelpers.cs index 648fc0a5a..a386c73e1 100644 --- a/Source/Testably.Abstractions.AccessControl/AccessControlHelpers.cs +++ b/Source/Testably.Abstractions.AccessControl/AccessControlHelpers.cs @@ -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) + /// + /// Retrieves the from the + /// or throws a if it is not supported. + /// + public static IFileSystemExtensibility GetExtensibilityOrThrow( + this FileSystemStream fileStream) => fileStream as IFileSystemExtensibility ?? throw new NotSupportedException( $"{fileStream.GetType()} does not support IFileSystemExtensibility."); diff --git a/Source/Testably.Abstractions.Compression/Internal/Execute.cs b/Source/Testably.Abstractions.Compression/Internal/Execute.cs index 4d241a2f2..c9a0fa9d0 100644 --- a/Source/Testably.Abstractions.Compression/Internal/Execute.cs +++ b/Source/Testably.Abstractions.Compression/Internal/Execute.cs @@ -65,7 +65,15 @@ public static async Task WhenRealFileSystemAsync(IFileSystem fileSystem, ? await onRealFileSystem() : onMockFileSystem(); - private static bool IsRealFileSystem(IFileSystem fileSystem) + /// + /// Returns when the is the real file + /// system (and therefore has an underlying operating-system file system to delegate to). + /// + /// + /// 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. + /// + public static bool IsRealFileSystem(this IFileSystem fileSystem) => string.Equals(fileSystem.GetType().Name, "RealFileSystem", StringComparison.Ordinal); } diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/CopyOnWriteViewBacking.cs b/Source/Testably.Abstractions.MemoryMappedFiles/CopyOnWriteViewBacking.cs new file mode 100644 index 000000000..d4b75afb1 --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/CopyOnWriteViewBacking.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; + +namespace Testably.Abstractions; + +/// +/// A copy-on-write view backing that emulates the page privatization of real memory-mapped +/// files: reads pass through to the shared backing until this view writes to a page; the +/// written page is copied into private memory at that moment and is from then on isolated +/// from the shared backing in both directions. +/// +internal sealed class CopyOnWriteViewBacking(MemoryMappedViewBacking shared, long capacity) + : MemoryMappedViewBacking +{ + /// + /// The granularity at which written ranges are privatized, matching the 4096-byte pages + /// of the operating systems backing the real memory-mapped file. + /// + private const int PageSize = 4096; + +#if NET9_0_OR_GREATER + private readonly System.Threading.Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private readonly Dictionary _privatePages = new(); + + /// + public override void Flush() + { + // Copy-on-write changes are never persisted to the underlying file. + } + + /// + public override void ReadAt(long position, byte[] buffer, int offset, int count) + { + lock (_lock) + { + int read = 0; + while (read < count) + { + long pageIndex = (position + read) / PageSize; + int offsetInPage = (int)((position + read) % PageSize); + int chunk = Math.Min(count - read, PageSize - offsetInPage); + if (_privatePages.TryGetValue(pageIndex, out byte[]? page)) + { + Array.Copy(page, offsetInPage, buffer, offset + read, chunk); + } + else + { + // Coalesce the run of consecutive non-privatized pages into a single read + // from the shared backing instead of one read per 4096-byte page. + while (read + chunk < count && + !_privatePages.ContainsKey(++pageIndex)) + { + chunk += Math.Min(count - read - chunk, PageSize); + } + + shared.ReadAt(position + read, buffer, offset + read, chunk); + } + + read += chunk; + } + } + } + + /// + public override void WriteAt(long position, byte[] buffer, int offset, int count) + { + lock (_lock) + { + int written = 0; + while (written < count) + { + long pageIndex = (position + written) / PageSize; + int offsetInPage = (int)((position + written) % PageSize); + int chunk = Math.Min(count - written, PageSize - offsetInPage); + byte[] page = GetOrCreatePrivatePage(pageIndex); + Array.Copy(buffer, offset + written, page, offsetInPage, chunk); + written += chunk; + } + } + } + + private byte[] GetOrCreatePrivatePage(long pageIndex) + { + if (!_privatePages.TryGetValue(pageIndex, out byte[]? page)) + { + page = new byte[PageSize]; + long pageStart = pageIndex * PageSize; + int available = (int)Math.Min(PageSize, capacity - pageStart); + shared.ReadAt(pageStart, page, 0, available); + _privatePages[pageIndex] = page; + } + + return page; + } +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/FileSystemExtensions.cs b/Source/Testably.Abstractions.MemoryMappedFiles/FileSystemExtensions.cs new file mode 100644 index 000000000..28f4ff57e --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/FileSystemExtensions.cs @@ -0,0 +1,21 @@ +namespace Testably.Abstractions; + +/// +/// Extension property to support abstractions for +/// . +/// +public static class FileSystemExtensions +{ + /// + extension(IFileSystem fileSystem) + { + /// + /// Factory for abstracting creation of + /// . + /// + #pragma warning disable CA1822, S2325, MA0041 // False positive: an extension property cannot be static. + public IMemoryMappedFileFactory MemoryMappedFile + => new MemoryMappedFileFactory(fileSystem); + #pragma warning restore CA1822, S2325, MA0041 + } +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/IMemoryMappedFile.cs b/Source/Testably.Abstractions.MemoryMappedFiles/IMemoryMappedFile.cs new file mode 100644 index 000000000..6e538e0ab --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/IMemoryMappedFile.cs @@ -0,0 +1,28 @@ +using System; +using System.IO.MemoryMappedFiles; + +namespace Testably.Abstractions; + +/// +public interface IMemoryMappedFile : IFileSystemEntity, IDisposable +{ + /// + IMemoryMappedViewAccessor CreateViewAccessor(); + + /// + IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size); + + /// + IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size, + MemoryMappedFileAccess access); + + /// + MemoryMappedFileSystemViewStream CreateViewStream(); + + /// + MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size); + + /// + MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size, + MemoryMappedFileAccess access); +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/IMemoryMappedFileFactory.cs b/Source/Testably.Abstractions.MemoryMappedFiles/IMemoryMappedFileFactory.cs new file mode 100644 index 000000000..374f9d00f --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/IMemoryMappedFileFactory.cs @@ -0,0 +1,121 @@ +using System.IO; +using System.IO.MemoryMappedFiles; + +namespace Testably.Abstractions; + +/// +public interface IMemoryMappedFileFactory : IFileSystemEntity +{ + /// + IMemoryMappedFile CreateFromFile(string path); + + /// + IMemoryMappedFile CreateFromFile(string path, FileMode mode); + + /// + IMemoryMappedFile CreateFromFile(string path, FileMode mode, string? mapName); + + /// + IMemoryMappedFile CreateFromFile(string path, FileMode mode, string? mapName, + long capacity); + + /// + IMemoryMappedFile CreateFromFile(string path, FileMode mode, string? mapName, + long capacity, MemoryMappedFileAccess access); + + /// + /// + /// In this abstraction the file is provided as a instead of a + /// , so it works against both the real and the mocked file system. + /// + IMemoryMappedFile CreateFromFile(FileSystemStream fileStream, string? mapName, + long capacity, MemoryMappedFileAccess access, HandleInheritability inheritability, + bool leaveOpen); + + /// + /// + /// This creates operating-system shared memory that is not backed by a file, so it is + /// not supported on the MockFileSystem and throws a + /// there. On the real file system it forwards to + /// . + /// + IMemoryMappedFile CreateNew(string? mapName, long capacity); + + /// + /// + /// This creates operating-system shared memory that is not backed by a file, so it is + /// not supported on the MockFileSystem and throws a + /// there. + /// + IMemoryMappedFile CreateNew(string? mapName, long capacity, + MemoryMappedFileAccess access); + + /// + /// + /// This creates operating-system shared memory that is not backed by a file, so it is + /// not supported on the MockFileSystem and throws a + /// there. + /// + IMemoryMappedFile CreateNew(string? mapName, long capacity, + MemoryMappedFileAccess access, MemoryMappedFileOptions options, + HandleInheritability inheritability); + + /// + /// + /// This creates or opens operating-system shared memory that is not backed by a file, so it is + /// not supported on the MockFileSystem and throws a + /// there. + /// + [SupportedOSPlatform("windows")] + IMemoryMappedFile CreateOrOpen(string mapName, long capacity); + + /// + /// + /// This creates or opens operating-system shared memory that is not backed by a file, so it is + /// not supported on the MockFileSystem and throws a + /// there. + /// + [SupportedOSPlatform("windows")] + IMemoryMappedFile CreateOrOpen(string mapName, long capacity, + MemoryMappedFileAccess access); + + /// + /// + /// This creates or opens operating-system shared memory that is not backed by a file, so it is + /// not supported on the MockFileSystem and throws a + /// there. + /// + [SupportedOSPlatform("windows")] + IMemoryMappedFile CreateOrOpen(string mapName, long capacity, + MemoryMappedFileAccess access, MemoryMappedFileOptions options, + HandleInheritability inheritability); + + /// + /// + /// This opens existing operating-system shared memory that is not backed by a file, so it is + /// not supported on the MockFileSystem and throws a + /// there. + /// + [SupportedOSPlatform("windows")] + IMemoryMappedFile OpenExisting(string mapName); + + /// + /// + /// This opens existing operating-system shared memory that is not backed by a file, so it is + /// not supported on the MockFileSystem and throws a + /// there. + /// + [SupportedOSPlatform("windows")] + IMemoryMappedFile OpenExisting(string mapName, + MemoryMappedFileRights desiredAccessRights); + + /// + /// + /// This opens existing operating-system shared memory that is not backed by a file, so it is + /// not supported on the MockFileSystem and throws a + /// there. + /// + [SupportedOSPlatform("windows")] + IMemoryMappedFile OpenExisting(string mapName, + MemoryMappedFileRights desiredAccessRights, HandleInheritability inheritability); +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/IMemoryMappedViewAccessor.cs b/Source/Testably.Abstractions.MemoryMappedFiles/IMemoryMappedViewAccessor.cs new file mode 100644 index 000000000..3e4e33bfd --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/IMemoryMappedViewAccessor.cs @@ -0,0 +1,118 @@ +using System; +using System.IO; +using System.IO.MemoryMappedFiles; + +namespace Testably.Abstractions; + +/// +public interface IMemoryMappedViewAccessor : IFileSystemEntity, IDisposable +{ + /// + bool CanRead { get; } + + /// + bool CanWrite { get; } + + /// + long Capacity { get; } + + /// + long PointerOffset { get; } + + /// + void Flush(); + + /// + #pragma warning disable S3874 // The `out` modifier mirrors the BCL `UnmanagedMemoryAccessor.Read{T}` signature, so code can switch between the real and the mocked API unchanged. + void Read(long position, out T structure) where T : struct; + #pragma warning restore S3874 + + /// + int ReadArray(long position, T[] array, int offset, int count) where T : struct; + + /// + bool ReadBoolean(long position); + + /// + byte ReadByte(long position); + + /// + char ReadChar(long position); + + /// + decimal ReadDecimal(long position); + + /// + double ReadDouble(long position); + + /// + short ReadInt16(long position); + + /// + int ReadInt32(long position); + + /// + long ReadInt64(long position); + + /// + sbyte ReadSByte(long position); + + /// + float ReadSingle(long position); + + /// + ushort ReadUInt16(long position); + + /// + uint ReadUInt32(long position); + + /// + ulong ReadUInt64(long position); + + /// + void Write(long position, bool value); + + /// + void Write(long position, byte value); + + /// + void Write(long position, char value); + + /// + void Write(long position, decimal value); + + /// + void Write(long position, double value); + + /// + void Write(long position, short value); + + /// + void Write(long position, int value); + + /// + void Write(long position, long value); + + /// + void Write(long position, sbyte value); + + /// + void Write(long position, float value); + + /// + void Write(long position, ushort value); + + /// + void Write(long position, uint value); + + /// + void Write(long position, ulong value); + + /// + #pragma warning disable S3874 // The `ref` modifier mirrors the BCL `UnmanagedMemoryAccessor.Write{T}` signature, so code can switch between the real and the mocked API unchanged. + void Write(long position, ref T structure) where T : struct; + #pragma warning restore S3874 + + /// + void WriteArray(long position, T[] array, int offset, int count) where T : struct; +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/Internal/MemoryMappedFileHelpers.cs b/Source/Testably.Abstractions.MemoryMappedFiles/Internal/MemoryMappedFileHelpers.cs new file mode 100644 index 000000000..c3efdac54 --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/Internal/MemoryMappedFileHelpers.cs @@ -0,0 +1,168 @@ +using System; +using System.IO.MemoryMappedFiles; +using System.Runtime.CompilerServices; +#if NETSTANDARD2_0 || NETSTANDARD2_1 +using System.Linq; +using System.Reflection; +#endif +using Testably.Abstractions.Helpers; + +namespace Testably.Abstractions.Internal; + +internal static class MemoryMappedFileHelpers +{ + /// + /// Returns the stride between two elements of an array of , + /// matching the aligned size used by the BCL UnmanagedMemoryAccessor array + /// operations: sizes 1 and 2 are kept, larger sizes are rounded up to a multiple of 4. + /// + public static int AlignedSizeOf() where T : struct + { + int size = Unsafe.SizeOf(); + if (size is 1 or 2) + { + return size; + } + + return (size + 3) & ~3; + } + + /// + /// Throws an when is a struct + /// containing object references, matching the BCL UnmanagedMemoryAccessor, which + /// never reinterprets raw bytes as references. + /// + public static void ThrowIfContainsReferences() where T : struct + { +#if NETSTANDARD2_0 || NETSTANDARD2_1 + if (ReferenceCheck.ContainsReferences) +#else + if (RuntimeHelpers.IsReferenceOrContainsReferences()) +#endif + { + #pragma warning disable MA0015 // Matches the parameter-less BCL message for a reference-containing struct. + throw new ArgumentException( + "The specified Type must be a struct containing no references."); + #pragma warning restore MA0015 + } + } + +#if NETSTANDARD2_0 || NETSTANDARD2_1 + private static class ReferenceCheck where T : struct + { + public static readonly bool ContainsReferences = Check(typeof(T)); + + #pragma warning disable S3011 // Read-only metadata inspection: the object references of a struct usually sit in non-public fields, which is exactly what `RuntimeHelpers.IsReferenceOrContainsReferences` (used on the modern frameworks) inspects. + private static bool Check(Type type) + => type + .GetFields(BindingFlags.Instance | BindingFlags.Public | + BindingFlags.NonPublic) + .Any(field + => (!field.FieldType.IsValueType && !field.FieldType.IsPointer) || + (field.FieldType.IsValueType && field.FieldType != type && + Check(field.FieldType))); + #pragma warning restore S3011 + } +#endif + + /// + /// Returns whether a view with the given supports reading. + /// + public static bool SupportsReading(this MemoryMappedFileAccess access) + => access is not MemoryMappedFileAccess.Write; + + /// + /// Returns whether a view with the given supports writing. + /// + public static bool SupportsWriting(this MemoryMappedFileAccess access) + => access is not (MemoryMappedFileAccess.Read + or MemoryMappedFileAccess.ReadExecute); + + /// + /// Throws an when the + /// is not a defined value, matching the BCL. + /// + public static void ThrowIfOutOfRange(this MemoryMappedFileAccess access, + string paramName) + { + if (access < MemoryMappedFileAccess.ReadWrite || + access > MemoryMappedFileAccess.ReadWriteExecute) + { + throw new ArgumentOutOfRangeException(paramName); + } + } + + /// + /// Throws an when the + /// is negative, matching the message of the BCL argument validation. + /// + public static void ThrowIfNegative(long value, string paramName) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(paramName, value, + $"{paramName} ('{value}') must be a non-negative value."); + } + } + + /// + /// Throws an when a memory-mapped file over an empty file + /// is requested without an explicit capacity, matching the BCL. + /// + public static void ThrowIfEmptyFileWithZeroCapacity(long capacity, long fileLength) + { + if (capacity == 0 && fileLength == 0) + { + #pragma warning disable MA0015 // Matches the parameter-less BCL message for an empty file. + throw new ArgumentException( + "A positive capacity must be specified for a Memory Mapped File backed by an empty file."); + #pragma warning restore MA0015 + } + } + + /// + /// Disposes a view over the : pending writes are flushed to the + /// underlying file (matching the real memory-mapped view, which writes its dirty pages on + /// unmap) and the view's reference to the shared backing is released via + /// , disposing the underlying stream once the memory-mapped + /// file and all views are gone. + /// + public static void DisposeView(MemoryMappedViewBacking backing, IDisposable backingOwner) + { + try + { + backing.Flush(); + } + catch (ObjectDisposedException) + { + // The caller-owned stream (`leaveOpen: true`) was already disposed, so there is + // nothing left to flush; disposing the view must not throw. + } + finally + { + backingOwner.Dispose(); + } + } + + /// + /// Retrieves the from the + /// or throws a if it is not supported. + /// + public static IFileSystemExtensibility GetExtensibilityOrThrow( + this FileSystemStream fileStream) + => fileStream as IFileSystemExtensibility + ?? throw new NotSupportedException( + $"{fileStream.GetType()} does not support IFileSystemExtensibility."); + + /// + /// Returns when the is the real file + /// system (and therefore has an underlying operating-system file system to delegate to). + /// + /// + /// 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. + /// + public static bool IsRealFileSystem(this IFileSystem fileSystem) + => string.Equals(fileSystem.GetType().Name, "RealFileSystem", + StringComparison.Ordinal); +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileFactory.cs b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileFactory.cs new file mode 100644 index 000000000..729b6bb6d --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileFactory.cs @@ -0,0 +1,262 @@ +using System; +using System.IO; +using System.IO.MemoryMappedFiles; +using Testably.Abstractions.Helpers; +using Testably.Abstractions.Internal; + +namespace Testably.Abstractions; + +internal sealed class MemoryMappedFileFactory(IFileSystem fileSystem) : IMemoryMappedFileFactory +{ + #region IMemoryMappedFileFactory Members + + /// + public IFileSystem FileSystem { get; } = fileSystem; + + /// + public IMemoryMappedFile CreateFromFile(string path) + => CreateFromFile(path, FileMode.Open, null, 0, + MemoryMappedFileAccess.ReadWrite); + + /// + public IMemoryMappedFile CreateFromFile(string path, FileMode mode) + => CreateFromFile(path, mode, null, 0, MemoryMappedFileAccess.ReadWrite); + + /// + public IMemoryMappedFile CreateFromFile(string path, FileMode mode, string? mapName) + => CreateFromFile(path, mode, mapName, 0, MemoryMappedFileAccess.ReadWrite); + + /// + public IMemoryMappedFile CreateFromFile(string path, FileMode mode, string? mapName, + long capacity) + => CreateFromFile(path, mode, mapName, capacity, + MemoryMappedFileAccess.ReadWrite); + + /// + public IMemoryMappedFile CreateFromFile(string path, FileMode mode, string? mapName, + long capacity, MemoryMappedFileAccess access) + { + // The argument validation order matches the BCL: map name, capacity, access range, + // mode, write access. + ValidateMapName(mapName); + ValidateCapacity(capacity); + access.ThrowIfOutOfRange(nameof(access)); + if (mode == FileMode.Append) + { + throw new ArgumentException( + "FileMode.Append is not permitted when creating new memory mapped files. Instead, use FileMode.OpenOrCreate.", + nameof(mode)); + } + + if (mode == FileMode.Truncate) + { + throw new ArgumentException( + "FileMode.Truncate is not permitted when creating new memory mapped files.", + nameof(mode)); + } + + ThrowIfWriteAccess(access); + + bool fileExisted = mode == FileMode.Open || FileSystem.File.Exists(path); + FileSystemStream stream = + FileSystem.FileStream.New(path, mode, ToFileAccess(access), PathBasedFileShare); + try + { + IFileSystemExtensibility extensibility = stream.GetExtensibilityOrThrow(); + if (extensibility.TryGetWrappedInstance(out FileStream? realStream)) + { + MemoryMappedFile instance = MemoryMappedFile.CreateFromFile( + realStream, mapName, capacity, access, HandleInheritability.None, + leaveOpen: true); + return new MemoryMappedFileWrapper(FileSystem, instance, stream); + } + + ThrowIfMapNamed(mapName); + return new MemoryMappedFileMock(FileSystem, stream, capacity, access, + ownsStream: true); + } + catch + { + stream.Dispose(); + // The BCL deletes a file that was only created by this call when the creation of + // the memory-mapped file fails afterwards. + if (!fileExisted) + { + FileSystem.File.Delete(path); + } + + throw; + } + } + + /// + public IMemoryMappedFile CreateFromFile(FileSystemStream fileStream, string? mapName, + long capacity, MemoryMappedFileAccess access, HandleInheritability inheritability, + bool leaveOpen) + { + if (fileStream == null) + { + throw new ArgumentNullException(nameof(fileStream)); + } + + // The argument validation order matches the BCL: map name, capacity, access, empty + // file, inheritability. + ValidateMapName(mapName); + ValidateCapacity(capacity); + access.ThrowIfOutOfRange(nameof(access)); + ThrowIfWriteAccess(access); + MemoryMappedFileHelpers.ThrowIfEmptyFileWithZeroCapacity(capacity, fileStream.Length); + ValidateInheritability(inheritability); + IFileSystemExtensibility extensibility = fileStream.GetExtensibilityOrThrow(); + if (extensibility.TryGetWrappedInstance(out FileStream? realStream)) + { + MemoryMappedFile instance = MemoryMappedFile.CreateFromFile( + realStream, mapName, capacity, access, inheritability, leaveOpen); + return new MemoryMappedFileWrapper(FileSystem, instance, backingStream: null); + } + + ThrowIfMapNamed(mapName); + return new MemoryMappedFileMock(FileSystem, fileStream, capacity, access, + ownsStream: !leaveOpen); + } + + /// + public IMemoryMappedFile CreateNew(string? mapName, long capacity) + => Forward(() => MemoryMappedFile.CreateNew(mapName, capacity)); + + /// + public IMemoryMappedFile CreateNew(string? mapName, long capacity, + MemoryMappedFileAccess access) + => Forward(() => MemoryMappedFile.CreateNew(mapName, capacity, access)); + + /// + public IMemoryMappedFile CreateNew(string? mapName, long capacity, + MemoryMappedFileAccess access, MemoryMappedFileOptions options, + HandleInheritability inheritability) + => Forward(() => MemoryMappedFile.CreateNew(mapName, capacity, access, options, + inheritability)); + + /// + [SupportedOSPlatform("windows")] + public IMemoryMappedFile CreateOrOpen(string mapName, long capacity) + => Forward(() => MemoryMappedFile.CreateOrOpen(mapName, capacity)); + + /// + [SupportedOSPlatform("windows")] + public IMemoryMappedFile CreateOrOpen(string mapName, long capacity, + MemoryMappedFileAccess access) + => Forward(() => MemoryMappedFile.CreateOrOpen(mapName, capacity, access)); + + /// + [SupportedOSPlatform("windows")] + public IMemoryMappedFile CreateOrOpen(string mapName, long capacity, + MemoryMappedFileAccess access, MemoryMappedFileOptions options, + HandleInheritability inheritability) + => Forward(() => MemoryMappedFile.CreateOrOpen(mapName, capacity, access, options, + inheritability)); + + /// + [SupportedOSPlatform("windows")] + public IMemoryMappedFile OpenExisting(string mapName) + => Forward(() => MemoryMappedFile.OpenExisting(mapName)); + + /// + [SupportedOSPlatform("windows")] + public IMemoryMappedFile OpenExisting(string mapName, + MemoryMappedFileRights desiredAccessRights) + => Forward(() => MemoryMappedFile.OpenExisting(mapName, desiredAccessRights)); + + /// + [SupportedOSPlatform("windows")] + public IMemoryMappedFile OpenExisting(string mapName, + MemoryMappedFileRights desiredAccessRights, HandleInheritability inheritability) + => Forward(() => MemoryMappedFile.OpenExisting(mapName, desiredAccessRights, + inheritability)); + + #endregion + + private MemoryMappedFileWrapper Forward(Func onRealFileSystem) + { + if (!FileSystem.IsRealFileSystem()) + { + throw new NotSupportedException( + "Named or anonymous memory-mapped files that are not backed by a file are not supported on the mocked file system."); + } + + return new MemoryMappedFileWrapper(FileSystem, onRealFileSystem(), + backingStream: null); + } + + private static FileAccess ToFileAccess(MemoryMappedFileAccess access) + { +#if NETSTANDARD2_0 + if (access == MemoryMappedFileAccess.CopyOnWrite && IsNetFramework) + { + // On .NET Framework the BCL opens the file of a copy-on-write mapping with read + // access only, so e.g. a read-only file can be mapped copy-on-write; modern .NET + // requests read-write access instead. + return FileAccess.Read; + } +#endif + return access.SupportsWriting() + ? FileAccess.ReadWrite + : FileAccess.Read; + } + +#if NETSTANDARD2_0 + private static readonly bool IsNetFramework = + System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription + .StartsWith(".NET Framework", StringComparison.Ordinal); + + private static readonly FileShare PathBasedFileShare = + IsNetFramework ? FileShare.None : FileShare.Read; +#else + private const FileShare PathBasedFileShare = FileShare.Read; +#endif + + private static void ThrowIfMapNamed(string? mapName) + { + if (mapName != null) + { + throw new NotSupportedException( + "Named memory-mapped files are not supported on the mocked file system."); + } + } + + private static void ThrowIfWriteAccess(MemoryMappedFileAccess access) + { + if (access == MemoryMappedFileAccess.Write) + { + throw new ArgumentException( + "MemoryMappedFileAccess.Write is not permitted when creating new memory mapped files. Use MemoryMappedFileAccess.ReadWrite instead.", + nameof(access)); + } + } + + private static void ValidateInheritability(HandleInheritability inheritability) + { + if (inheritability is < HandleInheritability.None or > HandleInheritability.Inheritable) + { + throw new ArgumentOutOfRangeException(nameof(inheritability)); + } + } + + private static void ValidateCapacity(long capacity) + { + if (capacity < 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity), capacity, + "The capacity must be greater than or equal to 0. 0 represents the size of the file being mapped."); + } + } + + private static void ValidateMapName(string? mapName) + { + if (mapName is { Length: 0, }) + { + #pragma warning disable MA0015 // Matches the parameter-less BCL message for an empty map name. + throw new ArgumentException("Map name cannot be an empty string."); + #pragma warning restore MA0015 + } + } +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileMock.cs b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileMock.cs new file mode 100644 index 000000000..3fca31140 --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileMock.cs @@ -0,0 +1,293 @@ +using System; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Threading; +using Testably.Abstractions.Internal; + +namespace Testably.Abstractions; + +/// +/// A memory-mapped file backed directly by the (in-memory) bytes of a +/// of the MockFileSystem. +/// +internal sealed class MemoryMappedFileMock : IMemoryMappedFile +{ + private const string AccessToPathDeniedMessage = "Access to the path is denied."; + + private readonly MemoryMappedFileAccess _access; + private readonly SharedBacking _backing; + private readonly long _capacity; + private volatile bool _disposed; + private readonly IDisposable _fileReference; + private readonly MemoryMappedViewBacking _sharedViewBacking; + + public MemoryMappedFileMock(IFileSystem fileSystem, FileSystemStream stream, + long capacity, MemoryMappedFileAccess access, bool ownsStream) + { + FileSystem = fileSystem; + _access = access; + + MemoryMappedFileHelpers.ThrowIfEmptyFileWithZeroCapacity(capacity, stream.Length); + if (capacity == 0) + { + capacity = stream.Length; + } + else if (capacity < stream.Length) + { + throw new ArgumentOutOfRangeException(nameof(capacity), capacity, + "The capacity may not be smaller than the file size."); + } + + // The mapping always reads the file, and the write-through accesses also require write + // access to it; the real memory-mapped file fails in the same way when the file handle + // was opened without the required access. A copy-on-write mapping never writes to the + // file, so (like the real one) it only needs read access. + bool requiresWritableStream = access is MemoryMappedFileAccess.ReadWrite + or MemoryMappedFileAccess.ReadWriteExecute; + if (!stream.CanRead || (requiresWritableStream && !stream.CanWrite)) + { + throw new UnauthorizedAccessException(AccessToPathDeniedMessage); + } + + if (capacity > stream.Length) + { + if (access is MemoryMappedFileAccess.Read) + { + #pragma warning disable MA0015 // Matches the parameter-less BCL message for this combination. + throw new ArgumentException( + "The capacity may not be larger than the file size when creating a read-only memory-mapped file."); + #pragma warning restore MA0015 + } + + if (access is MemoryMappedFileAccess.ReadExecute) + { + // The BCL only special-cases `Read` above; growing the file for a `ReadExecute` + // mapping fails later through the read-only file handle, surfacing on Windows as + // this UnauthorizedAccessException. + throw new UnauthorizedAccessException(AccessToPathDeniedMessage); + } + + if (access is MemoryMappedFileAccess.CopyOnWrite) + { + // A copy-on-write mapping may never modify the underlying file, so it cannot be + // grown to the requested capacity; matches the IOException of the BCL on Windows. + throw new IOException( + "Not enough memory resources are available to process this command."); + } + + if (capacity > int.MaxValue) + { + // The mocked file system stores the complete file content in memory, so a file + // cannot be grown beyond 2 GB; a deliberate exception replaces the + // ArgumentOutOfRangeException that would otherwise leak from the internal stream. + throw new NotSupportedException( + "The mocked file system stores the file content in memory, which limits the size of a memory-mapped file to 2 GB."); + } + + stream.SetLength(capacity); + } + + _capacity = capacity; + _backing = new SharedBacking(stream, ownsStream); + _sharedViewBacking = new StreamViewBacking(stream); + _fileReference = _backing.Acquire(); + } + + #region IMemoryMappedFile Members + + /// + public IFileSystem FileSystem { get; } + + /// + public IMemoryMappedViewAccessor CreateViewAccessor() + => CreateViewAccessor(0, 0, MemoryMappedFileAccess.ReadWrite); + + /// + public IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size) + => CreateViewAccessor(offset, size, MemoryMappedFileAccess.ReadWrite); + + /// + public IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size, + MemoryMappedFileAccess access) + { + long viewSize = ValidateAndNormalizeView(offset, size, access); + ValidateViewAccess(access); + MemoryMappedViewBacking backing = + CreateViewBacking(access, out IDisposable backingOwner); + return new MemoryMappedViewAccessorMock(FileSystem, backing, offset, viewSize, + access, backingOwner); + } + + /// + public MemoryMappedFileSystemViewStream CreateViewStream() + => CreateViewStream(0, 0, MemoryMappedFileAccess.ReadWrite); + + /// + public MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size) + => CreateViewStream(offset, size, MemoryMappedFileAccess.ReadWrite); + + /// + public MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size, + MemoryMappedFileAccess access) + { + long viewSize = ValidateAndNormalizeView(offset, size, access); + ValidateViewAccess(access); + MemoryMappedViewBacking backing = + CreateViewBacking(access, out IDisposable backingOwner); + return new MemoryMappedViewStreamMock(backing, offset, viewSize, access, + backingOwner); + } + + /// + public void Dispose() + { + // The memory-mapped file releases only its own reference to the shared backing; any + // view that is still open keeps the backing alive, matching the independent lifetime + // of the real memory-mapped file and its views. + _disposed = true; + _fileReference.Dispose(); + } + + #endregion + + /// + /// Returns the for a view with the given + /// . + /// + /// + /// For a page-privatizing backing over + /// the shared stream is returned, so that reads keep observing the shared bytes until the + /// view writes a page, and writes are neither persisted to the underlying file nor + /// visible to other views (matching the real memory-mapped file). + /// Every view holds a reference that keeps the shared underlying stream alive until the + /// view is disposed. + /// + private MemoryMappedViewBacking CreateViewBacking(MemoryMappedFileAccess access, + out IDisposable backingOwner) + { + backingOwner = _backing.Acquire(); + if (access != MemoryMappedFileAccess.CopyOnWrite) + { + return _sharedViewBacking; + } + + return new CopyOnWriteViewBacking(_sharedViewBacking, _capacity); + } + + private long ValidateAndNormalizeView(long offset, long size, + MemoryMappedFileAccess access) + { + MemoryMappedFileHelpers.ThrowIfNegative(offset, nameof(offset)); + MemoryMappedFileHelpers.ThrowIfNegative(size, nameof(size)); + access.ThrowIfOutOfRange(nameof(access)); + + if (_disposed) + { + throw new ObjectDisposedException(null); + } + + if (size > long.MaxValue - offset) + { + // A view whose `offset + size` overflows `long` can never be reserved; the real + // memory-mapped file fails with this IOException from the operating system. + throw new IOException("Not enough memory to map view."); + } + + // `_capacity - offset` cannot overflow because `0 <= offset <= _capacity` holds whenever + // this subtraction is evaluated. + if (offset > _capacity || size > _capacity - offset) + { + throw new UnauthorizedAccessException(AccessToPathDeniedMessage); + } + + return size == 0 ? _capacity - offset : size; + } + + private void ValidateViewAccess(MemoryMappedFileAccess access) + { + // Read, ReadExecute and CopyOnWrite mappings never permit write-through views; a + // copy-on-write mapping only allows Read or CopyOnWrite views, matching the BCL. + bool mappingProhibitsWritableViews = _access is MemoryMappedFileAccess.Read + or MemoryMappedFileAccess.ReadExecute + or MemoryMappedFileAccess.CopyOnWrite; + bool viewRequiresWrite = access is MemoryMappedFileAccess.Write + or MemoryMappedFileAccess.ReadWrite + or MemoryMappedFileAccess.ReadWriteExecute; + if (mappingProhibitsWritableViews && viewRequiresWrite) + { + throw new UnauthorizedAccessException(AccessToPathDeniedMessage); + } + + // Execute views require a mapping that was created with execute access; the real + // memory-mapped file fails in the same way because the section lacks the execute + // page protection. + bool mappingSupportsExecute = _access is MemoryMappedFileAccess.ReadExecute + or MemoryMappedFileAccess.ReadWriteExecute; + bool viewRequiresExecute = access is MemoryMappedFileAccess.ReadExecute + or MemoryMappedFileAccess.ReadWriteExecute; + if (viewRequiresExecute && !mappingSupportsExecute) + { + throw new UnauthorizedAccessException(AccessToPathDeniedMessage); + } + } + + /// + /// A reference-counted holder around the shared backing , so the + /// memory-mapped file and its views can be disposed in any order: the stream is disposed + /// only once the file and every view that acquired a reference have released it. + /// + private sealed class SharedBacking(Stream stream, bool ownsStream) + { + private int _refCount; + + public IDisposable Acquire() + { + while (true) + { + int current = Volatile.Read(ref _refCount); + if (current < 0) + { + // The last reference was already released (and the stream disposed), so the + // count must not be resurrected; the SafeHandle-based reference counting of + // the real memory-mapped file fails such a race in the same way. + throw new ObjectDisposedException(null); + } + + if (Interlocked.CompareExchange(ref _refCount, current + 1, current) == current) + { + return new Reference(this); + } + } + } + + private void Release() + { + // Once the count drops to zero it is atomically marked as released (-1), so a racing + // `Acquire` either wins (keeping the stream alive for the new view) or observes the + // released state and throws; the stream is disposed exactly once. + if (Interlocked.Decrement(ref _refCount) == 0 && + Interlocked.CompareExchange(ref _refCount, -1, 0) == 0 && + ownsStream) + { + stream.Dispose(); + } + } + + private sealed class Reference(SharedBacking owner) : IDisposable + { + private int _disposed; + + #region IDisposable Members + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + owner.Release(); + } + } + + #endregion + } + } +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileSystemViewStream.cs b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileSystemViewStream.cs new file mode 100644 index 000000000..80e3182bb --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileSystemViewStream.cs @@ -0,0 +1,211 @@ +using System; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Threading; +using System.Threading.Tasks; + +namespace Testably.Abstractions; + +/// +/// Wrapper around a which is used as a replacement for a +/// . As such it implements the same properties and +/// methods as a . +/// +/// +/// This mirrors the design of : all +/// members are delegated to the wrapped , while the members specific to +/// are added on top. +/// +/// The base is never initialized with unmanaged memory, +/// because the bytes live in the wrapped . Members that only exist on +/// the base and cannot be overridden or re-declared therefore throw an +/// : , +/// and when accessed through a reference typed +/// as the base . +/// +public abstract class MemoryMappedFileSystemViewStream : UnmanagedMemoryStream +{ + /// + public override bool CanRead + => _stream.CanRead; + + /// + public override bool CanSeek + => _stream.CanSeek; + + /// + public override bool CanTimeout + => _stream.CanTimeout; + + /// + public override bool CanWrite + => _stream.CanWrite; + + /// + /// + /// Re-declared because the base is never initialized + /// with unmanaged memory (the bytes live in the wrapped ), so the + /// inherited non-virtual would throw an + /// . + /// + public new virtual long Capacity + => _stream.Length; + + /// + public override long Length + => _stream.Length; + + /// + public abstract long PointerOffset { get; } + + /// + public override long Position + { + get => _stream.Position; + set => _stream.Position = value; + } + + /// + public override int ReadTimeout + { + get => _stream.ReadTimeout; + set => _stream.ReadTimeout = value; + } + + /// + public override int WriteTimeout + { + get => _stream.WriteTimeout; + set => _stream.WriteTimeout = value; + } + + private readonly Stream _stream; + + /// + /// Initializes a new instance of . + /// + /// The wrapped . + protected MemoryMappedFileSystemViewStream(Stream stream) + { + _stream = stream; + } + + /// + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, + AsyncCallback? callback, object? state) + => _stream.BeginRead(buffer, offset, count, callback, state); + + /// + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, + AsyncCallback? callback, object? state) + => _stream.BeginWrite(buffer, offset, count, callback, state); + + /// + public override void Close() + { + base.Close(); + _stream.Close(); + } + + /// + public override Task CopyToAsync(Stream destination, int bufferSize, + CancellationToken cancellationToken) + => _stream.CopyToAsync(destination, bufferSize, cancellationToken); + + /// + public override int EndRead(IAsyncResult asyncResult) + => _stream.EndRead(asyncResult); + + /// + public override void EndWrite(IAsyncResult asyncResult) + => _stream.EndWrite(asyncResult); + + /// + public override void Flush() + => _stream.Flush(); + + /// + public override Task FlushAsync(CancellationToken cancellationToken) + => _stream.FlushAsync(cancellationToken); + + /// + public override int Read(byte[] buffer, int offset, int count) + => _stream.Read(buffer, offset, count); + +#if FEATURE_SPAN + /// + #pragma warning disable S927 // The names of the base declarations conflict: `UnmanagedMemoryStream.Read` kept the historical `destination`, while the root `Stream.Read` declares `buffer`; `buffer` matches the root declaration and all other streams of this repository. + public override int Read(Span buffer) + => _stream.Read(buffer); + #pragma warning restore S927 +#endif + + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + => _stream.ReadAsync(buffer, offset, count, cancellationToken); + +#if FEATURE_SPAN + /// + public override ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken = new()) + => _stream.ReadAsync(buffer, cancellationToken); +#endif + + /// + public override int ReadByte() + => _stream.ReadByte(); + + /// + #pragma warning disable S927 // The names of the base declarations conflict: `UnmanagedMemoryStream.Seek` kept the historical `loc`, while the root `Stream.Seek` declares `origin`; `origin` matches the root declaration and all other streams of this repository. + public override long Seek(long offset, SeekOrigin origin) + => _stream.Seek(offset, origin); + #pragma warning restore S927 + + /// + public override void SetLength(long value) + => _stream.SetLength(value); + + /// + public override string? ToString() + => _stream.ToString(); + + /// + public override void Write(byte[] buffer, int offset, int count) + => _stream.Write(buffer, offset, count); + +#if FEATURE_SPAN + /// + #pragma warning disable S927 // The names of the base declarations conflict: `UnmanagedMemoryStream.Write` kept the historical `source`, while the root `Stream.Write` declares `buffer`; `buffer` matches the root declaration and all other streams of this repository. + public override void Write(ReadOnlySpan buffer) + => _stream.Write(buffer); + #pragma warning restore S927 +#endif + + /// + public override Task WriteAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + => _stream.WriteAsync(buffer, offset, count, cancellationToken); + +#if FEATURE_SPAN + /// + public override ValueTask WriteAsync(ReadOnlyMemory buffer, + CancellationToken cancellationToken = new()) + => _stream.WriteAsync(buffer, cancellationToken); +#endif + + /// + public override void WriteByte(byte value) + => _stream.WriteByte(value); + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileWrapper.cs b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileWrapper.cs new file mode 100644 index 000000000..9a85c54f0 --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedFileWrapper.cs @@ -0,0 +1,62 @@ +using System.IO.MemoryMappedFiles; + +namespace Testably.Abstractions; + +internal sealed class MemoryMappedFileWrapper( + IFileSystem fileSystem, + MemoryMappedFile instance, + FileSystemStream? backingStream) + : IMemoryMappedFile +{ + #region IMemoryMappedFile Members + + /// + public IFileSystem FileSystem { get; } = fileSystem; + + /// + public IMemoryMappedViewAccessor CreateViewAccessor() + => new MemoryMappedViewAccessorWrapper(FileSystem, + instance.CreateViewAccessor()); + + /// + public IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size) + => new MemoryMappedViewAccessorWrapper(FileSystem, + instance.CreateViewAccessor(offset, size)); + + /// + public IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size, + MemoryMappedFileAccess access) + => new MemoryMappedViewAccessorWrapper(FileSystem, + instance.CreateViewAccessor(offset, size, access)); + + /// + public MemoryMappedFileSystemViewStream CreateViewStream() + => new MemoryMappedViewStreamWrapper(instance.CreateViewStream()); + + /// + public MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size) + => new MemoryMappedViewStreamWrapper(instance.CreateViewStream(offset, size)); + + /// + public MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size, + MemoryMappedFileAccess access) + => new MemoryMappedViewStreamWrapper( + instance.CreateViewStream(offset, size, access)); + + /// + public void Dispose() + { + try + { + instance.Dispose(); + } + finally + { + // The factory-created backing stream is released even when disposing the real + // memory-mapped file throws, so the file does not stay locked until finalization. + backingStream?.Dispose(); + } + } + + #endregion +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewAccessorMock.cs b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewAccessorMock.cs new file mode 100644 index 000000000..23d3fc678 --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewAccessorMock.cs @@ -0,0 +1,444 @@ +using System; +using System.IO.MemoryMappedFiles; +using System.Runtime.CompilerServices; +using Testably.Abstractions.Internal; + +namespace Testably.Abstractions; + +/// +/// A view accessor backed directly by the (in-memory) bytes of a +/// of the MockFileSystem. +/// +internal sealed class MemoryMappedViewAccessorMock : IMemoryMappedViewAccessor +{ + private readonly MemoryMappedFileAccess _access; + private readonly MemoryMappedViewBacking _backing; + private readonly IDisposable _backingOwner; + private bool _disposed; + private readonly long _offset; + private readonly long _size; + + public MemoryMappedViewAccessorMock(IFileSystem fileSystem, + MemoryMappedViewBacking backing, long offset, long size, + MemoryMappedFileAccess access, IDisposable backingOwner) + { + FileSystem = fileSystem; + _backing = backing; + _offset = offset; + _size = size; + _access = access; + _backingOwner = backingOwner; + } + + #region IMemoryMappedViewAccessor Members + + /// + public bool CanRead + => !_disposed && _access.SupportsReading(); + + /// + public bool CanWrite + => !_disposed && _access.SupportsWriting(); + + /// + public long Capacity + => _size; + + /// + public IFileSystem FileSystem { get; } + + /// + public long PointerOffset + => 0; + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + MemoryMappedFileHelpers.DisposeView(_backing, _backingOwner); + } + + /// + public void Flush() + { + ThrowIfDisposed(); + _backing.Flush(); + } + + /// + public void Read(long position, out T structure) where T : struct + { + // The BCL validates the position, the open state and the readability before the + // reference check of `SizeOf{T}` runs. + MemoryMappedFileHelpers.ThrowIfNegative(position, nameof(position)); + ThrowIfCannotRead(); + MemoryMappedFileHelpers.ThrowIfContainsReferences(); + byte[] bytes = ReadBytes(position, Unsafe.SizeOf()); + structure = Unsafe.ReadUnaligned(ref bytes[0]); + } + + /// + public int ReadArray(long position, T[] array, int offset, int count) + where T : struct + { + ValidateArrayArguments(array, offset, count); + ThrowIfCannotRead(); + MemoryMappedFileHelpers.ThrowIfNegative(position, nameof(position)); + MemoryMappedFileHelpers.ThrowIfContainsReferences(); + ThrowIfPositionAtOrBeyondCapacity(position); + + // The elements of an array are strided by the aligned size (the BCL rounds sizes other + // than 1 and 2 up to a multiple of 4), while each element itself only occupies its + // actual size. + int structureSize = Unsafe.SizeOf(); + int alignedSize = MemoryMappedFileHelpers.AlignedSizeOf(); + long available = _size - position; + int itemsToRead = (int)Math.Min(count, available / alignedSize); + if (itemsToRead == 0) + { + return 0; + } + + int byteCount = ((itemsToRead - 1) * alignedSize) + structureSize; + byte[] bytes = new byte[byteCount]; + _backing.ReadAt(_offset + position, bytes, 0, byteCount); + if (alignedSize == structureSize) + { + // Without padding between the elements the range is contiguous, so a single bulk + // copy replaces the per-element loop (relevant e.g. for large byte arrays). + Unsafe.CopyBlockUnaligned(ref Unsafe.As(ref array[offset]), + ref bytes[0], (uint)byteCount); + } + else + { + for (int i = 0; i < itemsToRead; i++) + { + array[offset + i] = Unsafe.ReadUnaligned(ref bytes[i * alignedSize]); + } + } + + return itemsToRead; + } + + /// + public bool ReadBoolean(long position) + => ReadBytes(position, 1)[0] != 0; + + /// + public byte ReadByte(long position) + => ReadBytes(position, 1)[0]; + + /// + public char ReadChar(long position) + { + Read(position, out char value); + return value; + } + + /// + public decimal ReadDecimal(long position) + { + // The typed decimal overload uses the `decimal.GetBits` layout (lo, mid, hi, flags), + // which differs from the in-memory layout the generic `Read{T}` would use. + byte[] bytes = ReadBytes(position, 16); + int[] bits = new int[4]; + for (int i = 0; i < 4; i++) + { + bits[i] = BitConverter.ToInt32(bytes, i * 4); + } + + return new decimal(bits); + } + + /// + public double ReadDouble(long position) + { + Read(position, out double value); + return value; + } + + /// + public short ReadInt16(long position) + { + Read(position, out short value); + return value; + } + + /// + public int ReadInt32(long position) + { + Read(position, out int value); + return value; + } + + /// + public long ReadInt64(long position) + { + Read(position, out long value); + return value; + } + + /// + public sbyte ReadSByte(long position) + => (sbyte)ReadBytes(position, 1)[0]; + + /// + public float ReadSingle(long position) + { + Read(position, out float value); + return value; + } + + /// + public ushort ReadUInt16(long position) + { + Read(position, out ushort value); + return value; + } + + /// + public uint ReadUInt32(long position) + { + Read(position, out uint value); + return value; + } + + /// + public ulong ReadUInt64(long position) + { + Read(position, out ulong value); + return value; + } + + /// + public void Write(long position, bool value) + => WriteBytes(position, [ + (byte)(value ? 1 : 0), + ]); + + /// + public void Write(long position, byte value) + => WriteBytes(position, [ + value, + ]); + + /// + public void Write(long position, char value) + => Write(position, ref value); + + /// + public void Write(long position, decimal value) + { + // The typed decimal overload uses the `decimal.GetBits` layout (lo, mid, hi, flags), + // which differs from the in-memory layout the generic `Write{T}` would use. + int[] bits = decimal.GetBits(value); + byte[] bytes = new byte[16]; + for (int i = 0; i < 4; i++) + { + BitConverter.GetBytes(bits[i]).CopyTo(bytes, i * 4); + } + + WriteBytes(position, bytes); + } + + /// + public void Write(long position, double value) + => Write(position, ref value); + + /// + public void Write(long position, short value) + => Write(position, ref value); + + /// + public void Write(long position, int value) + => Write(position, ref value); + + /// + public void Write(long position, long value) + => Write(position, ref value); + + /// + public void Write(long position, sbyte value) + => WriteBytes(position, [ + (byte)value, + ]); + + /// + public void Write(long position, float value) + => Write(position, ref value); + + /// + public void Write(long position, ushort value) + => Write(position, ref value); + + /// + public void Write(long position, uint value) + => Write(position, ref value); + + /// + public void Write(long position, ulong value) + => Write(position, ref value); + + /// + public void Write(long position, ref T structure) where T : struct + { + // The BCL validates the position, the open state and the writability before the + // reference check of `SizeOf{T}` runs. + MemoryMappedFileHelpers.ThrowIfNegative(position, nameof(position)); + ThrowIfCannotWrite(); + MemoryMappedFileHelpers.ThrowIfContainsReferences(); + byte[] bytes = new byte[Unsafe.SizeOf()]; + Unsafe.WriteUnaligned(ref bytes[0], structure); + WriteBytes(position, bytes); + } + + /// + public void WriteArray(long position, T[] array, int offset, int count) + where T : struct + { + ValidateArrayArguments(array, offset, count); + ThrowIfCannotWrite(); + MemoryMappedFileHelpers.ThrowIfNegative(position, nameof(position)); + MemoryMappedFileHelpers.ThrowIfContainsReferences(); + ThrowIfPositionAtOrBeyondCapacity(position); + + // The elements of an array are strided by the aligned size (the BCL rounds sizes other + // than 1 and 2 up to a multiple of 4), while each element itself only occupies its + // actual size, leaving the padding bytes between elements untouched. + int structureSize = Unsafe.SizeOf(); + int alignedSize = MemoryMappedFileHelpers.AlignedSizeOf(); + // Validate up-front so the write is atomic: the BCL rejects the whole call before writing + // any element when the array does not fit, rather than writing partially and then failing. + if (position > _size - ((long)count * alignedSize)) + { + #pragma warning disable MA0015 // Matches the parameter-less BCL message for this combination. + throw new ArgumentException("Not enough space available in the buffer."); + #pragma warning restore MA0015 + } + + if (count == 0) + { + return; + } + + int byteCount = ((count - 1) * alignedSize) + structureSize; + byte[] bytes = new byte[byteCount]; + if (alignedSize != structureSize && count > 1) + { + // Read-modify-write of the covered range, so the padding bytes between elements keep + // their current file content (the BCL writes only the actual bytes of each element). + _backing.ReadAt(_offset + position, bytes, 0, byteCount); + } + + if (alignedSize == structureSize) + { + // Without padding between the elements the range is contiguous, so a single bulk + // copy replaces the per-element loop (relevant e.g. for large byte arrays). + Unsafe.CopyBlockUnaligned(ref bytes[0], + ref Unsafe.As(ref array[offset]), (uint)byteCount); + } + else + { + for (int i = 0; i < count; i++) + { + T value = array[offset + i]; + Unsafe.WriteUnaligned(ref bytes[i * alignedSize], value); + } + } + + _backing.WriteAt(_offset + position, bytes, 0, bytes.Length); + } + + #endregion + + private void EnsureInBounds(long position, int count, bool forReading) + { + MemoryMappedFileHelpers.ThrowIfNegative(position, nameof(position)); + + if (position > _size - count) + { + ThrowIfPositionAtOrBeyondCapacity(position); + + throw new ArgumentException( + forReading + ? "There are not enough bytes remaining in the accessor to read at this position." + : "There are not enough bytes remaining in the accessor to write at this position.", + nameof(position)); + } + } + + private void ThrowIfPositionAtOrBeyondCapacity(long position) + { + if (position >= _size) + { + throw new ArgumentOutOfRangeException(nameof(position), + "The position may not be greater or equal to the capacity of the accessor."); + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(null, "Cannot access a closed accessor."); + } + } + + private void ThrowIfCannotRead() + { + ThrowIfDisposed(); + if (!CanRead) + { + throw new NotSupportedException("Accessor does not support reading."); + } + } + + private void ThrowIfCannotWrite() + { + ThrowIfDisposed(); + if (!CanWrite) + { + throw new NotSupportedException("Accessor does not support writing."); + } + } + + private byte[] ReadBytes(long position, int count) + { + ThrowIfCannotRead(); + EnsureInBounds(position, count, forReading: true); + byte[] buffer = new byte[count]; + _backing.ReadAt(_offset + position, buffer, 0, count); + return buffer; + } + + private static void ValidateArrayArguments(T[] array, int offset, int count) + { + if (array == null) + { + throw new ArgumentNullException(nameof(array)); + } + + MemoryMappedFileHelpers.ThrowIfNegative(offset, nameof(offset)); + MemoryMappedFileHelpers.ThrowIfNegative(count, nameof(count)); + + if (array.Length - offset < count) + { + #pragma warning disable MA0015 // Matches the parameter-less BCL message for this combination. + throw new ArgumentException( + "The number of bytes requested does not fit into the buffer."); + #pragma warning restore MA0015 + } + } + + private void WriteBytes(long position, byte[] bytes) + { + ThrowIfCannotWrite(); + EnsureInBounds(position, bytes.Length, forReading: false); + _backing.WriteAt(_offset + position, bytes, 0, bytes.Length); + } +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewAccessorWrapper.cs b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewAccessorWrapper.cs new file mode 100644 index 000000000..a7849a38f --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewAccessorWrapper.cs @@ -0,0 +1,162 @@ +using System.IO.MemoryMappedFiles; + +namespace Testably.Abstractions; + +internal sealed class MemoryMappedViewAccessorWrapper( + IFileSystem fileSystem, + MemoryMappedViewAccessor instance) + : IMemoryMappedViewAccessor +{ + #region IMemoryMappedViewAccessor Members + + /// + public bool CanRead + => instance.CanRead; + + /// + public bool CanWrite + => instance.CanWrite; + + /// + public long Capacity + => instance.Capacity; + + /// + public IFileSystem FileSystem { get; } = fileSystem; + + /// + public long PointerOffset + => instance.PointerOffset; + + /// + public void Dispose() + => instance.Dispose(); + + /// + public void Flush() + => instance.Flush(); + + /// + public void Read(long position, out T structure) where T : struct + => instance.Read(position, out structure); + + /// + public int ReadArray(long position, T[] array, int offset, int count) + where T : struct + => instance.ReadArray(position, array, offset, count); + + /// + public bool ReadBoolean(long position) + => instance.ReadBoolean(position); + + /// + public byte ReadByte(long position) + => instance.ReadByte(position); + + /// + public char ReadChar(long position) + => instance.ReadChar(position); + + /// + public decimal ReadDecimal(long position) + => instance.ReadDecimal(position); + + /// + public double ReadDouble(long position) + => instance.ReadDouble(position); + + /// + public short ReadInt16(long position) + => instance.ReadInt16(position); + + /// + public int ReadInt32(long position) + => instance.ReadInt32(position); + + /// + public long ReadInt64(long position) + => instance.ReadInt64(position); + + /// + public sbyte ReadSByte(long position) + => instance.ReadSByte(position); + + /// + public float ReadSingle(long position) + => instance.ReadSingle(position); + + /// + public ushort ReadUInt16(long position) + => instance.ReadUInt16(position); + + /// + public uint ReadUInt32(long position) + => instance.ReadUInt32(position); + + /// + public ulong ReadUInt64(long position) + => instance.ReadUInt64(position); + + /// + public void Write(long position, bool value) + => instance.Write(position, value); + + /// + public void Write(long position, byte value) + => instance.Write(position, value); + + /// + public void Write(long position, char value) + => instance.Write(position, value); + + /// + public void Write(long position, decimal value) + => instance.Write(position, value); + + /// + public void Write(long position, double value) + => instance.Write(position, value); + + /// + public void Write(long position, short value) + => instance.Write(position, value); + + /// + public void Write(long position, int value) + => instance.Write(position, value); + + /// + public void Write(long position, long value) + => instance.Write(position, value); + + /// + public void Write(long position, sbyte value) + => instance.Write(position, value); + + /// + public void Write(long position, float value) + => instance.Write(position, value); + + /// + public void Write(long position, ushort value) + => instance.Write(position, value); + + /// + public void Write(long position, uint value) + => instance.Write(position, value); + + /// + public void Write(long position, ulong value) + => instance.Write(position, value); + + /// + public void Write(long position, ref T structure) where T : struct + => instance.Write(position, ref structure); + + /// + public void WriteArray(long position, T[] array, int offset, int count) + where T : struct + => instance.WriteArray(position, array, offset, count); + + #endregion +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewBacking.cs b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewBacking.cs new file mode 100644 index 000000000..61de12893 --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewBacking.cs @@ -0,0 +1,31 @@ +namespace Testably.Abstractions; + +/// +/// Positional read/write access to the bytes backing a view of a memory-mapped file. +/// +/// +/// Views never own a position: every operation takes an explicit position, so views can be +/// used concurrently and never disturb the state their backing is built on. +/// multiplexes the single shared (possibly caller-owned) +/// stream; adds page privatization on top of it. +/// +internal abstract class MemoryMappedViewBacking +{ + /// + /// Flushes any written bytes to the underlying file. + /// + public abstract void Flush(); + + /// + /// Reads bytes at the absolute + /// into . A range beyond the end of the underlying content + /// (because the caller truncated a shared backing stream) is zero-filled, matching the + /// zeroed pages the real memory-mapped view exposes. + /// + public abstract void ReadAt(long position, byte[] buffer, int offset, int count); + + /// + /// Writes the given bytes at the absolute . + /// + public abstract void WriteAt(long position, byte[] buffer, int offset, int count); +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewStreamMock.cs b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewStreamMock.cs new file mode 100644 index 000000000..c7ab9c615 --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewStreamMock.cs @@ -0,0 +1,213 @@ +using System; +using System.IO; +using System.IO.MemoryMappedFiles; +using Testably.Abstractions.Internal; + +namespace Testably.Abstractions; + +/// +/// A view stream backed directly by the (in-memory) bytes of a +/// of the MockFileSystem. +/// +internal sealed class MemoryMappedViewStreamMock( + MemoryMappedViewBacking backing, + long offset, + long size, + MemoryMappedFileAccess access, + IDisposable backingOwner) + : MemoryMappedFileSystemViewStream(new BoundedViewStream(backing, offset, size, access, + backingOwner)) +{ + /// + public override long PointerOffset + => 0; + + private sealed class BoundedViewStream : Stream + { + /// + public override bool CanRead + => !_disposed && _canRead; + + /// + public override bool CanSeek + => !_disposed; + + /// + public override bool CanWrite + => !_disposed && _canWrite; + + /// + public override long Length + { + get + { + ThrowIfDisposed(); + return _size; + } + } + + /// + public override long Position + { + get + { + ThrowIfDisposed(); + return _position; + } + set + { + ThrowIfDisposed(); + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), value, + "Non-negative number required."); + } + + _position = value; + } + } + + private readonly MemoryMappedViewBacking _backing; + private readonly IDisposable _backingOwner; + private readonly bool _canRead; + private readonly bool _canWrite; + private bool _disposed; + private readonly long _offset; + private long _position; + private readonly long _size; + + public BoundedViewStream(MemoryMappedViewBacking backing, long offset, long size, + MemoryMappedFileAccess access, IDisposable backingOwner) + { + _backing = backing; + _offset = offset; + _size = size; + _backingOwner = backingOwner; + _canRead = access.SupportsReading(); + _canWrite = access.SupportsWriting(); + } + + /// + public override void Flush() + { + ThrowIfDisposed(); + _backing.Flush(); + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + EnsureValidBufferRange(buffer, offset, count); + ThrowIfDisposed(); + if (!_canRead) + { + throw new NotSupportedException("Stream does not support reading."); + } + + long remaining = _size - _position; + if (remaining <= 0) + { + return 0; + } + + int toRead = (int)Math.Min(count, remaining); + _backing.ReadAt(_offset + _position, buffer, offset, toRead); + _position += toRead; + return toRead; + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + ThrowIfDisposed(); + long target = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _position + offset, + SeekOrigin.End => _size + offset, + _ => throw new ArgumentException("Invalid seek origin.", nameof(origin)), + }; + if (target < 0) + { + throw new IOException( + "An attempt was made to move the position before the beginning of the stream."); + } + + _position = target; + return _position; + } + + /// + public override void SetLength(long value) + => throw new NotSupportedException( + "The length of a memory-mapped view stream cannot be changed."); + + /// + public override void Write(byte[] buffer, int offset, int count) + { + EnsureValidBufferRange(buffer, offset, count); + ThrowIfDisposed(); + if (!_canWrite) + { + throw new NotSupportedException("Stream does not support writing."); + } + + if (_position > _size - count) + { + throw new NotSupportedException( + "Unable to expand length of this stream beyond its capacity."); + } + + _backing.WriteAt(_offset + _position, buffer, offset, count); + _position += count; + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + MemoryMappedFileHelpers.DisposeView(_backing, _backingOwner); + } + + base.Dispose(disposing); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(null, "Cannot access a closed Stream."); + } + } + + private static void EnsureValidBufferRange(byte[] buffer, int offset, int count) + { + if (buffer == null) + { + throw new ArgumentNullException(nameof(buffer)); + } + + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), offset, + "Non-negative number required."); + } + + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count), count, + "Non-negative number required."); + } + + if (buffer.Length - offset < count) + { + #pragma warning disable MA0015 // Matches the parameter-less BCL message for this combination. + throw new ArgumentException( + "Offset and length were out of bounds for the array or count is greater than the number of elements from index to the end of the source collection."); + #pragma warning restore MA0015 + } + } + } +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewStreamWrapper.cs b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewStreamWrapper.cs new file mode 100644 index 000000000..0dd4893bb --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/MemoryMappedViewStreamWrapper.cs @@ -0,0 +1,15 @@ +using System.IO.MemoryMappedFiles; + +namespace Testably.Abstractions; + +internal sealed class MemoryMappedViewStreamWrapper(MemoryMappedViewStream instance) + : MemoryMappedFileSystemViewStream(instance) +{ + /// + public override long Capacity + => instance.Capacity; + + /// + public override long PointerOffset + => instance.PointerOffset; +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/StreamViewBacking.cs b/Source/Testably.Abstractions.MemoryMappedFiles/StreamViewBacking.cs new file mode 100644 index 000000000..e58ce872b --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/StreamViewBacking.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; + +namespace Testably.Abstractions; + +/// +/// Positional read/write access to the backing shared by all views of a +/// memory-mapped file. +/// +/// +/// All views of a memory-mapped file share a single seekable backing stream (which can even +/// be a caller-owned when it was created with +/// leaveOpen: true). Every operation therefore restores the stream position afterwards +/// and serializes access via an internal lock, so views never disturb the position of the +/// shared stream and can be used concurrently. +/// +internal sealed class StreamViewBacking(Stream stream) : MemoryMappedViewBacking +{ +#if NET9_0_OR_GREATER + private readonly System.Threading.Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + /// + public override void Flush() + { + lock (_lock) + { + stream.Flush(); + } + } + + /// + public override void ReadAt(long position, byte[] buffer, int offset, int count) + { + lock (_lock) + { + long previousPosition = stream.Position; + try + { + stream.Position = position; + int read = 0; + while (read < count) + { + int r = stream.Read(buffer, offset + read, count - read); + if (r == 0) + { + // The stream ends before the requested range (the caller truncated a + // shared backing stream), so the remainder is zero-filled, matching the + // zeroed pages the real memory-mapped view exposes. + Array.Clear(buffer, offset + read, count - read); + break; + } + + read += r; + } + } + finally + { + stream.Position = previousPosition; + } + } + } + + /// + /// Writes the given bytes at the absolute . The write is + /// immediately visible to all views (they share this stream); it reaches the underlying + /// file when a view is flushed or disposed, or earlier when the stream persists its + /// pending writes on a reposition, like the real FileStream. + /// + public override void WriteAt(long position, byte[] buffer, int offset, int count) + { + lock (_lock) + { + long previousPosition = stream.Position; + try + { + stream.Position = position; + stream.Write(buffer, offset, count); + } + finally + { + stream.Position = previousPosition; + } + } + } +} diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/Testably.Abstractions.MemoryMappedFiles.csproj b/Source/Testably.Abstractions.MemoryMappedFiles/Testably.Abstractions.MemoryMappedFiles.csproj new file mode 100644 index 000000000..66160428b --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/Testably.Abstractions.MemoryMappedFiles.csproj @@ -0,0 +1,30 @@ + + + + Testably.Abstractions + Memory-mapped file extension methods abstracting `System.IO.MemoryMappedFiles` with `Testably.Abstractions`. + Docs/MemoryMappedFiles.md + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Testably.Abstractions.MemoryMappedFiles/Usings.cs b/Source/Testably.Abstractions.MemoryMappedFiles/Usings.cs new file mode 100644 index 000000000..13afd09ac --- /dev/null +++ b/Source/Testably.Abstractions.MemoryMappedFiles/Usings.cs @@ -0,0 +1,6 @@ +#if NETSTANDARD2_0 || NETSTANDARD2_1 +global using Testably.Abstractions.Polyfills; +#else +global using System.Runtime.Versioning; +#endif +global using System.IO.Abstractions; diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs index ce21826bc..8dd8fb970 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading; @@ -177,10 +178,9 @@ public override int WriteTimeout private bool _isContentChanged; private bool _isDisposed; private readonly IStorageLocation _location; - private long _maxWrite; - private long _minWrite = long.MaxValue; private readonly FileMode _mode; private readonly FileOptions _options; + private readonly List<(long Start, long End)> _pendingWrites = new(); private readonly MemoryStream _stream; internal FileStreamMock(MockFileSystem fileSystem, @@ -349,9 +349,11 @@ public override IAsyncResult BeginWrite(byte[] buffer, throw ExceptionFactory.StreamDoesNotSupportWriting(); } - _minWrite = Position; - _maxWrite = Position + count; - return base.BeginWrite(buffer, offset, count, callback, state); + long position = Position; + FlushWhenWriteIsDisjoint(position, count); + IAsyncResult result = base.BeginWrite(buffer, offset, count, callback, state); + TrackWrite(position, count); + return result; } /// @@ -580,7 +582,13 @@ public override void SetLength(long value) throw ExceptionFactory.StreamDoesNotSupportWriting(); } + long previousLength = Length; base.SetLength(value); + if (value != previousLength) + { + _isContentChanged = true; + InternalFlush(); + } } /// @@ -604,10 +612,11 @@ public override void Write(byte[] buffer, int offset, int count) throw ExceptionFactory.StreamDoesNotSupportWriting(); } - _isContentChanged = true; - _minWrite = Position; - _maxWrite = Position + count; + long position = Position; + FlushWhenWriteIsDisjoint(position, count); base.Write(buffer, offset, count); + TrackWrite(position, count); + _isContentChanged = true; } #if FEATURE_SPAN @@ -623,10 +632,11 @@ public override void Write(ReadOnlySpan buffer) throw ExceptionFactory.StreamDoesNotSupportWriting(); } - _isContentChanged = true; - _minWrite = Position; - _maxWrite = Position + buffer.Length; + long position = Position; + FlushWhenWriteIsDisjoint(position, buffer.Length); base.Write(buffer); + TrackWrite(position, buffer.Length); + _isContentChanged = true; } #endif @@ -643,10 +653,11 @@ public override async Task WriteAsync(byte[] buffer, int offset, int count, throw ExceptionFactory.StreamDoesNotSupportWriting(); } - _isContentChanged = true; - _minWrite = Position; - _maxWrite = Position + count; + long position = Position; + FlushWhenWriteIsDisjoint(position, count); await base.WriteAsync(buffer, offset, count, cancellationToken); + TrackWrite(position, count); + _isContentChanged = true; } #if FEATURE_SPAN @@ -663,10 +674,11 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, throw ExceptionFactory.StreamDoesNotSupportWriting(); } - _isContentChanged = true; - _minWrite = Position; - _maxWrite = Position + buffer.Length; + long position = Position; + FlushWhenWriteIsDisjoint(position, buffer.Length); await base.WriteAsync(buffer, cancellationToken); + TrackWrite(position, buffer.Length); + _isContentChanged = true; } #endif @@ -682,10 +694,11 @@ public override void WriteByte(byte value) throw ExceptionFactory.StreamDoesNotSupportWriting(); } - _isContentChanged = true; - _minWrite = Position; - _maxWrite = Position + 1L; + long position = Position; + FlushWhenWriteIsDisjoint(position, 1L); base.WriteByte(value); + TrackWrite(position, 1L); + _isContentChanged = true; } /// @@ -719,11 +732,49 @@ private void InitializeStream() else { _isContentChanged = true; - _minWrite = Position; - _maxWrite = Position; } } + /// + /// Persists the pending writes when the write at does not + /// adjoin them, like the real FileStream flushes its buffer on a reposition. + /// Called before the write, so the not yet written bytes are not published. + /// + private void FlushWhenWriteIsDisjoint(long position, long count) + { + long end = position + count; + if (_pendingWrites.Count > 0 && + (position > _pendingWrites[_pendingWrites.Count - 1].End || + end < _pendingWrites[0].Start)) + { + InternalFlush(); + } + } + + /// + /// Records the range of a write, so can preserve it when + /// the underlying container changes. Called only after the wrapped stream accepted the + /// write, so a write with rejected arguments leaves no pending range behind. + /// + private void TrackWrite(long position, long count) + { + long end = position + count; + int index = 0; + while (index < _pendingWrites.Count && _pendingWrites[index].End < position) + { + index++; + } + + while (index < _pendingWrites.Count && _pendingWrites[index].Start <= end) + { + position = Math.Min(position, _pendingWrites[index].Start); + end = Math.Max(end, _pendingWrites[index].End); + _pendingWrites.RemoveAt(index); + } + + _pendingWrites.Insert(index, (position, end)); + } + private void InternalFlush() { if (!_isContentChanged) @@ -732,28 +783,102 @@ private void InternalFlush() } _isContentChanged = false; + long length = Length; long position = _stream.Position; + long containerLength = _container.GetBytes().Length; + if (_pendingWrites.Count > 0 && + (length == containerLength || + (length > containerLength && + _pendingWrites[_pendingWrites.Count - 1].End == length))) + { + long start = _pendingWrites[0].Start; + byte[] data = new byte[_pendingWrites[_pendingWrites.Count - 1].End - start]; + _stream.Position = start; + _ = _stream.Read(data, 0, data.Length); + _stream.Position = position; + _pendingWrites.Clear(); + _container.WriteRange(data, start); + return; + } + _stream.Seek(0, SeekOrigin.Begin); - byte[] data = new byte[Length]; - _ = _stream.Read(data, 0, (int)Length); + byte[] content = new byte[length]; + _ = _stream.Read(content, 0, (int)length); _stream.Seek(position, SeekOrigin.Begin); - _container.WriteBytes(data); - _minWrite = long.MaxValue; - _maxWrite = 0; + _pendingWrites.Clear(); + _container.WriteBytes(content); } private void OnBytesChanged(object? sender, EventArgs e) { + if (e is BytesChangedEventArgs rangeUpdate) + { + ApplyRangeUpdate(rangeUpdate.Bytes, rangeUpdate.Offset); + return; + } + byte[] existingContents = _container.GetBytes(); long position = _stream.Position; - if (_minWrite < _maxWrite) + List<(long Start, byte[] Data)> pendingWrites = new(_pendingWrites.Count); + foreach ((long start, long end) in _pendingWrites) { - _stream.Position = _minWrite; - _ = _stream.Read(existingContents, (int)_minWrite, (int)(_maxWrite - _minWrite)); + long length = Math.Min(end, _stream.Length) - start; + if (length <= 0) + { + continue; + } + + byte[] data = new byte[length]; + _stream.Position = start; + _ = _stream.Read(data, 0, (int)length); + pendingWrites.Add((start, data)); } _stream.Position = 0; _stream.Write(existingContents, 0, existingContents.Length); + _stream.SetLength(existingContents.Length); + foreach ((long start, byte[] data) in pendingWrites) + { + _stream.Position = start; + _stream.Write(data, 0, data.Length); + } + + _stream.Position = position; + } + + /// + /// Applies a ranged update of the underlying container to the local stream without + /// processing the complete file content, preserving the own pending writes that overlap + /// the changed range. + /// + private void ApplyRangeUpdate(byte[] bytes, long offset) + { + long position = _stream.Position; + long end = offset + bytes.Length; + List<(long Start, byte[] Data)> pendingWrites = new(_pendingWrites.Count); + foreach ((long pendingStart, long pendingEnd) in _pendingWrites) + { + long overlapStart = Math.Max(pendingStart, offset); + long overlapEnd = Math.Min(Math.Min(pendingEnd, _stream.Length), end); + if (overlapEnd <= overlapStart) + { + continue; + } + + byte[] data = new byte[overlapEnd - overlapStart]; + _stream.Position = overlapStart; + _ = _stream.Read(data, 0, data.Length); + pendingWrites.Add((overlapStart, data)); + } + + _stream.Position = offset; + _stream.Write(bytes, 0, bytes.Length); + foreach ((long start, byte[] data) in pendingWrites) + { + _stream.Position = start; + _stream.Write(data, 0, data.Length); + } + _stream.Position = position; } diff --git a/Source/Testably.Abstractions.Testing/Storage/BytesChangedEventArgs.cs b/Source/Testably.Abstractions.Testing/Storage/BytesChangedEventArgs.cs new file mode 100644 index 000000000..a212cffb2 --- /dev/null +++ b/Source/Testably.Abstractions.Testing/Storage/BytesChangedEventArgs.cs @@ -0,0 +1,21 @@ +using System; + +namespace Testably.Abstractions.Testing.Storage; + +/// +/// Event arguments of when only a range of the +/// file content was changed via , so +/// that subscribers can apply the change without processing the complete file content. +/// +internal sealed class BytesChangedEventArgs(byte[] bytes, long offset) : EventArgs +{ + /// + /// The changed bytes, starting at . + /// + public byte[] Bytes { get; } = bytes; + + /// + /// The offset in the file content at which the were written. + /// + public long Offset { get; } = offset; +} diff --git a/Source/Testably.Abstractions.Testing/Storage/IStorageContainer.cs b/Source/Testably.Abstractions.Testing/Storage/IStorageContainer.cs index ccab079e2..bb63f84be 100644 --- a/Source/Testably.Abstractions.Testing/Storage/IStorageContainer.cs +++ b/Source/Testably.Abstractions.Testing/Storage/IStorageContainer.cs @@ -103,6 +103,16 @@ IStorageAccessHandle RequestAccess(FileAccess access, FileShare share, /// void WriteBytes(byte[] bytes); + /// + /// Writes the at the given into the + /// content of the , keeping the remaining content unchanged and + /// extending the file when the range ends beyond the current length. + /// + /// The event carries a , + /// so that subscribers can apply the change without processing the complete file content. + /// + void WriteRange(byte[] bytes, long offset); + /// /// A container to allow reading/writing s with consistent . /// diff --git a/Source/Testably.Abstractions.Testing/Storage/InMemoryContainer.cs b/Source/Testably.Abstractions.Testing/Storage/InMemoryContainer.cs index 6d4d881ce..2b5dd794a 100644 --- a/Source/Testably.Abstractions.Testing/Storage/InMemoryContainer.cs +++ b/Source/Testably.Abstractions.Testing/Storage/InMemoryContainer.cs @@ -230,6 +230,19 @@ public IStorageContainer UpdateLocation(IStorageLocation newLocation) /// public void WriteBytes(byte[] bytes) + => WriteBytesInternal(bytes, EventArgs.Empty); + + /// + public void WriteRange(byte[] bytes, long offset) + { + long newLength = Math.Max(_bytes.Length, offset + bytes.Length); + byte[] newBytes = new byte[newLength]; + Array.Copy(_bytes, newBytes, _bytes.Length); + Array.Copy(bytes, 0L, newBytes, offset, bytes.Length); + WriteBytesInternal(newBytes, new BytesChangedEventArgs(bytes, offset)); + } + + private void WriteBytesInternal(byte[] bytes, EventArgs eventArgs) { NotifyFilters notifyFilters = NotifyFilters.LastAccess | NotifyFilters.LastWrite | @@ -269,7 +282,7 @@ public void WriteBytes(byte[] bytes) } _fileSystem.ChangeHandler.NotifyCompletedChange(fileSystemChange); - BytesChanged?.Invoke(this, EventArgs.Empty); + BytesChanged?.Invoke(this, eventArgs); } #endregion diff --git a/Source/Testably.Abstractions.Testing/Storage/NullContainer.cs b/Source/Testably.Abstractions.Testing/Storage/NullContainer.cs index bd126dbe7..9dccbf8fb 100644 --- a/Source/Testably.Abstractions.Testing/Storage/NullContainer.cs +++ b/Source/Testably.Abstractions.Testing/Storage/NullContainer.cs @@ -118,6 +118,12 @@ public void WriteBytes(byte[] bytes) // Do nothing in NullContainer } + /// + public void WriteRange(byte[] bytes, long offset) + { + // Do nothing in NullContainer + } + #endregion internal static IStorageContainer New(MockFileSystem fileSystem) diff --git a/Testably.Abstractions.slnx b/Testably.Abstractions.slnx index 92d58e5c0..cb65d13e5 100644 --- a/Testably.Abstractions.slnx +++ b/Testably.Abstractions.slnx @@ -12,6 +12,7 @@ + @@ -57,6 +58,7 @@ + @@ -67,5 +69,6 @@ + diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/ApiAcceptance.cs b/Tests/Api/Testably.Abstractions.Api.Tests/ApiAcceptance.cs index 18391c15f..1770ea25f 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/ApiAcceptance.cs +++ b/Tests/Api/Testably.Abstractions.Api.Tests/ApiAcceptance.cs @@ -18,6 +18,7 @@ public async Task AcceptApiChanges() [ "Testably.Abstractions.AccessControl", "Testably.Abstractions.Compression", + "Testably.Abstractions.MemoryMappedFiles", "Testably.Abstractions.Testing", "Testably.Abstractions", ]; diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/ApiApprovalTests.cs b/Tests/Api/Testably.Abstractions.Api.Tests/ApiApprovalTests.cs index b517d09a8..6f0fd91f6 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/ApiApprovalTests.cs +++ b/Tests/Api/Testably.Abstractions.Api.Tests/ApiApprovalTests.cs @@ -50,6 +50,18 @@ public async Task VerifyPublicApiForTestablyAbstractionsCompression(string frame await Expect.That(publicApi).IsEqualTo(expectedApi); } + [Test] + [MethodDataSource(nameof(TargetFrameworks))] + public async Task VerifyPublicApiForTestablyAbstractionsMemoryMappedFiles(string framework) + { + const string assemblyName = "Testably.Abstractions.MemoryMappedFiles"; + + string publicApi = Helper.CreatePublicApi(framework, assemblyName); + string expectedApi = Helper.GetExpectedApi(framework, assemblyName); + + await Expect.That(publicApi).IsEqualTo(expectedApi); + } + [Test] [MethodDataSource(nameof(TargetFrameworks))] public async Task VerifyPublicApiForTestablyAbstractionsTesting(string framework) diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net10.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net10.0.txt new file mode 100644 index 000000000..51842fce4 --- /dev/null +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net10.0.txt @@ -0,0 +1,124 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/Testably/Testably.Abstractions.git")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(@"Testably.Abstractions.MemoryMappedFiles.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001006104741100251820044d92b34b0519a1de0bccd80d6199aadbdcd5931d035462d42f70b0ae7a7db37bab63afb8a8ad0dc21392bb01f1243bfc51df4b5f1975b1b9746fecbed88913b783fccb69efc59e23b0e019e065abd38731711a2d6ac2569ab57d4b4d529f5903f5bee0f4388b2a5f4d5e0fddab6aac18d96aa78c2e73e0")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] +namespace Testably.Abstractions +{ + public static class FileSystemExtensions + { + extension(System.IO.Abstractions.IFileSystem fileSystem) + { + public Testably.Abstractions.IMemoryMappedFileFactory MemoryMappedFile { get; } + } + } + public interface IMemoryMappedFile : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + } + public interface IMemoryMappedFileFactory : System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(System.IO.Abstractions.FileSystemStream fileStream, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.HandleInheritability inheritability, bool leaveOpen); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights, System.IO.HandleInheritability inheritability); + } + public interface IMemoryMappedViewAccessor : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + bool CanRead { get; } + bool CanWrite { get; } + long Capacity { get; } + long PointerOffset { get; } + void Flush(); + void Read(long position, out T structure) + where T : struct; + int ReadArray(long position, T[] array, int offset, int count) + where T : struct; + bool ReadBoolean(long position); + byte ReadByte(long position); + char ReadChar(long position); + decimal ReadDecimal(long position); + double ReadDouble(long position); + short ReadInt16(long position); + int ReadInt32(long position); + long ReadInt64(long position); + sbyte ReadSByte(long position); + float ReadSingle(long position); + ushort ReadUInt16(long position); + uint ReadUInt32(long position); + ulong ReadUInt64(long position); + void Write(long position, bool value); + void Write(long position, byte value); + void Write(long position, char value); + void Write(long position, decimal value); + void Write(long position, double value); + void Write(long position, float value); + void Write(long position, int value); + void Write(long position, long value); + void Write(long position, sbyte value); + void Write(long position, short value); + void Write(long position, uint value); + void Write(long position, ulong value); + void Write(long position, ushort value); + void Write(long position, ref T structure) + where T : struct; + void WriteArray(long position, T[] array, int offset, int count) + where T : struct; + } + public abstract class MemoryMappedFileSystemViewStream : System.IO.UnmanagedMemoryStream + { + protected MemoryMappedFileSystemViewStream(System.IO.Stream stream) { } + public override bool CanRead { get; } + public override bool CanSeek { get; } + public override bool CanTimeout { get; } + public override bool CanWrite { get; } + public virtual long Capacity { get; } + public override long Length { get; } + public abstract long PointerOffset { get; } + public override long Position { get; set; } + public override int ReadTimeout { get; set; } + public override int WriteTimeout { get; set; } + public override System.IAsyncResult BeginRead(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override System.IAsyncResult BeginWrite(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override void Close() { } + public override System.Threading.Tasks.Task CopyToAsync(System.IO.Stream destination, int bufferSize, System.Threading.CancellationToken cancellationToken) { } + protected override void Dispose(bool disposing) { } + public override int EndRead(System.IAsyncResult asyncResult) { } + public override void EndWrite(System.IAsyncResult asyncResult) { } + public override void Flush() { } + public override System.Threading.Tasks.Task FlushAsync(System.Threading.CancellationToken cancellationToken) { } + public override int Read(System.Span buffer) { } + public override int Read(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask ReadAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override int ReadByte() { } + public override long Seek(long offset, System.IO.SeekOrigin origin) { } + public override void SetLength(long value) { } + public override string? ToString() { } + public override void Write(System.ReadOnlySpan buffer) { } + public override void Write(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task WriteAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override void WriteByte(byte value) { } + } +} \ No newline at end of file diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net6.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net6.0.txt new file mode 100644 index 000000000..e730bcaae --- /dev/null +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net6.0.txt @@ -0,0 +1,124 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/Testably/Testably.Abstractions.git")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(@"Testably.Abstractions.MemoryMappedFiles.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001006104741100251820044d92b34b0519a1de0bccd80d6199aadbdcd5931d035462d42f70b0ae7a7db37bab63afb8a8ad0dc21392bb01f1243bfc51df4b5f1975b1b9746fecbed88913b783fccb69efc59e23b0e019e065abd38731711a2d6ac2569ab57d4b4d529f5903f5bee0f4388b2a5f4d5e0fddab6aac18d96aa78c2e73e0")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v6.0", FrameworkDisplayName=".NET 6.0")] +namespace Testably.Abstractions +{ + public static class FileSystemExtensions + { + extension(System.IO.Abstractions.IFileSystem fileSystem) + { + public Testably.Abstractions.IMemoryMappedFileFactory MemoryMappedFile { get; } + } + } + public interface IMemoryMappedFile : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + } + public interface IMemoryMappedFileFactory : System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(System.IO.Abstractions.FileSystemStream fileStream, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.HandleInheritability inheritability, bool leaveOpen); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights, System.IO.HandleInheritability inheritability); + } + public interface IMemoryMappedViewAccessor : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + bool CanRead { get; } + bool CanWrite { get; } + long Capacity { get; } + long PointerOffset { get; } + void Flush(); + void Read(long position, out T structure) + where T : struct; + int ReadArray(long position, T[] array, int offset, int count) + where T : struct; + bool ReadBoolean(long position); + byte ReadByte(long position); + char ReadChar(long position); + decimal ReadDecimal(long position); + double ReadDouble(long position); + short ReadInt16(long position); + int ReadInt32(long position); + long ReadInt64(long position); + sbyte ReadSByte(long position); + float ReadSingle(long position); + ushort ReadUInt16(long position); + uint ReadUInt32(long position); + ulong ReadUInt64(long position); + void Write(long position, bool value); + void Write(long position, byte value); + void Write(long position, char value); + void Write(long position, decimal value); + void Write(long position, double value); + void Write(long position, float value); + void Write(long position, int value); + void Write(long position, long value); + void Write(long position, sbyte value); + void Write(long position, short value); + void Write(long position, uint value); + void Write(long position, ulong value); + void Write(long position, ushort value); + void Write(long position, ref T structure) + where T : struct; + void WriteArray(long position, T[] array, int offset, int count) + where T : struct; + } + public abstract class MemoryMappedFileSystemViewStream : System.IO.UnmanagedMemoryStream + { + protected MemoryMappedFileSystemViewStream(System.IO.Stream stream) { } + public override bool CanRead { get; } + public override bool CanSeek { get; } + public override bool CanTimeout { get; } + public override bool CanWrite { get; } + public virtual long Capacity { get; } + public override long Length { get; } + public abstract long PointerOffset { get; } + public override long Position { get; set; } + public override int ReadTimeout { get; set; } + public override int WriteTimeout { get; set; } + public override System.IAsyncResult BeginRead(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override System.IAsyncResult BeginWrite(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override void Close() { } + public override System.Threading.Tasks.Task CopyToAsync(System.IO.Stream destination, int bufferSize, System.Threading.CancellationToken cancellationToken) { } + protected override void Dispose(bool disposing) { } + public override int EndRead(System.IAsyncResult asyncResult) { } + public override void EndWrite(System.IAsyncResult asyncResult) { } + public override void Flush() { } + public override System.Threading.Tasks.Task FlushAsync(System.Threading.CancellationToken cancellationToken) { } + public override int Read(System.Span buffer) { } + public override int Read(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask ReadAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override int ReadByte() { } + public override long Seek(long offset, System.IO.SeekOrigin origin) { } + public override void SetLength(long value) { } + public override string? ToString() { } + public override void Write(System.ReadOnlySpan buffer) { } + public override void Write(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task WriteAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override void WriteByte(byte value) { } + } +} \ No newline at end of file diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net8.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net8.0.txt new file mode 100644 index 000000000..9ed8c6890 --- /dev/null +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net8.0.txt @@ -0,0 +1,124 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/Testably/Testably.Abstractions.git")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(@"Testably.Abstractions.MemoryMappedFiles.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001006104741100251820044d92b34b0519a1de0bccd80d6199aadbdcd5931d035462d42f70b0ae7a7db37bab63afb8a8ad0dc21392bb01f1243bfc51df4b5f1975b1b9746fecbed88913b783fccb69efc59e23b0e019e065abd38731711a2d6ac2569ab57d4b4d529f5903f5bee0f4388b2a5f4d5e0fddab6aac18d96aa78c2e73e0")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")] +namespace Testably.Abstractions +{ + public static class FileSystemExtensions + { + extension(System.IO.Abstractions.IFileSystem fileSystem) + { + public Testably.Abstractions.IMemoryMappedFileFactory MemoryMappedFile { get; } + } + } + public interface IMemoryMappedFile : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + } + public interface IMemoryMappedFileFactory : System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(System.IO.Abstractions.FileSystemStream fileStream, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.HandleInheritability inheritability, bool leaveOpen); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights, System.IO.HandleInheritability inheritability); + } + public interface IMemoryMappedViewAccessor : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + bool CanRead { get; } + bool CanWrite { get; } + long Capacity { get; } + long PointerOffset { get; } + void Flush(); + void Read(long position, out T structure) + where T : struct; + int ReadArray(long position, T[] array, int offset, int count) + where T : struct; + bool ReadBoolean(long position); + byte ReadByte(long position); + char ReadChar(long position); + decimal ReadDecimal(long position); + double ReadDouble(long position); + short ReadInt16(long position); + int ReadInt32(long position); + long ReadInt64(long position); + sbyte ReadSByte(long position); + float ReadSingle(long position); + ushort ReadUInt16(long position); + uint ReadUInt32(long position); + ulong ReadUInt64(long position); + void Write(long position, bool value); + void Write(long position, byte value); + void Write(long position, char value); + void Write(long position, decimal value); + void Write(long position, double value); + void Write(long position, float value); + void Write(long position, int value); + void Write(long position, long value); + void Write(long position, sbyte value); + void Write(long position, short value); + void Write(long position, uint value); + void Write(long position, ulong value); + void Write(long position, ushort value); + void Write(long position, ref T structure) + where T : struct; + void WriteArray(long position, T[] array, int offset, int count) + where T : struct; + } + public abstract class MemoryMappedFileSystemViewStream : System.IO.UnmanagedMemoryStream + { + protected MemoryMappedFileSystemViewStream(System.IO.Stream stream) { } + public override bool CanRead { get; } + public override bool CanSeek { get; } + public override bool CanTimeout { get; } + public override bool CanWrite { get; } + public virtual long Capacity { get; } + public override long Length { get; } + public abstract long PointerOffset { get; } + public override long Position { get; set; } + public override int ReadTimeout { get; set; } + public override int WriteTimeout { get; set; } + public override System.IAsyncResult BeginRead(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override System.IAsyncResult BeginWrite(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override void Close() { } + public override System.Threading.Tasks.Task CopyToAsync(System.IO.Stream destination, int bufferSize, System.Threading.CancellationToken cancellationToken) { } + protected override void Dispose(bool disposing) { } + public override int EndRead(System.IAsyncResult asyncResult) { } + public override void EndWrite(System.IAsyncResult asyncResult) { } + public override void Flush() { } + public override System.Threading.Tasks.Task FlushAsync(System.Threading.CancellationToken cancellationToken) { } + public override int Read(System.Span buffer) { } + public override int Read(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask ReadAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override int ReadByte() { } + public override long Seek(long offset, System.IO.SeekOrigin origin) { } + public override void SetLength(long value) { } + public override string? ToString() { } + public override void Write(System.ReadOnlySpan buffer) { } + public override void Write(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task WriteAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override void WriteByte(byte value) { } + } +} \ No newline at end of file diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net9.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net9.0.txt new file mode 100644 index 000000000..8566cbeba --- /dev/null +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_net9.0.txt @@ -0,0 +1,124 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/Testably/Testably.Abstractions.git")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(@"Testably.Abstractions.MemoryMappedFiles.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001006104741100251820044d92b34b0519a1de0bccd80d6199aadbdcd5931d035462d42f70b0ae7a7db37bab63afb8a8ad0dc21392bb01f1243bfc51df4b5f1975b1b9746fecbed88913b783fccb69efc59e23b0e019e065abd38731711a2d6ac2569ab57d4b4d529f5903f5bee0f4388b2a5f4d5e0fddab6aac18d96aa78c2e73e0")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v9.0", FrameworkDisplayName=".NET 9.0")] +namespace Testably.Abstractions +{ + public static class FileSystemExtensions + { + extension(System.IO.Abstractions.IFileSystem fileSystem) + { + public Testably.Abstractions.IMemoryMappedFileFactory MemoryMappedFile { get; } + } + } + public interface IMemoryMappedFile : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + } + public interface IMemoryMappedFileFactory : System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(System.IO.Abstractions.FileSystemStream fileStream, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.HandleInheritability inheritability, bool leaveOpen); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights, System.IO.HandleInheritability inheritability); + } + public interface IMemoryMappedViewAccessor : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + bool CanRead { get; } + bool CanWrite { get; } + long Capacity { get; } + long PointerOffset { get; } + void Flush(); + void Read(long position, out T structure) + where T : struct; + int ReadArray(long position, T[] array, int offset, int count) + where T : struct; + bool ReadBoolean(long position); + byte ReadByte(long position); + char ReadChar(long position); + decimal ReadDecimal(long position); + double ReadDouble(long position); + short ReadInt16(long position); + int ReadInt32(long position); + long ReadInt64(long position); + sbyte ReadSByte(long position); + float ReadSingle(long position); + ushort ReadUInt16(long position); + uint ReadUInt32(long position); + ulong ReadUInt64(long position); + void Write(long position, bool value); + void Write(long position, byte value); + void Write(long position, char value); + void Write(long position, decimal value); + void Write(long position, double value); + void Write(long position, float value); + void Write(long position, int value); + void Write(long position, long value); + void Write(long position, sbyte value); + void Write(long position, short value); + void Write(long position, uint value); + void Write(long position, ulong value); + void Write(long position, ushort value); + void Write(long position, ref T structure) + where T : struct; + void WriteArray(long position, T[] array, int offset, int count) + where T : struct; + } + public abstract class MemoryMappedFileSystemViewStream : System.IO.UnmanagedMemoryStream + { + protected MemoryMappedFileSystemViewStream(System.IO.Stream stream) { } + public override bool CanRead { get; } + public override bool CanSeek { get; } + public override bool CanTimeout { get; } + public override bool CanWrite { get; } + public virtual long Capacity { get; } + public override long Length { get; } + public abstract long PointerOffset { get; } + public override long Position { get; set; } + public override int ReadTimeout { get; set; } + public override int WriteTimeout { get; set; } + public override System.IAsyncResult BeginRead(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override System.IAsyncResult BeginWrite(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override void Close() { } + public override System.Threading.Tasks.Task CopyToAsync(System.IO.Stream destination, int bufferSize, System.Threading.CancellationToken cancellationToken) { } + protected override void Dispose(bool disposing) { } + public override int EndRead(System.IAsyncResult asyncResult) { } + public override void EndWrite(System.IAsyncResult asyncResult) { } + public override void Flush() { } + public override System.Threading.Tasks.Task FlushAsync(System.Threading.CancellationToken cancellationToken) { } + public override int Read(System.Span buffer) { } + public override int Read(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask ReadAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override int ReadByte() { } + public override long Seek(long offset, System.IO.SeekOrigin origin) { } + public override void SetLength(long value) { } + public override string? ToString() { } + public override void Write(System.ReadOnlySpan buffer) { } + public override void Write(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task WriteAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override void WriteByte(byte value) { } + } +} \ No newline at end of file diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_netstandard2.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_netstandard2.0.txt new file mode 100644 index 000000000..0f66d946c --- /dev/null +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_netstandard2.0.txt @@ -0,0 +1,114 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/Testably/Testably.Abstractions.git")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(@"Testably.Abstractions.MemoryMappedFiles.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001006104741100251820044d92b34b0519a1de0bccd80d6199aadbdcd5931d035462d42f70b0ae7a7db37bab63afb8a8ad0dc21392bb01f1243bfc51df4b5f1975b1b9746fecbed88913b783fccb69efc59e23b0e019e065abd38731711a2d6ac2569ab57d4b4d529f5903f5bee0f4388b2a5f4d5e0fddab6aac18d96aa78c2e73e0")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")] +namespace Testably.Abstractions +{ + public static class FileSystemExtensions + { + extension(System.IO.Abstractions.IFileSystem fileSystem) + { + public Testably.Abstractions.IMemoryMappedFileFactory MemoryMappedFile { get; } + } + } + public interface IMemoryMappedFile : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + } + public interface IMemoryMappedFileFactory : System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(System.IO.Abstractions.FileSystemStream fileStream, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.HandleInheritability inheritability, bool leaveOpen); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName); + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights); + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights, System.IO.HandleInheritability inheritability); + } + public interface IMemoryMappedViewAccessor : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + bool CanRead { get; } + bool CanWrite { get; } + long Capacity { get; } + long PointerOffset { get; } + void Flush(); + void Read(long position, out T structure) + where T : struct; + int ReadArray(long position, T[] array, int offset, int count) + where T : struct; + bool ReadBoolean(long position); + byte ReadByte(long position); + char ReadChar(long position); + decimal ReadDecimal(long position); + double ReadDouble(long position); + short ReadInt16(long position); + int ReadInt32(long position); + long ReadInt64(long position); + sbyte ReadSByte(long position); + float ReadSingle(long position); + ushort ReadUInt16(long position); + uint ReadUInt32(long position); + ulong ReadUInt64(long position); + void Write(long position, bool value); + void Write(long position, byte value); + void Write(long position, char value); + void Write(long position, decimal value); + void Write(long position, double value); + void Write(long position, float value); + void Write(long position, int value); + void Write(long position, long value); + void Write(long position, sbyte value); + void Write(long position, short value); + void Write(long position, uint value); + void Write(long position, ulong value); + void Write(long position, ushort value); + void Write(long position, ref T structure) + where T : struct; + void WriteArray(long position, T[] array, int offset, int count) + where T : struct; + } + public abstract class MemoryMappedFileSystemViewStream : System.IO.UnmanagedMemoryStream + { + protected MemoryMappedFileSystemViewStream(System.IO.Stream stream) { } + public override bool CanRead { get; } + public override bool CanSeek { get; } + public override bool CanTimeout { get; } + public override bool CanWrite { get; } + public virtual long Capacity { get; } + public override long Length { get; } + public abstract long PointerOffset { get; } + public override long Position { get; set; } + public override int ReadTimeout { get; set; } + public override int WriteTimeout { get; set; } + public override System.IAsyncResult BeginRead(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override System.IAsyncResult BeginWrite(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override void Close() { } + public override System.Threading.Tasks.Task CopyToAsync(System.IO.Stream destination, int bufferSize, System.Threading.CancellationToken cancellationToken) { } + protected override void Dispose(bool disposing) { } + public override int EndRead(System.IAsyncResult asyncResult) { } + public override void EndWrite(System.IAsyncResult asyncResult) { } + public override void Flush() { } + public override System.Threading.Tasks.Task FlushAsync(System.Threading.CancellationToken cancellationToken) { } + public override int Read(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.Task ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override int ReadByte() { } + public override long Seek(long offset, System.IO.SeekOrigin origin) { } + public override void SetLength(long value) { } + public override string? ToString() { } + public override void Write(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.Task WriteAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override void WriteByte(byte value) { } + } +} \ No newline at end of file diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_netstandard2.1.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_netstandard2.1.txt new file mode 100644 index 000000000..64df10a70 --- /dev/null +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.MemoryMappedFiles_netstandard2.1.txt @@ -0,0 +1,118 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/Testably/Testably.Abstractions.git")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(@"Testably.Abstractions.MemoryMappedFiles.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001006104741100251820044d92b34b0519a1de0bccd80d6199aadbdcd5931d035462d42f70b0ae7a7db37bab63afb8a8ad0dc21392bb01f1243bfc51df4b5f1975b1b9746fecbed88913b783fccb69efc59e23b0e019e065abd38731711a2d6ac2569ab57d4b4d529f5903f5bee0f4388b2a5f4d5e0fddab6aac18d96aa78c2e73e0")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName=".NET Standard 2.1")] +namespace Testably.Abstractions +{ + public static class FileSystemExtensions + { + extension(System.IO.Abstractions.IFileSystem fileSystem) + { + public Testably.Abstractions.IMemoryMappedFileFactory MemoryMappedFile { get; } + } + } + public interface IMemoryMappedFile : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size); + Testably.Abstractions.IMemoryMappedViewAccessor CreateViewAccessor(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size); + Testably.Abstractions.MemoryMappedFileSystemViewStream CreateViewStream(long offset, long size, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + } + public interface IMemoryMappedFileFactory : System.IO.Abstractions.IFileSystemEntity + { + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(string path, System.IO.FileMode mode, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateFromFile(System.IO.Abstractions.FileSystemStream fileStream, string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.HandleInheritability inheritability, bool leaveOpen); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateNew(string? mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity); + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access); + Testably.Abstractions.IMemoryMappedFile CreateOrOpen(string mapName, long capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess access, System.IO.MemoryMappedFiles.MemoryMappedFileOptions options, System.IO.HandleInheritability inheritability); + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName); + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights); + Testably.Abstractions.IMemoryMappedFile OpenExisting(string mapName, System.IO.MemoryMappedFiles.MemoryMappedFileRights desiredAccessRights, System.IO.HandleInheritability inheritability); + } + public interface IMemoryMappedViewAccessor : System.IDisposable, System.IO.Abstractions.IFileSystemEntity + { + bool CanRead { get; } + bool CanWrite { get; } + long Capacity { get; } + long PointerOffset { get; } + void Flush(); + void Read(long position, out T structure) + where T : struct; + int ReadArray(long position, T[] array, int offset, int count) + where T : struct; + bool ReadBoolean(long position); + byte ReadByte(long position); + char ReadChar(long position); + decimal ReadDecimal(long position); + double ReadDouble(long position); + short ReadInt16(long position); + int ReadInt32(long position); + long ReadInt64(long position); + sbyte ReadSByte(long position); + float ReadSingle(long position); + ushort ReadUInt16(long position); + uint ReadUInt32(long position); + ulong ReadUInt64(long position); + void Write(long position, bool value); + void Write(long position, byte value); + void Write(long position, char value); + void Write(long position, decimal value); + void Write(long position, double value); + void Write(long position, float value); + void Write(long position, int value); + void Write(long position, long value); + void Write(long position, sbyte value); + void Write(long position, short value); + void Write(long position, uint value); + void Write(long position, ulong value); + void Write(long position, ushort value); + void Write(long position, ref T structure) + where T : struct; + void WriteArray(long position, T[] array, int offset, int count) + where T : struct; + } + public abstract class MemoryMappedFileSystemViewStream : System.IO.UnmanagedMemoryStream + { + protected MemoryMappedFileSystemViewStream(System.IO.Stream stream) { } + public override bool CanRead { get; } + public override bool CanSeek { get; } + public override bool CanTimeout { get; } + public override bool CanWrite { get; } + public virtual long Capacity { get; } + public override long Length { get; } + public abstract long PointerOffset { get; } + public override long Position { get; set; } + public override int ReadTimeout { get; set; } + public override int WriteTimeout { get; set; } + public override System.IAsyncResult BeginRead(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override System.IAsyncResult BeginWrite(byte[] buffer, int offset, int count, System.AsyncCallback? callback, object? state) { } + public override void Close() { } + public override System.Threading.Tasks.Task CopyToAsync(System.IO.Stream destination, int bufferSize, System.Threading.CancellationToken cancellationToken) { } + protected override void Dispose(bool disposing) { } + public override int EndRead(System.IAsyncResult asyncResult) { } + public override void EndWrite(System.IAsyncResult asyncResult) { } + public override void Flush() { } + public override System.Threading.Tasks.Task FlushAsync(System.Threading.CancellationToken cancellationToken) { } + public override int Read(System.Span buffer) { } + public override int Read(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask ReadAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override int ReadByte() { } + public override long Seek(long offset, System.IO.SeekOrigin origin) { } + public override void SetLength(long value) { } + public override string? ToString() { } + public override void Write(System.ReadOnlySpan buffer) { } + public override void Write(byte[] buffer, int offset, int count) { } + public override System.Threading.Tasks.ValueTask WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken = default) { } + public override System.Threading.Tasks.Task WriteAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) { } + public override void WriteByte(byte value) { } + } +} \ No newline at end of file diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Testably.Abstractions.Api.Tests.csproj b/Tests/Api/Testably.Abstractions.Api.Tests/Testably.Abstractions.Api.Tests.csproj index e411216d2..14a24081d 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Testably.Abstractions.Api.Tests.csproj +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Testably.Abstractions.Api.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/CopyOnWriteTests.cs b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/CopyOnWriteTests.cs new file mode 100644 index 000000000..36f21b3f7 --- /dev/null +++ b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/CopyOnWriteTests.cs @@ -0,0 +1,180 @@ +using System.IO; +using System.IO.MemoryMappedFiles; +using Skip = Testably.Abstractions.TestHelpers.Skip; + +namespace Testably.Abstractions.MemoryMappedFiles.Tests.MemoryMappedFile; + +[FileSystemTests] +public class CopyOnWriteTests(FileSystemTestData testData) + : FileSystemTestBase(testData) +{ + [Test] + public async Task CopyOnWriteMapping_CopyOnWriteView_ShouldBeAllowed() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.CopyOnWrite); + using IMemoryMappedViewAccessor accessor = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.CopyOnWrite); + + accessor.Write(0, 42); + + await That(accessor.ReadInt32(0)).IsEqualTo(42); + } + + [Test] + public async Task + CopyOnWriteMapping_WithCapacityLargerThanFile_ShouldThrowIOException_AndNotGrowFile() + { + Skip.IfNot(FileSystem is MockFileSystem || Test.RunsOnWindows, + "The behavior of a copy-on-write mapping with a capacity larger than the file size is platform-specific; the mock mirrors Windows."); + + FileSystem.File.WriteAllBytes("data.bin", new byte[10]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 100, MemoryMappedFileAccess.CopyOnWrite); + } + + await That(Act).Throws(); + await That(FileSystem.File.ReadAllBytes("data.bin").Length).IsEqualTo(10) + .Because("a copy-on-write mapping must never modify the underlying file"); + } + + [Test] + public async Task CopyOnWriteView_AfterOwnWrite_ShouldNotSeeWritesToThePrivatizedPage() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor copyOnWrite = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.CopyOnWrite); + using IMemoryMappedViewAccessor readWrite = mappedFile.CreateViewAccessor(0, 100); + + copyOnWrite.Write(50, 42); + readWrite.Write(52, 777); + + await That(copyOnWrite.ReadInt32(52)).IsEqualTo(0) + .Because( + "the copy-on-write view privatized the whole page when it wrote at offset 50, freezing it against later writes from other views"); + } + + [Test] + public async Task CopyOnWriteView_BeforeOwnWrite_ShouldSeeWritesFromOtherViews() + { + Skip.If(FileSystem is not MockFileSystem && Test.RunsOnMac, + "POSIX leaves it unspecified whether a copy-on-write view sees later writes made through other views; macOS snapshots the pages eagerly, while Windows and Linux privatize them lazily. The mock mirrors Windows."); + + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor copyOnWrite = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.CopyOnWrite); + using IMemoryMappedViewAccessor readWrite = mappedFile.CreateViewAccessor(0, 100); + + readWrite.Write(0, 1234567); + + await That(copyOnWrite.ReadInt32(0)).IsEqualTo(1234567) + .Because( + "pages the copy-on-write view has not written are only privatized lazily, so they keep reflecting writes made through other views"); + } + + [Test] + public async Task CopyOnWriteMapping_OverReadOnlyStream_ShouldBeSupported() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using FileSystemStream stream = + FileSystem.FileStream.New("data.bin", FileMode.Open, FileAccess.Read); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.CopyOnWrite, HandleInheritability.None, + leaveOpen: true); + using IMemoryMappedViewAccessor accessor = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.CopyOnWrite); + + accessor.Write(0, 42); + + await That(accessor.ReadInt32(0)).IsEqualTo(42) + .Because("a copy-on-write mapping never writes to the file, so it only needs read access to it"); + } + + [Test] + public async Task CopyOnWriteMapping_WritableView_ShouldThrowUnauthorizedAccessException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.CopyOnWrite); + + void Act() => mappedFile.CreateViewAccessor(); + + await That(Act).Throws() + .Because("a copy-on-write mapping only permits Read or CopyOnWrite views"); + } + + [Test] + public async Task CopyOnWrite_ShouldBeWritable_ButNotPersistToUnderlyingFile() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + using (IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin")) + { + using IMemoryMappedViewAccessor accessor = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.CopyOnWrite); + + await That(accessor.CanWrite).IsTrue(); + + accessor.Write(13, 1234567); + + // The write is visible through the same (copy-on-write) view ... + await That(accessor.ReadInt32(13)).IsEqualTo(1234567); + } + + // ... but is never persisted to the underlying file. + byte[] bytes = FileSystem.File.ReadAllBytes("data.bin"); + await That(BitConverter.ToInt32(bytes, 13)).IsEqualTo(0); + } + + [Test] + public async Task CopyOnWriteView_AfterBackingStreamWasTruncated_ShouldReadZerosFromSharedPages() + { + Skip.IfNot(FileSystem is MockFileSystem, + "The operating system rejects truncating a file with a user-mapped section."); + + // Two pages of 4096 bytes; the copy-on-write view only privatizes the first one. + FileSystem.File.WriteAllBytes("data.bin", new byte[8192]); + using FileSystemStream stream = FileSystem.FileStream.New( + "data.bin", FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: true); + using IMemoryMappedViewAccessor accessor = + mappedFile.CreateViewAccessor(0, 8192, MemoryMappedFileAccess.CopyOnWrite); + + accessor.Write(0, 42); + stream.SetLength(4096); + byte[] buffer = new byte[8]; + int read = accessor.ReadArray(8000, buffer, 0, 8); + + await That(read).IsEqualTo(8) + .Because("the view spans the full capacity, which does not shrink with the file"); + await That(buffer).IsEqualTo(new byte[8]) + .Because("reads of the truncated range on a non-privatized page return zeros"); + } + + [Test] + public async Task CopyOnWrite_ShouldNotBeVisibleToOtherViews() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor copyOnWrite = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.CopyOnWrite); + using IMemoryMappedViewAccessor readWrite = + mappedFile.CreateViewAccessor(0, 100); + + copyOnWrite.Write(0, 1234567); + + await That(readWrite.ReadInt32(0)).IsEqualTo(0); + } +} diff --git a/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/CreateFromFileTests.cs b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/CreateFromFileTests.cs new file mode 100644 index 000000000..359734f03 --- /dev/null +++ b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/CreateFromFileTests.cs @@ -0,0 +1,605 @@ +using System.IO; +using System.IO.MemoryMappedFiles; +using Skip = Testably.Abstractions.TestHelpers.Skip; + +namespace Testably.Abstractions.MemoryMappedFiles.Tests.MemoryMappedFile; + +[FileSystemTests] +public class CreateFromFileTests(FileSystemTestData testData) + : FileSystemTestBase(testData) +{ + [Test] + public async Task CreateFromFile_OnEmptyFile_WithoutCapacity_ShouldThrowArgumentException() + { + FileSystem.File.WriteAllBytes("empty.bin", []); + + void Act() + { + using IMemoryMappedFile _ = + FileSystem.MemoryMappedFile.CreateFromFile("empty.bin"); + } + + await That(Act).Throws(); + } + + [Test] + public async Task + CreateFromFile_WithCapacitySmallerThanFile_ShouldThrowArgumentOutOfRangeException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 50, MemoryMappedFileAccess.ReadWrite); + } + + await That(Act).Throws() + .WithParamName("capacity"); + } + + [Test] + public async Task + CreateFromFile_WithNegativeCapacity_AndCreateMode_ShouldNotTruncateExistingFile() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Create, null, -1, MemoryMappedFileAccess.ReadWrite); + } + + await That(Act).Throws() + .WithParamName("capacity"); + await That(FileSystem.File.ReadAllBytes("data.bin").Length).IsEqualTo(100) + .Because("the capacity is validated before the file is opened, so the FileMode.Create must not truncate it"); + } + + [Test] + public async Task + CreateFromFile_WithReadOnlyStream_AndReadWriteAccess_ShouldThrowUnauthorizedAccessException() + { + Skip.IfNot(FileSystem is MockFileSystem || Test.RunsOnWindows, + "Mapping a read-write view over a read-only stream is only rejected on Windows; the mock mirrors Windows."); + + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using FileSystemStream stream = + FileSystem.FileStream.New("data.bin", FileMode.Open, FileAccess.Read); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: true); + } + + await That(Act).Throws() + .Because("a read-write mapping requires a writable stream"); + } + + [Test] + public async Task + CreateFromFile_WithInvalidInheritability_ShouldThrowArgumentOutOfRangeException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using FileSystemStream stream = + FileSystem.FileStream.New("data.bin", FileMode.Open, FileAccess.ReadWrite); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, (HandleInheritability)5, + leaveOpen: true); + } + + await That(Act).Throws() + .WithParamName("inheritability"); + } + + [Test] + public async Task CreateFromFile_WithNullFileStream_ShouldThrowArgumentNullException() + { + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + null!, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: true); + } + + await That(Act).Throws() + .WithParamName("fileStream"); + } + + [Test] + public async Task CreateViewAccessor_AfterDispose_ShouldThrowObjectDisposedException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + mappedFile.Dispose(); + + void Act() => mappedFile.CreateViewAccessor(); + + await That(Act).Throws(); + } + + [Test] + public async Task CreateViewStream_AfterDispose_ShouldThrowObjectDisposedException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + mappedFile.Dispose(); + + void Act() => mappedFile.CreateViewStream(); + + await That(Act).Throws(); + } + + [Test] + public async Task + CreateViewAccessor_WithInvalidAccess_ShouldThrowArgumentOutOfRangeException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + + void Act() => mappedFile.CreateViewAccessor(0, 0, (MemoryMappedFileAccess)42); + + await That(Act).Throws() + .WithParamName("access"); + } + + [Test] + public async Task CreateFromFile_WithFileSystemStream_AndLeaveOpen_ShouldKeepStreamOpen() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using FileSystemStream stream = + FileSystem.FileStream.New("data.bin", FileMode.Open, FileAccess.ReadWrite); + + using (IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: true)) + { + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Write(0, 12345); + } + + await That(stream.CanRead).IsTrue() + .Because("the stream is still usable after disposing the memory-mapped file"); + stream.Position = 0; + } + + [Test] + public async Task CreateFromFile_WithFileSystemStream_AndNotLeaveOpen_ShouldDisposeStream() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + FileSystemStream stream = + FileSystem.FileStream.New("data.bin", FileMode.Open, FileAccess.ReadWrite); + + using (IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: false)) + { + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Write(0, 12345); + } + + void Act() => stream.Position = 0; + + await That(Act).Throws(); + } + + [Test] + public async Task CreateFromFile_WithLargerCapacity_ShouldGrowFile() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[10]); + + using (IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 100, MemoryMappedFileAccess.ReadWrite)) + { + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Write(50, 4242); + } + + await That(FileSystem.File.ReadAllBytes("data.bin").Length).IsEqualTo(100); + } + + [Test] + public async Task CreateFromFile_WithLargerCapacity_WithoutViewWrites_ShouldGrowFile() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[10]); + + using (IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 100, MemoryMappedFileAccess.ReadWrite)) + { + // The file is grown to the capacity even when no view ever writes. + } + + await That(FileSystem.File.ReadAllBytes("data.bin").Length).IsEqualTo(100); + } + + [Test] + public async Task CreateFromFile_WithLargerCapacity_ShouldGrowFileImmediately() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[10]); + + using (IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 100, MemoryMappedFileAccess.ReadWrite)) + { + await That(FileSystem.FileInfo.New("data.bin").Length).IsEqualTo(100) + .Because("the BCL grows the file when the mapping is created, not when it is disposed"); + } + } + + [Test] + public async Task DisposeViews_AfterCallerOwnedStreamWasDisposed_ShouldNotThrow() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + FileSystemStream stream = + FileSystem.FileStream.New("data.bin", FileMode.Open, FileAccess.ReadWrite); + IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: true); + IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + MemoryMappedFileSystemViewStream viewStream = mappedFile.CreateViewStream(); + stream.Dispose(); + + void Act() + { + accessor.Dispose(); + viewStream.Dispose(); + mappedFile.Dispose(); + } + + await That(Act).DoesNotThrow() + .Because("disposing the views must stay safe after the caller disposed its own stream"); + } + + [Test] + public async Task CreateFromFile_WithNegativeCapacity_ShouldThrowArgumentOutOfRangeException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, -1, MemoryMappedFileAccess.ReadWrite); + } + + await That(Act).Throws() + .WithParamName("capacity"); + } + + [Test] + public async Task + CreateFromFile_WithReadAccess_AndCapacityLargerThanFile_ShouldThrowArgumentException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 200, MemoryMappedFileAccess.Read); + } + + await That(Act).Throws(); + } + + [Test] + public async Task + CreateFromFile_WithReadExecuteAccess_AndCapacityLargerThanFile_ShouldThrowUnauthorizedAccessException() + { + Skip.IfNot(FileSystem is MockFileSystem || Test.RunsOnWindows, + "Growing a file through a read-only handle is rejected with an UnauthorizedAccessException only on Windows; the mock mirrors Windows."); + + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 200, MemoryMappedFileAccess.ReadExecute); + } + + await That(Act).Throws() + .Because("only MemoryMappedFileAccess.Read is special-cased with an ArgumentException by the BCL"); + } + + [Test] + public async Task + CreateViewAccessor_WithExecuteAccess_OnMappingWithoutExecuteAccess_ShouldThrowUnauthorizedAccessException() + { + Skip.IfNot(FileSystem is MockFileSystem || Test.RunsOnWindows, + "An execute view on a mapping without execute access is only rejected on Windows; the mock mirrors Windows."); + + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.ReadWrite); + + void Act() + { + using IMemoryMappedViewAccessor _ = mappedFile.CreateViewAccessor( + 0, 10, MemoryMappedFileAccess.ReadExecute); + } + + await That(Act).Throws() + .Because("the mapping was created without execute page protection"); + } + + [Test] + public async Task CreateFromFile_WhenCreationFails_ShouldDeleteCreatedFile() + { + void Act() + { + using IMemoryMappedFile _ = + FileSystem.MemoryMappedFile.CreateFromFile("new.bin", FileMode.CreateNew); + } + + await That(Act).Throws() + .Because("a memory-mapped file over an empty file requires a positive capacity"); + await That(FileSystem.File.Exists("new.bin")).IsFalse() + .Because("the file created by the failed call is deleted again, matching the BCL"); + } + + [Test] + public async Task CreateFromFile_WithAppendMode_ShouldThrowArgumentException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin", FileMode.Append); + } + + await That(Act).Throws() + .WithParamName("mode"); + } + + [Test] + public async Task CreateFromFile_WithEmptyMapName_ShouldThrowArgumentException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, ""); + } + + await That(Act).Throws(); + } + + [Test] + public async Task + CreateFromFile_WithFileSystemStream_AndLeaveOpen_ShouldNotChangeStreamPosition() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using FileSystemStream stream = + FileSystem.FileStream.New("data.bin", FileMode.Open, FileAccess.ReadWrite); + stream.Position = 7; + + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: true); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Write(50, 1234567); + _ = accessor.ReadInt32(50); + + await That(stream.Position).IsEqualTo(7L) + .Because("view operations must not move the position of the caller-owned stream"); + } + + [Test] + public async Task CreateFromFile_WithMapName_OnMockFileSystem_ShouldThrowNotSupportedException() + { + Skip.IfNot(FileSystem is MockFileSystem); + + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, "myMap"); + } + + await That(Act).Throws() + .Because("named mappings are operating-system shared memory, which the mock cannot honor"); + } + + [Test] + public async Task CreateFromFile_WithTruncateMode_ShouldThrowArgumentException_AndKeepFileContent() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Truncate, null, 100, MemoryMappedFileAccess.ReadWrite); + } + + await That(Act).Throws() + .WithParamName("mode"); + await That(FileSystem.File.ReadAllBytes("data.bin").Length).IsEqualTo(100) + .Because("the file must not be touched when the mode is rejected"); + } + + [Test] + public async Task CreateFromFile_WithWriteAccess_ShouldThrowArgumentException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.Write); + } + + await That(Act).Throws() + .WithParamName("access"); + } + + [Test] + public async Task CreateFromFile_WrittenData_ShouldPersistToFile() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + using (IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin")) + { + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Write(0, (byte)42); + accessor.Write(1, (byte)43); + } + + byte[] bytes = FileSystem.File.ReadAllBytes("data.bin"); + await That(bytes[0]).IsEqualTo(42); + await That(bytes[1]).IsEqualTo(43); + } + + [Test] + public async Task CreateViewAccessor_BeyondFileCapacity_ShouldThrowUnauthorizedAccessException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + + void Act() => mappedFile.CreateViewAccessor(50, 100); + + await That(Act).Throws(); + } + + [Test] + public async Task CreateViewAccessor_WithNegativeOffset_ShouldThrowArgumentOutOfRangeException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + + void Act() => mappedFile.CreateViewAccessor(-1, 10); + + await That(Act).Throws() + .WithParamName("offset"); + } + + [Test] + public async Task CreateViewAccessor_WithNegativeSize_ShouldThrowArgumentOutOfRangeException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + + void Act() => mappedFile.CreateViewAccessor(0, -1); + + await That(Act).Throws() + .WithParamName("size"); + } + + [Test] + public async Task + CreateViewAccessor_WithOffsetBeyondCapacity_ShouldThrowUnauthorizedAccessException() + { + Skip.IfNot(FileSystem is MockFileSystem || Test.RunsOnWindows, + "An offset beyond the capacity is rejected with an UnauthorizedAccessException only on Windows; the mock mirrors Windows."); + + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + + void Act() => mappedFile.CreateViewAccessor(long.MaxValue, 0); + + await That(Act).Throws(); + } + + [Test] + public async Task CreateViewAccessor_WithSizeThatWouldOverflow_ShouldThrowIOException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + + void Act() => mappedFile.CreateViewAccessor(1, long.MaxValue); + + await That(Act).Throws() + .Because( + "`offset + size` would overflow `long`, so the operating system can never reserve the view"); + } + + [Test] + public async Task CreateFromFile_WithPath_ShouldAllowConcurrentReaders() + { + Skip.If(Test.IsNetFramework, + "The BCL of the .NET Framework opens the backing file with FileShare.None."); + + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + + void Act() => FileSystem.File + .Open("data.bin", FileMode.Open, FileAccess.Read, FileShare.ReadWrite) + .Dispose(); + + await That(Act).DoesNotThrow() + .Because( + "the BCL opens the backing file of a path-based memory-mapped file with FileShare.Read"); + } + + [Test] + public async Task CreateFromFile_WithPath_ShouldNotAllowConcurrentWriters() + { + Skip.IfNot(Test.RunsOnWindows, + "File sharing is only enforced on Windows; on Unix the FileShare.Read of the backing stream is not honored."); + + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + + void Act() => FileSystem.File + .Open("data.bin", FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite) + .Dispose(); + + await That(Act).Throws() + .Because( + "the backing file of a path-based memory-mapped file is opened without write sharing"); + } + + [Test] + public async Task + CreateFromFile_WithCapacityGreaterThan2GB_OnMockFileSystem_ShouldThrowNotSupportedException() + { + Skip.IfNot(FileSystem is MockFileSystem, + "The 2 GB limit only applies to the in-memory content of the MockFileSystem."); + + void Act() + { + using IMemoryMappedFile _ = FileSystem.MemoryMappedFile.CreateFromFile( + "huge.bin", FileMode.CreateNew, null, 3L * 1024 * 1024 * 1024, + MemoryMappedFileAccess.ReadWrite); + } + + await That(Act).Throws(); + await That(FileSystem.File.Exists("huge.bin")).IsFalse() + .Because("the file only created by this call is deleted when the creation fails"); + } + + [Test] + public async Task View_ShouldRemainUsable_AfterMappedFileIsDisposed() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + IMemoryMappedViewAccessor accessor; + using (IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin")) + { + accessor = mappedFile.CreateViewAccessor(); + accessor.Write(0, 12345); + } + + await That(accessor.ReadInt32(0)).IsEqualTo(12345) + .Because( + "The memory-mapped file is disposed, but the still-open view keeps working (the file and its views have independent lifetimes)"); + accessor.Write(4, 67890); + await That(accessor.ReadInt32(4)).IsEqualTo(67890); + accessor.Dispose(); + } +} diff --git a/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/MultipleViewsTests.cs b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/MultipleViewsTests.cs new file mode 100644 index 000000000..0515fdd4f --- /dev/null +++ b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/MultipleViewsTests.cs @@ -0,0 +1,56 @@ +using System.IO; + +namespace Testably.Abstractions.MemoryMappedFiles.Tests.MemoryMappedFile; + +[FileSystemTests] +public class MultipleViewsTests(FileSystemTestData testData) + : FileSystemTestBase(testData) +{ + [Test] + public async Task ViewsAtDifferentOffsets_ShouldNotInterfere() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor first = mappedFile.CreateViewAccessor(0, 40); + using IMemoryMappedViewAccessor second = mappedFile.CreateViewAccessor(40, 40); + + first.Write(0, 111); + second.Write(0, 222); + + await That(first.ReadInt32(0)).IsEqualTo(111); + await That(second.ReadInt32(0)).IsEqualTo(222); + } + + [Test] + public async Task ViewsOverSameRegion_ShouldShareData() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor writer = mappedFile.CreateViewAccessor(); + using IMemoryMappedViewAccessor reader = mappedFile.CreateViewAccessor(); + + writer.Write(0, 987654); + + await That(reader.ReadInt32(0)).IsEqualTo(987654); + } + + [Test] + public async Task ViewStreamAndAccessor_ShouldShareData() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + using (Stream stream = mappedFile.CreateViewStream(0, 50)) + { + stream.Write([1, 2, 3, 4,], 0, 4); + stream.Flush(); + } + + await That(accessor.ReadByte(0)).IsEqualTo(1); + await That(accessor.ReadByte(3)).IsEqualTo(4); + } +} diff --git a/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/Tests.cs b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/Tests.cs new file mode 100644 index 000000000..e5a5a9446 --- /dev/null +++ b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedFile/Tests.cs @@ -0,0 +1,158 @@ +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Runtime.InteropServices; +using Skip = Testably.Abstractions.TestHelpers.Skip; + +namespace Testably.Abstractions.MemoryMappedFiles.Tests.MemoryMappedFile; + +[FileSystemTests] +public class Tests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + public async Task CreateFromFile_CreateViewAccessor_ShouldRoundtripGenericStruct() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + Point value = new() + { + X = 3, + Y = 7, + }; + + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(16, ref value); + accessor.Read(16, out Point result); + + await That(result.X).IsEqualTo(3); + await That(result.Y).IsEqualTo(7); + } + + [Test] + public async Task CreateFromFile_CreateViewAccessor_ShouldRoundtripPrimitive() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(8, 1234567); + + await That(accessor.ReadInt32(8)).IsEqualTo(1234567); + } + + [Test] + public async Task CreateFromFile_CreateViewStream_ShouldRoundtrip() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + byte[] payload = [1, 2, 3, 4, 5,]; + + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + + using (Stream writeStream = mappedFile.CreateViewStream()) + { + writeStream.Write(payload, 0, payload.Length); + writeStream.Flush(); + } + + byte[] result = new byte[payload.Length]; + using (Stream readStream = mappedFile.CreateViewStream()) + { + int read = 0; + while (read < result.Length) + { + int r = readStream.Read(result, read, result.Length - read); + if (r == 0) + { + break; + } + + read += r; + } + } + + await That(result).IsEqualTo(payload); + } + + [Test] + public async Task CreateFromFile_WithFileSystemStream_ShouldRoundtrip() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + + using FileSystemStream stream = + FileSystem.FileStream.New("data.bin", FileMode.Open, FileAccess.ReadWrite); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: true); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(0, 98765); + + await That(accessor.ReadInt32(0)).IsEqualTo(98765); + } + + [Test] + public async Task CreateNewAndOpenExisting_OnRealFileSystem_ShouldRoundtrip() + { + Skip.If(FileSystem is MockFileSystem); + Skip.IfNot(Test.RunsOnWindows); + + string mapName = Guid.NewGuid().ToString("N"); + + using IMemoryMappedFile created = + FileSystem.MemoryMappedFile.CreateNew(mapName, 1024); + using (IMemoryMappedViewAccessor writeAccessor = created.CreateViewAccessor()) + { + writeAccessor.Write(0, 4242); + } + + #pragma warning disable CA1416 + using IMemoryMappedFile opened = + FileSystem.MemoryMappedFile.OpenExisting(mapName); + #pragma warning restore CA1416 + using IMemoryMappedViewAccessor readAccessor = opened.CreateViewAccessor(); + + await That(readAccessor.ReadInt32(0)).IsEqualTo(4242); + } + + [Test] + public async Task FilelessFactoryMethods_OnMockFileSystem_ShouldThrowNotSupported() + { + Skip.IfNot(FileSystem is MockFileSystem); + + string mapName = Guid.NewGuid().ToString("N"); + + void CreateNew() + => FileSystem.MemoryMappedFile.CreateNew(mapName, 1024); + + #pragma warning disable CA1416 + void CreateOrOpen() + => FileSystem.MemoryMappedFile.CreateOrOpen(mapName, 1024); + + void OpenExisting() + => FileSystem.MemoryMappedFile.OpenExisting(mapName); + #pragma warning restore CA1416 + + await That(CreateNew).Throws(); + await That(CreateOrOpen).Throws(); + await That(OpenExisting).Throws(); + } + + [Test] + public async Task FileSystemExtension_ShouldBeSet() + { + IMemoryMappedFileFactory result = FileSystem.MemoryMappedFile; + + await That(result.FileSystem).IsSameAs(FileSystem); + } + + [StructLayout(LayoutKind.Sequential)] + private record struct Point + { + public int X; + public int Y; + } +} diff --git a/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedViewAccessor/Tests.cs b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedViewAccessor/Tests.cs new file mode 100644 index 000000000..511abcf87 --- /dev/null +++ b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedViewAccessor/Tests.cs @@ -0,0 +1,783 @@ +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Runtime.InteropServices; + +namespace Testably.Abstractions.MemoryMappedFiles.Tests.MemoryMappedViewAccessor; + +[FileSystemTests] +public class Tests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + public async Task Capacity_WithExplicitSize_ShouldMatchRequestedSize() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(10, 20); + + await That(accessor.Capacity).IsEqualTo(20L); + } + + [Test] + public async Task DefaultAccessor_OnReadOnlyMapping_ShouldThrowUnauthorizedAccessException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + + void Act() => mappedFile.CreateViewAccessor(); + + await That(Act).Throws() + .Because("the default view access is `ReadWrite`, which the read-only mapping does not permit"); + } + + [Test] + public async Task DefaultAccessor_ShouldSupportReadAndWrite() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + await That(accessor.CanRead).IsTrue(); + await That(accessor.CanWrite).IsTrue(); + } + + [Test] + public async Task PointerOffset_ForViewAtStart_ShouldBeZero() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 20); + + await That(accessor.PointerOffset).IsEqualTo(0L); + } + + [Test] + public async Task ReadArray_OnWriteOnlyAccessor_ShouldThrowNotSupportedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.Write); + + void Act() => accessor.ReadArray(0, new int[1], 0, 1); + + await That(Act).Throws(); + } + + [Test] + public async Task ReadArray_AtCapacity_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 100); + + void Act() => accessor.ReadArray(100, new int[1], 0, 1); + + await That(Act).Throws() + .WithParamName("position") + .Because("the BCL rejects a position at or beyond the capacity before computing how many items fit"); + } + + [Test] + public async Task WriteArray_AtCapacity_WithZeroCount_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 100); + + void Act() => accessor.WriteArray(100, Array.Empty(), 0, 0); + + await That(Act).Throws() + .WithParamName("position") + .Because("the BCL checks the position against the capacity independent of the count"); + } + + [Test] + public async Task WriteArray_BeyondCapacity_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 100); + + void Act() => accessor.WriteArray(150, new int[1], 0, 1); + + await That(Act).Throws() + .WithParamName("position"); + } + + [Test] + public async Task ReadByte_AtCapacity_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 10); + + void Act() => accessor.ReadByte(10); + + await That(Act).Throws() + .WithParamName("position") + .Because("the BCL distinguishes a position at or beyond the capacity from a partially fitting read"); + } + + [Test] + public async Task Write_AtCapacity_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 10); + + void Act() => accessor.Write(10, (byte)1); + + await That(Act).Throws() + .WithParamName("position") + .Because("the BCL distinguishes a position at or beyond the capacity from a partially fitting write"); + } + + [Test] + public async Task Read_BeyondCapacity_ShouldThrowArgumentException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 100); + + void Act() => accessor.ReadInt32(98); + + await That(Act).Throws() + .WithParamName("position"); + } + + [Test] + public async Task Read_OnWriteOnlyAccessor_ShouldThrowNotSupportedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.Write); + + void Act() => accessor.ReadInt32(0); + + await That(Act).Throws(); + } + + [Test] + public async Task Read_WithNegativePosition_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + void Act() => accessor.ReadInt32(-1); + + await That(Act).Throws() + .WithParamName("position"); + } + + [Test] + public async Task ReadAccessor_ShouldNotSupportWriting() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + + using IMemoryMappedViewAccessor accessor = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.Read); + + await That(accessor.CanRead).IsTrue(); + await That(accessor.CanWrite).IsFalse(); + } + + [Test] + public async Task ReadArray_WhenPartiallyOutOfBounds_ShouldReturnItemsThatFit() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 100); + int[] target = new int[10]; + + int read = accessor.ReadArray(90, target, 0, target.Length); + + await That(read).IsEqualTo(2) + .Because("only 10 bytes remain from position 90, so 2 four-byte integers fit."); + } + + [Test] + public async Task ReadArray_WithNegativePosition_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + void Act() => accessor.ReadArray(-1, new int[4], 0, 4); + + await That(Act).Throws() + .WithParamName("position"); + } + + [Test] + public async Task ReadArray_WithNullArray_ShouldThrowArgumentNullException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + void Act() => accessor.ReadArray(0, null!, 0, 1); + + await That(Act).Throws() + .WithParamName("array"); + } + + [Test] + public async Task ReadWrite_Boolean_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(3, true); + + await That(accessor.ReadBoolean(3)).IsTrue(); + } + + [Test] + public async Task ReadWrite_Byte_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(3, (byte)200); + + await That(accessor.ReadByte(3)).IsEqualTo(200); + } + + [Test] + public async Task ReadWrite_Char_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(3, 'Z'); + + await That(accessor.ReadChar(3)).IsEqualTo('Z'); + } + + [Test] + public async Task ReadWrite_Decimal_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(8, 79228162514.264337593543950335m); + + await That(accessor.ReadDecimal(8)).IsEqualTo(79228162514.264337593543950335m); + } + + [Test] + public async Task ReadWrite_Double_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(8, 2.718281828459045); + + await That(accessor.ReadDouble(8)).IsEqualTo(2.718281828459045); + } + + [Test] + public async Task ReadWrite_GenericStruct_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + Point value = new() + { + X = 3, + Y = 7, + }; + + accessor.Write(16, ref value); + accessor.Read(16, out Point result); + + await That(result.X).IsEqualTo(3); + await That(result.Y).IsEqualTo(7); + } + + [Test] + public async Task ReadWrite_Int16_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(3, (short)-12345); + + await That(accessor.ReadInt16(3)).IsEqualTo(-12345); + } + + [Test] + public async Task ReadWrite_Int32_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(8, 1234567); + + await That(accessor.ReadInt32(8)).IsEqualTo(1234567); + } + + [Test] + public async Task ReadWrite_Int64_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(8, -9_000_000_000L); + + await That(accessor.ReadInt64(8)).IsEqualTo(-9_000_000_000L); + } + + [Test] + public async Task ReadWrite_SByte_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(3, (sbyte)-42); + + await That(accessor.ReadSByte(3)).IsEqualTo(-42); + } + + [Test] + public async Task ReadWrite_Single_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(8, 3.14159f); + + await That(accessor.ReadSingle(8)).IsEqualTo(3.14159f); + } + + [Test] + public async Task ReadWrite_UInt16_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(3, (ushort)54321); + + await That(accessor.ReadUInt16(3)).IsEqualTo(54321); + } + + [Test] + public async Task ReadWrite_UInt32_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(8, 3_000_000_000u); + + await That(accessor.ReadUInt32(8)).IsEqualTo(3_000_000_000u); + } + + [Test] + public async Task ReadWrite_UInt64_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(8, 18_000_000_000_000_000_000UL); + + await That(accessor.ReadUInt64(8)).IsEqualTo(18_000_000_000_000_000_000UL); + } + + [Test] + public async Task ReadWriteArray_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + int[] source = [10, 20, 30, 40, 50]; + + accessor.WriteArray(8, source, 0, source.Length); + int[] target = new int[source.Length]; + int read = accessor.ReadArray(8, target, 0, target.Length); + + await That(read).IsEqualTo(5); + await That(target).IsEqualTo(source); + } + + [Test] + public async Task Write_BeyondCapacity_ShouldThrowArgumentException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 100); + + void Act() => accessor.Write(98, 1234567); + + await That(Act).Throws() + .WithParamName("position"); + } + + [Test] + public async Task Write_OnReadOnlyAccessor_ShouldThrowNotSupportedException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + using IMemoryMappedViewAccessor accessor = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.Read); + + void Act() => accessor.Write(0, 1234567); + + await That(Act).Throws(); + } + + [Test] + public async Task WriteAccessor_OnReadOnlyMapping_ShouldThrowUnauthorizedAccessException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + + void Act() => + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.ReadWrite); + + await That(Act).Throws(); + } + + [Test] + public async Task WriteAccessor_ShouldNotSupportReading() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + + using IMemoryMappedViewAccessor accessor = + mappedFile.CreateViewAccessor(0, 100, MemoryMappedFileAccess.Write); + + await That(accessor.CanRead).IsFalse(); + await That(accessor.CanWrite).IsTrue(); + } + + [Test] + public async Task WriteArray_WhenTooLargeForRemainingCapacity_ShouldThrowAndNotWritePartially() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 100); + int[] source = new int[30]; + for (int i = 0; i < source.Length; i++) + { + source[i] = i + 1; + } + + void Act() => accessor.WriteArray(0, source, 0, source.Length); + + await That(Act).Throws() + .Because("30 four-byte integers (120 bytes) do not fit into the 100-byte view"); + await That(accessor.ReadInt32(0)).IsEqualTo(0) + .Because("The failure is atomic: no element was written, so the region is still zero"); + await That(accessor.ReadInt32(4)).IsEqualTo(0); + } + + [Test] + public async Task WriteArray_WithNegativePosition_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + void Act() => accessor.WriteArray(-1, new int[4], 0, 4); + + await That(Act).Throws() + .WithParamName("position"); + } + + [Test] + public async Task WriteArray_WithNullArray_ShouldThrowArgumentNullException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + void Act() => accessor.WriteArray(0, null!, 0, 1); + + await That(Act).Throws() + .WithParamName("array"); + } + + [Test] + public async Task WriteGenericStruct_ShouldUseManagedSize_NotMarshalledSize() + { + // A struct with a single bool has a managed size of 1 byte, but a marshalled + // size of 4 bytes. The view must use the managed size, matching the BCL, so the + // three bytes following the bool must remain untouched. + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Write(1, byte.MaxValue); + accessor.Write(2, byte.MaxValue); + accessor.Write(3, byte.MaxValue); + WithBool value = new() + { + Flag = false, + }; + + accessor.Write(0, ref value); + + await That(accessor.ReadByte(0)).IsEqualTo(0); + await That(accessor.ReadByte(1)).IsEqualTo(byte.MaxValue); + await That(accessor.ReadByte(2)).IsEqualTo(byte.MaxValue); + await That(accessor.ReadByte(3)).IsEqualTo(byte.MaxValue); + } + + [Test] + public async Task WriteGenericStruct_ShouldUseSequentialLayout() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + Point value = new() + { + X = 111, + Y = 222, + }; + + accessor.Write(16, ref value); + + await That(accessor.ReadInt32(16)).IsEqualTo(111); + await That(accessor.ReadInt32(20)).IsEqualTo(222); + } + + [Test] + public async Task Read_WithReferenceContainingStruct_ShouldThrowArgumentException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + void Act() => accessor.Read(0, out WithReference _); + + await That(Act).Throws() + .Because("a struct containing object references can never be reinterpreted from raw bytes"); + } + + [Test] + public async Task Write_WithReferenceContainingStruct_ShouldThrowArgumentException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + WithReference value = new(); + + void Act() => accessor.Write(0, ref value); + + await That(Act).Throws() + .Because("a struct containing object references can never be written as raw bytes"); + } + + [Test] + public async Task + Read_WithReferenceContainingStruct_AfterDispose_ShouldThrowObjectDisposedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Dispose(); + + void Act() => accessor.Read(0, out WithReference _); + + await That(Act).Throws() + .Because("the BCL validates the open state before the reference check"); + } + + [Test] + public async Task + Read_WithReferenceContainingStruct_AndNegativePosition_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + void Act() => accessor.Read(-1, out WithReference _); + + await That(Act).Throws() + .WithParamName("position") + .Because("the BCL validates the position before the reference check"); + } + + [Test] + public async Task + Write_WithReferenceContainingStruct_AfterDispose_ShouldThrowObjectDisposedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Dispose(); + WithReference value = new(); + + void Act() => accessor.Write(0, ref value); + + await That(Act).Throws() + .Because("the BCL validates the open state before the reference check"); + } + + [Test] + public async Task + Write_WithReferenceContainingStruct_AndNegativePosition_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + WithReference value = new(); + + void Act() => accessor.Write(-1, ref value); + + await That(Act).Throws() + .WithParamName("position") + .Because("the BCL validates the position before the reference check"); + } + + [Test] + public async Task ReadArray_WithReferenceContainingStruct_ShouldThrowArgumentException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + void Act() => _ = accessor.ReadArray(0, new WithReference[1], 0, 1); + + await That(Act).Throws() + .Because("a struct containing object references can never be reinterpreted from raw bytes"); + } + + [Test] + public async Task WriteArray_WithReferenceContainingStruct_ShouldThrowArgumentException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + void Act() => accessor.WriteArray(0, new WithReference[1], 0, 1); + + await That(Act).Throws() + .Because("a struct containing object references can never be written as raw bytes"); + } + + [Test] + public async Task WriteArray_With3ByteStruct_ShouldStrideByAlignedSize() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[10]); + + using (IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin")) + { + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 10); + accessor.WriteArray(0, [ + new Rgb(1, 2, 3), + new Rgb(4, 5, 6), + ], 0, 2); + } + + byte[] bytes = FileSystem.File.ReadAllBytes("data.bin"); + byte[] expected = [1, 2, 3, 0, 4, 5, 6, 0, 0, 0,]; + await That(bytes).IsEqualTo(expected) + .Because("array elements are strided by the aligned size (4 for a 3-byte struct), leaving the padding bytes untouched"); + } + + [Test] + public async Task ReadArray_With3ByteStruct_ShouldStrideByAlignedSize() + { + FileSystem.File.WriteAllBytes("data.bin", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10,]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 10); + Rgb[] target = new Rgb[4]; + + int count = accessor.ReadArray(0, target, 0, 4); + + await That(count).IsEqualTo(2) + .Because("only complete aligned strides (4 bytes for a 3-byte struct) are counted"); + await That(target[0]).IsEqualTo(new Rgb(1, 2, 3)); + await That(target[1]).IsEqualTo(new Rgb(5, 6, 7)) + .Because("the second element starts at the aligned offset 4"); + } + + [Test] + public async Task WriteArray_With3ByteStruct_WithoutRoomForAlignedStride_ShouldThrowArgumentException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[7]); + using IMemoryMappedFile mappedFile = + FileSystem.MemoryMappedFile.CreateFromFile("data.bin"); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(0, 7); + + void Act() => accessor.WriteArray(0, new Rgb[2], 0, 2); + + await That(Act).Throws() + .Because("two elements require two full aligned strides (8 bytes), even though the last element itself would fit in 7 bytes"); + } + + [Test] + public async Task Read_AfterDispose_ShouldThrowObjectDisposedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Dispose(); + + void Act() => _ = accessor.ReadInt32(0); + + await That(Act).Throws(); + } + + [Test] + public async Task Write_AfterDispose_ShouldThrowObjectDisposedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Dispose(); + + void Act() => accessor.Write(0, 42); + + await That(Act).Throws(); + } + + [Test] + public async Task Flush_AfterDispose_ShouldThrowObjectDisposedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + accessor.Dispose(); + + void Act() => accessor.Flush(); + + await That(Act).Throws(); + } + + [Test] + public async Task CanReadAndCanWrite_AfterDispose_ShouldBeFalse() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Dispose(); + + await That(accessor.CanRead).IsFalse(); + await That(accessor.CanWrite).IsFalse(); + } + + [Test] + public async Task Flush_ShouldMakeWritesVisibleToOtherStreams() + { + // A path-based memory-mapped file holds the file without write sharing (and without any + // sharing on the .NET Framework), so the mapping is created over a caller-owned stream + // that permits concurrent readers. + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using FileSystemStream stream = FileSystem.FileStream.New( + "data.bin", FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: true); + using IMemoryMappedViewAccessor accessor = mappedFile.CreateViewAccessor(); + + accessor.Write(0, 1234567); + accessor.Flush(); + + using FileSystemStream reader = FileSystem.FileStream.New( + "data.bin", FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + byte[] buffer = new byte[4]; + _ = reader.Read(buffer, 0, 4); + await That(BitConverter.ToInt32(buffer, 0)).IsEqualTo(1234567); + } + + [StructLayout(LayoutKind.Sequential)] + private record struct Point + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private record struct Rgb(byte R, byte G, byte B); + + private record struct WithReference + { + #pragma warning disable CS0649 // The field is only used to make the struct contain a reference. + public string? Text; + #pragma warning restore CS0649 + } + + [StructLayout(LayoutKind.Sequential)] + private record struct WithBool + { + public bool Flag; + } +} diff --git a/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedViewStream/Tests.cs b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedViewStream/Tests.cs new file mode 100644 index 000000000..a7bf4138a --- /dev/null +++ b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/MemoryMappedViewStream/Tests.cs @@ -0,0 +1,291 @@ +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Linq; +using Skip = Testably.Abstractions.TestHelpers.Skip; + +namespace Testably.Abstractions.MemoryMappedFiles.Tests.MemoryMappedViewStream; + +[FileSystemTests] +public class Tests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + public async Task Capacity_ShouldBeAtLeastTheViewSize() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + + using MemoryMappedFileSystemViewStream stream = mappedFile.CreateViewStream(0, 20); + + await That(stream.Capacity).IsGreaterThanOrEqualTo(20L) + .Because("the real file system rounds the capacity up to the system page size, so it is only guaranteed to be at least the requested size."); + } + + [Test] + public async Task DefaultViewStream_OnReadOnlyMapping_ShouldThrowUnauthorizedAccessException() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + + void Act() => mappedFile.CreateViewStream(); + + await That(Act).Throws() + .Because("the default view access is `ReadWrite`, which the read-only mapping does not permit"); + } + + [Test] + public async Task DefaultViewStream_ShouldSupportReadWriteAndSeek() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + + using Stream stream = mappedFile.CreateViewStream(); + + await That(stream.CanRead).IsTrue(); + await That(stream.CanWrite).IsTrue(); + await That(stream.CanSeek).IsTrue(); + } + + [Test] + public async Task Length_ShouldMatchViewSize() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + + using Stream stream = mappedFile.CreateViewStream(0, 50); + + await That(stream.Length).IsEqualTo(50L); + } + + [Test] + public async Task PointerOffset_ForViewAtStart_ShouldBeZero() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + + using MemoryMappedFileSystemViewStream stream = mappedFile.CreateViewStream(0, 20); + + await That(stream.PointerOffset).IsEqualTo(0L); + } + + [Test] + public async Task Position_SetToNegative_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = mappedFile.CreateViewStream(0, 50); + + void Act() => stream.Position = -1; + + await That(Act).Throws(); + } + + [Test] + public async Task Read_AfterDispose_ShouldThrowObjectDisposedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + Stream stream = mappedFile.CreateViewStream(0, 50); + stream.Dispose(); + + void Act() => _ = stream.Read(new byte[1], 0, 1); + + await That(Act).Throws(); + } + + [Test] + public async Task Write_AfterDispose_ShouldThrowObjectDisposedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + Stream stream = mappedFile.CreateViewStream(0, 50); + stream.Dispose(); + + void Act() => stream.Write(new byte[1], 0, 1); + + await That(Act).Throws(); + } + + [Test] + public async Task CanReadWriteSeek_AfterDispose_ShouldBeFalse() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + Stream stream = mappedFile.CreateViewStream(0, 50); + + stream.Dispose(); + + await That(stream.CanRead).IsFalse(); + await That(stream.CanWrite).IsFalse(); + await That(stream.CanSeek).IsFalse(); + } + + [Test] + public async Task Read_AtEndOfStream_ShouldReturnZero() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = mappedFile.CreateViewStream(0, 10); + stream.Seek(0, SeekOrigin.End); + + int read = stream.Read(new byte[10], 0, 10); + + await That(read).IsEqualTo(0); + } + + [Test] + public async Task Read_WithCountLargerThanBuffer_ShouldThrowArgumentException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = mappedFile.CreateViewStream(0, 50); + + void Act() => _ = stream.Read(new byte[10], 5, 10); + + await That(Act).Throws(); + } + + [Test] + public async Task Read_WithNegativeOffset_ShouldThrowArgumentOutOfRangeException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = mappedFile.CreateViewStream(0, 50); + + void Act() => _ = stream.Read(new byte[10], -1, 1); + + await That(Act).Throws(); + } + + [Test] + public async Task Read_WithNullBuffer_ShouldThrowArgumentNullException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = mappedFile.CreateViewStream(0, 50); + + void Act() => _ = stream.Read(null!, 0, 1); + + await That(Act).Throws(); + } + + [Test] + public async Task ReadOnlyViewStream_ShouldNotSupportWriting() + { + FileSystem.File.WriteAllBytes("data.bin", new byte[100]); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + "data.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + using Stream stream = + mappedFile.CreateViewStream(0, 50, MemoryMappedFileAccess.Read); + + await That(stream.CanWrite).IsFalse(); + + void Act() => stream.Write(new byte[5], 0, 5); + + await That(Act).Throws(); + } + + [Test] + public async Task ReadWrite_ShouldRoundtrip() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + byte[] payload = [1, 2, 3, 4, 5,]; + + using (Stream writeStream = mappedFile.CreateViewStream(0, 50)) + { + writeStream.Write(payload, 0, payload.Length); + writeStream.Flush(); + } + + byte[] result = new byte[payload.Length]; + using (Stream readStream = mappedFile.CreateViewStream(0, 50)) + { + int read = 0; + while (read < result.Length) + { + int r = readStream.Read(result, read, result.Length - read); + if (r == 0) + { + break; + } + + read += r; + } + } + + await That(result).IsEqualTo(payload); + } + + [Test] + public async Task Seek_BeforeBeginning_ShouldThrowIOException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = mappedFile.CreateViewStream(0, 50); + + void Act() => stream.Seek(-1, SeekOrigin.Begin); + + await That(Act).Throws(); + } + + [Test] + public async Task Seek_FromBeginCurrentAndEnd_ShouldUpdatePosition() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = mappedFile.CreateViewStream(0, 50); + + await That(stream.Seek(10, SeekOrigin.Begin)).IsEqualTo(10L); + await That(stream.Seek(5, SeekOrigin.Current)).IsEqualTo(15L); + await That(stream.Seek(-10, SeekOrigin.End)).IsEqualTo(40L); + await That(stream.Position).IsEqualTo(40L); + } + + [Test] + public async Task SetLength_ShouldThrowNotSupportedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = mappedFile.CreateViewStream(0, 50); + + void Act() => stream.SetLength(10); + + await That(Act).Throws(); + } + + [Test] + public async Task Read_AfterBackingStreamWasTruncated_ShouldReturnZeros() + { + Skip.IfNot(FileSystem is MockFileSystem, + "The operating system rejects truncating a file with a user-mapped section."); + + FileSystem.File.WriteAllBytes("data.bin", + Enumerable.Repeat((byte)7, 100).ToArray()); + using FileSystemStream stream = FileSystem.FileStream.New( + "data.bin", FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + using IMemoryMappedFile mappedFile = FileSystem.MemoryMappedFile.CreateFromFile( + stream, null, 0, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, + leaveOpen: true); + using Stream viewStream = mappedFile.CreateViewStream(); + + stream.SetLength(50); + viewStream.Position = 60; + byte[] buffer = [9, 9, 9, 9,]; + int read = viewStream.Read(buffer, 0, 4); + + await That(read).IsEqualTo(4) + .Because("the view spans the full capacity, which does not shrink with the file"); + await That(buffer).IsEqualTo(new byte[4]) + .Because("reads of the truncated range return zeros"); + } + + [Test] + public async Task Write_BeyondCapacity_ShouldThrowNotSupportedException() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = mappedFile.CreateViewStream(0, 10); + + void Act() => stream.Write(new byte[20], 0, 20); + + await That(Act).Throws(); + } + + [Test] + public async Task WriteOnlyViewStream_ShouldNotSupportReading() + { + using IMemoryMappedFile mappedFile = FileSystem.CreateMappedFile(); + using Stream stream = + mappedFile.CreateViewStream(0, 50, MemoryMappedFileAccess.Write); + + await That(stream.CanRead).IsFalse(); + + void Act() => _ = stream.Read(new byte[5], 0, 5); + + await That(Act).Throws(); + } +} diff --git a/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/TestHelpers/MemoryMappedFileTestHelpers.cs b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/TestHelpers/MemoryMappedFileTestHelpers.cs new file mode 100644 index 000000000..4edc6a311 --- /dev/null +++ b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/TestHelpers/MemoryMappedFileTestHelpers.cs @@ -0,0 +1,15 @@ +namespace Testably.Abstractions.MemoryMappedFiles.Tests.TestHelpers; + +public static class MemoryMappedFileTestHelpers +{ + /// + /// Creates a file with zero-bytes at and + /// returns a memory-mapped file over it. + /// + public static IMemoryMappedFile CreateMappedFile(this IFileSystem fileSystem, + int size = 100, string path = "data.bin") + { + fileSystem.File.WriteAllBytes(path, new byte[size]); + return fileSystem.MemoryMappedFile.CreateFromFile(path); + } +} diff --git a/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/TestHelpers/Usings.cs b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/TestHelpers/Usings.cs new file mode 100644 index 000000000..50cda72be --- /dev/null +++ b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/TestHelpers/Usings.cs @@ -0,0 +1,10 @@ +global using System; +global using System.Threading.Tasks; +global using System.IO.Abstractions; +global using Testably.Abstractions.MemoryMappedFiles.Tests.TestHelpers; +global using Testably.Abstractions.TestHelpers; +global using Testably.Abstractions.Testing; +global using TUnit; +global using aweXpect; +global using aweXpect.Testably; +global using static aweXpect.Expect; diff --git a/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/Testably.Abstractions.MemoryMappedFiles.Tests.csproj b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/Testably.Abstractions.MemoryMappedFiles.Tests.csproj new file mode 100644 index 000000000..671c83314 --- /dev/null +++ b/Tests/Testably.Abstractions.MemoryMappedFiles.Tests/Testably.Abstractions.MemoryMappedFiles.Tests.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Tests/Testably.Abstractions.Parity.Tests/ParityTests.cs b/Tests/Testably.Abstractions.Parity.Tests/ParityTests.cs index c0b34871b..60e3bc7b8 100644 --- a/Tests/Testably.Abstractions.Parity.Tests/ParityTests.cs +++ b/Tests/Testably.Abstractions.Parity.Tests/ParityTests.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; +using System.IO.MemoryMappedFiles; using System.Threading; using System.Threading.Tasks; using Testably.Abstractions.RandomSystem; @@ -127,6 +128,41 @@ public async Task IGuid_EnsureParityWith_Guid() await That(parityErrors).IsEmpty(); } + [Test] + public async Task + IMemoryMappedFileAndIMemoryMappedFileFactory_EnsureParityWith_MemoryMappedFile() + { + List parityErrors = Parity.MemoryMappedFile + .GetErrorsToStaticType( + typeof(MemoryMappedFile)); + parityErrors.AddRange(Parity.MemoryMappedFile + .GetErrorsToInstanceType( + typeof(MemoryMappedFile))); + + await That(parityErrors).IsEmpty(); + } + + [Test] + public async Task IMemoryMappedViewAccessor_EnsureParityWith_MemoryMappedViewAccessor() + { + List parityErrors = Parity.MemoryMappedViewAccessor + .GetErrorsToInstanceType( + typeof(MemoryMappedViewAccessor)); + + await That(parityErrors).IsEmpty(); + } + + [Test] + public async Task + MemoryMappedFileSystemViewStream_EnsureParityWith_MemoryMappedViewStream() + { + List parityErrors = Parity.MemoryMappedViewStream + .GetErrorsToInstanceType( + typeof(MemoryMappedViewStream)); + + await That(parityErrors).IsEmpty(); + } + [Test] public async Task IPath_EnsureParityWith_Path() { diff --git a/Tests/Testably.Abstractions.Parity.Tests/TestHelpers/Parity.cs b/Tests/Testably.Abstractions.Parity.Tests/TestHelpers/Parity.cs index fdcb2877b..be65b71a2 100644 --- a/Tests/Testably.Abstractions.Parity.Tests/TestHelpers/Parity.cs +++ b/Tests/Testably.Abstractions.Parity.Tests/TestHelpers/Parity.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; +using System.IO.MemoryMappedFiles; using System.Linq; using System.Reflection; using System.Threading; @@ -17,6 +18,9 @@ public class Parity { nameof(FileStream), nameof(FileSystemStream) }, + { + nameof(MemoryMappedViewStream), nameof(MemoryMappedFileSystemViewStream) + }, }); public ParityCheck DateTime { get; } = new(excludeMethods: @@ -98,6 +102,35 @@ public class Parity public ParityCheck Guid { get; } = new(); + public ParityCheck MemoryMappedFile { get; } = new(excludeMethods: + [ + ..typeof(MemoryMappedFile).GetMethods().Where(m => + string.Equals(m.ReturnType.Name, "MemoryMappedFileSecurity", + StringComparison.Ordinal) || + m.GetParameters().Any(p => p.ParameterType.Name is "MemoryMappedFileSecurity" + or "SafeMemoryMappedFileHandle" or "SafeFileHandle")), + ], excludeProperties: + [ + typeof(MemoryMappedFile).GetProperty(nameof(System.IO.MemoryMappedFiles + .MemoryMappedFile.SafeMemoryMappedFileHandle)), + ]); + + public ParityCheck MemoryMappedViewAccessor { get; } = new(excludeProperties: + [ + typeof(MemoryMappedViewAccessor).GetProperty(nameof(System.IO.MemoryMappedFiles + .MemoryMappedViewAccessor.SafeMemoryMappedViewHandle)), + ]); + + public ParityCheck MemoryMappedViewStream { get; } = new(excludeMethods: + [ + typeof(MemoryMappedViewStream).GetMethod(nameof(Stream.Seek), + [typeof(long), typeof(SeekOrigin)]), + ], excludeProperties: + [ + typeof(MemoryMappedViewStream).GetProperty(nameof(System.IO.MemoryMappedFiles + .MemoryMappedViewStream.SafeMemoryMappedViewHandle)), + ]); + public ParityCheck Path { get; } = new(excludeFields: new[] { #pragma warning disable CS0618 diff --git a/Tests/Testably.Abstractions.Parity.Tests/Testably.Abstractions.Parity.Tests.csproj b/Tests/Testably.Abstractions.Parity.Tests/Testably.Abstractions.Parity.Tests.csproj index 6c3d3c8a7..e419f1876 100644 --- a/Tests/Testably.Abstractions.Parity.Tests/Testably.Abstractions.Parity.Tests.csproj +++ b/Tests/Testably.Abstractions.Parity.Tests/Testably.Abstractions.Parity.Tests.csproj @@ -3,6 +3,7 @@ + diff --git a/Tests/Testably.Abstractions.Testing.Tests/TestHelpers/LockableContainer.cs b/Tests/Testably.Abstractions.Testing.Tests/TestHelpers/LockableContainer.cs index 9ed53f5ac..fed732a4b 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/TestHelpers/LockableContainer.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/TestHelpers/LockableContainer.cs @@ -122,6 +122,17 @@ public void WriteBytes(byte[] bytes) BytesChanged?.Invoke(this, EventArgs.Empty); } + /// + public void WriteRange(byte[] bytes, long offset) + { + long newLength = Math.Max(_bytes.Length, offset + bytes.Length); + byte[] newBytes = new byte[newLength]; + Array.Copy(_bytes, newBytes, _bytes.Length); + Array.Copy(bytes, 0L, newBytes, offset, bytes.Length); + _bytes = newBytes; + BytesChanged?.Invoke(this, new BytesChangedEventArgs(bytes, offset)); + } + #endregion private sealed class AccessHandle(FileAccess access, FileShare share, bool deleteAccess) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileStream/AdjustTimesTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileStream/AdjustTimesTests.cs index 1171a077a..0c933ee60 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileStream/AdjustTimesTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileStream/AdjustTimesTests.cs @@ -463,6 +463,28 @@ await That(lastAccessTime).IsBetween(creationTimeStart).And(creationTimeEnd) await That(lastWriteTime).IsOnOrAfter(updateTime.ApplySystemClockTolerance()); } + [Test] + [AutoArguments] + public async Task SetLength_WithInvalidValue_ShouldNotAdjustTimes( + string path, byte[] bytes) + { + SkipIfLongRunningTestsShouldBeSkipped(); + + FileSystem.File.WriteAllBytes(path, bytes); + DateTime lastWriteTime = FileSystem.File.GetLastWriteTimeUtc(path); + TimeSystem.Thread.Sleep(FileTestHelper.AdjustTimesDelay); + + using (FileSystemStream stream = FileSystem.File.Open(path, FileMode.Open)) + { + void Act() => stream.SetLength(-1); + await That(Act).Throws(); + } + + await That(FileSystem.File.GetLastWriteTimeUtc(path)).IsEqualTo(lastWriteTime) + .Within(TimeComparison.Tolerance) + .Because("a failed SetLength must not flush unchanged content to the file"); + } + #region Helpers private DateTime WaitToBeUpdatedToAfter(Func callback, diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileStream/Tests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileStream/Tests.cs index bfc6a097e..600bc490e 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileStream/Tests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileStream/Tests.cs @@ -292,4 +292,137 @@ void Act() await That(Act).Throws().WithHResult(-2146233067); } + + [Test] + [AutoArguments] + public async Task SetLength_Truncate_ShouldShrinkOtherStreamsWithPendingWrites( + string path) + { + FileSystem.File.WriteAllBytes(path, new byte[100]); + using (FileSystemStream stream1 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + using FileSystemStream stream2 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + stream2.SetLength(4); + stream1.Position = 0; + stream1.WriteByte(1); + } + + byte[] result = FileSystem.File.ReadAllBytes(path); + await That(result.Length).IsEqualTo(4) + .Because("the flush of the first stream must not resurrect the truncated content"); + await That(result[0]).IsEqualTo(1); + } + + [Test] + [AutoArguments] + public async Task SetLength_Truncate_WhenOtherStreamFlushesAfterwards_ShouldKeepNewLength( + string path) + { + FileSystem.File.WriteAllBytes(path, new byte[100]); + using (FileSystemStream stream1 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + using FileSystemStream stream2 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + stream1.SetLength(10); + stream2.Position = 0; + stream2.WriteByte(2); + stream2.Flush(); + } + + byte[] result = FileSystem.File.ReadAllBytes(path); + await That(result.Length).IsEqualTo(10) + .Because("the other stream's flush must not revert the truncation"); + await That(result[0]).IsEqualTo(2); + } + + [Test] + [AutoArguments] + public async Task SetLength_Truncate_WhileOtherStreamHasPendingWrites_ShouldNotThrow( + string path) + { + FileSystem.File.WriteAllBytes(path, new byte[100]); + using FileSystemStream stream1 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + using FileSystemStream stream2 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + stream1.Position = 90; + stream1.Write(new byte[10], 0, 10); + + void Act() + { + stream2.SetLength(10); + stream2.Flush(); + } + + await That(Act).DoesNotThrow(); + } + + [Test] + [AutoArguments] + public async Task SetLength_WithoutOtherWrites_ShouldPersistNewLength(string path) + { + FileSystem.File.WriteAllBytes(path, new byte[10]); + + using (FileSystemStream stream = FileSystem.File.Open(path, FileMode.Open)) + { + stream.SetLength(100); + } + + await That(FileSystem.File.ReadAllBytes(path).Length).IsEqualTo(100); + } + + [Test] + [AutoArguments] + public async Task Write_ScatteredWrites_WhenOtherStreamFlushes_ShouldKeepAllPendingWrites( + string path) + { + FileSystem.File.WriteAllBytes(path, new byte[100]); + using (FileSystemStream stream1 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + using FileSystemStream stream2 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + stream1.Position = 10; + stream1.WriteByte(1); + stream1.Position = 20; + stream1.WriteByte(2); + stream2.Position = 80; + stream2.WriteByte(3); + stream2.Flush(); + } + + byte[] result = FileSystem.File.ReadAllBytes(path); + await That(result[10]).IsEqualTo(1); + await That(result[20]).IsEqualTo(2); + await That(result[80]).IsEqualTo(3); + } + + [Test] + [AutoArguments] + public async Task Write_WithInvalidArguments_ShouldNotCorruptContentOfOtherStreams( + string path) + { + FileSystem.File.WriteAllBytes(path, new byte[10]); + using (FileSystemStream stream1 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + using FileSystemStream stream2 = FileSystem.FileStream.New( + path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + void Act() => stream1.Write(new byte[5], 0, 10); + + await That(Act).Throws(); + + stream2.Position = 0; + stream2.WriteByte(1); + stream2.Flush(); + } + + byte[] result = FileSystem.File.ReadAllBytes(path); + await That(result[0]).IsEqualTo(1) + .Because( + "the rejected write must not leave a pending write range that overwrites the flushed content"); + } }