fix(adminui): /hosts timer dispose-race hardening + IAsyncDisposable parity with DriverStatusPanel (review)

This commit is contained in:
Joseph Doherty
2026-06-18 11:50:07 -04:00
parent 6457eba830
commit 07bd63f808
@@ -18,7 +18,7 @@
@inject IDriverStatusSnapshotStore DriverStore @inject IDriverStatusSnapshotStore DriverStore
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory @inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject Microsoft.Extensions.Logging.ILogger<Hosts> Logger @inject Microsoft.Extensions.Logging.ILogger<Hosts> Logger
@implements IDisposable @implements IAsyncDisposable
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Cluster hosts</h4> <h4 class="mb-0">Cluster hosts</h4>
@@ -226,12 +226,20 @@ else
await LoadConfigAsync(); await LoadConfigAsync();
RebuildDriverGroups(); RebuildDriverGroups();
DriverStore.SnapshotChanged += OnSnapshotChanged; DriverStore.SnapshotChanged += OnSnapshotChanged;
_timer = new Timer(_ => InvokeAsync(async () => _timer = new Timer(_ => _ = InvokeAsync(async () =>
{ {
Refresh(); try
await LoadConfigAsync(); {
RebuildDriverGroups(); Refresh();
StateHasChanged(); 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, }), null,
TimeSpan.FromSeconds(RefreshIntervalSeconds), TimeSpan.FromSeconds(RefreshIntervalSeconds),
TimeSpan.FromSeconds(RefreshIntervalSeconds)); TimeSpan.FromSeconds(RefreshIntervalSeconds));
@@ -337,10 +345,13 @@ else
_ => "chip-idle", _ => "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; 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( private sealed record MemberRow(