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
@@ -502,6 +502,92 @@ public sealed class BundleImporterLoadTests
() => rig.Importer.LoadAsync(stream, passphrase: null));
}
// ─────────────────────────────────────────────────────────────────────
// Register row 43 — abandoned-session release.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Abandon_releases_the_session_slot_and_zeroes_its_decrypted_content()
{
// Row 43: only ApplyAsync used to release a session, so any caller that
// loaded a bundle and walked away held one of MaxConcurrentImportSessions
// (plus the decrypted plaintext) for the full TTL.
var rig = BuildRig();
using var packed = PackEncryptedBundle(
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, SmallContent(), "correct");
var session = await rig.Importer.LoadAsync(
new MemoryStream(((MemoryStream)packed).ToArray()), passphrase: "correct");
var decrypted = session.DecryptedContent;
Assert.NotEmpty(decrypted);
Assert.Contains(decrypted, b => b != 0);
Assert.NotNull(rig.SessionStore.Get(session.SessionId));
rig.Importer.Abandon(session.SessionId);
Assert.Null(rig.SessionStore.Get(session.SessionId));
// Zeroed in place, so even a caller still holding the reference (the Razor
// page keeps one for the Result step) can no longer read the plaintext.
Assert.All(decrypted, b => Assert.Equal(0, b));
}
[Fact]
public async Task Abandon_is_idempotent_and_tolerates_unknown_ids()
{
// The Razor page calls Abandon from Dispose even after a successful Apply
// already released the same id; a double release must be a silent no-op.
var rig = BuildRig();
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
var session = await rig.Importer.LoadAsync(packed, passphrase: null);
rig.Importer.Abandon(session.SessionId);
rig.Importer.Abandon(session.SessionId);
rig.Importer.Abandon(Guid.NewGuid());
Assert.Null(rig.SessionStore.Get(session.SessionId));
}
[Fact]
public async Task Abandon_does_not_release_another_callers_session()
{
// Sessions are keyed by a random GUID handed only to their opener, so one
// operator abandoning their wizard must not evict a concurrent import.
var rig = BuildRig();
using var packedA = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
using var packedB = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
var a = await rig.Importer.LoadAsync(packedA, passphrase: null);
var b = await rig.Importer.LoadAsync(packedB, passphrase: null);
rig.Importer.Abandon(a.SessionId);
Assert.Null(rig.SessionStore.Get(a.SessionId));
var survivor = rig.SessionStore.Get(b.SessionId);
Assert.NotNull(survivor);
Assert.NotEmpty(survivor!.DecryptedContent);
}
[Fact]
public async Task Abandon_frees_a_concurrent_import_session_slot()
{
// The availability half of row 43: abandoned sessions used to exhaust the
// cap and lock out every import on the node until the TTL expired.
var rig = BuildRig(opts => opts.MaxConcurrentImportSessions = 2);
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
var bundleBytes = ((MemoryStream)packed).ToArray();
var first = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null);
await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null);
var full = await Assert.ThrowsAsync<InvalidOperationException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null));
Assert.Contains("Too many concurrent import sessions", full.Message);
rig.Importer.Abandon(first.SessionId);
var third = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null);
Assert.NotNull(rig.SessionStore.Get(third.SessionId));
}
/// <summary>
/// Builds a zip directly so the test can write a manifest whose
/// <c>ContentHash</c> or <c>BundleFormatVersion</c> intentionally