From caeaeed6a6e4c4c7ba5b625ad949e19e368a0e64 Mon Sep 17 00:00:00 2001
From: Simon Cropp <simon.cropp@gmail.com>
Date: Sun, 5 Jul 2026 18:00:23 +1000
Subject: [PATCH 1/2] Fix correctness and perf issues in compare, recording,
 naming and checks

- StreamComparer: compare both streams' read counts and return NotEqual on a
  length difference, closing a silent false-pass for non-seekable/empty
  received streams
- Callbacks: await every registered OnFirstVerify/OnDelete/OnVerifyMismatch
  handler via GetInvocationList instead of only the last (static and instance)
- Recording.TryStop: snapshot the items then Clear and Pause the shared State,
  so a stop consumed inside the engine's async flow is observable to the test
  (no re-appending on a second verify, IsRecording reflects it)
- Recording: Disposable/NamedDisposable use TryPause so disposing after an
  explicit Stop is a no-op instead of throwing
- Recording.IgnoreNames: copy-on-write HashSet read via Volatile.Read, so
  IsIgnored on Recording.Add cannot race a concurrent mutation
- MatchingFileFinder: account for a trailing separator on the directory so
  UseDirectory("snapshots/") still finds verified files and cleans received
- InnerVerifyChecks: look for .gitignore (was .gitIgnore), so the check runs on
  case-sensitive file systems; message updated to match
- Naming: pass pathFriendly through to collection items so pathFriendly:false
  callers keep path chars raw in snapshot content
- DanglingSnapshotsCheck: materialize tracked files into HashSets once instead
  of LINQ Contains over a ConcurrentBag per file (O(files*tracked) -> O(files))
- Add tests for the above; update the two VerifyChecksTests snapshots for the
  .gitignore casing
---
 src/Verify.Tests/CallbackTests.cs             | 29 +++++++++++
 .../VerifyChecksTests.GitIgnore.verified.txt  |  4 +-
 .../VerifyChecksTests.Invalid.verified.txt    |  4 +-
 .../Naming/MatchingFileFinderTests.cs         | 51 +++++++++++++++++++
 .../Naming/NameForParameterTests.cs           | 15 ++++++
 src/Verify.Tests/RecordingTests.cs            | 31 +++++++++++
 src/Verify.Tests/StreamComparerTests.cs       | 28 ++++++++++
 src/Verify/Callbacks/VerifierSettings.cs      | 30 +++++++----
 src/Verify/Callbacks/VerifySettings.cs        | 16 ++++--
 src/Verify/Compare/StreamComparer.cs          | 17 ++++---
 .../ConventionCheck/DanglingSnapshotsCheck.cs | 10 +++-
 .../ConventionCheck/InnerVerifyChecks.cs      |  6 +--
 src/Verify/Naming/MatchingFileFinder.cs       | 12 ++++-
 src/Verify/Naming/VerifierSettings.cs         |  2 +-
 src/Verify/Recording/Recording.cs             | 37 +++++++++++---
 src/Verify/Recording/Recording_Named.cs       |  4 +-
 16 files changed, 260 insertions(+), 36 deletions(-)
 create mode 100644 src/Verify.Tests/CallbackTests.cs
 create mode 100644 src/Verify.Tests/Naming/MatchingFileFinderTests.cs

diff --git a/src/Verify.Tests/CallbackTests.cs b/src/Verify.Tests/CallbackTests.cs
new file mode 100644
index 0000000000..ab132c4110
--- /dev/null
+++ b/src/Verify.Tests/CallbackTests.cs
@@ -0,0 +1,29 @@
+public class CallbackTests
+{
+    [Fact]
+    public async Task OnFirstVerifyAwaitsAllHandlers()
+    {
+        var settings = new VerifySettings();
+        var ran = new List<int>();
+        // The slower handler is registered first: if only the last handler is
+        // awaited (the bug), it will not have completed by the assert.
+        settings.OnFirstVerify(
+            async (_, _, _) =>
+            {
+                await Task.Delay(200);
+                ran.Add(1);
+            });
+        settings.OnFirstVerify(
+            async (_, _, _) =>
+            {
+                await Task.Delay(1);
+                ran.Add(2);
+            });
+
+        var filePair = new FilePair("txt", "received.txt", "verified.txt");
+        var result = new NewResult(filePair, null);
+        await settings.RunOnFirstVerify(result, false);
+
+        Assert.Equal(2, ran.Count);
+    }
+}
diff --git a/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GitIgnore.verified.txt b/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GitIgnore.verified.txt
index ebb01c0e49..84bb9d1fe4 100644
--- a/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GitIgnore.verified.txt
+++ b/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GitIgnore.verified.txt
@@ -1,8 +1,8 @@
 ﻿{
   Type: VerifyCheckException,
   Message:
-Expected .gitIgnore to contain settings for Verify.
-Path: file:///{ProjectDirectory}InnerVerifyChecksTests/Invalid/.gitIgnore
+Expected .gitignore to contain settings for Verify.
+Path: file:///{ProjectDirectory}InnerVerifyChecksTests/Invalid/.gitignore
 Recommended settings:
 
 # Verify
diff --git a/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.Invalid.verified.txt b/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.Invalid.verified.txt
index ebb01c0e49..84bb9d1fe4 100644
--- a/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.Invalid.verified.txt
+++ b/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.Invalid.verified.txt
@@ -1,8 +1,8 @@
 ﻿{
   Type: VerifyCheckException,
   Message:
-Expected .gitIgnore to contain settings for Verify.
-Path: file:///{ProjectDirectory}InnerVerifyChecksTests/Invalid/.gitIgnore
+Expected .gitignore to contain settings for Verify.
+Path: file:///{ProjectDirectory}InnerVerifyChecksTests/Invalid/.gitignore
 Recommended settings:
 
 # Verify
diff --git a/src/Verify.Tests/Naming/MatchingFileFinderTests.cs b/src/Verify.Tests/Naming/MatchingFileFinderTests.cs
new file mode 100644
index 0000000000..16a4eddf20
--- /dev/null
+++ b/src/Verify.Tests/Naming/MatchingFileFinderTests.cs
@@ -0,0 +1,51 @@
+public class MatchingFileFinderTests
+{
+    [Fact]
+    public void FindVerifiedWithTrailingSeparator()
+    {
+        var directory = CreateTempDirectory();
+        try
+        {
+            File.WriteAllText(Path.Combine(directory, "SomeTest.verified.txt"), "a");
+            File.WriteAllText(Path.Combine(directory, "Other.verified.txt"), "b");
+
+            var withSeparator = directory + Path.DirectorySeparatorChar;
+            var found = MatchingFileFinder.FindVerified("SomeTest", withSeparator)
+                .ToList();
+
+            Assert.Single(found);
+            Assert.EndsWith("SomeTest.verified.txt", found[0]);
+        }
+        finally
+        {
+            Directory.Delete(directory, true);
+        }
+    }
+
+    [Fact]
+    public void FindVerifiedWithoutTrailingSeparator()
+    {
+        var directory = CreateTempDirectory();
+        try
+        {
+            File.WriteAllText(Path.Combine(directory, "SomeTest.verified.txt"), "a");
+            File.WriteAllText(Path.Combine(directory, "Other.verified.txt"), "b");
+
+            var found = MatchingFileFinder.FindVerified("SomeTest", directory)
+                .ToList();
+
+            Assert.Single(found);
+        }
+        finally
+        {
+            Directory.Delete(directory, true);
+        }
+    }
+
+    static string CreateTempDirectory()
+    {
+        var directory = Path.Combine(Path.GetTempPath(), $"MatchingFileFinder{Guid.NewGuid():N}");
+        Directory.CreateDirectory(directory);
+        return directory;
+    }
+}
diff --git a/src/Verify.Tests/Naming/NameForParameterTests.cs b/src/Verify.Tests/Naming/NameForParameterTests.cs
index 973f201b17..d006f67f77 100644
--- a/src/Verify.Tests/Naming/NameForParameterTests.cs
+++ b/src/Verify.Tests/Naming/NameForParameterTests.cs
@@ -12,6 +12,21 @@ public Task StringEmpty() =>
     public Task StringInvalidPathChar() =>
         Verify(VerifierSettings.GetNameForParameter("a/a", counter: CounterBuilder.Empty()));
 
+    [Fact]
+    public void CollectionItemPathFriendlyFalseNotCleaned()
+    {
+        // pathFriendly must flow to collection items, so path chars are kept
+        // raw when the caller does not want file-name cleaning applied.
+        var name = VerifierSettings.GetNameForParameter(
+            new List<string>
+            {
+                "a/b"
+            },
+            counter: CounterBuilder.Empty(),
+            pathFriendly: false);
+        Assert.Contains("a/b", name);
+    }
+
     [Fact]
     public Task Int() =>
         Verify(VerifierSettings.GetNameForParameter(10, counter: CounterBuilder.Empty()));
diff --git a/src/Verify.Tests/RecordingTests.cs b/src/Verify.Tests/RecordingTests.cs
index fd4a7c581a..edff2ad480 100644
--- a/src/Verify.Tests/RecordingTests.cs
+++ b/src/Verify.Tests/RecordingTests.cs
@@ -10,6 +10,37 @@ public Task IgnoreNames()
         return Verify();
     }
 
+    [Fact]
+    public async Task StoppedInChildContextIsNotRecording()
+    {
+        Recording.Start();
+        Recording.Add("name", "value");
+        // Consuming inside a child execution context (as the verify engine does)
+        // must not leave the recording active in this context.
+        await Task.Run(() => Recording.TryStop(out _));
+        Assert.False(Recording.IsRecording());
+    }
+
+    [Fact]
+    public void DisposeAfterStopDoesNotThrow()
+    {
+        using (Recording.Start())
+        {
+            Recording.Add("name", "value");
+            Recording.Stop();
+        }
+    }
+
+    [Fact]
+    public void DisposeNamedAfterStopDoesNotThrow()
+    {
+        using (Recording.Start("DisposeNamedAfterStop"))
+        {
+            Recording.Add("DisposeNamedAfterStop", "name", "value");
+            Recording.Stop("DisposeNamedAfterStop");
+        }
+    }
+
     [Fact]
     public Task Dates()
     {
diff --git a/src/Verify.Tests/StreamComparerTests.cs b/src/Verify.Tests/StreamComparerTests.cs
index 58d66c73ca..5db835b3d7 100644
--- a/src/Verify.Tests/StreamComparerTests.cs
+++ b/src/Verify.Tests/StreamComparerTests.cs
@@ -35,6 +35,34 @@ public async Task BinaryNotEquals()
         Assert.False(result.IsEqual);
     }
 
+    [Fact]
+    public async Task PrefixIsNotEqual()
+    {
+        // received is an 8-byte-aligned prefix of verified; must not be Equal.
+        using var received = new MemoryStream(new byte[16]);
+        using var verified = new MemoryStream(new byte[32]);
+        var result = await StreamComparer.AreEqual(received, verified);
+        Assert.False(result.IsEqual);
+    }
+
+    [Fact]
+    public async Task EmptyReceivedIsNotEqual()
+    {
+        using var received = new MemoryStream();
+        using var verified = new MemoryStream(new byte[16]);
+        var result = await StreamComparer.AreEqual(received, verified);
+        Assert.False(result.IsEqual);
+    }
+
+    [Fact]
+    public async Task LongerReceivedIsNotEqual()
+    {
+        using var received = new MemoryStream(new byte[32]);
+        using var verified = new MemoryStream(new byte[16]);
+        var result = await StreamComparer.AreEqual(received, verified);
+        Assert.False(result.IsEqual);
+    }
+
     [Fact]
     public async Task ShouldNotLock()
     {
diff --git a/src/Verify/Callbacks/VerifierSettings.cs b/src/Verify/Callbacks/VerifierSettings.cs
index 4b2a07185e..7fd12a72e3 100644
--- a/src/Verify/Callbacks/VerifierSettings.cs
+++ b/src/Verify/Callbacks/VerifierSettings.cs
@@ -35,14 +35,20 @@ public static void OnFirstVerify(FirstVerify firstVerify)
         handleOnFirstVerify += firstVerify;
     }
 
-    internal static Task RunOnFirstVerify(NewResult item, bool autoVerify)
+    internal static async Task RunOnFirstVerify(NewResult item, bool autoVerify)
     {
         if (handleOnFirstVerify is null)
         {
-            return Task.CompletedTask;
+            return;
         }
 
-        return handleOnFirstVerify(item.File, item.ReceivedText?.ToString(), autoVerify);
+        var receivedText = item.ReceivedText?.ToString();
+        // Await every registered handler; invoking the multicast delegate
+        // directly would only await the last handler's Task.
+        foreach (var handler in handleOnFirstVerify.GetInvocationList())
+        {
+            await ((FirstVerify) handler)(item.File, receivedText, autoVerify);
+        }
     }
 
     static VerifyDelete? handleOnVerifyDelete;
@@ -55,24 +61,30 @@ public static void OnDelete(VerifyDelete verifyDelete)
 
     static VerifyMismatch? handleOnVerifyMismatch;
 
-    internal static Task RunOnVerifyDelete(string file, bool autoVerify)
+    internal static async Task RunOnVerifyDelete(string file, bool autoVerify)
     {
         if (handleOnVerifyDelete is null)
         {
-            return Task.CompletedTask;
+            return;
         }
 
-        return handleOnVerifyDelete(file, autoVerify);
+        foreach (var handler in handleOnVerifyDelete.GetInvocationList())
+        {
+            await ((VerifyDelete) handler)(file, autoVerify);
+        }
     }
 
-    internal static Task RunOnVerifyMismatch(FilePair item, string? message, bool autoVerify)
+    internal static async Task RunOnVerifyMismatch(FilePair item, string? message, bool autoVerify)
     {
         if (handleOnVerifyMismatch is null)
         {
-            return Task.CompletedTask;
+            return;
         }
 
-        return handleOnVerifyMismatch(item, message, autoVerify);
+        foreach (var handler in handleOnVerifyMismatch.GetInvocationList())
+        {
+            await ((VerifyMismatch) handler)(item, message, autoVerify);
+        }
     }
 
     public static void OnVerifyMismatch(VerifyMismatch verifyMismatch)
diff --git a/src/Verify/Callbacks/VerifySettings.cs b/src/Verify/Callbacks/VerifySettings.cs
index 6e36bedb7d..dab8111cd2 100644
--- a/src/Verify/Callbacks/VerifySettings.cs
+++ b/src/Verify/Callbacks/VerifySettings.cs
@@ -10,7 +10,11 @@ internal async Task RunOnFirstVerify(NewResult item, bool autoVerify)
     {
         if (handleOnFirstVerify is not null)
         {
-            await handleOnFirstVerify(item.File, item.ReceivedText?.ToString(), autoVerify);
+            var receivedText = item.ReceivedText?.ToString();
+            foreach (var handler in handleOnFirstVerify.GetInvocationList())
+            {
+                await ((FirstVerify) handler)(item.File, receivedText, autoVerify);
+            }
         }
 
         await VerifierSettings.RunOnFirstVerify(item, autoVerify);
@@ -26,7 +30,10 @@ internal async Task RunOnVerifyDelete(string file, bool autoVerify)
     {
         if (handleOnVerifyDelete is not null)
         {
-            await handleOnVerifyDelete(file, autoVerify);
+            foreach (var handler in handleOnVerifyDelete.GetInvocationList())
+            {
+                await ((VerifyDelete) handler)(file, autoVerify);
+            }
         }
 
         await VerifierSettings.RunOnVerifyDelete(file, autoVerify);
@@ -38,7 +45,10 @@ internal async Task RunOnVerifyMismatch(FilePair item, string? message, bool aut
     {
         if (handleOnVerifyMismatch is not null)
         {
-            await handleOnVerifyMismatch(item, message, autoVerify);
+            foreach (var handler in handleOnVerifyMismatch.GetInvocationList())
+            {
+                await ((VerifyMismatch) handler)(item, message, autoVerify);
+            }
         }
 
         await VerifierSettings.RunOnVerifyMismatch(item, message, autoVerify);
diff --git a/src/Verify/Compare/StreamComparer.cs b/src/Verify/Compare/StreamComparer.cs
index ee8741e7ad..832a1df893 100644
--- a/src/Verify/Compare/StreamComparer.cs
+++ b/src/Verify/Compare/StreamComparer.cs
@@ -14,18 +14,23 @@ public static async Task<CompareResult> AreEqual(Stream stream1, Stream stream2)
 
         while (true)
         {
-            var count = await ReadBufferAsync(stream1, buffer1);
+            var count1 = await ReadBufferAsync(stream1, buffer1);
+            var count2 = await ReadBufferAsync(stream2, buffer2);
 
-            //no need to compare size here since only enter on files being same size
+            // 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();
+            }
 
-            if (count == 0)
+            if (count1 == 0)
             {
                 return CompareResult.Equal;
             }
 
-            await ReadBufferAsync(stream2, buffer2);
-
-            for (var i = 0; i < count; i += sizeof(long))
+            for (var i = 0; i < count1; i += sizeof(long))
             {
                 if (BitConverter.ToInt64(buffer1, i) != BitConverter.ToInt64(buffer2, i))
                 {
diff --git a/src/Verify/ConventionCheck/DanglingSnapshotsCheck.cs b/src/Verify/ConventionCheck/DanglingSnapshotsCheck.cs
index 7c5aa6164f..d2d1d2bba9 100644
--- a/src/Verify/ConventionCheck/DanglingSnapshotsCheck.cs
+++ b/src/Verify/ConventionCheck/DanglingSnapshotsCheck.cs
@@ -41,11 +41,17 @@ static void AppendItems(StringBuilder builder, List<string> list, string title)
             }
         }
 
+        // Materialize the tracked files into sets once. Enumerating a ConcurrentBag
+        // (via LINQ Contains) per file on disk snapshots the whole bag each time,
+        // making the check O(files * tracked).
+        var tracked = new HashSet<string>(trackedFiles);
+        var trackedIgnoreCase = new HashSet<string>(trackedFiles, StringComparer.OrdinalIgnoreCase);
+
         List<string> untracked = [];
         List<string> incorrectCase = [];
         foreach (var file in filesOnDisk)
         {
-            if (trackedFiles.Contains(file))
+            if (tracked.Contains(file))
             {
                 continue;
             }
@@ -57,7 +63,7 @@ static void AppendItems(StringBuilder builder, List<string> list, string title)
                 continue;
             }
 
-            if (trackedFiles.Contains(file, StringComparer.OrdinalIgnoreCase))
+            if (trackedIgnoreCase.Contains(file))
             {
                 incorrectCase.Add(suffix);
             }
diff --git a/src/Verify/ConventionCheck/InnerVerifyChecks.cs b/src/Verify/ConventionCheck/InnerVerifyChecks.cs
index e4cc2f1767..945afdcce8 100644
--- a/src/Verify/ConventionCheck/InnerVerifyChecks.cs
+++ b/src/Verify/ConventionCheck/InnerVerifyChecks.cs
@@ -338,10 +338,10 @@ static string GetPath(string path) =>
 
     internal static async Task CheckGitIgnore(string solutionDirectory)
     {
-        var path = Path.Combine(solutionDirectory, ".gitIgnore");
+        var path = Path.Combine(solutionDirectory, ".gitignore");
         if (!File.Exists(path))
         {
-            path = Path.Combine(solutionDirectory, "../.gitIgnore");
+            path = Path.Combine(solutionDirectory, "../.gitignore");
         }
 
         if (!File.Exists(path))
@@ -359,7 +359,7 @@ internal static async Task CheckGitIgnore(string solutionDirectory)
 
         throw new VerifyCheckException(
             $"""
-             Expected .gitIgnore to contain settings for Verify.
+             Expected .gitignore to contain settings for Verify.
              Path: {GetPath(path)}
              Recommended settings:
 
diff --git a/src/Verify/Naming/MatchingFileFinder.cs b/src/Verify/Naming/MatchingFileFinder.cs
index 07cdbbc7a0..f7a1450be0 100644
--- a/src/Verify/Naming/MatchingFileFinder.cs
+++ b/src/Verify/Naming/MatchingFileFinder.cs
@@ -21,7 +21,17 @@ public static IEnumerable<string> FindVerified(string fileNamePrefix, string dir
 
     static List<string> Find(string directory, string searchPattern, string nonIndexedPattern, string indexedPattern)
     {
-        var startIndex = directory.Length + 1;
+        // Directory.EnumerateFiles inserts a separator only when the directory
+        // does not already end with one, so the file-name offset depends on
+        // whether the directory has a trailing separator (e.g. UseDirectory("snapshots/")).
+        var startIndex = directory.Length;
+        if (directory.Length > 0 &&
+            directory[^1] != Path.DirectorySeparatorChar &&
+            directory[^1] != Path.AltDirectorySeparatorChar)
+        {
+            startIndex++;
+        }
+
         var list = new List<string>();
         var nonIndexedPatternSpan = nonIndexedPattern.AsSpan();
         var indexedPatternSpan = indexedPattern.AsSpan();
diff --git a/src/Verify/Naming/VerifierSettings.cs b/src/Verify/Naming/VerifierSettings.cs
index 0abe0351e2..2bfa08659f 100644
--- a/src/Verify/Naming/VerifierSettings.cs
+++ b/src/Verify/Naming/VerifierSettings.cs
@@ -139,7 +139,7 @@ public static void AppendParameter(object? parameter, StringBuilder builder, boo
 
                 foreach (var item in enumerable)
                 {
-                    AppendParameter(item, builder, false, counter);
+                    AppendParameter(item, builder, false, counter, pathFriendly);
                     builder.Append(',');
                 }
 
diff --git a/src/Verify/Recording/Recording.cs b/src/Verify/Recording/Recording.cs
index 3e95f62661..f24828ca48 100644
--- a/src/Verify/Recording/Recording.cs
+++ b/src/Verify/Recording/Recording.cs
@@ -2,13 +2,28 @@
 
 public static partial class Recording
 {
-    static List<string> ignored = [];
+    static HashSet<string> ignored = [];
+    static readonly object ignoredLock = new();
 
-    public static void IgnoreNames(params string[] names) =>
-        ignored.AddRange(names);
+    public static void IgnoreNames(params string[] names)
+    {
+        // Copy-on-write: IsIgnored reads this on every Recording.Add, potentially
+        // from other threads, so publish a new set rather than mutating in place.
+        lock (ignoredLock)
+        {
+            var copy = new HashSet<string>(ignored);
+            foreach (var name in names)
+            {
+                copy.Add(name);
+            }
+
+            ignored = copy;
+        }
+    }
 
     public static bool IsIgnored(string name) =>
-        ignored.Contains(name);
+        Volatile.Read(ref ignored)
+            .Contains(name);
 
     static AsyncLocal<State?> asyncLocal = new();
 
@@ -57,7 +72,15 @@ public static bool TryStop([NotNullWhen(true)] out IReadOnlyCollection<ToAppend>
             return false;
         }
 
-        recorded = value.Items;
+        // Nulling asyncLocal only affects the current execution context. When the
+        // verify engine consumes a recording mid-verify, that null does not flow
+        // back to the calling test, which would otherwise keep seeing an active,
+        // unconsumed recording (re-appending the same items on the next verify).
+        // Snapshot the items, then stop the shared State so the stop is observable
+        // through the caller's reference.
+        recorded = value.Items.ToList();
+        value.Clear();
+        value.Pause();
         asyncLocal.Value = null;
         return true;
     }
@@ -114,8 +137,10 @@ public static IDisposable Start()
     class Disposable :
         IDisposable
     {
+        // TryPause (not Pause) so disposing after an explicit Stop is a no-op
+        // rather than throwing from CurrentState.
         public void Dispose() =>
-            Pause();
+            TryPause();
     }
 
     public static void Pause() =>
diff --git a/src/Verify/Recording/Recording_Named.cs b/src/Verify/Recording/Recording_Named.cs
index 00c6232ce9..86bb07f10c 100644
--- a/src/Verify/Recording/Recording_Named.cs
+++ b/src/Verify/Recording/Recording_Named.cs
@@ -75,8 +75,10 @@ public static IDisposable Start(string identifier)
     class NamedDisposable(string identifier) :
         IDisposable
     {
+        // TryPause (not Pause) so disposing after an explicit Stop is a no-op
+        // rather than throwing from CurrentStateNamed.
         public void Dispose() =>
-            Pause(identifier);
+            TryPause(identifier);
     }
 
     public static void Pause(string identifier) =>

From 7d24d339b3fc5cfb718a5b7894245e72289e5be8 Mon Sep 17 00:00:00 2001
From: GitHub Action <action@github.com>
Date: Sun, 5 Jul 2026 08:01:14 +0000
Subject: [PATCH 2/2] Docs changes

---
 docs/comparer.md  | 19 ++++++++++++-------
 docs/recording.md | 22 +++++++++++-----------
 2 files changed, 23 insertions(+), 18 deletions(-)

diff --git a/docs/comparer.md b/docs/comparer.md
index 22f93e6e7c..a5cbf2ab0a 100644
--- a/docs/comparer.md
+++ b/docs/comparer.md
@@ -124,18 +124,23 @@ public static async Task<CompareResult> AreEqual(Stream stream1, Stream stream2)
 
     while (true)
     {
-        var count = await ReadBufferAsync(stream1, buffer1);
+        var count1 = await ReadBufferAsync(stream1, buffer1);
+        var count2 = await ReadBufferAsync(stream2, buffer2);
 
-        //no need to compare size here since only enter on files being same size
+        // 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();
+        }
 
-        if (count == 0)
+        if (count1 == 0)
         {
             return CompareResult.Equal;
         }
 
-        await ReadBufferAsync(stream2, buffer2);
-
-        for (var i = 0; i < count; i += sizeof(long))
+        for (var i = 0; i < count1; i += sizeof(long))
         {
             if (BitConverter.ToInt64(buffer1, i) != BitConverter.ToInt64(buffer2, i))
             {
@@ -172,7 +177,7 @@ static async Task<int> ReadBufferAsync(Stream stream, byte[] buffer)
     return bytesRead;
 }
 ```
-<sup><a href='/src/Verify/Compare/StreamComparer.cs#L3-L65' title='Snippet source file'>snippet source</a> | <a href='#snippet-DefualtCompare' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify/Compare/StreamComparer.cs#L3-L70' title='Snippet source file'>snippet source</a> | <a href='#snippet-DefualtCompare' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 
diff --git a/docs/recording.md b/docs/recording.md
index 137857f7ac..998e98a0c3 100644
--- a/docs/recording.md
+++ b/docs/recording.md
@@ -25,7 +25,7 @@ public Task Usage()
     return Verify("TheValue");
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L23-L33' title='Snippet source file'>snippet source</a> | <a href='#snippet-Recording' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L54-L64' title='Snippet source file'>snippet source</a> | <a href='#snippet-Recording' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 Results in:
@@ -61,7 +61,7 @@ public Task TryAdd()
     return Verify("TheValue");
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L59-L71' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingTryAdd' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L90-L102' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingTryAdd' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 
@@ -85,7 +85,7 @@ public Task RecordingScoped()
     return Verify();
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L82-L97' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingScoped' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L113-L128' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingScoped' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 Results in:
@@ -117,7 +117,7 @@ public Task SameKey()
     return Verify("TheValue");
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L281-L292' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingSameKey' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L312-L323' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingSameKey' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 Results in:
@@ -156,7 +156,7 @@ public Task Identifier()
     return Verify(Recording.Stop("identifier"));
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L99-L109' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingIdentifier' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L130-L140' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingIdentifier' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 Results in:
@@ -188,7 +188,7 @@ public Task Case()
     return Verify("TheValue");
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L303-L314' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingIgnoreCase' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L334-L345' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingIgnoreCase' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 Results in:
@@ -223,7 +223,7 @@ public Task Stop()
     return Verify(appends.Where(_ => _.Name != "name1"));
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L141-L153' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingStop' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L172-L184' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingStop' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 Results in:
@@ -255,7 +255,7 @@ public Task StopNotInResult()
     return Verify("other data");
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L155-L167' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingStopNotInResult' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L186-L198' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingStopNotInResult' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 Results in:
@@ -284,7 +284,7 @@ public void IsRecording()
     Assert.True(Recording.IsRecording());
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L111-L121' title='Snippet source file'>snippet source</a> | <a href='#snippet-IsRecording' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L142-L152' title='Snippet source file'>snippet source</a> | <a href='#snippet-IsRecording' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 This can be helpful if the cost of capturing data, to add to recording, is high.
@@ -307,7 +307,7 @@ public Task Clear()
     return Verify();
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L201-L213' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingClear' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L232-L244' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingClear' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 Results in:
@@ -343,7 +343,7 @@ public Task PauseResume()
     return Verify();
 }
 ```
-<sup><a href='/src/Verify.Tests/RecordingTests.cs#L225-L240' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingPauseResume' title='Start of snippet'>anchor</a></sup>
+<sup><a href='/src/Verify.Tests/RecordingTests.cs#L256-L271' title='Snippet source file'>snippet source</a> | <a href='#snippet-RecordingPauseResume' title='Start of snippet'>anchor</a></sup>
 <!-- endSnippet -->
 
 Results in:
