From e6191ec55a7f706034d06afef9bb6e5f218b2064 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 11:16:01 -0400 Subject: [PATCH] fix(m9/T25): guard health-poll timer against dispose race (ObjectDisposedException) --- .../Pages/Design/DataConnections.razor | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor index 4cac2de1..e0c41737 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnections.razor @@ -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)); + } + + /// + /// 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. + /// + 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(); } }