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:
+55
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user