fix(m9/T25): guard health-poll timer against dispose race (ObjectDisposedException)
This commit is contained in:
@@ -213,33 +213,75 @@
|
|||||||
private Timer? _healthTimer;
|
private Timer? _healthTimer;
|
||||||
private const int HealthRefreshSeconds = 10;
|
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;
|
private bool HasSiteSelected => ResolveSelectedSiteId() != null;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await LoadDataAsync();
|
await LoadDataAsync();
|
||||||
await LoadConnectionHealthAsync();
|
await LoadConnectionHealthAsync(_disposeCts.Token);
|
||||||
|
|
||||||
// Poll the live health map on the same cadence as the Health dashboard.
|
// 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
|
// Best-effort: a failed query leaves the prior map in place rather than
|
||||||
// disturbing the tree.
|
// disturbing the tree.
|
||||||
|
// CentralUI-022 pattern: guard against dispose race — see _disposed field.
|
||||||
_healthTimer = new Timer(_ =>
|
_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();
|
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
|
// Best-effort load of the connection-id → health map. A transient fault leaves
|
||||||
// the existing badges untouched — health is advisory, never blocks the page.
|
// the existing badges untouched — health is advisory, never blocks the page.
|
||||||
private async Task LoadConnectionHealthAsync()
|
private async Task LoadConnectionHealthAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_connectionHealth = await ConnectionHealthQuery.GetConnectionHealthAsync();
|
_connectionHealth = await ConnectionHealthQuery.GetConnectionHealthAsync(ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Cancelled on disposal — leave the prior map in place.
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -382,6 +424,12 @@
|
|||||||
|
|
||||||
public void Dispose()
|
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();
|
_healthTimer?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user