fix(auditlog,health): harden hosted-service shutdown against disposed CTS

The host does not guarantee IHostedService.StopAsync is driven before the DI
container is disposed — WebApplicationFactory's teardown reaches Dispose first
— so cancelling the internal CTS from StopAsync threw ObjectDisposedException
and aborted the host's whole shutdown sequence. Four services shared the same
copy-pasted lifecycle and the same two races: StopAsync cancelling an already-
disposed CTS, and StartAsync reading _cts.Token lazily inside the Task.Run
lambda, which faults the loop task the host awaits when Dispose wins that race.

Each service now captures the token on the caller's thread, tolerates a
disposed CTS, and cancels-before-disposing so the loop is always signalled and
its pending Task.Delay sees a cancelled token rather than a dead source.
SiteAuditBacklogReporter also gains the outer OperationCanceledException guard
its sibling SiteAuditRetentionService already carried (arch-review 04 R2, R7),
without which a shutdown landing mid-probe threw TaskCanceledException out of
Host.StopAsync.

Surfaced while verifying the Gitea #15 test-harness fix: in Host.Tests the
aborted teardown skipped the fixture's env-var restore, contaminating every
later test in the run.

Refs: Gitea #15
This commit is contained in:
Joseph Doherty
2026-07-16 23:31:19 -04:00
parent 128f159692
commit 9110a4eb01
8 changed files with 294 additions and 28 deletions
@@ -86,8 +86,16 @@ public sealed class AuditLogPartitionMaintenanceService : IHostedService, IDispo
// Linked CTS lets StopAsync's cancellation AND the host's shutdown
// token both terminate the loop; either side firing aborts the
// pending Task.Delay.
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_loop = Task.Run(() => RunLoopAsync(_cts.Token));
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_cts = cts;
// Read Token here, on the caller's thread, rather than inside the lambda:
// the lambda runs whenever the thread pool gets to it, so a Dispose landing
// first would make the deferred _cts.Token read throw ObjectDisposedException
// and fault the loop task — which StopAsync hands to the host to await. The
// token struct stays usable once captured.
var token = cts.Token;
_loop = Task.Run(() => RunLoopAsync(token), CancellationToken.None);
return Task.CompletedTask;
}
@@ -148,15 +156,43 @@ public sealed class AuditLogPartitionMaintenanceService : IHostedService, IDispo
/// <returns>The background loop task, or a completed task if the loop was never started.</returns>
public Task StopAsync(CancellationToken ct)
{
_cts?.Cancel();
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Stop-after-Dispose is a legal ordering — WebApplicationFactory's
// teardown disposes the container before driving StopAsync. Dispose
// already cancelled the loop, so there is nothing left to signal, and
// letting this escape would abort the host's whole shutdown sequence.
}
return _loop ?? Task.CompletedTask;
}
/// <summary>
/// Disposes the internal <see cref="CancellationTokenSource"/> used to stop the maintenance loop.
/// Cancels and disposes the internal <see cref="CancellationTokenSource"/> used to
/// stop the maintenance loop.
/// </summary>
/// <remarks>
/// Cancels before disposing so the loop is always signalled, even when the host
/// disposes the container without having driven <see cref="StopAsync"/> first.
/// It also keeps the loop's pending <c>Task.Delay(interval, token)</c> safe: an
/// already-cancelled token makes Delay complete as cancelled rather than register
/// a callback against a dead source and throw.
/// </remarks>
public void Dispose()
{
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed — Dispose is idempotent.
}
_cts?.Dispose();
}
}
@@ -102,30 +102,46 @@ public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable
// Linked CTS lets StopAsync's cancellation AND the host's shutdown
// token both terminate the loop; either side firing aborts the
// pending Task.Delay.
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_loop = Task.Run(() => RunLoopAsync(_cts.Token));
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_cts = cts;
// Read Token on the caller's thread, not inside the lambda: the lambda runs
// whenever the thread pool gets to it, so a Dispose landing first would make
// the deferred _cts.Token read throw and fault the loop task the host awaits.
var token = cts.Token;
_loop = Task.Run(() => RunLoopAsync(token), CancellationToken.None);
return Task.CompletedTask;
}
private async Task RunLoopAsync(CancellationToken ct)
{
// First tick runs immediately so the very first health report after
// process start carries a real backlog snapshot — without this the
// dashboard would show null for the first 30 s after a deploy.
await SafeProbeAsync(ct).ConfigureAwait(false);
while (!ct.IsCancellationRequested)
try
{
try
{
await Task.Delay(_refreshInterval, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
// First tick runs immediately so the very first health report after
// process start carries a real backlog snapshot — without this the
// dashboard would show null for the first 30 s after a deploy.
await SafeProbeAsync(ct).ConfigureAwait(false);
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(_refreshInterval, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
await SafeProbeAsync(ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Shutdown landed mid-probe: SafeProbeAsync rethrows OCE by design so the probe
// aborts promptly, but the loop task must complete CLEANLY — StopAsync hands
// _loop straight to the host, and a canceled task there is shutdown-log noise
// (arch-review 04 round 2, R7). Cancellation here IS the clean exit.
}
}
@@ -155,13 +171,35 @@ public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StopAsync(CancellationToken ct)
{
_cts?.Cancel();
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Stop-after-Dispose is a legal ordering; Dispose already cancelled the
// loop. Letting this escape would abort the host's shutdown sequence.
}
return _loop ?? Task.CompletedTask;
}
/// <summary>Releases the internal <see cref="CancellationTokenSource"/> used to stop the polling loop.</summary>
public void Dispose()
{
// Cancel before disposing so the loop is always signalled even when the host
// disposes the container without having driven StopAsync first, and so the
// loop's pending Task.Delay(interval, token) sees an already-cancelled token
// rather than registering against a dead source.
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed — Dispose is idempotent.
}
_cts?.Dispose();
}
}
@@ -54,8 +54,14 @@ public sealed class SiteAuditRetentionService : IHostedService, IDisposable
public Task StartAsync(CancellationToken ct)
{
// Linked CTS so both StopAsync and the host shutdown token abort the loop.
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_loop = Task.Run(() => RunLoopAsync(_cts.Token));
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_cts = cts;
// Read Token on the caller's thread, not inside the lambda: the lambda runs
// whenever the thread pool gets to it, so a Dispose landing first would make
// the deferred _cts.Token read throw and fault the loop task the host awaits.
var token = cts.Token;
_loop = Task.Run(() => RunLoopAsync(token), CancellationToken.None);
return Task.CompletedTask;
}
@@ -131,13 +137,35 @@ public sealed class SiteAuditRetentionService : IHostedService, IDisposable
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StopAsync(CancellationToken ct)
{
_cts?.Cancel();
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Stop-after-Dispose is a legal ordering; Dispose already cancelled the
// loop. Letting this escape would abort the host's shutdown sequence.
}
return _loop ?? Task.CompletedTask;
}
/// <summary>Releases the internal <see cref="CancellationTokenSource"/>.</summary>
public void Dispose()
{
// Cancel before disposing so the loop is always signalled even when the host
// disposes the container without having driven StopAsync first, and so the
// loop's pending Task.Delay(interval, token) sees an already-cancelled token
// rather than registering against a dead source.
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed — Dispose is idempotent.
}
_cts?.Dispose();
}
}
@@ -85,8 +85,14 @@ public sealed class SiteEventLogFailureCountReporter : IHostedService, IDisposab
// Linked CTS lets StopAsync's cancellation AND the host's shutdown
// token both terminate the loop; either side firing aborts the
// pending Task.Delay.
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_loop = Task.Run(() => RunLoopAsync(_cts.Token));
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_cts = cts;
// Read Token on the caller's thread, not inside the lambda: the lambda runs
// whenever the thread pool gets to it, so a Dispose landing first would make
// the deferred _cts.Token read throw and fault the loop task the host awaits.
var token = cts.Token;
_loop = Task.Run(() => RunLoopAsync(token), CancellationToken.None);
return Task.CompletedTask;
}
@@ -134,13 +140,35 @@ public sealed class SiteEventLogFailureCountReporter : IHostedService, IDisposab
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StopAsync(CancellationToken ct)
{
_cts?.Cancel();
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Stop-after-Dispose is a legal ordering; Dispose already cancelled the
// loop. Letting this escape would abort the host's shutdown sequence.
}
return _loop ?? Task.CompletedTask;
}
/// <summary>Releases the internal <see cref="CancellationTokenSource"/> used to stop the polling loop.</summary>
public void Dispose()
{
// Cancel before disposing so the loop is always signalled even when the host
// disposes the container without having driven StopAsync first, and so the
// loop's pending Task.Delay(interval, token) sees an already-cancelled token
// rather than registering against a dead source.
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed — Dispose is idempotent.
}
_cts?.Dispose();
}
}