fix: release the alarm drain latch on escape; cancel before disposing the page poll timers

This commit is contained in:
Joseph Doherty
2026-08-16 05:40:27 -04:00
parent b9e9df4081
commit 445bb855e2
5 changed files with 306 additions and 22 deletions
@@ -569,32 +569,77 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// reads the set under, so the last drainer out and the next push in can never
/// both decide "someone else will handle it".
/// </summary>
/// <remarks>
/// <b>INVARIANT: <c>_drainScheduled</c> is released on EVERY exit path, the
/// exceptional one included.</b> The flag is the only thing that lets
/// <see cref="OnUpstreamChange"/> start a drainer, so an escape that leaves it
/// stuck <c>true</c> permanently and silently stops evaluation for every scripted
/// alarm in the process: pushes keep filling <c>_valueCache</c> and the dirty set,
/// no drainer is ever started again, no predicate ever runs again, and nothing is
/// logged. <see cref="ReevaluateAsync"/> catches everything today, so the throw
/// surface is currently empty — the <c>finally</c> below is what keeps the
/// invariant true when it stops being (an audit-mode Serilog sink rethrowing out
/// of that catch block's own <c>Error()</c> call is enough).
/// </remarks>
private async Task DrainDirtyAsync(CancellationToken ct)
{
while (true)
// Set inside _dirtyLock, together with the flag clear, so the finally can tell
// "I already released" from "I am unwinding" without ever double-releasing —
// a second clear could stomp a _drainScheduled = true a racing push had just
// set for the drainer it started, leaving two drainers running.
var released = false;
try
{
string[] batch;
lock (_dirtyLock)
while (true)
{
// Bail promptly on a disposing engine, but ALWAYS release
// _drainScheduled on the way out — leaving it stuck true would make a
// hypothetical post-dispose push believe a drainer is coming. (Such
// pushes are already dropped in OnUpstreamChange; this keeps the flag
// honest regardless.)
if (_disposed || _dirtyAlarmIds.Count == 0)
string[] batch;
lock (_dirtyLock)
{
// Bail promptly on a disposing engine, but ALWAYS release
// _drainScheduled on the way out — leaving it stuck true would make a
// hypothetical post-dispose push believe a drainer is coming. (Such
// pushes are already dropped in OnUpstreamChange; this keeps the flag
// honest regardless.)
if (_disposed || _dirtyAlarmIds.Count == 0)
{
_dirtyAlarmIds.Clear();
_drainScheduled = false;
released = true;
return;
}
batch = [.. _dirtyAlarmIds];
_dirtyAlarmIds.Clear();
_drainScheduled = false;
return;
}
batch = [.. _dirtyAlarmIds];
_dirtyAlarmIds.Clear();
// Reused as-is: the gate / evaluate / persist / emit-outside-the-gate shape
// is already correct for a batch. Only the spawn-per-push is gone.
await ReevaluateAsync(batch, ct).ConfigureAwait(false);
}
}
finally
{
// Exceptional exit only (see `released`). The batch the faulted pass was
// carrying dies with it — ReevaluateAsync's own catch is what normally makes
// that a logged non-event — but ids that queued BEHIND it are still in the
// set and would otherwise sit there until some later push happened to arrive,
// so hand the latch straight to a replacement drainer instead of releasing
// it. Handing over under the same lock keeps "exactly one drainer" true with
// no window a push could start a second one in. This cannot spin: every pass
// swaps the set out and clears it before evaluating, so a replacement finds
// work only if fresh pushes arrived, and a permanently-throwing evaluation
// costs one pass per push — the same rate as a healthy one.
if (!released)
{
bool restart;
lock (_dirtyLock)
{
restart = !_disposed && _dirtyAlarmIds.Count > 0;
if (!restart) _drainScheduled = false;
}
// Reused as-is: the gate / evaluate / persist / emit-outside-the-gate shape
// is already correct for a batch. Only the spawn-per-push is gone.
await ReevaluateAsync(batch, ct).ConfigureAwait(false);
if (restart) TrackBackgroundTask(DrainDirtyAsync(ct));
}
}
}
@@ -114,6 +114,10 @@ else
// goes away aborts its query instead of running it to completion against a component that's gone.
private readonly CancellationTokenSource _cts = new();
// Set first thing in DisposeAsync. A tick that resumes after teardown must not call
// StateHasChanged — the renderer is gone and it throws, inside a task nobody awaits.
private volatile bool _disposed;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
@@ -170,7 +174,10 @@ else
// Release first: a StateHasChanged on a torn-down circuit must never leave the guard set.
Volatile.Write(ref _tickRunning, 0);
_refreshing = false;
StateHasChanged();
// This one sits OUTSIDE the try, so it has no catch to fall into — and a render
// request against a disposed renderer throws. Skip it once the page is gone; there
// is nothing left to render to.
if (!_disposed) StateHasChanged();
}
}
@@ -186,13 +193,20 @@ else
public async ValueTask DisposeAsync()
{
_disposed = true;
// Cancel before draining so a tick already in flight aborts its queries rather than running
// them to completion against a component that's gone.
_cts.Cancel();
// Drain the timer so an in-flight callback can't touch a component that's already gone
// (System.Threading.Timer's async dispose awaits any in-flight callback — .NET 6+).
if (_timer is not null) await _timer.DisposeAsync();
_cts.Dispose();
// ...but the callback body is `_ = InvokeAsync(LoadAsync)`, so that await covered only the
// SCHEDULING of the tick, never the tick itself: a query can still be running against
// _cts.Token right now. Disposing the source out from under it turns the clean
// cancellation the line above arranged into an ObjectDisposedException mid-query, so skip
// the dispose while a tick holds it. An undisposed CancellationTokenSource with no timer
// and no materialised WaitHandle costs nothing and is collected with the component.
if (Volatile.Read(ref _tickRunning) == 0) _cts.Dispose();
}
private sealed record NodeRow(
@@ -337,6 +337,10 @@ else
// goes away aborts its queries instead of running them against a component that's gone.
private readonly CancellationTokenSource _cts = new();
// Set first thing in DisposeAsync. A tick that resumes after teardown must not call
// StateHasChanged — the renderer is gone and it throws, inside a task nobody awaits.
private volatile bool _disposed;
protected override async Task OnInitializedAsync()
{
Refresh();
@@ -390,7 +394,10 @@ else
// Release first: a StateHasChanged on a torn-down circuit must never leave the guard set.
Volatile.Write(ref _tickRunning, 0);
_refreshing = false;
StateHasChanged();
// This one sits OUTSIDE the try, so it has no catch to fall into — and a render
// request against a disposed renderer throws. Skip it once the page is gone; there
// is nothing left to render to.
if (!_disposed) StateHasChanged();
}
}
@@ -409,10 +416,15 @@ else
.Select(d => new HostsDriverInstanceInfo(d.DriverInstanceId, d.ClusterId, d.Name, d.DriverType, d.Enabled))
.ToListAsync(_cts.Token);
}
catch (OperationCanceledException)
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
{
// The circuit went away mid-load. Rethrow so the caller treats it as a clean exit — it
// must NOT fall into the swallow-and-clear below, which would blank the cached config.
// ObjectDisposedException belongs here for the same reason: teardown disposes _cts (and
// with it the DbContext's token registration) while a query may still be running, and
// that shape reaches this method as ODE rather than OCE. Sending it to the generic catch
// blanked _nodes/_instances on the way out — the exact swallow-and-clear this rethrow
// exists to prevent, and permanent, because a torn-down page never ticks again.
throw;
}
catch (Exception ex)
@@ -513,6 +525,7 @@ else
public async ValueTask DisposeAsync()
{
_disposed = true;
// Unsubscribe first so the singleton store can't invoke a handler on a disposed component.
DriverStore.SnapshotChanged -= OnSnapshotChanged;
// Cancel before draining so a tick already in flight aborts its queries rather than running
@@ -521,7 +534,13 @@ else
// Drain the timer so an in-flight callback can't touch a component that's already gone
// (System.Threading.Timer's async dispose awaits any in-flight callback — .NET 6+).
if (_timer is not null) await _timer.DisposeAsync();
_cts.Dispose();
// ...but the callback body is `_ = InvokeAsync(TickAsync)`, so that await covered only the
// SCHEDULING of the tick, never the tick itself: LoadConfigAsync can still be running
// against _cts.Token right now. Disposing the source out from under it turns the clean
// cancellation the line above arranged into an ObjectDisposedException mid-query, so skip
// the dispose while a tick holds it. An undisposed CancellationTokenSource with no timer
// and no materialised WaitHandle costs nothing and is collected with the component.
if (Volatile.Read(ref _tickRunning) == 0) _cts.Dispose();
}
private sealed record MemberRow(