@@ -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>
2547internal 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>
0 commit comments