Skip to content

Commit 40ecfe5

Browse files
Fix combination callbacks (#1887)
* Await every registered combination callback UseCallbacks combined the callbacks with +=. Invoking a multicast Task returning delegate returns only the last target's Task, so when UseCallbacks was called more than once, for example by an extension package plus user code, every earlier callback ran unawaited: its exceptions unobserved and its async work racing the combination method. Extensions.Then documents the same hazard for Func<Task>. They are now held in lists and awaited in order. The no callback path still returns Task.CompletedTask without allocating. * Docs changes --------- Co-authored-by: GitHub Action <action@github.com>
1 parent bd66bfa commit 40ecfe5

4 files changed

Lines changed: 87 additions & 15 deletions

File tree

docs/combinations.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ public Task BuildAddressExceptionsDisabledTest()
175175
city);
176176
}
177177
```
178-
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L214-L230' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CaptureExceptionsFalse' title='Start of snippet'>anchor</a></sup>
178+
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L251-L267' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CaptureExceptionsFalse' title='Start of snippet'>anchor</a></sup>
179179
<!-- endSnippet -->
180180

181181

@@ -401,7 +401,7 @@ class CustomCombinationConverter :
401401
string.Join(", ", keys.Select(_ => _.Value));
402402
}
403403
```
404-
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L260-L269' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CustomSerializationConverter' title='Start of snippet'>anchor</a></sup>
404+
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L297-L306' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CustomSerializationConverter' title='Start of snippet'>anchor</a></sup>
405405
<!-- endSnippet -->
406406

407407
Full control of serialization can be achieved by inheriting from `WriteOnlyJsonConverter<CombinationResults>`.
@@ -420,7 +420,7 @@ static CustomCombinationConverter customConverter = new();
420420
public static void Init() =>
421421
VerifierSettings.AddExtraSettings(_ => _.Converters.Insert(0, customConverter));
422422
```
423-
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L232-L240' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CustomSerializationModuleInitializer' title='Start of snippet'>anchor</a></sup>
423+
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L269-L277' title='Snippet source file'>snippet source</a> | <a href='#snippet-CombinationSample_CustomSerializationModuleInitializer' title='Start of snippet'>anchor</a></sup>
424424
<!-- endSnippet -->
425425

426426

@@ -551,5 +551,5 @@ Headers can be enabled globally:
551551
public static void EnableIncludeHeaders() =>
552552
CombinationSettings.IncludeHeaders();
553553
```
554-
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L272-L278' title='Snippet source file'>snippet source</a> | <a href='#snippet-GlobalCombinationHeader' title='Start of snippet'>anchor</a></sup>
554+
<sup><a href='/src/StaticSettingsTests/CombinationTests.cs#L309-L315' title='Snippet source file'>snippet source</a> | <a href='#snippet-GlobalCombinationHeader' title='Start of snippet'>anchor</a></sup>
555555
<!-- endSnippet -->
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
list: Result,
3+
1: Exception: from the first before callback
4+
}

src/StaticSettingsTests/CombinationTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,43 @@ await Verify(new
7979
.UseMethodName("CallbackResults");
8080
}
8181

82+
// Two registrations, for example an extension package plus user code. A multicast
83+
// delegate would return only the last target's Task, leaving the first to run
84+
// unawaited: its exceptions unobserved and its work racing the combination method.
85+
[Fact]
86+
public async Task EveryRegisteredCallbackIsAwaited()
87+
{
88+
var exceptionMessages = new List<string>();
89+
90+
// Fails only after an await, so the failure is carried by the returned Task rather
91+
// than thrown synchronously. Registered first, so a multicast delegate would return
92+
// the second registration's Task instead and this one would never be observed.
93+
CombinationSettings.UseCallbacks(
94+
async _ =>
95+
{
96+
await Task.Yield();
97+
throw new("from the first before callback");
98+
},
99+
(_, _) => Task.CompletedTask,
100+
(_, exception) =>
101+
{
102+
exceptionMessages.Add(exception.Message);
103+
return Task.CompletedTask;
104+
});
105+
106+
CombinationSettings.UseCallbacks(
107+
_ => Task.CompletedTask,
108+
(_, _) => Task.CompletedTask,
109+
(_, _) => Task.CompletedTask);
110+
111+
int[] list = [1];
112+
await Combination()
113+
.Verify((int param1) => param1, list)
114+
.UseMethodName("EveryRegisteredCallbackIsAwaited_run");
115+
116+
Assert.Equal(["from the first before callback"], exceptionMessages);
117+
}
118+
82119
[Fact]
83120
public async Task AfterCallbackRawValueWhenRecording()
84121
{

src/Verify/Combinations/CombinationSettings.cs

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace VerifyTests;
1+
namespace VerifyTests;
22

33
public delegate Task BeforeCombination(IReadOnlyList<object?> keys);
44
public delegate Task AfterCombination(IReadOnlyList<object?> keys, object? result);
@@ -16,7 +16,11 @@ public static void IncludeHeaders() =>
1616
public static void CaptureExceptions() =>
1717
CaptureExceptionsEnabled = true;
1818

19-
static BeforeCombination? before;
19+
// Held as lists rather than combined with `+=`. Invoking a multicast Task returning
20+
// delegate returns only the last target's Task, so every earlier callback would run
21+
// unawaited: its exceptions unobserved, and its async work racing the combination
22+
// method. Same hazard the Then extension documents for Func<Task>.
23+
static List<BeforeCombination>? before;
2024

2125
internal static Task RunBeforeCallbacks(IReadOnlyList<object?> keys)
2226
{
@@ -25,10 +29,18 @@ internal static Task RunBeforeCallbacks(IReadOnlyList<object?> keys)
2529
return Task.CompletedTask;
2630
}
2731

28-
return before(keys);
32+
return RunAll(before, keys);
2933
}
3034

31-
static AfterCombination? after;
35+
static async Task RunAll(List<BeforeCombination> callbacks, IReadOnlyList<object?> keys)
36+
{
37+
foreach (var callback in callbacks)
38+
{
39+
await callback(keys);
40+
}
41+
}
42+
43+
static List<AfterCombination>? after;
3244

3345
internal static Task RunAfterCallbacks(IReadOnlyList<object?> keys, object? result)
3446
{
@@ -37,10 +49,18 @@ internal static Task RunAfterCallbacks(IReadOnlyList<object?> keys, object? resu
3749
return Task.CompletedTask;
3850
}
3951

40-
return after(keys, result);
52+
return RunAll(after, keys, result);
4153
}
4254

43-
static CombinationException? combinationException;
55+
static async Task RunAll(List<AfterCombination> callbacks, IReadOnlyList<object?> keys, object? result)
56+
{
57+
foreach (var callback in callbacks)
58+
{
59+
await callback(keys, result);
60+
}
61+
}
62+
63+
static List<CombinationException>? combinationException;
4464

4565
internal static Task RunExceptionCallbacks(IReadOnlyList<object?> keys, Exception exception)
4666
{
@@ -49,14 +69,25 @@ internal static Task RunExceptionCallbacks(IReadOnlyList<object?> keys, Exceptio
4969
return Task.CompletedTask;
5070
}
5171

52-
return combinationException(keys, exception);
72+
return RunAll(combinationException, keys, exception);
73+
}
74+
75+
static async Task RunAll(List<CombinationException> callbacks, IReadOnlyList<object?> keys, Exception exception)
76+
{
77+
foreach (var callback in callbacks)
78+
{
79+
await callback(keys, exception);
80+
}
5381
}
5482

5583
public static void UseCallbacks(BeforeCombination before, AfterCombination after, CombinationException exception)
5684
{
57-
CombinationSettings.before += before;
58-
CombinationSettings.after += after;
59-
combinationException += exception;
85+
CombinationSettings.before ??= [];
86+
CombinationSettings.before.Add(before);
87+
CombinationSettings.after ??= [];
88+
CombinationSettings.after.Add(after);
89+
combinationException ??= [];
90+
combinationException.Add(exception);
6091
}
6192

6293
public static void Reset()
@@ -67,4 +98,4 @@ public static void Reset()
6798
after = null;
6899
before = null;
69100
}
70-
}
101+
}

0 commit comments

Comments
 (0)