fix(m9/T25): guard health-poll timer against dispose race (ObjectDisposedException)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user