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:
Joseph Doherty
2026-08-19 16:22:00 -04:00
parent f1ef95f4da
commit 6d0d8b6258
4 changed files with 552 additions and 14 deletions
@@ -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)
@@ -110,6 +110,20 @@ public partial class TransportImport : ComponentBase, IDisposable
private BundleSession? _session;
private bool _uploadInProgress;
/// <summary>
/// Guards <see cref="_session"/> together with <see cref="_disposed"/> (register row
/// 103, the P6-6 lesser instance — mirrors <c>DebugView</c>'s <c>_sessionLock</c>).
/// <see cref="TryLoadAsync"/> awaits <see cref="IBundleImporter.LoadAsync"/>, and the
/// circuit can be torn down inside that window; publishing the freshly-opened session
/// and observing disposal must be one atomic step so exactly one side (this method or
/// <see cref="Dispose"/>) releases it — otherwise the session is neither published nor
/// abandoned and strands one of <c>MaxConcurrentImportSessions</c> for the full TTL.
/// </summary>
private readonly object _sessionLock = new();
/// <summary>Set once <see cref="Dispose"/> has run; checked under <see cref="_sessionLock"/>.</summary>
private volatile bool _disposed;
// Staging directory for in-flight bundle uploads. Lives under the system
// temp directory rather than wwwroot/ because the file is never served to
// a browser — it is only read by the in-process IBundleImporter.
@@ -241,8 +255,25 @@ public partial class TransportImport : ComponentBase, IDisposable
using var stream = new FileStream(
_bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 81920, useAsync: true);
_session = await BundleImporter.LoadAsync(
var loaded = await BundleImporter.LoadAsync(
stream, passphrase, CancellationToken.None, ClientAddress.ClientAddress);
// Register row 103 (P6-6 lesser instance): the component may have been
// disposed while LoadAsync was in flight (navigation away mid-upload).
// Publish and re-check disposal atomically under _sessionLock so exactly
// one side tears the freshly-opened session down.
bool abandoned;
lock (_sessionLock)
{
abandoned = _disposed;
if (!abandoned) _session = loaded;
}
if (abandoned)
{
try { BundleImporter.Abandon(loaded.SessionId); } catch { /* best-effort */ }
return;
}
_errorMessage = null;
}
catch (ArgumentException)
@@ -921,8 +952,12 @@ public partial class TransportImport : ComponentBase, IDisposable
/// </summary>
private void ReleaseSession()
{
var session = _session;
_session = null;
BundleSession? session;
lock (_sessionLock)
{
session = _session;
_session = null;
}
if (session is null) return;
try
{
@@ -938,10 +973,26 @@ public partial class TransportImport : ComponentBase, IDisposable
/// Ensures neither the staged temp file nor the server-side bundle session
/// survives circuit teardown. Blazor invokes Dispose when the user navigates
/// away or the circuit ends, so an abandoned wizard cleans up automatically.
/// <para>
/// Register row 103: the flag is set and the session taken under the same lock
/// <see cref="TryLoadAsync"/> publishes through, so a <see cref="TryLoadAsync"/> still
/// awaiting <see cref="IBundleImporter.LoadAsync"/> either hands the session over here
/// or abandons it itself — never neither (mirrors <c>DebugView</c>'s P6-6 fix).
/// </para>
/// </summary>
public void Dispose()
{
ReleaseSession();
BundleSession? session;
lock (_sessionLock)
{
_disposed = true;
session = _session;
_session = null;
}
if (session is not null)
{
try { BundleImporter.Abandon(session.SessionId); } catch { /* best-effort */ }
}
DeleteBundleTempFile();
}
}