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
@@ -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();
}
}