From 0665492c21b747bc4912aaf3de17a92a10af8d33 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sun, 5 Jul 2026 23:04:50 +1000 Subject: [PATCH 1/2] Fix verifier cleanup composition, stream/xml disposal and comparison - Compose async cleanups with a Then helper so both are awaited; `+=` on a Func builds a multicast delegate that only awaits the last target, so converter/user async cleanups were fire-and-forget - Run cleanup in a finally around HandleResults so it still runs when the comparison throws - IsAutoVerify: only gate the global autoVerify delegate on typeName, so the per-settings delegate works for the file-level InnerVerifier(directory, name) - VerifyXml(Stream) disposes the stream, matching the other stream APIs - Comparer.Text reads the verified file with VerifierSettings.Encoding so a BOM-less non-UTF8 UseEncoding round-trips (writes already use it) - StreamComparer rents its buffers from ArrayPool and compares exactly the bytes read via SequenceEqual (a rented buffer's trailing bytes are arbitrary) - Add tests for the above --- ...onverterAsyncCleanupIsAwaited.verified.txt | 1 + .../Converters/AsyncCleanupTests.cs | 30 +++++++++++ src/Verify.Tests/StreamComparerTests.cs | 30 +++++++++++ ...XmlStreamTests.DisposesStream.verified.xml | 1 + src/Verify.Tests/VerifyXmlStreamTests.cs | 11 ++++ src/Verify/Compare/Comparer.cs | 4 +- src/Verify/Compare/StreamComparer.cs | 53 +++++++++++-------- src/Verify/Extensions.cs | 9 ++++ src/Verify/Verifier/InnerVerifier_Inner.cs | 16 ++++-- src/Verify/Verifier/InnerVerifier_Stream.cs | 2 +- src/Verify/Verifier/InnerVerifier_Xml.cs | 7 ++- src/Verify/Verifier/VerifyEngine.cs | 11 ++-- 12 files changed, 138 insertions(+), 37 deletions(-) create mode 100644 src/Verify.Tests/Converters/AsyncCleanupTests.ConverterAsyncCleanupIsAwaited.verified.txt create mode 100644 src/Verify.Tests/Converters/AsyncCleanupTests.cs create mode 100644 src/Verify.Tests/VerifyXmlStreamTests.DisposesStream.verified.xml create mode 100644 src/Verify.Tests/VerifyXmlStreamTests.cs diff --git a/src/Verify.Tests/Converters/AsyncCleanupTests.ConverterAsyncCleanupIsAwaited.verified.txt b/src/Verify.Tests/Converters/AsyncCleanupTests.ConverterAsyncCleanupIsAwaited.verified.txt new file mode 100644 index 0000000000..497a50daaf --- /dev/null +++ b/src/Verify.Tests/Converters/AsyncCleanupTests.ConverterAsyncCleanupIsAwaited.verified.txt @@ -0,0 +1 @@ +content \ No newline at end of file diff --git a/src/Verify.Tests/Converters/AsyncCleanupTests.cs b/src/Verify.Tests/Converters/AsyncCleanupTests.cs new file mode 100644 index 0000000000..44213535a7 --- /dev/null +++ b/src/Verify.Tests/Converters/AsyncCleanupTests.cs @@ -0,0 +1,30 @@ +public class AsyncCleanupTests +{ + static bool asyncCleanupRan; + + [ModuleInitializer] + public static void Init() => + VerifierSettings.RegisterFileConverter( + (instance, _) => new( + info: null, + "txt", + instance.Value, + cleanup: async () => + { + await Task.Delay(200); + asyncCleanupRan = true; + })); + + [Fact] + public async Task ConverterAsyncCleanupIsAwaited() + { + asyncCleanupRan = false; + var target = new TargetForAsyncCleanup("content"); + await Verify(target); + // The converter's async cleanup must be awaited before Verify returns; + // composing cleanups with += only awaits the last one. + Assert.True(asyncCleanupRan); + } + + public record TargetForAsyncCleanup(string Value); +} diff --git a/src/Verify.Tests/StreamComparerTests.cs b/src/Verify.Tests/StreamComparerTests.cs index 5db835b3d7..a812e8d1d5 100644 --- a/src/Verify.Tests/StreamComparerTests.cs +++ b/src/Verify.Tests/StreamComparerTests.cs @@ -9,6 +9,36 @@ public async Task BinaryEquals() Assert.True(result.IsEqual); } + [Fact] + public async Task EqualWithLengthNotMultipleOfEight() + { + // 13 bytes exercises a partial final block; the trailing bytes of a + // rented buffer must not affect the result. + var bytes = new byte[13]; + for (var index = 0; index < bytes.Length; index++) + { + bytes[index] = (byte) index; + } + + using var stream1 = new MemoryStream(bytes); + using var stream2 = new MemoryStream((byte[]) bytes.Clone()); + var result = await StreamComparer.AreEqual(stream1, stream2); + Assert.True(result.IsEqual); + } + + [Fact] + public async Task NotEqualInPartialFinalBlock() + { + var bytes1 = new byte[13]; + var bytes2 = new byte[13]; + bytes2[12] = 1; + + using var stream1 = new MemoryStream(bytes1); + using var stream2 = new MemoryStream(bytes2); + var result = await StreamComparer.AreEqual(stream1, stream2); + Assert.False(result.IsEqual); + } + [Fact] public async Task BinaryNotEqualsSameLength() { diff --git a/src/Verify.Tests/VerifyXmlStreamTests.DisposesStream.verified.xml b/src/Verify.Tests/VerifyXmlStreamTests.DisposesStream.verified.xml new file mode 100644 index 0000000000..3b4bd3e948 --- /dev/null +++ b/src/Verify.Tests/VerifyXmlStreamTests.DisposesStream.verified.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Verify.Tests/VerifyXmlStreamTests.cs b/src/Verify.Tests/VerifyXmlStreamTests.cs new file mode 100644 index 0000000000..0acac3e139 --- /dev/null +++ b/src/Verify.Tests/VerifyXmlStreamTests.cs @@ -0,0 +1,11 @@ +public class VerifyXmlStreamTests +{ + [Fact] + public async Task DisposesStream() + { + var stream = new MemoryStream(""u8.ToArray()); + await VerifyXml(stream); + // VerifyXml(Stream) must dispose the stream, like the other stream APIs. + Assert.False(stream.CanRead); + } +} diff --git a/src/Verify/Compare/Comparer.cs b/src/Verify/Compare/Comparer.cs index 3e4a27b850..688e150132 100644 --- a/src/Verify/Compare/Comparer.cs +++ b/src/Verify/Compare/Comparer.cs @@ -9,7 +9,9 @@ public static async Task Text(FilePair filePair, StringBuilder r return new(Equality.New, null, received, null); } - var verified = await File.ReadAllTextAsync(filePair.VerifiedPath); + // Read with the configured encoding so a BOM-less non-UTF8 UseEncoding + // round-trips (writes go through the same encoding). + var verified = await File.ReadAllTextAsync(filePair.VerifiedPath, VerifierSettings.Encoding); if (verified.Contains('\r')) { throw new($@"Verified file must use \n line endings, but it contains a \r (carriage return). Path: {filePair.VerifiedPath}. See https://github.com/verifytests/verify#text-file-settings"); diff --git a/src/Verify/Compare/StreamComparer.cs b/src/Verify/Compare/StreamComparer.cs index 832a1df893..ec8931ead0 100644 --- a/src/Verify/Compare/StreamComparer.cs +++ b/src/Verify/Compare/StreamComparer.cs @@ -9,35 +9,41 @@ public static async Task AreEqual(Stream stream1, Stream stream2) EnsureAtStart(stream1); EnsureAtStart(stream2); - var buffer1 = new byte[bufferSize]; - var buffer2 = new byte[bufferSize]; - - while (true) + var buffer1 = ArrayPool.Shared.Rent(bufferSize); + var buffer2 = ArrayPool.Shared.Rent(bufferSize); + try { - var count1 = await ReadBufferAsync(stream1, buffer1); - var count2 = await ReadBufferAsync(stream2, buffer2); - - // Callers do not always guarantee the streams are the same length - // (e.g. a non-seekable received stream), so a length difference must - // be treated as not-equal instead of a short-circuit to equal. - if (count1 != count2) + while (true) { - return CompareResult.NotEqual(); - } + var count1 = await ReadBufferAsync(stream1, buffer1); + var count2 = await ReadBufferAsync(stream2, buffer2); - if (count1 == 0) - { - return CompareResult.Equal; - } + // Callers do not always guarantee the streams are the same length + // (e.g. a non-seekable received stream), so a length difference must + // be treated as not-equal instead of a short-circuit to equal. + if (count1 != count2) + { + return CompareResult.NotEqual(); + } - for (var i = 0; i < count1; i += sizeof(long)) - { - if (BitConverter.ToInt64(buffer1, i) != BitConverter.ToInt64(buffer2, i)) + if (count1 == 0) + { + return CompareResult.Equal; + } + + // Compare exactly the bytes read (a rented buffer's trailing bytes + // are arbitrary, so they must not be included). + if (!buffer1.AsSpan(0, count1).SequenceEqual(buffer2.AsSpan(0, count1))) { return CompareResult.NotEqual(); } } } + finally + { + ArrayPool.Shared.Return(buffer1); + ArrayPool.Shared.Return(buffer2); + } } static void EnsureAtStart(Stream stream) @@ -51,10 +57,13 @@ static void EnsureAtStart(Stream stream) static async Task ReadBufferAsync(Stream stream, byte[] buffer) { + // Read up to bufferSize (not buffer.Length): a rented buffer may be + // larger, and both streams must be read in equal-sized chunks so the + // length comparison above stays aligned. var bytesRead = 0; - while (bytesRead < buffer.Length) + while (bytesRead < bufferSize) { - var read = await stream.ReadAsync(buffer, bytesRead, buffer.Length - bytesRead); + var read = await stream.ReadAsync(buffer, bytesRead, bufferSize - bytesRead); if (read == 0) { // Reached end of stream. diff --git a/src/Verify/Extensions.cs b/src/Verify/Extensions.cs index 0873a696e9..359de280fa 100644 --- a/src/Verify/Extensions.cs +++ b/src/Verify/Extensions.cs @@ -35,6 +35,15 @@ public static string Extension(this FileStream file) public static Version MajorMinor(this Version version) => new(version.Major, version.Minor); + // Compose two async actions so both are awaited in order. Using `+=` on a + // Func builds a multicast delegate that only awaits the last target. + public static Func Then(this Func first, Func second) => + async () => + { + await first(); + await second(); + }; + public static async Task> ToList(this IAsyncEnumerable target) { var list = new List(); diff --git a/src/Verify/Verifier/InnerVerifier_Inner.cs b/src/Verify/Verifier/InnerVerifier_Inner.cs index 3bfc93d4e0..4259a81f2d 100644 --- a/src/Verify/Verifier/InnerVerifier_Inner.cs +++ b/src/Verify/Verifier/InnerVerifier_Inner.cs @@ -16,7 +16,7 @@ async Task VerifyInner(object? root, Func? cleanup, IEnumera cleanup ??= () => Task.CompletedTask; var (extraTargets, extraCleanup) = await GetTargets(targets, doExtensionConversion); - cleanup += extraCleanup; + cleanup = cleanup.Then(extraCleanup); resultTargets.AddRange(extraTargets); var engine = new VerifyEngine( directory, @@ -27,9 +27,15 @@ async Task VerifyInner(object? root, Func? cleanup, IEnumera settings.TypeName ?? typeName, settings.MethodName ?? methodName); - await engine.HandleResults(resultTargets); - - await cleanup(); + try + { + await engine.HandleResults(resultTargets); + } + finally + { + // Always run cleanup (stream/converter disposal), even if comparison throws. + await cleanup(); + } await engine.ThrowIfRequired(); @@ -59,7 +65,7 @@ async Task VerifyInner(object? root, Func? cleanup, IEnumera } var (info, converted, itemCleanup) = await DoExtensionConversion(target.Extension, target.StreamData, null, target.Name); - cleanup += itemCleanup; + cleanup = cleanup.Then(itemCleanup); if (info != null) { result.Add( diff --git a/src/Verify/Verifier/InnerVerifier_Stream.cs b/src/Verify/Verifier/InnerVerifier_Stream.cs index 51344cbd87..7c66fb9c52 100644 --- a/src/Verify/Verifier/InnerVerifier_Stream.cs +++ b/src/Verify/Verifier/InnerVerifier_Stream.cs @@ -151,7 +151,7 @@ static async Task GetTarget(Stream stream, string extension) var result = await conversion(target.Name, targetStream, settings.Context); if (result.Cleanup != null) { - cleanup += result.Cleanup; + cleanup = cleanup.Then(result.Cleanup); } if (result.Info != null) diff --git a/src/Verify/Verifier/InnerVerifier_Xml.cs b/src/Verify/Verifier/InnerVerifier_Xml.cs index 87cd17ea4a..b04b44406f 100644 --- a/src/Verify/Verifier/InnerVerifier_Xml.cs +++ b/src/Verify/Verifier/InnerVerifier_Xml.cs @@ -34,8 +34,11 @@ public async Task VerifyXml(Stream? target) return await VerifyInner(target, null, emptyTargets, true, false); } - var document = await XDocument.LoadAsync(target, LoadOptions.None, default); - return await VerifyXml(document); + using (target) + { + var document = await XDocument.LoadAsync(target, LoadOptions.None, default); + return await VerifyXml(document); + } } async Task VerifyXml(XmlNode? target) diff --git a/src/Verify/Verifier/VerifyEngine.cs b/src/Verify/Verifier/VerifyEngine.cs index a5e1d55ecb..562a50a88b 100644 --- a/src/Verify/Verifier/VerifyEngine.cs +++ b/src/Verify/Verifier/VerifyEngine.cs @@ -168,12 +168,11 @@ public async Task ThrowIfRequired() internal bool IsAutoVerify(string verifiedFile) { - if (typeName == null) - { - return false; - } - - if (VerifierSettings.autoVerify != null) + // The global delegate needs the type/method name; the per-settings + // delegate does not, so it must not be gated on typeName (which is null + // for the file-level InnerVerifier(directory, name) API). + if (typeName != null && + VerifierSettings.autoVerify != null) { return VerifierSettings.autoVerify(typeName, methodName!, verifiedFile); } From b2c6d9e5b04088404f4c503de696e250df2e46ce Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 5 Jul 2026 13:05:50 +0000 Subject: [PATCH 2/2] Docs changes --- docs/comparer.md | 55 ++++++++++++++++++++++++++++-------------------- docs/naming.md | 2 +- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/docs/comparer.md b/docs/comparer.md index a5cbf2ab0a..1254329715 100644 --- a/docs/comparer.md +++ b/docs/comparer.md @@ -119,35 +119,41 @@ public static async Task AreEqual(Stream stream1, Stream stream2) EnsureAtStart(stream1); EnsureAtStart(stream2); - var buffer1 = new byte[bufferSize]; - var buffer2 = new byte[bufferSize]; - - while (true) + var buffer1 = ArrayPool.Shared.Rent(bufferSize); + var buffer2 = ArrayPool.Shared.Rent(bufferSize); + try { - var count1 = await ReadBufferAsync(stream1, buffer1); - var count2 = await ReadBufferAsync(stream2, buffer2); - - // Callers do not always guarantee the streams are the same length - // (e.g. a non-seekable received stream), so a length difference must - // be treated as not-equal instead of a short-circuit to equal. - if (count1 != count2) + while (true) { - return CompareResult.NotEqual(); - } + var count1 = await ReadBufferAsync(stream1, buffer1); + var count2 = await ReadBufferAsync(stream2, buffer2); - if (count1 == 0) - { - return CompareResult.Equal; - } + // Callers do not always guarantee the streams are the same length + // (e.g. a non-seekable received stream), so a length difference must + // be treated as not-equal instead of a short-circuit to equal. + if (count1 != count2) + { + return CompareResult.NotEqual(); + } - for (var i = 0; i < count1; i += sizeof(long)) - { - if (BitConverter.ToInt64(buffer1, i) != BitConverter.ToInt64(buffer2, i)) + if (count1 == 0) + { + return CompareResult.Equal; + } + + // Compare exactly the bytes read (a rented buffer's trailing bytes + // are arbitrary, so they must not be included). + if (!buffer1.AsSpan(0, count1).SequenceEqual(buffer2.AsSpan(0, count1))) { return CompareResult.NotEqual(); } } } + finally + { + ArrayPool.Shared.Return(buffer1); + ArrayPool.Shared.Return(buffer2); + } } static void EnsureAtStart(Stream stream) @@ -161,10 +167,13 @@ static void EnsureAtStart(Stream stream) static async Task ReadBufferAsync(Stream stream, byte[] buffer) { + // Read up to bufferSize (not buffer.Length): a rented buffer may be + // larger, and both streams must be read in equal-sized chunks so the + // length comparison above stays aligned. var bytesRead = 0; - while (bytesRead < buffer.Length) + while (bytesRead < bufferSize) { - var read = await stream.ReadAsync(buffer, bytesRead, buffer.Length - bytesRead); + var read = await stream.ReadAsync(buffer, bytesRead, bufferSize - bytesRead); if (read == 0) { // Reached end of stream. @@ -177,7 +186,7 @@ static async Task ReadBufferAsync(Stream stream, byte[] buffer) return bytesRead; } ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/naming.md b/docs/naming.md index 125f7333d6..b62e2f443d 100644 --- a/docs/naming.md +++ b/docs/naming.md @@ -789,7 +789,7 @@ public static string NameWithParent(this Type type) return type.Name; } ``` -snippet source | anchor +snippet source | anchor Any path calculated in `DerivePathInfo` should be fully qualified to remove the inconsistency of the current directory.