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)
|
||||
|
||||
+55
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user