From 0f67cc9cff810988e7bf1948070d7f69e2e3a9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 12 Jul 2026 14:55:11 +0200 Subject: [PATCH 1/3] fix: handle shared file contents across handles Each MockFileStream kept its own MemoryStream buffer that was only synced to the shared MockFileData.Contents on flush, so a second handle opened with FileShare.ReadWrite never saw writes made through another handle. On top of that, InternalFlush unconditionally wrote its buffer back, so a read-only handle's Flush could clobber another handle's writes. Track the last-synced Contents reference and reload the buffer from it on every read entry point (Read, ReadByte, ReadAsync, span overloads) when another handle has replaced it. InternalFlush now writes back only when the buffer actually differs from the last synced snapshot, matching the no-op behavior of a real FileStream.Flush with no pending writes. --- .../MockFileStream.cs | 93 ++++++++++++++++++- .../MockFileStreamTests.cs | 32 +++++++ 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileStream.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileStream.cs index c633dd8ef..fca762972 100644 --- a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileStream.cs +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileStream.cs @@ -38,6 +38,8 @@ public NullFileSystemStream() : base(Null, ".", true) private readonly MockFileData fileData; private bool disposed; + private byte[] lastKnownContents; + /// public MockFileStream( IMockFileDataAccessor mockFileDataAccessor, @@ -104,6 +106,7 @@ public MockFileStream( mockFileDataAccessor.FileHandles.AddHandle(path, guid, access, share); this.access = access; this.share = share; + lastKnownContents = fileData.Contents; } private static void ThrowIfInvalidModeAccess(FileMode mode, FileAccess access) @@ -138,11 +141,46 @@ private static void ThrowIfInvalidModeAccess(FileMode mode, FileAccess access) /// public override int Read(byte[] buffer, int offset, int count) { + RefreshBufferFromSharedFileData(); mockFileDataAccessor.AdjustTimes(fileData, TimeAdjustments.LastAccessTime); return base.Read(buffer, offset, count); } +#if FEATURE_SPAN + /// + public override int Read(Span buffer) + { + RefreshBufferFromSharedFileData(); + return base.Read(buffer); + } +#endif + + /// + public override int ReadByte() + { + RefreshBufferFromSharedFileData(); + return base.ReadByte(); + } + + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + RefreshBufferFromSharedFileData(); + return base.ReadAsync(buffer, offset, count, cancellationToken); + } + +#if FEATURE_SPAN + /// + public override ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken = new()) + { + RefreshBufferFromSharedFileData(); + return base.ReadAsync(buffer, cancellationToken); + } +#endif + /// protected override void Dispose(bool disposing) { @@ -296,16 +334,63 @@ private void InternalFlush() /* reset back to the beginning .. */ var position = Position; Seek(0, SeekOrigin.Begin); - /* .. read everything out */ + /* .. read everything out (bypassing the read-refresh, so we capture this handle's own buffer) */ var data = new byte[Length]; - _ = Read(data, 0, (int)Length); + _ = base.Read(data, 0, (int)Length); /* restore to original position */ Seek(position, SeekOrigin.Begin); - /* .. put it in the mock system */ - mockFileData.Contents = data; + /* .. put it in the mock system, but only if this handle actually changed the contents since it last synchronized. */ + if (!ContentsEqual(data, lastKnownContents)) + { + mockFileData.Contents = data; + lastKnownContents = data; + } } } + private static bool ContentsEqual(byte[] left, byte[] right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left == null || right == null || left.Length != right.Length) + { + return false; + } + + for (var i = 0; i < left.Length; i++) + { + if (left[i] != right[i]) + { + return false; + } + } + + return true; + } + + private void RefreshBufferFromSharedFileData() + { + var sharedContents = fileData.Contents; + if (ReferenceEquals(sharedContents, lastKnownContents)) + { + return; + } + + var position = base.Position; + base.Position = 0; + base.SetLength(sharedContents.Length); + if (sharedContents.Length > 0) + { + base.Write(sharedContents, 0, sharedContents.Length); + } + + base.Position = position; + lastKnownContents = sharedContents; + } + private void OnClose() { if (options.HasFlag(FileOptions.DeleteOnClose) && mockFileDataAccessor.FileExists(path)) diff --git a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileStreamTests.cs b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileStreamTests.cs index a8f815a15..cbbc7ab3b 100644 --- a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileStreamTests.cs +++ b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileStreamTests.cs @@ -10,6 +10,38 @@ [TestFixture] public class MockFileStreamTests { + [Test] + public async Task MockFileStream_SharedReadWrite_SecondHandleSeesWritesFromFirstHandle() + { + // Arrange + var filename = XFS.Path(@"C:\temp\shared.bin"); + var fileSystem = new MockFileSystem(); + fileSystem.AddEmptyFile(filename); + var results = new List(); + + // Act + using (var file1 = fileSystem.FileStream.New(filename, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite)) + using (var file2 = fileSystem.FileStream.New(filename, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + var buffer = new byte[4]; + for (int ix = 0; ix < 10; ix++) + { + var bytes = BitConverter.GetBytes(ix); + file1.Position = 0; + file1.Write(bytes, 0, bytes.Length); + file1.Flush(); + + file2.Position = 0; + file2.Flush(); + file2.Read(buffer, 0, buffer.Length); + results.Add(BitConverter.ToInt32(buffer, 0)); + } + } + + // Assert + await That(results).IsEqualTo(new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + } + [Test] public async Task MockFileStream_Flush_WritesByteToFile() { From 7b85ec10f37bfc82e82ee036709bc9a02210bd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 12 Jul 2026 15:03:18 +0200 Subject: [PATCH 2/3] test(MockFileStream): use Read return value to satisfy CA2022 The shared-contents regression test ignored the byte count returned by Stream.Read, which the analyzer flags as CA2022 (inexact read) and the build treats as an error. Capture the count and assert it equals the requested length. --- .../MockFileStreamTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileStreamTests.cs b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileStreamTests.cs index cbbc7ab3b..77fe218af 100644 --- a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileStreamTests.cs +++ b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileStreamTests.cs @@ -33,7 +33,8 @@ public async Task MockFileStream_SharedReadWrite_SecondHandleSeesWritesFromFirst file2.Position = 0; file2.Flush(); - file2.Read(buffer, 0, buffer.Length); + var bytesRead = file2.Read(buffer, 0, buffer.Length); + await That(bytesRead).IsEqualTo(buffer.Length); results.Add(BitConverter.ToInt32(buffer, 0)); } } From 0fb6cce0670e1f08ef3c20c3886a53d7429c2db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 12 Jul 2026 15:11:29 +0200 Subject: [PATCH 3/3] test(api): approve new MockFileStream read overrides in public API The shared-contents fix added public overrides to MockFileStream (ReadByte and ReadAsync(byte[]) on all frameworks; Read(Span) and ReadAsync(Memory) where FEATURE_SPAN is defined). Regenerate the expected public API surface via the AcceptApiChanges test so VerifyPublicApiForTestingHelpers passes. --- ...stableIO.System.IO.Abstractions.TestingHelpers_net10.0.txt | 4 ++++ ...estableIO.System.IO.Abstractions.TestingHelpers_net472.txt | 2 ++ ...estableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt | 4 ++++ ...estableIO.System.IO.Abstractions.TestingHelpers_net8.0.txt | 4 ++++ ...estableIO.System.IO.Abstractions.TestingHelpers_net9.0.txt | 4 ++++ ...O.System.IO.Abstractions.TestingHelpers_netstandard2.0.txt | 2 ++ ...O.System.IO.Abstractions.TestingHelpers_netstandard2.1.txt | 4 ++++ 7 files changed, 24 insertions(+) diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net10.0.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net10.0.txt index 28283ec09..d7c082044 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net10.0.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net10.0.txt @@ -404,7 +404,11 @@ namespace System.IO.Abstractions.TestingHelpers public object GetAccessControl() { } [System.Runtime.Versioning.SupportedOSPlatform("windows")] public object GetAccessControl(System.IO.Abstractions.IFileSystemAclSupport.AccessControlSections includeSections) { } + 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() { } [System.Runtime.Versioning.SupportedOSPlatform("windows")] public void SetAccessControl(object value) { } public override void SetLength(long value) { } diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net472.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net472.txt index bce734d4f..e64a25b2e 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net472.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net472.txt @@ -316,6 +316,8 @@ namespace System.IO.Abstractions.TestingHelpers public object GetAccessControl() { } public object GetAccessControl(System.IO.Abstractions.IFileSystemAclSupport.AccessControlSections includeSections) { } 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 void SetAccessControl(object value) { } public override void SetLength(long value) { } public override void Write(byte[] buffer, int offset, int count) { } diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt index 7fa0eecba..9aff85574 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt @@ -366,7 +366,11 @@ namespace System.IO.Abstractions.TestingHelpers public object GetAccessControl() { } [System.Runtime.Versioning.SupportedOSPlatform("windows")] public object GetAccessControl(System.IO.Abstractions.IFileSystemAclSupport.AccessControlSections includeSections) { } + 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() { } [System.Runtime.Versioning.SupportedOSPlatform("windows")] public void SetAccessControl(object value) { } public override void SetLength(long value) { } diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net8.0.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net8.0.txt index e898230a1..e5788bd55 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net8.0.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net8.0.txt @@ -390,7 +390,11 @@ namespace System.IO.Abstractions.TestingHelpers public object GetAccessControl() { } [System.Runtime.Versioning.SupportedOSPlatform("windows")] public object GetAccessControl(System.IO.Abstractions.IFileSystemAclSupport.AccessControlSections includeSections) { } + 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() { } [System.Runtime.Versioning.SupportedOSPlatform("windows")] public void SetAccessControl(object value) { } public override void SetLength(long value) { } diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net9.0.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net9.0.txt index dea869d1f..35cf35b2e 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net9.0.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net9.0.txt @@ -404,7 +404,11 @@ namespace System.IO.Abstractions.TestingHelpers public object GetAccessControl() { } [System.Runtime.Versioning.SupportedOSPlatform("windows")] public object GetAccessControl(System.IO.Abstractions.IFileSystemAclSupport.AccessControlSections includeSections) { } + 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() { } [System.Runtime.Versioning.SupportedOSPlatform("windows")] public void SetAccessControl(object value) { } public override void SetLength(long value) { } diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.0.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.0.txt index fd8f7e4fc..1dc0ed02e 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.0.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.0.txt @@ -316,6 +316,8 @@ namespace System.IO.Abstractions.TestingHelpers public object GetAccessControl() { } public object GetAccessControl(System.IO.Abstractions.IFileSystemAclSupport.AccessControlSections includeSections) { } 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 void SetAccessControl(object value) { } public override void SetLength(long value) { } public override void Write(byte[] buffer, int offset, int count) { } diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.1.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.1.txt index 0bf9bd3e7..cba08bc11 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.1.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.1.txt @@ -341,7 +341,11 @@ namespace System.IO.Abstractions.TestingHelpers public override System.Threading.Tasks.Task FlushAsync(System.Threading.CancellationToken cancellationToken) { } public object GetAccessControl() { } public object GetAccessControl(System.IO.Abstractions.IFileSystemAclSupport.AccessControlSections includeSections) { } + 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 void SetAccessControl(object value) { } public override void SetLength(long value) { } public override void Write(System.ReadOnlySpan buffer) { }