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
@@ -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