From 7cfb2e727da1b33da53e9135934a5182ebd2d833 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 16 Aug 2026 04:20:59 -0400 Subject: [PATCH] fix(dashboard): parallel bounded alarm drains, best-effort disposal catch-alls, ConfigureAwait alignment --- docs/GatewayDashboardDesign.md | 6 +++-- .../Components/Pages/AlarmsPage.razor | 23 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index ea16c5d..10e2152 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -246,9 +246,11 @@ the pill therefore reports is the case it exists for — the channel completing a page that is still watching. `AlarmsPage` owns two loops of its own (the 3 s alarm poll and the provider-status -badge) and bounds each drain at 5 seconds on dispose, for the same reason +badge) and bounds their drain at 5 seconds on dispose, for the same reason `DashboardPageBase` bounds its watch drain: both loops render through the renderer's -dispatcher, and disposal can run on it. +dispatcher, and disposal can run on it. The two are drained concurrently, so the +bound on disposal is 5 seconds in total rather than per loop — a wedged dispatcher +blocks both loops at once, and draining them in sequence would time out twice. ### SignalR hubs (remote clients) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor index 86d2838..0b1825c 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor @@ -323,6 +323,14 @@ catch (OperationCanceledException) { } + catch + { + // Catch-all for the same reason ProviderStatusLoopAsync has one: a teardown race + // can fault InvokeAsync (a disposed renderer) after cancellation has already been + // requested. Letting that fault the task would surface it out of the drain in + // DisposeAsync, skipping _cts.Dispose(). The loop ends here and the page holds its + // last rendered rows — it is being disposed or has nothing left to poll with. + } } private async Task RefreshAlarmsAsync() @@ -341,10 +349,13 @@ /// public async ValueTask DisposeAsync() { - await _cts.CancelAsync(); + await _cts.CancelAsync().ConfigureAwait(false); - await DrainAsync(_pollTask); - await DrainAsync(_providerStatusTask); + // Drained together, not one after the other: the wedged dispatcher this bound exists + // for blocks both loops at once, so sequential drains would time out twice and make + // the real bound 10 seconds. DrainAsync tolerates a null task. + await Task.WhenAll(DrainAsync(_pollTask), DrainAsync(_providerStatusTask)) + .ConfigureAwait(false); _cts.Dispose(); GC.SuppressFinalize(this); @@ -362,7 +373,7 @@ try { - await loop.WaitAsync(LoopDrainTimeout); + await loop.WaitAsync(LoopDrainTimeout).ConfigureAwait(false); } catch (TimeoutException) { @@ -370,5 +381,9 @@ catch (OperationCanceledException) { } + catch + { + // Other disposal-time errors are best-effort. + } } }