Skip to content

Commit b514fa9

Browse files
Fix verifier cleanup composition, stream/xml disposal and comparison (#1771)
* Fix verifier cleanup composition, stream/xml disposal and comparison - Compose async cleanups with a Then helper so both are awaited; `+=` on a Func<Task> 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 * Docs changes --------- Co-authored-by: GitHub Action <action@github.com>
1 parent 47a50c0 commit b514fa9

14 files changed

Lines changed: 171 additions & 61 deletions

docs/comparer.md

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -119,35 +119,41 @@ public static async Task<CompareResult> AreEqual(Stream stream1, Stream stream2)
119119
EnsureAtStart(stream1);
120120
EnsureAtStart(stream2);
121121

122-
var buffer1 = new byte[bufferSize];
123-
var buffer2 = new byte[bufferSize];
124-
125-
while (true)
122+
var buffer1 = ArrayPool<byte>.Shared.Rent(bufferSize);
123+
var buffer2 = ArrayPool<byte>.Shared.Rent(bufferSize);
124+
try
126125
{
127-
var count1 = await ReadBufferAsync(stream1, buffer1);
128-
var count2 = await ReadBufferAsync(stream2, buffer2);
129-
130-
// Callers do not always guarantee the streams are the same length
131-
// (e.g. a non-seekable received stream), so a length difference must
132-
// be treated as not-equal instead of a short-circuit to equal.
133-
if (count1 != count2)
126+
while (true)
134127
{
135-
return CompareResult.NotEqual();
136-
}
128+
var count1 = await ReadBufferAsync(stream1, buffer1);
129+
var count2 = await ReadBufferAsync(stream2, buffer2);
137130

138-
if (count1 == 0)
139-
{
140-
return CompareResult.Equal;
141-
}
131+
// Callers do not always guarantee the streams are the same length
132+
// (e.g. a non-seekable received stream), so a length difference must
133+
// be treated as not-equal instead of a short-circuit to equal.
134+
if (count1 != count2)
135+
{
136+
return CompareResult.NotEqual();
137+
}
142138

143-
for (var i = 0; i < count1; i += sizeof(long))
144-
{
145-
if (BitConverter.ToInt64(buffer1, i) != BitConverter.ToInt64(buffer2, i))
139+
if (count1 == 0)
140+
{
141+
return CompareResult.Equal;
142+
}
143+
144+
// Compare exactly the bytes read (a rented buffer's trailing bytes
145+
// are arbitrary, so they must not be included).
146+
if (!buffer1.AsSpan(0, count1).SequenceEqual(buffer2.AsSpan(0, count1)))
146147
{
147148
return CompareResult.NotEqual();
148149
}
149150
}
150151
}
152+
finally
153+
{
154+
ArrayPool<byte>.Shared.Return(buffer1);
155+
ArrayPool<byte>.Shared.Return(buffer2);
156+
}
151157
}
152158

153159
static void EnsureAtStart(Stream stream)
@@ -161,10 +167,13 @@ static void EnsureAtStart(Stream stream)
161167

162168
static async Task<int> ReadBufferAsync(Stream stream, byte[] buffer)
163169
{
170+
// Read up to bufferSize (not buffer.Length): a rented buffer may be
171+
// larger, and both streams must be read in equal-sized chunks so the
172+
// length comparison above stays aligned.
164173
var bytesRead = 0;
165-
while (bytesRead < buffer.Length)
174+
while (bytesRead < bufferSize)
166175
{
167-
var read = await stream.ReadAsync(buffer, bytesRead, buffer.Length - bytesRead);
176+
var read = await stream.ReadAsync(buffer, bytesRead, bufferSize - bytesRead);
168177
if (read == 0)
169178
{
170179
// Reached end of stream.
@@ -177,7 +186,7 @@ static async Task<int> ReadBufferAsync(Stream stream, byte[] buffer)
177186
return bytesRead;
178187
}
179188
```
180-
<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>
189+
<sup><a href='/src/Verify/Compare/StreamComparer.cs#L3-L79' title='Snippet source file'>snippet source</a> | <a href='#snippet-DefualtCompare' title='Start of snippet'>anchor</a></sup>
181190
<!-- endSnippet -->
182191

183192

docs/naming.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,7 @@ public static string NameWithParent(this Type type)
789789
return type.Name;
790790
}
791791
```
792-
<sup><a href='/src/Verify/Extensions.cs#L123-L135' title='Snippet source file'>snippet source</a> | <a href='#snippet-NameWithParent' title='Start of snippet'>anchor</a></sup>
792+
<sup><a href='/src/Verify/Extensions.cs#L132-L144' title='Snippet source file'>snippet source</a> | <a href='#snippet-NameWithParent' title='Start of snippet'>anchor</a></sup>
793793
<!-- endSnippet -->
794794

795795
Any path calculated in `DerivePathInfo` should be fully qualified to remove the inconsistency of the current directory.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
content
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
public class AsyncCleanupTests
2+
{
3+
static bool asyncCleanupRan;
4+
5+
[ModuleInitializer]
6+
public static void Init() =>
7+
VerifierSettings.RegisterFileConverter<TargetForAsyncCleanup>(
8+
(instance, _) => new(
9+
info: null,
10+
"txt",
11+
instance.Value,
12+
cleanup: async () =>
13+
{
14+
await Task.Delay(200);
15+
asyncCleanupRan = true;
16+
}));
17+
18+
[Fact]
19+
public async Task ConverterAsyncCleanupIsAwaited()
20+
{
21+
asyncCleanupRan = false;
22+
var target = new TargetForAsyncCleanup("content");
23+
await Verify(target);
24+
// The converter's async cleanup must be awaited before Verify returns;
25+
// composing cleanups with += only awaits the last one.
26+
Assert.True(asyncCleanupRan);
27+
}
28+
29+
public record TargetForAsyncCleanup(string Value);
30+
}

src/Verify.Tests/StreamComparerTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,36 @@ public async Task BinaryEquals()
99
Assert.True(result.IsEqual);
1010
}
1111

12+
[Fact]
13+
public async Task EqualWithLengthNotMultipleOfEight()
14+
{
15+
// 13 bytes exercises a partial final block; the trailing bytes of a
16+
// rented buffer must not affect the result.
17+
var bytes = new byte[13];
18+
for (var index = 0; index < bytes.Length; index++)
19+
{
20+
bytes[index] = (byte) index;
21+
}
22+
23+
using var stream1 = new MemoryStream(bytes);
24+
using var stream2 = new MemoryStream((byte[]) bytes.Clone());
25+
var result = await StreamComparer.AreEqual(stream1, stream2);
26+
Assert.True(result.IsEqual);
27+
}
28+
29+
[Fact]
30+
public async Task NotEqualInPartialFinalBlock()
31+
{
32+
var bytes1 = new byte[13];
33+
var bytes2 = new byte[13];
34+
bytes2[12] = 1;
35+
36+
using var stream1 = new MemoryStream(bytes1);
37+
using var stream2 = new MemoryStream(bytes2);
38+
var result = await StreamComparer.AreEqual(stream1, stream2);
39+
Assert.False(result.IsEqual);
40+
}
41+
1242
[Fact]
1343
public async Task BinaryNotEqualsSameLength()
1444
{
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<root />
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
public class VerifyXmlStreamTests
2+
{
3+
[Fact]
4+
public async Task DisposesStream()
5+
{
6+
var stream = new MemoryStream("<root />"u8.ToArray());
7+
await VerifyXml(stream);
8+
// VerifyXml(Stream) must dispose the stream, like the other stream APIs.
9+
Assert.False(stream.CanRead);
10+
}
11+
}

src/Verify/Compare/Comparer.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ public static async Task<EqualityResult> Text(FilePair filePair, StringBuilder r
99
return new(Equality.New, null, received, null);
1010
}
1111

12-
var verified = await File.ReadAllTextAsync(filePair.VerifiedPath);
12+
// Read with the configured encoding so a BOM-less non-UTF8 UseEncoding
13+
// round-trips (writes go through the same encoding).
14+
var verified = await File.ReadAllTextAsync(filePair.VerifiedPath, VerifierSettings.Encoding);
1315
if (verified.Contains('\r'))
1416
{
1517
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");

src/Verify/Compare/StreamComparer.cs

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,35 +9,41 @@ public static async Task<CompareResult> AreEqual(Stream stream1, Stream stream2)
99
EnsureAtStart(stream1);
1010
EnsureAtStart(stream2);
1111

12-
var buffer1 = new byte[bufferSize];
13-
var buffer2 = new byte[bufferSize];
14-
15-
while (true)
12+
var buffer1 = ArrayPool<byte>.Shared.Rent(bufferSize);
13+
var buffer2 = ArrayPool<byte>.Shared.Rent(bufferSize);
14+
try
1615
{
17-
var count1 = await ReadBufferAsync(stream1, buffer1);
18-
var count2 = await ReadBufferAsync(stream2, buffer2);
19-
20-
// Callers do not always guarantee the streams are the same length
21-
// (e.g. a non-seekable received stream), so a length difference must
22-
// be treated as not-equal instead of a short-circuit to equal.
23-
if (count1 != count2)
16+
while (true)
2417
{
25-
return CompareResult.NotEqual();
26-
}
18+
var count1 = await ReadBufferAsync(stream1, buffer1);
19+
var count2 = await ReadBufferAsync(stream2, buffer2);
2720

28-
if (count1 == 0)
29-
{
30-
return CompareResult.Equal;
31-
}
21+
// Callers do not always guarantee the streams are the same length
22+
// (e.g. a non-seekable received stream), so a length difference must
23+
// be treated as not-equal instead of a short-circuit to equal.
24+
if (count1 != count2)
25+
{
26+
return CompareResult.NotEqual();
27+
}
3228

33-
for (var i = 0; i < count1; i += sizeof(long))
34-
{
35-
if (BitConverter.ToInt64(buffer1, i) != BitConverter.ToInt64(buffer2, i))
29+
if (count1 == 0)
30+
{
31+
return CompareResult.Equal;
32+
}
33+
34+
// Compare exactly the bytes read (a rented buffer's trailing bytes
35+
// are arbitrary, so they must not be included).
36+
if (!buffer1.AsSpan(0, count1).SequenceEqual(buffer2.AsSpan(0, count1)))
3637
{
3738
return CompareResult.NotEqual();
3839
}
3940
}
4041
}
42+
finally
43+
{
44+
ArrayPool<byte>.Shared.Return(buffer1);
45+
ArrayPool<byte>.Shared.Return(buffer2);
46+
}
4147
}
4248

4349
static void EnsureAtStart(Stream stream)
@@ -51,10 +57,13 @@ static void EnsureAtStart(Stream stream)
5157

5258
static async Task<int> ReadBufferAsync(Stream stream, byte[] buffer)
5359
{
60+
// Read up to bufferSize (not buffer.Length): a rented buffer may be
61+
// larger, and both streams must be read in equal-sized chunks so the
62+
// length comparison above stays aligned.
5463
var bytesRead = 0;
55-
while (bytesRead < buffer.Length)
64+
while (bytesRead < bufferSize)
5665
{
57-
var read = await stream.ReadAsync(buffer, bytesRead, buffer.Length - bytesRead);
66+
var read = await stream.ReadAsync(buffer, bytesRead, bufferSize - bytesRead);
5867
if (read == 0)
5968
{
6069
// Reached end of stream.

src/Verify/Extensions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ public static string Extension(this FileStream file)
3535
public static Version MajorMinor(this Version version) =>
3636
new(version.Major, version.Minor);
3737

38+
// Compose two async actions so both are awaited in order. Using `+=` on a
39+
// Func<Task> builds a multicast delegate that only awaits the last target.
40+
public static Func<Task> Then(this Func<Task> first, Func<Task> second) =>
41+
async () =>
42+
{
43+
await first();
44+
await second();
45+
};
46+
3847
public static async Task<List<T>> ToList<T>(this IAsyncEnumerable<T> target)
3948
{
4049
var list = new List<T>();

0 commit comments

Comments
 (0)