From 0ebfafa98b603624511179b819275c300f8dacfc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 08:39:11 -0400 Subject: [PATCH] fix(centralui): safe-invoke fire-and-forget calls in AlarmSummary and TreeView (review 2026-10-21 07/M-4) --- .../Pages/Monitoring/AlarmSummary.razor | 12 +++++++++++- .../Components/Shared/TreeView.razor | 16 ++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor index 3c8935ef..39035890 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor @@ -449,7 +449,7 @@ private void OnLiveAlarmsChanged(int siteId) { if (_disposed) return; - _ = InvokeAsync(() => + _ = SafeInvokeAsync(() => { // Drop stale callbacks for a site we've since navigated away from, and // let the poll drive until the aggregator has actually seeded (so we never @@ -464,6 +464,16 @@ }); } + // Runs the action on the render thread; a callback racing circuit teardown makes + // InvokeAsync throw ObjectDisposedException into an otherwise-unobserved task + // (pattern from DebugView.SafeInvokeAsync). + private async Task SafeInvokeAsync(Action action) + { + if (_disposed) return; + try { await InvokeAsync(action); } + catch (ObjectDisposedException) { /* disposed between the guard and the dispatch */ } + } + // Rebuilds rows/rollup/visible-rows from the live snapshot. Leaves _notReporting // as the last poll computed it (the alarm-only cache can't know it). Idempotent. private void ApplyLiveSnapshot(int siteId) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor index 72c854ba..c1b53274 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor @@ -207,7 +207,7 @@ else // Clear selection if the selected key no longer exists in the current items tree if (Selectable && SelectedKey != null && _items is not null && !KeyExistsInTree(_items, SelectedKey)) { - _ = SelectedKeyChanged.InvokeAsync(null); + _ = SafeFireAndForgetAsync(() => SelectedKeyChanged.InvokeAsync(null)); } } @@ -553,10 +553,22 @@ else if (StorageKey != null) { var json = System.Text.Json.JsonSerializer.Serialize(_expandedKeys.ToList()); - _ = JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json); + _ = SafeFireAndForgetAsync(() => + JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json).AsTask()); } } + // Fire-and-forget calls (an EventCallback, a JS persist) can race circuit teardown; + // swallow the expected disposal/disconnect failures so no unobserved-task exception + // reaches the log (review 2026-10-21 07/M-4). + private static async Task SafeFireAndForgetAsync(Func work) + { + try { await work(); } + catch (ObjectDisposedException) { } + catch (JSDisconnectedException) { } + catch (TaskCanceledException) { } + } + private async Task OnContentClick(object key) { // Clicking a node makes it the tab entry point (roving tabindex follows the