From 4b6a93bb7884f8b653a1a82ac6bdc85b65cd76c6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 16 Aug 2026 04:03:31 -0400 Subject: [PATCH] fix(adminui): fleet/hosts poll ticks no longer overlap; cancel on dispose --- .../Components/Pages/Fleet.razor | 33 +++++++-- .../Components/Pages/Hosts.razor | 74 +++++++++++++------ 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor index fbf97694..629e76f1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor @@ -106,6 +106,14 @@ else private DateTime? _lastRefreshUtc; private Timer? _timer; + // Overlap guard: a tick whose DB query outruns the refresh interval must not stack behind the + // next one. A manual Refresh landing on top of an in-flight tick is a no-op by the same guard. + private int _tickRunning; + + // Page-lifetime cancellation: cancelled in Dispose() so a tick in flight when the circuit goes + // away aborts its query instead of running it to completion against a component that's gone. + private readonly CancellationTokenSource _cts = new(); + protected override async Task OnInitializedAsync() { await LoadAsync(); @@ -118,17 +126,18 @@ else private async Task LoadAsync() { - _refreshing = true; - StateHasChanged(); + if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return; try { - await using var db = await DbFactory.CreateDbContextAsync(); + _refreshing = true; + StateHasChanged(); + await using var db = await DbFactory.CreateDbContextAsync(_cts.Token); // Project the most-recent NodeDeploymentState per node — that's the row the // DriverHostActor most recently touched, regardless of which deployment it was for. var states = await db.NodeDeploymentStates.AsNoTracking() .GroupBy(s => s.NodeId) .Select(g => g.OrderByDescending(s => s.StartedAtUtc).First()) - .ToListAsync(); + .ToListAsync(_cts.Token); var byNode = states.ToDictionary(s => s.NodeId); // Union with current Akka driver members so a freshly-joined node that has no @@ -151,8 +160,15 @@ else }).ToList(); _lastRefreshUtc = DateTime.UtcNow; } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // Circuit disposed while a tick was in flight — a cancelled tick is a clean exit, not a + // fault (the discarded timer task would otherwise swallow it silently). + } finally { + // Release first: a StateHasChanged on a torn-down circuit must never leave the guard set. + Volatile.Write(ref _tickRunning, 0); _refreshing = false; StateHasChanged(); } @@ -168,7 +184,14 @@ else private static string StatusLabel(NodeDeploymentStatus? status) => status?.ToString() ?? "waiting"; - public void Dispose() => _timer?.Dispose(); + public void Dispose() + { + // Cancel BEFORE disposing the timer so a tick already in flight aborts its query rather than + // running to completion; the CTS is disposed last, once nothing new can observe the token. + _cts.Cancel(); + _timer?.Dispose(); + _cts.Dispose(); + } private sealed record NodeRow( string NodeId, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index 09f8e3ba..43eb8d89 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -329,44 +329,66 @@ else // HostsDriverRow instance and would otherwise close a dialog an operator just opened. private string? _controlsDriverId; + // Overlap guard: a tick whose config load outruns the 5 s interval must not stack behind the + // next one. A manual Refresh landing on top of an in-flight tick is a no-op by the same guard. + private int _tickRunning; + + // Page-lifetime cancellation: cancelled in DisposeAsync() so a tick in flight when the circuit + // goes away aborts its queries instead of running them against a component that's gone. + private readonly CancellationTokenSource _cts = new(); + protected override async Task OnInitializedAsync() { Refresh(); await LoadConfigAsync(); RebuildDriverGroups(); DriverStore.SnapshotChanged += OnSnapshotChanged; - _timer = new Timer(_ => _ = InvokeAsync(async () => - { - try - { - Refresh(); - await LoadConfigAsync(); - RebuildDriverGroups(); - StateHasChanged(); - } - catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException) - { - // Circuit disposed while a tick was in flight — ignore (the discarded task would - // otherwise swallow this silently). Mirrors DriverStatusPanel's drain-on-dispose. - } - }), null, + _timer = new Timer(_ => _ = InvokeAsync(TickAsync), null, TimeSpan.FromSeconds(RefreshIntervalSeconds), TimeSpan.FromSeconds(RefreshIntervalSeconds)); } - private async Task RefreshAsync() + private async Task TickAsync() { - _refreshing = true; - StateHasChanged(); + if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return; try { + Refresh(); + await LoadConfigAsync(); + RebuildDriverGroups(); + StateHasChanged(); + } + catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException) + { + // Circuit disposed while a tick was in flight — ignore (the discarded task would + // otherwise swallow this silently). Mirrors DriverStatusPanel's drain-on-dispose. + } + finally + { + Volatile.Write(ref _tickRunning, 0); + } + } + + private async Task RefreshAsync() + { + if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return; + try + { + _refreshing = true; + StateHasChanged(); await Task.Yield(); Refresh(); await LoadConfigAsync(); RebuildDriverGroups(); } + catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException) + { + // Same clean-exit tolerance as the timer path. + } finally { + // Release first: a StateHasChanged on a torn-down circuit must never leave the guard set. + Volatile.Write(ref _tickRunning, 0); _refreshing = false; StateHasChanged(); } @@ -379,13 +401,19 @@ else { try { - await using var db = await DbFactory.CreateDbContextAsync(); + await using var db = await DbFactory.CreateDbContextAsync(_cts.Token); _nodes = await db.ClusterNodes.AsNoTracking() .Select(n => new HostsNodeInfo(n.ClusterId, n.NodeId, n.Host, n.OpcUaPort)) - .ToListAsync(); + .ToListAsync(_cts.Token); _instances = await db.DriverInstances.AsNoTracking() .Select(d => new HostsDriverInstanceInfo(d.DriverInstanceId, d.ClusterId, d.Name, d.DriverType, d.Enabled)) - .ToListAsync(); + .ToListAsync(_cts.Token); + } + catch (OperationCanceledException) + { + // The circuit went away mid-load. Rethrow so the caller treats it as a clean exit — it + // must NOT fall into the swallow-and-clear below, which would blank the cached config. + throw; } catch (Exception ex) { @@ -487,9 +515,13 @@ else { // Unsubscribe first so the singleton store can't invoke a handler on a disposed component. DriverStore.SnapshotChanged -= OnSnapshotChanged; + // Cancel before draining so a tick already in flight aborts its queries rather than running + // them to completion against a component that's gone. + _cts.Cancel(); // Drain the timer so an in-flight callback can't touch a component that's already gone // (System.Threading.Timer's async dispose awaits any in-flight callback — .NET 6+). if (_timer is not null) await _timer.DisposeAsync(); + _cts.Dispose(); } private sealed record MemberRow(