From 07bd63f8085de6a471000854c4dbeca9bf402468 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 11:50:07 -0400 Subject: [PATCH] fix(adminui): /hosts timer dispose-race hardening + IAsyncDisposable parity with DriverStatusPanel (review) --- .../Components/Pages/Hosts.razor | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) 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 d0cec0e0..069df324 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 @@ -18,7 +18,7 @@ @inject IDriverStatusSnapshotStore DriverStore @inject IDbContextFactory DbFactory @inject Microsoft.Extensions.Logging.ILogger Logger -@implements IDisposable +@implements IAsyncDisposable

Cluster hosts

@@ -226,12 +226,20 @@ else await LoadConfigAsync(); RebuildDriverGroups(); DriverStore.SnapshotChanged += OnSnapshotChanged; - _timer = new Timer(_ => InvokeAsync(async () => + _timer = new Timer(_ => _ = InvokeAsync(async () => { - Refresh(); - await LoadConfigAsync(); - RebuildDriverGroups(); - StateHasChanged(); + 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, TimeSpan.FromSeconds(RefreshIntervalSeconds), TimeSpan.FromSeconds(RefreshIntervalSeconds)); @@ -337,10 +345,13 @@ else _ => "chip-idle", }; - public void Dispose() + public async ValueTask DisposeAsync() { - _timer?.Dispose(); + // Unsubscribe first so the singleton store can't invoke a handler on a disposed component. DriverStore.SnapshotChanged -= OnSnapshotChanged; + // 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(); } private sealed record MemberRow(