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