diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index 10e2152..798e018 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -252,6 +252,18 @@ 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. +Both loops handle faults *inside* the loop and retry: only cancellation ends them. +A failing alarm query or a render that faults on one tick leaves the page's last +rows in place and is retried on the next tick — a fault that stopped polling for the +life of the page would leave stale rows behind with nothing to say so. The poll loop +also surfaces the fault in the same `Alarm query failed` banner that a query error +uses, and clears it on the first tick that succeeds; that dispatch is itself +best-effort, because the fault being reported may be an `InvokeAsync` against a +disposed renderer, in which case reporting fails the same way and the loop simply +exits on its next cancellation check. Neither loop method can fault, which is what +the bounded drain relies on — it must never observe a faulted loop task, since that +would surface out of `DisposeAsync` and skip the `CancellationTokenSource` dispose. + ### SignalR hubs (remote clients) Updates for out-of-process clients flow over three SignalR hubs, all guarded by the 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 0b1825c..634b4ba 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 @@ -309,27 +309,76 @@ }; } + // Fault handling sits inside the loop, matching ProviderStatusLoopAsync: a query or render + // fault on one tick is transient (a provider blip, a momentarily unavailable session), so it + // is surfaced on the page and retried on the next tick rather than ending polling for the + // life of the page. Cancellation is the only exit. The loop method itself therefore cannot + // fault, which is what DrainAsync in DisposeAsync relies on. private async Task PollLoopAsync() + { + if (!await PollOnceAsync().ConfigureAwait(false)) + { + return; + } + + using PeriodicTimer timer = new(TimeSpan.FromSeconds(3)); + while (true) + { + try + { + if (!await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false)) + { + return; + } + } + catch (OperationCanceledException) + { + return; + } + + if (!await PollOnceAsync().ConfigureAwait(false)) + { + return; + } + } + } + + // Returns false only when cancellation has ended the poll; a non-cancellation fault returns + // true so the caller waits for the next tick and tries again. + private async Task PollOnceAsync() { try { await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false); - using PeriodicTimer timer = new(TimeSpan.FromSeconds(3)); - while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false)) - { - await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false); - } + return true; } catch (OperationCanceledException) { + return false; + } + catch (Exception ex) + { + await ReportPollFaultAsync(ex).ConfigureAwait(false); + return true; + } + } + + private async Task ReportPollFaultAsync(Exception fault) + { + try + { + await InvokeAsync(() => + { + _queryError = fault.Message; + StateHasChanged(); + }).ConfigureAwait(false); } 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. + // Reporting is best-effort: the fault being reported may itself be the teardown race + // this catch-all exists for — an InvokeAsync against a disposed renderer — in which + // case the dispatch fails the same way and there is no page left to show it on. The + // poll loop keeps ticking either way and exits on the next cancellation check. } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor index 770af52..af26880 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor @@ -117,8 +117,9 @@ else @if (_recentEvents.Count == 0) {
- Waiting for events. The dashboard mirrors the session's gRPC event stream — events - appear here only while a gRPC client is also consuming this session's events. + Waiting for events. The dashboard subscribes to this session's events directly, so + rows appear as the session's worker emits them while this page is open — no gRPC + client has to be consuming the session.
} else