fix(centralui): close register row 103 — take-once session/timer discipline for TransportImport + Topology (P6-6 lesser instances)
Mirrors the Medium wave's DebugView P6-6 fix (_sessionLock + _disposed, publish-then-recheck-disposal under one lock) for the two TTL-bounded/ unbounded lesser instances the Medium wave deferred: - TransportImport.razor.cs: TryLoadAsync awaits IBundleImporter.LoadAsync and used to publish the result into _session unconditionally. A circuit torn down mid-await left Dispose() with nothing to release (session still null), so the freshly-opened server-side BundleSession stayed registered, stranding one of MaxConcurrentImportSessions (register row 43) for the full session TTL. Now publishes and re-checks disposal atomically under _sessionLock, so exactly one side (TryLoadAsync or Dispose) abandons the session. - Topology.razor: OnInitializedAsync awaits LoadDataAsync before calling StartLiveUpdatesTimer. A circuit torn down mid-await left Dispose() with no timer to stop yet, so the timer StartLiveUpdatesTimer creates once the await resumes was never disposed — it ticked against a torn-down component every 15s until process restart. StartLiveUpdatesTimer/StopLiveUpdatesTimer/ Dispose now create/replace/take the timer atomically under _timerLock together with a _disposed check, so a late-arriving (re)start refuses to create a timer once disposed. Tests (bUnit, both proven to fail against the unfixed code): - TransportImportSessionDisposeRaceTests: dispose-during-load abandons the just-opened session immediately; dispose-after-successful-load abandons exactly once. - TopologyLiveUpdatesTimerDisposeRaceTests: a late StartLiveUpdatesTimer call after Dispose leaves no live timer; Dispose stops the timer exactly once (idempotent double-dispose); OnLiveUpdatesToggled(off) + Dispose leaves no live timer. All assert on deterministic field/call-order state, no sleeps. Register row 103 stays open for integration to close per the plan's shared constraints (register edits happen at integration only).
This commit is contained in:
@@ -160,6 +160,21 @@
|
||||
private Timer? _liveUpdatesTimer;
|
||||
private static readonly TimeSpan LiveUpdatesInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Guards <see cref="_liveUpdatesTimer"/> together with <see cref="_disposed"/>
|
||||
/// (register row 103, the P6-6 lesser instance — mirrors <c>DebugView</c>'s
|
||||
/// <c>_sessionLock</c>). <see cref="OnInitializedAsync"/> awaits
|
||||
/// <see cref="LoadDataAsync"/> before calling <see cref="StartLiveUpdatesTimer"/>; the
|
||||
/// circuit can be torn down inside that window, and <c>Dispose</c> may run on a
|
||||
/// non-UI thread, so creating/replacing the timer and observing disposal must be one
|
||||
/// atomic step — otherwise a timer created after <c>Dispose</c> already ran is never
|
||||
/// disposed and ticks forever against a torn-down component.
|
||||
/// </summary>
|
||||
private readonly object _timerLock = new();
|
||||
|
||||
/// <summary>Set once <see cref="Dispose"/> has run; checked under <see cref="_timerLock"/>.</summary>
|
||||
private volatile bool _disposed;
|
||||
|
||||
private TreeView<TopoNode> _tree = default!;
|
||||
private object? _selectedKey;
|
||||
private const string SelectedKeyStorage = "topology-tree-selected";
|
||||
@@ -194,23 +209,51 @@
|
||||
StartLiveUpdatesTimer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Re)creates the live-update timer, or leaves none running when disposed / live
|
||||
/// updates are off. Register row 103: creation and disposal of any timer this method
|
||||
/// is replacing both happen under <see cref="_timerLock"/> together with the
|
||||
/// <see cref="_disposed"/> check, so a call that resumes after <see cref="Dispose"/>
|
||||
/// already ran refuses to create a new timer instead of leaking one.
|
||||
/// </summary>
|
||||
private void StartLiveUpdatesTimer()
|
||||
{
|
||||
_liveUpdatesTimer?.Dispose();
|
||||
if (!_liveUpdates) return;
|
||||
_liveUpdatesTimer = new Timer(_ =>
|
||||
Timer? old;
|
||||
lock (_timerLock)
|
||||
{
|
||||
InvokeAsync(async () =>
|
||||
old = _liveUpdatesTimer;
|
||||
_liveUpdatesTimer = null;
|
||||
if (!_disposed && _liveUpdates)
|
||||
{
|
||||
_liveUpdatesTimer = new Timer(
|
||||
OnLiveUpdatesTick, null, LiveUpdatesInterval, LiveUpdatesInterval);
|
||||
}
|
||||
}
|
||||
old?.Dispose();
|
||||
}
|
||||
|
||||
private void OnLiveUpdatesTick(object? state)
|
||||
{
|
||||
if (_disposed) return;
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
if (_disposed || !_liveUpdates) return;
|
||||
try
|
||||
{
|
||||
if (!_liveUpdates) return;
|
||||
// Staleness is deliberately OFF the live loop: it only changes when a
|
||||
// template/instance is edited or redeployed, and recomputing it (a
|
||||
// full re-flatten per deployed instance) every 15s is the page's
|
||||
// dominant cost. The poll refreshes only the cheap deployed state.
|
||||
await LoadDeployedStateAsync();
|
||||
if (_disposed) return;
|
||||
StateHasChanged();
|
||||
});
|
||||
}, null, LiveUpdatesInterval, LiveUpdatesInterval);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Component disposed between the guard and the dispatch — ignore
|
||||
// (mirrors DebugView's SafeInvokeAsync / CentralUI-009).
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnLiveUpdatesToggled(ChangeEventArgs e)
|
||||
@@ -222,14 +265,37 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
_liveUpdatesTimer?.Dispose();
|
||||
_liveUpdatesTimer = null;
|
||||
StopLiveUpdatesTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private void StopLiveUpdatesTimer()
|
||||
{
|
||||
Timer? timer;
|
||||
lock (_timerLock)
|
||||
{
|
||||
timer = _liveUpdatesTimer;
|
||||
_liveUpdatesTimer = null;
|
||||
}
|
||||
timer?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register row 103: the flag is set and the timer taken under the same lock
|
||||
/// <see cref="StartLiveUpdatesTimer"/> creates through, so a still-pending
|
||||
/// <see cref="OnInitializedAsync"/> either hands its new timer over here (never
|
||||
/// created) or this method has nothing left to dispose — never both create AND leak.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_liveUpdatesTimer?.Dispose();
|
||||
Timer? timer;
|
||||
lock (_timerLock)
|
||||
{
|
||||
_disposed = true;
|
||||
timer = _liveUpdatesTimer;
|
||||
_liveUpdatesTimer = null;
|
||||
}
|
||||
timer?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
|
||||
Reference in New Issue
Block a user