fix(m9/T25): guard health-poll timer against dispose race (ObjectDisposedException)

This commit is contained in:
Joseph Doherty
2026-06-18 11:16:01 -04:00
parent 9a73094f03
commit e6191ec55a
@@ -213,33 +213,75 @@
private Timer? _healthTimer;
private const int HealthRefreshSeconds = 10;
// CentralUI-022 pattern: guard timer callbacks against the component being
// disposed while a tick is in flight (fire-and-forget Task, exception
// unobserved). Set _disposed = true FIRST in Dispose(), before stopping the
// timer, so a concurrent tick no-ops cleanly. Mirrors Deployments.razor and
// DebugView.razor. The CTS is used to cancel the in-flight health query when
// the component is disposed.
private volatile bool _disposed;
private bool _healthTickInFlight;
private readonly CancellationTokenSource _disposeCts = new();
private bool HasSiteSelected => ResolveSelectedSiteId() != null;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
await LoadConnectionHealthAsync();
await LoadConnectionHealthAsync(_disposeCts.Token);
// Poll the live health map on the same cadence as the Health dashboard.
// Best-effort: a failed query leaves the prior map in place rather than
// disturbing the tree.
// CentralUI-022 pattern: guard against dispose race — see _disposed field.
_healthTimer = new Timer(_ =>
{
InvokeAsync(async () =>
if (_disposed) return;
_ = DispatchHealthTickAsync();
}, null, TimeSpan.FromSeconds(HealthRefreshSeconds), TimeSpan.FromSeconds(HealthRefreshSeconds));
}
/// <summary>
/// Marshals a health-poll tick onto the renderer, guarded against the component
/// being disposed mid-flight (CentralUI-022 pattern). A re-entrancy flag ensures
/// overlapping ticks (slow query still running when next tick fires) do not stack
/// up concurrent queries.
/// </summary>
private async Task DispatchHealthTickAsync()
{
if (_disposed) return;
if (_healthTickInFlight) return;
_healthTickInFlight = true;
try
{
await InvokeAsync(async () =>
{
await LoadConnectionHealthAsync();
if (_disposed) return;
await LoadConnectionHealthAsync(_disposeCts.Token);
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(HealthRefreshSeconds), TimeSpan.FromSeconds(HealthRefreshSeconds));
}
catch (ObjectDisposedException)
{
// Component disposed between the guard and the dispatch — ignore.
}
finally
{
_healthTickInFlight = false;
}
}
// Best-effort load of the connection-id → health map. A transient fault leaves
// the existing badges untouched — health is advisory, never blocks the page.
private async Task LoadConnectionHealthAsync()
private async Task LoadConnectionHealthAsync(CancellationToken ct = default)
{
try
{
_connectionHealth = await ConnectionHealthQuery.GetConnectionHealthAsync();
_connectionHealth = await ConnectionHealthQuery.GetConnectionHealthAsync(ct);
}
catch (OperationCanceledException)
{
// Cancelled on disposal — leave the prior map in place.
}
catch
{
@@ -382,6 +424,12 @@
public void Dispose()
{
// CentralUI-022 pattern: set the guard FIRST so any tick already in flight
// on the thread-pool no-ops, then cancel the in-flight health query, then
// stop the timer. Mirrors Deployments.razor and DebugView.razor.
_disposed = true;
_disposeCts.Cancel();
_disposeCts.Dispose();
_healthTimer?.Dispose();
}
}