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 IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject Microsoft.Extensions.Logging.ILogger<Hosts> Logger
@implements IDisposable
@implements IAsyncDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Cluster hosts</h4>
@@ -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(