fix(transport): release abandoned BundleSessions instead of leaking a slot (row 43)

Only BundleImporter.ApplyAsync released an import session, so every path that
opened one and did NOT apply held one of MaxConcurrentImportSessions (8) — and
the DECRYPTED bundle content that T-007 deliberately zeroes on the apply paths —
for the full BundleSessionTtlMinutes (30). Eight abandonments locked out all
imports on the active node; observed live (R6: 3 back-to-back suite runs held 6
of 8 slots until central-b was restarted).

New IBundleImporter.Abandon(sessionId): the same zero-then-remove release
ApplyAsync performs, idempotent (double release / unknown id / expired id are
no-ops) and only ever given the random-GUID id its caller opened, so it can
never evict another circuit's session.

Wired into every abandonment path:
- Central UI wizard — ReleaseSession() from new upload, TryLoadAsync (before it
  overwrites _session), Back-to-Upload / ResetSessionState, the 3-strike lockout
  reset, and Dispose (navigation away / circuit teardown).
- CLI transport preview — a preview never applies, so nothing released it at
  all; now released in a finally.
- CLI transport import — covers the pre-apply aborts (blocker rows, unresolved
  site/connection mappings).

Class doc's "an abandoned wizard does not leak" claim is true again (it only
ever covered the staged plaintext temp file). Component-Transport.md +
docs/components/Transport.md now record the explicit-release contract and that
BundleSessionEvictionService is a TTL backstop, not an abandonment path.

Tests: 4 bUnit tests over a REAL BundleSessionStore (dispose / back-to-upload /
apply-then-dispose idempotency / concurrent-circuit isolation) — 3 of the 4 fail
against the pre-fix page; 4 importer tests (release + zeroing, idempotency,
cross-session isolation, and the slot-cap regression).
This commit is contained in:
Joseph Doherty
2026-08-15 05:42:08 -04:00
parent 9cf24a4496
commit 708389829b
8 changed files with 459 additions and 57 deletions
@@ -51,6 +51,21 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design;
/// retained on the component. The file is deleted on Back-to-Upload / Reset /
/// successful Apply / component Dispose, so an abandoned wizard does not leak
/// staged bundle plaintext beyond circuit teardown.
///
/// <para>
/// Server-side session release (register row 43): the staged temp file is only
/// half of an abandoned wizard's footprint — the server-side
/// <see cref="BundleSession"/> opened by <see cref="IBundleImporter.LoadAsync"/>
/// holds the DECRYPTED bundle content and occupies one of
/// <c>TransportOptions.MaxConcurrentImportSessions</c> slots for the full
/// <c>BundleSessionTtlMinutes</c>. Only <see cref="IBundleImporter.ApplyAsync"/>
/// used to release it, so eight abandoned wizards locked out every import on the
/// active node. Every path that drops <c>_session</c> — new upload, Back-to-Upload,
/// <see cref="ResetSessionState"/>, lockout reset, circuit teardown via
/// <see cref="Dispose"/> — now goes through <see cref="ReleaseSession"/>, which
/// calls <see cref="IBundleImporter.Abandon"/> (zero + remove, idempotent and
/// safe after a successful Apply has already released the same id).
/// </para>
/// </summary>
public partial class TransportImport : ComponentBase, IDisposable
{
@@ -149,7 +164,9 @@ public partial class TransportImport : ComponentBase, IDisposable
{
_errorMessage = null;
_uploadInProgress = true;
_session = null;
// Release the previous upload's server-side session before we lose the
// only reference to its id — otherwise re-selecting a file leaks a slot.
ReleaseSession();
DeleteBundleTempFile();
try
{
@@ -207,6 +224,10 @@ public partial class TransportImport : ComponentBase, IDisposable
_errorMessage = "No bundle staged — please re-select the file.";
return;
}
// A successful LoadAsync overwrites _session; release whatever it is
// replacing first so the id of the outgoing session is never dropped on
// the floor (that would strand its slot for the full TTL).
ReleaseSession();
try
{
// Read the staged bundle straight off disk; the
@@ -808,7 +829,7 @@ public partial class TransportImport : ComponentBase, IDisposable
private void ResetSessionState()
{
_session = null;
ReleaseSession();
DeleteBundleTempFile();
_preview = null;
_resolutions = null;
@@ -854,12 +875,41 @@ public partial class TransportImport : ComponentBase, IDisposable
}
/// <summary>
/// Ensures the staged temp file does not survive circuit
/// teardown. Blazor invokes Dispose when the user navigates away or the
/// circuit ends, so an abandoned wizard cleans up automatically.
/// Releases the server-side <see cref="BundleSession"/> this wizard opened
/// (zeroing its decrypted content) and clears the local reference. Idempotent:
/// a null <c>_session</c> is a no-op, and <see cref="IBundleImporter.Abandon"/>
/// tolerates an id that <see cref="IBundleImporter.ApplyAsync"/> already
/// released. Only ever passes the id THIS circuit opened, so a concurrent
/// operator's session is never touched.
/// <para>
/// Abandon failure must not block the wizard (or, from <see cref="Dispose"/>,
/// tear down the circuit), so it is swallowed on the same best-effort footing
/// as the staged-file delete below.
/// </para>
/// </summary>
private void ReleaseSession()
{
var session = _session;
_session = null;
if (session is null) return;
try
{
BundleImporter.Abandon(session.SessionId);
}
catch
{
// Best-effort: the session's own TTL is the backstop.
}
}
/// <summary>
/// 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.
/// </summary>
public void Dispose()
{
ReleaseSession();
DeleteBundleTempFile();
}
}
@@ -46,4 +46,25 @@ public interface IBundleImporter
string user,
CancellationToken ct = default,
BundleNameMap? nameMap = null);
/// <summary>
/// Releases a session opened by <see cref="LoadAsync"/> that will never be
/// applied — the operator navigated away, restarted the wizard, or a CLI
/// preview/import gave up before <see cref="ApplyAsync"/> ran. Zeroes the
/// session's decrypted plaintext and drops it from the session store, which
/// frees one of the <c>MaxConcurrentImportSessions</c> slots immediately
/// instead of at the end of <c>BundleSessionTtlMinutes</c>.
/// <para>
/// Idempotent and safe to call after <see cref="ApplyAsync"/> (which performs
/// the same release on both its success and failure paths) or with an unknown
/// / already-expired id — the call is then a no-op and never throws.
/// </para>
/// <para>
/// Ownership: session ids are random GUIDs handed only to the caller that
/// opened them, so abandoning the id you hold can never release a session
/// another circuit / CLI caller owns.
/// </para>
/// </summary>
/// <param name="sessionId">Session id returned by <see cref="LoadAsync"/>.</param>
void Abandon(Guid sessionId);
}
@@ -3571,16 +3571,27 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
var preview = await importer.PreviewAsync(session.SessionId);
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
// Surface the required site/connection mappings so an operator
// (CLI or UI) sees which references need resolving before applying.
return new PreviewBundleResult(
preview.Items, adds, mods, ids, blocks,
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
// Register row 43: a preview NEVER applies, so nothing else would ever
// release this session — every `transport preview` used to burn one of
// MaxConcurrentImportSessions (and hold the decrypted bundle) for the
// full BundleSessionTtlMinutes. Release it on the way out, always.
try
{
var preview = await importer.PreviewAsync(session.SessionId);
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
// Surface the required site/connection mappings so an operator
// (CLI or UI) sees which references need resolving before applying.
return new PreviewBundleResult(
preview.Items, adds, mods, ids, blocks,
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
}
finally
{
importer.Abandon(session.SessionId);
}
}
finally
{
@@ -3618,50 +3629,62 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
var preview = await importer.PreviewAsync(session.SessionId);
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
if (blockers.Count > 0)
// Register row 43: ApplyAsync releases the session on both its success
// and failure paths, but every abort BEFORE it (blockers, unresolved
// mappings) used to strand the session — and its decrypted content —
// for the full TTL. Abandon is idempotent, so covering the whole block
// is safe even when ApplyAsync already released the same id.
try
{
var details = string.Join("; ",
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
throw new ManagementCommandException(
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
}
var preview = await importer.PreviewAsync(session.SessionId);
// Resolve every required site/connection reference into a
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
// fall back to the preview's auto-match; otherwise (no match) create-new
// ONLY if the create-missing flag is set, else fail with a clear message.
var nameMap = BuildNameMap(cmd, preview);
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
// entity (e.g. one row per modified member of a template), but ApplyAsync
// requires a unique resolution per key. Last-write-wins matches the
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
// DataConnection rows the preview item Name is already site-qualified
// ({site}/{name}), so keying generically by (EntityType, Name) is
// automatically correct — no bare-connection-name special case needed.
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
if (blockers.Count > 0)
{
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Identical => ResolutionAction.Skip,
ConflictKind.Modified => policy,
_ => ResolutionAction.Skip,
};
var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
? $"{item.Name}-imported-{renameStamp}"
: null;
resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
item.EntityType, item.Name, action, renameTo);
}
var details = string.Join("; ",
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
throw new ManagementCommandException(
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
}
return await importer.ApplyAsync(
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
// Resolve every required site/connection reference into a
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
// fall back to the preview's auto-match; otherwise (no match) create-new
// ONLY if the create-missing flag is set, else fail with a clear message.
var nameMap = BuildNameMap(cmd, preview);
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
// entity (e.g. one row per modified member of a template), but ApplyAsync
// requires a unique resolution per key. Last-write-wins matches the
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
// DataConnection rows the preview item Name is already site-qualified
// ({site}/{name}), so keying generically by (EntityType, Name) is
// automatically correct — no bare-connection-name special case needed.
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
{
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Identical => ResolutionAction.Skip,
ConflictKind.Modified => policy,
_ => ResolutionAction.Skip,
};
var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
? $"{item.Name}-imported-{renameStamp}"
: null;
resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
item.EntityType, item.Name, action, renameTo);
}
return await importer.ApplyAsync(
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
}
finally
{
importer.Abandon(session.SessionId);
}
}
finally
{
@@ -1715,6 +1715,32 @@ public sealed class BundleImporter : IBundleImporter
}
}
/// <inheritdoc />
/// <remarks>
/// Register row 43: every abandonment path (Central UI wizard reset /
/// back-to-upload / circuit teardown, CLI preview, CLI import that aborts
/// before Apply) funnels here so a session that will never be applied stops
/// holding one of the <c>MaxConcurrentImportSessions</c> slots — and stops
/// holding its decrypted plaintext — for the rest of its TTL. Applies the same
/// zero-then-remove ordering as the T-007 release inside
/// <see cref="ApplyAsync"/>: zeroing first means a caller still holding the
/// session reference (the Razor page keeps one for the Result step) observes
/// the cleared buffer too.
/// </remarks>
public void Abandon(Guid sessionId)
{
// Get() returns null for an unknown or already-expired id, which makes the
// double-release after ApplyAsync (which already removed it) a no-op rather
// than a throw. Remove is called unconditionally so an expired-but-still-
// resident entry is dropped as well.
var session = _sessionStore.Get(sessionId);
if (session is not null)
{
ZeroDecryptedContent(session);
}
_sessionStore.Remove(sessionId);
}
/// <summary>
/// Enumerates the deployed instances stale-ed by this import.
/// Overwriting a template changes the flattened-config hash of every instance