fix(central-ui): resolve CentralUI-020..025 — auth-ping idle logout, DebugView race, push-handler disposal guard, JS-interop catch narrowing, claim-constant helper, SessionExpiry tests

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:16 -04:00
parent f82bcbed7c
commit d7d74ebe5e
28 changed files with 974 additions and 124 deletions
@@ -204,6 +204,17 @@
private int _totalPages;
private const int PageSize = 25;
// CentralUI-022: IDeploymentStatusNotifier is a process singleton that
// raises StatusChanged on the DeploymentManager service thread. Dispose()
// unsubscribes, but the notifier can read its subscriber list and begin
// invoking OnDeploymentStatusChanged just before this component is disposed.
// The handler then runs against a disposed component and InvokeAsync throws
// ObjectDisposedException as an unobserved fire-and-forget task exception.
// This flag (set first in Dispose()) makes a racing callback no-op, and the
// dispatch swallows the residual ObjectDisposedException — mirroring the
// DebugView (CentralUI-009) and ToastNotification (CentralUI-010) guards.
private volatile bool _disposed;
// CentralUI-006: deployment status updates are push-based, not polled.
// DeploymentManager raises IDeploymentStatusNotifier.StatusChanged on every
// deployment-record status write; this page subscribes to it and reloads,
@@ -220,12 +231,34 @@
private void OnDeploymentStatusChanged(ScadaLink.DeploymentManager.DeploymentStatusChange change)
{
if (!_autoRefresh) return;
_ = InvokeAsync(async () =>
// CentralUI-022: a callback racing disposal must not touch the component.
if (_disposed || !_autoRefresh) return;
_ = DispatchReloadAsync();
}
/// <summary>
/// Reloads the deployment table on the renderer's dispatcher, guarded
/// against the component being disposed mid-flight (CentralUI-022):
/// <c>InvokeAsync</c> throws <see cref="ObjectDisposedException"/> once the
/// circuit is gone, and this handler runs fire-and-forget so that exception
/// would otherwise go unobserved on the DeploymentManager thread.
/// </summary>
private async Task DispatchReloadAsync()
{
if (_disposed) return;
try
{
await LoadDataAsync();
StateHasChanged();
});
await InvokeAsync(async () =>
{
if (_disposed) return;
await LoadDataAsync();
StateHasChanged();
});
}
catch (ObjectDisposedException)
{
// Component disposed between the guard and the dispatch — ignore.
}
}
private void ToggleAutoRefresh()
@@ -316,8 +349,10 @@
public void Dispose()
{
// Unsubscribe so a status change after the circuit is gone does not
// touch a disposed component (the notifier is a process singleton).
// CentralUI-022: set the guard first so a callback already in flight on
// the DeploymentManager thread no-ops, then unsubscribe so no further
// status change reaches this disposed component.
_disposed = true;
DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged;
}
}