fix(session): stop managed sidecar disposal from crashing close - #4191
fix(session): stop managed sidecar disposal from crashing close#4191probepark wants to merge 4 commits into
Conversation
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Review of exact head e72cabbcf026ba986b9500a42166f4528697aa73 against 79e4e0a097aeb54cfe94f79b8919af778dbc5c68.
The focused suites and package check are green, the present wrong-mode directory remains fail-closed/unremoved, and SessionManager.close() no longer throws for the covered failure shapes. Two cleanup-safety blockers remain.
HIGH — the ENOENT preflight masks parent-path substitution and permanently drops cleanup authority (packages/coding-agent/src/session/blob-store.ts:390-404, EphemeralBlobStore.dispose() at :1239-1246).
Deterministic exact-head reproduction:
const instanceDir = openVerifiedResidentCacheInstanceDir(cacheRoot);
const store = EphemeralBlobStore.adoptVerifiedDir(instanceDir);
const parkedRoot = `${cacheRoot}-parked`;
renameSync(cacheRoot, parkedRoot);
mkdirSync(replacementRoot, { mode: 0o700 });
symlinkSync(replacementRoot, cacheRoot, "dir");
store.dispose(); // returns success: lstat(instanceDir) sees ENOENT through the substituted parent
unlinkSync(cacheRoot);
renameSync(parkedRoot, cacheRoot);
existsSync(instanceDir); // true: the owned directory was never removed
store.dispose(); // no-op because #disposed was set true
existsSync(instanceDir); // still trueThis passed as an adversarial test on the exact head. ENOENT proves only that the current pathname does not resolve; it does not prove that the owned target disappeared. A renamed/replaced parent therefore converts a path-trust failure into successful disposal, deletes the ownership-set entry, and makes the store permanently non-retryable while the original directory remains present.
HIGH — managed-sidecar release clears the only retry handle before disposal and logs the absolute cache path (packages/coding-agent/src/session/session-manager.ts:9942-9961).
Deterministic exact-head reproduction using the PR's managed-sidecar fixture:
const dispose = vi.spyOn(EphemeralBlobStore.prototype, "dispose")
.mockImplementation(function () {
throw new ResidentCacheTrustError("directory_untrusted", this.dir);
});
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
await manager.close(); // resolves
// warn metadata.error contains the complete absolute sidecar directory path
dispose.mockRestore();
await manager.close(); // resolves but performs no disposal retry
// the sidecar directory still existsThis also passed as an adversarial exact-head test. Because #managedSidecarCacheStore is cleared before dispose(), a retryable trust/permission failure becomes warning-only with no in-process cleanup authority. Repeated close is idempotent only by forgetting the orphan. The owned-directory set causes current-process sweeps to skip it; cleanup is deferred until a later process can classify the stale owner. The structured warning includes ResidentCacheTrustError.message, which embeds the absolute path.
Required change: distinguish target absence from parent/path substitution using retained/verified parent authority (and cover the substitution/race), preserve a retryable cleanup receipt/handle after failed sidecar disposal without letting close() throw, and log a bounded reason without the absolute path. Add regression coverage for parent substitution, repeated close after failure recovery, and warning payload confidentiality.
Remote state verified before submission: exact head/base, mergeable clean, 20 successful checks, 5 skipped, 0 failed/pending.
Signed-off-by: Bellman (@Yeachan-Heo)
VERDICT: REQUEST_CHANGES
REQUEST_CHANGES — exact head
|
e72cabb to
92f7b22
Compare
|
Both HIGH blockers are addressed at head HIGH 1 — ENOENT preflight masks parent-path substitution
You were right that the preflight proved the wrong thing. The store now retains a verified parent descriptor captured at adoption and revalidates it across disposal, so a substituted or raced parent fails closed and the store stays retryable instead of being marked disposed. Adoption itself now fails with Regression coverage in
HIGH 2 — release clears the retry handle before disposal, and logs the absolute path
The warning payload no longer carries Regression coverage in
Verification
Re-review requested. |
A managed session releases its cold-history sidecar resident cache during close(). That release called dispose() unguarded, so a ResidentCacheTrustError escaped teardown as an unhandled rejection and killed the process. Disposing an instance directory that is already gone also has nothing left to distrust, so treating ENOENT as a trust violation turned a benign race into a fatal crash. Lore-id: 7c1a94e2 Constraint: present-but-untrusted cache directories must still fail closed Rejected: catch the rejection at the process level | hides a real state leak Rejected: relax assertResidentCacheDirectory globally | weakens every verify path Confidence: high Scope-risk: narrow Reversibility: easy Tested: dispose of removed instance dir, untrusted-mode refusal, managed close under both failure shapes Not-tested: Windows (resident cache is disabled there)
The managed sidecar cache moved out of the resident text-cache root, but the release regression fixture still searched the old root and therefore never found the directory it needed to remove or protect. Discover the deterministic sidecar instance in its dedicated cache root so the teardown contracts exercise the production path again. Lore-id: 9d61b40e Constraint: managed sidecar fixtures must use the production cache topology Rejected: weaken the non-empty assertion | would make both regression cases tautological Confidence: high Scope-risk: narrow Reversibility: easy Tested: managed sidecar release test and resident-cache regression suite Not-tested: Windows (resident cache is disabled there)
An ENOENT observed through a substituted cache parent was treated as successful disposal, permanently retiring ownership while the original instance survived. Retain and revalidate the verified parent descriptor across disposal so substitution and lookup races fail closed and remain retryable. Lore-id: 19f0c6ab Constraint: verified target absence must remain idempotent Rejected: trust ENOENT alone | parent substitution can hide the owned directory Confidence: high Scope-risk: narrow Reversibility: simple-revert Tested: bun test packages/coding-agent/test/session/resident-cache-gc.test.ts Tested: bun --cwd=packages/coding-agent run check Not-tested: hosted exact-head CI
Managed close discarded the only sidecar store after a non-throwing disposal failure and exposed the absolute cache path through warning metadata. Keep failed stores in a bounded in-process retry set and emit only a fixed cleanup reason. Lore-id: b8624d7e Constraint: SessionManager.close must remain best-effort and non-throwing for sidecar cleanup failures Rejected: clear the active store before warning | repeated close cannot recover the orphan Confidence: high Scope-risk: narrow Reversibility: simple-revert Tested: bun test packages/coding-agent/test/session/managed-sidecar-cache-release.test.ts packages/coding-agent/test/session/session-memory-sidecar.test.ts Tested: bun --cwd=packages/coding-agent run check Not-tested: hosted exact-head CI
66d2169 to
def522b
Compare
|
Re-requesting review at rebased head
Local exact-head verification: 19 pass / 0 fail / 54 assertions across those three suites; |
What
SessionManager.close()could crash the whole CLI with an unhandledResidentCacheTrustError. Two changes:disposeVerifiedResidentCacheInstanceDir()now treats an instance directory that no longer exists as an already-completed disposal (ENOENT short-circuit) instead of raisingdirectory_unverifiable. A present-but-untrusted directory still fails closed.SessionManager.#releaseManagedSidecarCache()clears its sidecar state before disposing and downgrades a disposal failure to alogger.warn, matching the three other resident-store disposal sites in the same file.Why
Observed crash from the 0.12.21 binary:
Every other store disposal in
session-manager.ts(lines 344, 6708, 6813) is wrapped in try/catch + warn; the managed sidecar cache release at line 9947 was not, so any trust failure during teardown escapedclose()as an unhandled rejection and killed the process. On top of that, disposal of a directory that is already gone has nothing left to distrust — treating that race as a trust violation is what turned a benign cleanup race into a fatal crash. The unguarded throw also skipped the state resets on the following lines, stranding a half-released sidecar whose next release would throw again.Testing
bun test packages/coding-agent/test/session/managed-sidecar-cache-release.test.ts packages/coding-agent/test/session/resident-cache-gc.test.ts— 13 pass. Reverting only the two source files makes 3 of them fail, reproducing the exact reporteddirectory_unverifiablerejection.bun testacross the 9 resident-cache/session suites (session-resident-*,session/resident-cache-gc,ultragoal-redteam-resident-cache,session/session-memory-integration) — 89 + 83 pass, 0 fail.bun --cwd=packages/coding-agent run check— clean.New coverage:
ResidentCacheTrustErrorand is left in place.GJC verdict
devbun checkpasses