Skip to content

Commit 4f11def

Browse files
authored
fix: make the outbox relay's stop, start, and dispose safe to overlap and repeat (#327)
A stop overlapping a restart, or running after disposal, could await CancelAsync on a disposed stopping source and fail the host's final StopAsync with an ObjectDisposedException at test-factory teardown, or pair one loop generation's cancellation with another generation's wait and stall. A lock now guards every transition, the stopping source and execute task swap together as a matched generation, restarts retire the previous generation by canceling it, and disposal makes later stop and start calls benign no-ops. Retired sources are canceled but never disposed, because disposing a source whose queued cancellation notification has not yet run drops the pending callbacks and strands the loop.
1 parent 6b215a4 commit 4f11def

2 files changed

Lines changed: 161 additions & 11 deletions

File tree

src/Vulthil.SharedKernel.Outbox/OutboxBackgroundService.cs

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace Vulthil.SharedKernel.Outbox;
1111
/// test harness resetting the database) can pause it around operations that must not run concurrently with it.
1212
/// </summary>
1313
/// <remarks>
14+
/// <para>
1415
/// The service implements <see cref="IHostedService"/> directly instead of inheriting
1516
/// <see cref="BackgroundService"/> because <see cref="BackgroundService"/> is not restart-safe: the host observes
1617
/// only the execute task created by the first <c>StartAsync</c>, and on .NET 10 that task is scheduled with
@@ -21,6 +22,27 @@ namespace Vulthil.SharedKernel.Outbox;
2122
/// (cancellation is observed inside it and always ends in a graceful return), and the host never awaits the task.
2223
/// A genuine fault escaping the loop still stops the application — matching the <see cref="BackgroundService"/>
2324
/// default — via <see cref="IHostApplicationLifetime.StopApplication"/>.
25+
/// </para>
26+
/// <para>
27+
/// The lifecycle methods are idempotent and safe to overlap, because the harness restart cycle and the host's final
28+
/// stop are not serialized with each other by any contract: a lock guards every transition, the stopping source and
29+
/// execute task are only ever swapped together (so a stop always cancels the same loop generation it awaits), a
30+
/// restart retires the previous generation by canceling it, and disposal marks the service so a later stop only
31+
/// awaits the already-canceled loop and a later start is a no-op. Without this, a stop that overlapped a restart or
32+
/// ran after disposal could await <see cref="CancellationTokenSource.CancelAsync"/> on a disposed source and fail
33+
/// the host's <c>StopAsync</c> with an <see cref="ObjectDisposedException"/> at teardown, or pair the cancellation
34+
/// of one generation with the wait for another and stall until its caller's token fired.
35+
/// </para>
36+
/// <para>
37+
/// Retired stopping sources are canceled but never disposed: disposing a source whose cancellation notification is
38+
/// still queued silently drops the pending callbacks (callback execution atomically claims the registration store,
39+
/// and <c>Dispose</c> clears that same store), which would strand the retired loop in a wait that no longer ends.
40+
/// They hold no timer or kernel handle, so unreferenced retired sources are reclaimed by garbage collection. Only
41+
/// <see cref="Dispose"/> disposes a source, and only one whose cancellation it also requested itself — the
42+
/// synchronous first-caller <see cref="CancellationTokenSource.Cancel()"/> runs the callbacks to completion before
43+
/// the disposal. A source some stop already canceled may still have its notification in flight, so it is left to
44+
/// garbage collection like the retired ones.
45+
/// </para>
2446
/// </remarks>
2547
internal sealed class OutboxBackgroundService(
2648
ILogger<OutboxBackgroundService> logger,
@@ -30,7 +52,10 @@ internal sealed class OutboxBackgroundService(
3052
IOptions<OutboxProcessingOptions> options,
3153
IHostApplicationLifetime applicationLifetime) : IRestartableHostedService, IDisposable
3254
{
55+
private readonly Lock _lifecycleGate = new();
56+
3357
private CancellationTokenSource? _stoppingCts;
58+
private bool _disposed;
3459

3560
/// <summary>
3661
/// Gets the task running the current relay loop, or <see langword="null"/> before the first start. The task
@@ -40,38 +65,74 @@ internal sealed class OutboxBackgroundService(
4065
internal Task? ExecuteTask { get; private set; }
4166

4267
/// <inheritdoc />
43-
public Task StartAsync(CancellationToken cancellationToken)
68+
public async Task StartAsync(CancellationToken cancellationToken)
4469
{
45-
_stoppingCts?.Dispose();
46-
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
47-
var stoppingToken = _stoppingCts.Token;
48-
ExecuteTask = Task.Run(() => ExecuteAsync(stoppingToken), CancellationToken.None);
49-
return Task.CompletedTask;
70+
CancellationTokenSource? previous;
71+
lock (_lifecycleGate)
72+
{
73+
if (_disposed)
74+
{
75+
return;
76+
}
77+
78+
previous = _stoppingCts;
79+
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
80+
var stoppingToken = _stoppingCts.Token;
81+
ExecuteTask = Task.Run(() => ExecuteAsync(stoppingToken), CancellationToken.None);
82+
}
83+
84+
if (previous is not null)
85+
{
86+
await previous.CancelAsync();
87+
}
5088
}
5189

5290
/// <inheritdoc />
5391
public async Task StopAsync(CancellationToken cancellationToken)
5492
{
55-
if (ExecuteTask is null)
93+
Task? executeTask;
94+
var cancellation = Task.CompletedTask;
95+
lock (_lifecycleGate)
96+
{
97+
executeTask = ExecuteTask;
98+
if (executeTask is not null && !_disposed && _stoppingCts is { } stoppingCts)
99+
{
100+
cancellation = stoppingCts.CancelAsync();
101+
}
102+
}
103+
104+
if (executeTask is null)
56105
{
57106
return;
58107
}
59108

60109
try
61110
{
62-
await _stoppingCts!.CancelAsync();
111+
await cancellation;
63112
}
64113
finally
65114
{
66-
await ExecuteTask.WaitAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
115+
await executeTask.WaitAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
67116
}
68117
}
69118

70119
/// <inheritdoc />
71120
public void Dispose()
72121
{
73-
_stoppingCts?.Cancel();
74-
_stoppingCts?.Dispose();
122+
lock (_lifecycleGate)
123+
{
124+
if (_disposed)
125+
{
126+
return;
127+
}
128+
129+
_disposed = true;
130+
if (_stoppingCts is { IsCancellationRequested: false })
131+
{
132+
_stoppingCts.Cancel();
133+
_stoppingCts.Dispose();
134+
}
135+
}
75136
}
76137

77138
/// <summary>

tests/Vulthil.SharedKernel.Outbox.Tests/OutboxBackgroundServiceTests.cs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,95 @@ public async Task RestartingAfterStopRunsTheRelayAgain()
8585
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
8686
}
8787

88+
[Fact]
89+
public async Task StoppingAfterDisposeCompletesGracefully()
90+
{
91+
// Arrange
92+
var gate = new BlockingRelayGate();
93+
Use<IEnumerable<IOutboxRelayGate>>([gate]);
94+
await Target.StartAsync(CancellationToken);
95+
await gate.WaitForEntryAsync(CancellationToken);
96+
Target.Dispose();
97+
98+
// Act
99+
await Target.StopAsync(CancellationToken);
100+
101+
// Assert
102+
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
103+
}
104+
105+
[Fact]
106+
public async Task StoppingTwiceCompletesGracefully()
107+
{
108+
// Arrange
109+
var gate = new BlockingRelayGate();
110+
Use<IEnumerable<IOutboxRelayGate>>([gate]);
111+
await Target.StartAsync(CancellationToken);
112+
await gate.WaitForEntryAsync(CancellationToken);
113+
114+
// Act
115+
await Target.StopAsync(CancellationToken);
116+
await Target.StopAsync(CancellationToken);
117+
118+
// Assert
119+
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
120+
}
121+
122+
[Fact]
123+
public async Task DisposingTwiceIsSafe()
124+
{
125+
// Arrange
126+
var gate = new BlockingRelayGate();
127+
Use<IEnumerable<IOutboxRelayGate>>([gate]);
128+
await Target.StartAsync(CancellationToken);
129+
await gate.WaitForEntryAsync(CancellationToken);
130+
131+
// Act
132+
Target.Dispose();
133+
Target.Dispose();
134+
135+
// Assert
136+
await Target.ExecuteTask!;
137+
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
138+
}
139+
140+
[Fact]
141+
public async Task StoppingAndRestartingConcurrentlyConvergesCleanly()
142+
{
143+
// Arrange
144+
Use<IEnumerable<IOutboxRelayGate>>([new BlockingRelayGate()]);
145+
await Target.StartAsync(CancellationToken);
146+
147+
// Act
148+
for (var i = 0; i < 300; i++)
149+
{
150+
using var startSignal = new SemaphoreSlim(0, 2);
151+
var stopTask = Task.Run(
152+
async () =>
153+
{
154+
await startSignal.WaitAsync(CancellationToken);
155+
await Target.StopAsync(CancellationToken);
156+
},
157+
CancellationToken);
158+
var startTask = Task.Run(
159+
async () =>
160+
{
161+
await startSignal.WaitAsync(CancellationToken);
162+
await Target.StartAsync(CancellationToken);
163+
},
164+
CancellationToken);
165+
startSignal.Release(2);
166+
await Task.WhenAll(stopTask, startTask).WaitAsync(TimeSpan.FromSeconds(10), CancellationToken);
167+
await Target.StopAsync(CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), CancellationToken);
168+
await Target.StartAsync(CancellationToken);
169+
}
170+
171+
await Target.StopAsync(CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), CancellationToken);
172+
173+
// Assert
174+
Target.ExecuteTask!.Status.ShouldBe(TaskStatus.RanToCompletion);
175+
}
176+
88177
[Fact]
89178
public async Task AFaultOutsideTheProcessingLoopStopsTheApplication()
90179
{

0 commit comments

Comments
 (0)