fix(dashboard): alarm poll loop retries through faults and surfaces them; correct stale session-events empty-state copy
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 28s
ci / java (push) Successful in 2m23s
ci / portable (push) Successful in 9m26s

This commit is contained in:
Joseph Doherty
2026-08-16 04:32:54 -04:00
parent a37c304f26
commit dcbec978ee
3 changed files with 74 additions and 12 deletions
+12
View File
@@ -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 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. 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) ### SignalR hubs (remote clients)
Updates for out-of-process clients flow over three SignalR hubs, all guarded by the Updates for out-of-process clients flow over three SignalR hubs, all guarded by the
@@ -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() private async Task PollLoopAsync()
{
if (!await PollOnceAsync().ConfigureAwait(false))
{
return;
}
using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
while (true)
{ {
try try
{ {
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false); if (!await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
{ {
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false); return;
} }
} }
catch (OperationCanceledException) 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<bool> PollOnceAsync()
{
try
{
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
{ {
// Catch-all for the same reason ProviderStatusLoopAsync has one: a teardown race // Reporting is best-effort: the fault being reported may itself be the teardown race
// can fault InvokeAsync (a disposed renderer) after cancellation has already been // this catch-all exists for — an InvokeAsync against a disposed renderer — in which
// requested. Letting that fault the task would surface it out of the drain in // case the dispatch fails the same way and there is no page left to show it on. The
// DisposeAsync, skipping _cts.Dispose(). The loop ends here and the page holds its // poll loop keeps ticking either way and exits on the next cancellation check.
// last rendered rows — it is being disposed or has nothing left to poll with.
} }
} }
@@ -117,8 +117,9 @@ else
@if (_recentEvents.Count == 0) @if (_recentEvents.Count == 0)
{ {
<div class="empty-state"> <div class="empty-state">
Waiting for events. The dashboard mirrors the session's gRPC event stream — events Waiting for events. The dashboard subscribes to this session's events directly, so
appear here only while a gRPC client is also consuming this session's events. rows appear as the session's worker emits them while this page is open — no gRPC
client has to be consuming the session.
</div> </div>
} }
else else