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
|
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
|
||||||
|
{
|
||||||
|
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
|
try
|
||||||
{
|
{
|
||||||
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
|
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
|
||||||
using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
|
return true;
|
||||||
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user