fix(saf): publish _sweepTask only on CAS win so StopAsync drains the real in-flight sweep (plan R2-02 T8)

This commit is contained in:
Joseph Doherty
2026-07-13 10:09:40 -04:00
parent 6e1ab62d27
commit 61442b5f81
2 changed files with 96 additions and 4 deletions
@@ -394,7 +394,7 @@ public class StoreAndForwardService
});
_retryTimer = new Timer(
_ => Volatile.Write(ref _sweepTask, RetryPendingMessagesAsync()),
_ => KickSweep(),
null,
_options.RetryTimerInterval,
_options.RetryTimerInterval);
@@ -658,17 +658,37 @@ public class StoreAndForwardService
/// <summary>
/// Kicks a background retry sweep now (fire-and-forget). No-op before
/// <see cref="StartAsync"/> (storage may be uninitialized and the timer, whose
/// presence gates this, is not yet set). Overlap-safe: the sweep's
/// <see cref="_retryInProgress"/> CAS makes a concurrent kick a cheap no-op.
/// presence gates this, is not yet set). Overlap-safe: publishes into
/// <see cref="_sweepTask"/> only when the CAS is won — see <see cref="KickSweep"/>.
/// </summary>
public void TriggerSweep()
{
if (_retryTimer == null) return;
Volatile.Write(ref _sweepTask, RetryPendingMessagesAsync());
KickSweep();
}
/// <summary>The current drain handle — test seam for the N3 clobber regression.</summary>
internal Task? CurrentSweepTaskForTest => Volatile.Read(ref _sweepTask);
/// <summary>
/// Starts a sweep IF none is in flight, publishing the new task into
/// <see cref="_sweepTask"/> only when this call wins the <see cref="_retryInProgress"/>
/// CAS. A kick that loses the CAS returns without touching <see cref="_sweepTask"/> —
/// pre-fix it overwrote the drain handle with an instantly-completed no-op, so
/// <see cref="StopAsync"/> proceeded with disposal under a still-running sweep
/// (review 02 round 2, N3) — defeating the drain exactly when a sweep outlives a tick.
/// </summary>
private void KickSweep()
{
if (Interlocked.CompareExchange(ref _retryInProgress, 1, 0) != 0)
return;
Volatile.Write(ref _sweepTask, RunSweepOwnedAsync());
}
/// <summary>
/// Background retry sweep. Processes all pending messages that are due for retry.
/// Self-CASes for direct callers (existing tests); the entry CAS is skipped when
/// ownership is already held by <see cref="KickSweep"/>.
/// </summary>
/// <returns>A task representing the asynchronous retry sweep.</returns>
internal async Task RetryPendingMessagesAsync()
@@ -676,7 +696,16 @@ public class StoreAndForwardService
// Prevent overlapping retry sweeps
if (Interlocked.CompareExchange(ref _retryInProgress, 1, 0) != 0)
return;
await RunSweepOwnedAsync();
}
/// <summary>
/// The actual retry sweep body. The caller MUST already own the
/// <see cref="_retryInProgress"/> flag (won the CAS); this method releases it in its
/// finally. Never call directly without holding ownership.
/// </summary>
private async Task RunSweepOwnedAsync()
{
try
{
var gate = _deliveryGate;