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
@@ -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(