fix(dashboard): alarm poll loop retries through faults and surfaces them; correct stale session-events empty-state copy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<bool> 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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,8 +117,9 @@ else
|
||||
@if (_recentEvents.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
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.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user