diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 67265e4ba84..b2864d8f692 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -27,6 +27,7 @@ import { } from "./runtime-host.js"; import { prepareAcpxRuntimeSandbox, + removeOwnedAcpxRuntimeSandboxRoot, type AcpxRuntimeSandbox, } from "./runtime-sandbox.js"; @@ -563,6 +564,89 @@ describe("ACPX runtime host", () => { expect(fixture.commandClose).toHaveBeenCalledOnce(); }); + it.each([ + ["credential", "credential staging failed"], + ["command", "command open failed"], + ["runtime open", "provider open failed"], + ["post-handshake verification", "model status failed"], + ])( + "releases the sandbox lease when %s fails after sandbox acquisition", + async (_stage, failureMessage) => { + const fixture = await hostFixture(); + const prepareSandbox: NonNullable< + AcpxRuntimeHostDependencies["prepareSandbox"] + > = vi.fn((input) => prepareAcpxRuntimeSandbox(input)); + const dependencies = fixture.dependencies({ + openRuntime: async () => { + if (failureMessage === "provider open failed") { + throw new Error(failureMessage); + } + return runtimePort({ + getStatus: + failureMessage === "model status failed" + ? async () => { + throw new Error(failureMessage); + } + : undefined, + }); + }, + }); + dependencies.prepareSandbox = prepareSandbox; + if (failureMessage === "credential staging failed") { + dependencies.stageCredential = async () => { + throw new Error(failureMessage); + }; + } + if (failureMessage === "command open failed") { + dependencies.verifyInstallation = async (profile) => ({ + commandDigest: profile.commandDigest, + agentServerPackageJsonPath: join(fixture.root, "package.json"), + agentRuntimePackageJsonPath: null, + openCommand: async () => { + throw new Error(failureMessage); + }, + }); + } + + await expect( + AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "codex", + model: "gpt-5.6-sol", + permissionMode: "approve-all", + environment: { + PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: + '{"tokens":{"access_token":"canary"}}', + }, + }, + dependencies, + ), + ).rejects.toThrow(/failed/); + + const sandbox: AcpxRuntimeSandbox = + await prepareSandbox.mock.results[0]!.value; + // The root itself is never deleted outright: a later admission for + // the same session may still reuse it. + await expect(stat(sandbox.root)).resolves.toBeDefined(); + + // The claim this admission never released would otherwise pin the + // marker to its own identifier forever, so no later admission could + // ever win delete authority over this root. Reuse the exact binding + // this admission built its sandbox from to claim the root again, and + // prove that claim now carries real delete authority. + const [{ binding }] = prepareSandbox.mock.calls[0]!; + const retriedSandbox = await waitForAcpxAdmission(() => + prepareAcpxRuntimeSandbox({ binding, agent: "codex" }), + ); + expect(retriedSandbox.root).toBe(sandbox.root); + await removeOwnedAcpxRuntimeSandboxRoot(retriedSandbox); + await expect(stat(sandbox.root)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, + ); + it("retains failed admission cleanup until provider shutdown permits credential scrub", async () => { const fixture = await hostFixture(); let authPath = ""; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index a60a9ce2a08..b1d26244e23 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -202,6 +202,7 @@ interface RetainedAcpxAdmissionCleanup { readonly toolBridge: RunnerToolBridge | null; readonly credential: AcpxProviderLifetimeLease | null; readonly command: VerifiedAcpxCommandLease | null; + readonly sandbox: AcpxRuntimeSandbox | null; readonly reason: string; recovery: Promise | null; timer: ReturnType | null; @@ -293,6 +294,7 @@ export class AcpxRuntimeHost { if (installation.commandDigest !== profile.commandDigest) { throw new Error("Verified ACPX installation does not match its profile"); } + let sandbox: AcpxRuntimeSandbox | null = null; let command: VerifiedAcpxCommandLease | null = null; let credential: AcpxProviderLifetimeLease | null = null; let toolBridge: RunnerToolBridge | null = null; @@ -326,6 +328,7 @@ export class AcpxRuntimeHost { await cleanupAbortedRuntimeAdmission( null, retained.credential, + sandbox, "ACPX rejected runtime admission cleanup confirmed", ); retainedRejectedRuntimeAdmissions.delete(retained); @@ -335,7 +338,7 @@ export class AcpxRuntimeHost { retainRuntimeHostCleanup(ownedCleanup); }; try { - const sandbox = await acquireAbortableAdmissionResource({ + sandbox = await acquireAbortableAdmissionResource({ signal: options.signal, acquire: () => (dependencies.prepareSandbox ?? prepareAcpxRuntimeSandbox)({ @@ -349,12 +352,15 @@ export class AcpxRuntimeHost { reportFailure: (failure) => dependencies.reportRetainedCleanupFailure(failure), }); + // Every stage from here on runs after the sandbox has been claimed, so + // it is always populated for the remainder of this admission attempt. + const claimedSandbox = sandbox; if (options.agent === "codex") { credential = await acquireAbortableAdmissionResource({ signal: options.signal, acquire: () => (dependencies.stageCredential ?? stageManagedCodexCredential)({ - agentHomeDirectory: sandbox.agentHomeDirectory, + agentHomeDirectory: claimedSandbox.agentHomeDirectory, environment: options.environment, sourcePath: options.managedCodexCredentialSourcePath, }), @@ -368,7 +374,7 @@ export class AcpxRuntimeHost { signal: options.signal, acquire: () => acquireAcpxProviderLifetimeLease({ - agentHomeDirectory: sandbox.agentHomeDirectory, + agentHomeDirectory: claimedSandbox.agentHomeDirectory, }), resource: "provider_lifetime", releaseLate: (lateLifetime) => lateLifetime.close(), @@ -406,13 +412,13 @@ export class AcpxRuntimeHost { command: command!, profile, cwd: binding.workspacePath, - stateDirectory: sandbox.stateDirectory, + stateDirectory: claimedSandbox.stateDirectory, providerSessionKey: binding.profileSessionKey, permissionMode: binding.permissionMode, permissionPolicy: acpxRuntimePermissionPolicy( binding.permissionMode, ), - launchEnvironment: sandbox.launchEnvironment, + launchEnvironment: claimedSandbox.launchEnvironment, credentialFenceFds: admittedLifetime.lifetimeFenceFds, activateCredentialFenceOwner: admittedLifetime.activateLifetimeOwner.bind(admittedLifetime), @@ -450,6 +456,7 @@ export class AcpxRuntimeHost { retainAbortedRuntimeAdmissionCleanup({ pendingRuntime, credential, + sandbox, reason: "ACPX runtime admission aborted", failedAdmissionCleanupTransfer, }); @@ -490,11 +497,18 @@ export class AcpxRuntimeHost { toolBridge, }); } catch (error) { + // A pending runtime that raced the abort signal owns its credential, + // and therefore the sandbox it runs in, through the deferred cleanup + // that `onAbortedPending` or `retainFailedAdmissionCleanup` wired up + // above. This immediate cleanup must not also release the sandbox + // claim while that deferred close still uses the sandbox. + const sandboxOwnedHere = pendingRuntimeOwnsCredential ? null : sandbox; const cleanup = cleanupRuntimeResources( runtime, toolBridge, pendingRuntimeOwnsCredential ? null : credential, command, + sandboxOwnedHere, "ACPX runtime initialization failed", ); retainRuntimeHostCleanup(cleanup); @@ -505,6 +519,7 @@ export class AcpxRuntimeHost { toolBridge, credential: pendingRuntimeOwnsCredential ? null : credential, command, + sandbox: sandboxOwnedHere, reason: "ACPX runtime initialization failed", }); }); @@ -630,6 +645,9 @@ export class AcpxRuntimeHost { this.#toolBridge, this.#credential, this.#command, + // Omit the sandbox here. This close releases its claim explicitly + // below, so passing it here would release the same claim twice. + null, reason, ); if (cleanupError) errors.push(...cleanupError.errors); @@ -748,12 +766,18 @@ function raceAdmissionWithAbort( function retainAbortedRuntimeAdmissionCleanup(input: { pendingRuntime: Promise; credential: AcpxProviderLifetimeLease | null; + sandbox: AcpxRuntimeSandbox | null; reason: string; failedAdmissionCleanupTransfer: Promise; }): void { const cleanup = input.pendingRuntime.then( (runtime) => - cleanupAbortedRuntimeAdmission(runtime, input.credential, input.reason), + cleanupAbortedRuntimeAdmission( + runtime, + input.credential, + input.sandbox, + input.reason, + ), () => input.failedAdmissionCleanupTransfer, ); retainRuntimeHostCleanup(cleanup); @@ -762,6 +786,7 @@ function retainAbortedRuntimeAdmissionCleanup(input: { async function cleanupAbortedRuntimeAdmission( runtime: AcpxRuntimePort | null, credential: AcpxProviderLifetimeLease | null, + sandbox: AcpxRuntimeSandbox | null, reason: string, ): Promise { const cleanupError = await cleanupRuntimeResources( @@ -769,6 +794,7 @@ async function cleanupAbortedRuntimeAdmission( null, credential, null, + sandbox, reason, ); if (!cleanupError) return; @@ -777,6 +803,7 @@ async function cleanupAbortedRuntimeAdmission( toolBridge: null, credential, command: null, + sandbox, reason, }); } @@ -786,6 +813,7 @@ function retainFailedAcpxAdmissionCleanup(input: { toolBridge: RunnerToolBridge | null; credential: AcpxProviderLifetimeLease | null; command: VerifiedAcpxCommandLease | null; + sandbox: AcpxRuntimeSandbox | null; reason: string; }): void { const cleanup: RetainedAcpxAdmissionCleanup = { @@ -813,6 +841,7 @@ function startRetainedAcpxAdmissionCleanup( cleanup.toolBridge, cleanup.credential, cleanup.command, + cleanup.sandbox, `${cleanup.reason} (automatic cleanup recovery ${attempt})`, ); if (!cleanupError) { @@ -988,6 +1017,7 @@ async function cleanupRuntimeResources( toolBridge: RunnerToolBridge | null, credential: AcpxProviderLifetimeLease | null, command: VerifiedAcpxCommandLease | null, + sandbox: AcpxRuntimeSandbox | null, reason: string, ): Promise { const settle = async ( @@ -1029,6 +1059,22 @@ async function cleanupRuntimeResources( credentialOutcome, ]); const errors = outcomes.filter((error): error is unknown => error !== null); + // This admission failed, so it never reached a live host. Release its + // own occupancy lease, and its delete-authority claim on the marker if + // no other admission still holds a lease — the same release a live + // host's own close does. A later admission for the same deterministic + // session root can then reuse it, or win delete authority and remove it + // once nothing else needs it. Release the lease only once every + // resource that reads or writes inside the root has fully closed, so no + // other admission's lease count can run while this admission still uses + // the root. A failure here re-enters this same retry path, so a later + // attempt releases the lease once the rest of the state has settled. + if (errors.length === 0 && sandbox !== null) { + const sandboxError = await settle(() => + releaseAcpxRuntimeSandboxRootClaim(sandbox), + ); + if (sandboxError !== null) errors.push(sandboxError); + } return errors.length > 0 ? new AggregateError(errors, "ACPX runtime cleanup failed") : null; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index a9d334359b3..9ed35344eff 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -567,20 +567,26 @@ describe("ACPX runtime sandbox", () => { const recoveringProcess: typeof import("./runtime-sandbox.js") = await import("./runtime-sandbox.js"); - // Once recovery has read the dead gate and proved its holder is gone, - // but before it removes the gate by pathname, replace the file at that - // same path with a fresh, live gate — simulating a second admission - // that broke in on the same dead gate first and is now using it. Its - // content names this test process, the same real process the - // recovering call also runs in, plus a marker suffix production code - // never writes itself: a plain pid alone cannot tell this exact - // replacement apart from a later gate the recovering call might create - // for its own use, since both would then name the same pid. + // Once recovery has pinned the dead gate with its own extra link and + // proved its holder is gone, but before it decides whether the shared + // path still names that pinned gate, replace the file at that same path + // with a fresh, live gate — simulating a second admission that broke in + // on the same dead gate first and is now using it. Its content names + // this test process, the same real process the recovering call also + // runs in, plus a marker suffix production code never writes itself: a + // plain pid alone cannot tell this exact replacement apart from a later + // gate the recovering call might create for its own use, since both + // would then name the same pid. const replacementGateContents = `${process.pid}:live-replacement`; const recovered = recoveringProcess.prepareAcpxRuntimeSandbox( { binding: fixture.binding, agent: "codex" }, { beforeStaleGateRemoval: async () => { + // The pin never vacated the shared path: the dead gate this call + // inspected is still the exact thing sitting here. + await expect(readFile(gatePath, "utf8")).resolves.toBe( + String(deadHolder.pid), + ); await unlink(gatePath); await writeFile(gatePath, replacementGateContents, { flag: "wx" }); }, @@ -605,7 +611,7 @@ describe("ACPX runtime sandbox", () => { await recoveringProcess.removeOwnedAcpxRuntimeSandboxRoot(sandbox); }); - it("never clobbers a fresh gate that claims the path while a captured gate's restore is in flight", async () => { + it("never lets a concurrent claim win the gate while recovery still holds a captured live holder's gate pinned", async () => { const fixture = await sandboxFixture("codex"); const probe = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, @@ -623,48 +629,66 @@ describe("ACPX runtime sandbox", () => { const recoveringProcess: typeof import("./runtime-sandbox.js") = await import("./runtime-sandbox.js"); - // Recovery captures the dead gate under a private path, then finds the - // captured bytes do not match — a first replacement already claimed - // the shared path before the capture ran — and moves to put that - // captured file back. Claim the shared path a second time in the exact - // gap before that restore runs, simulating a third admission that beat - // the restore to the path. The restore must back off, never overwrite - // this second replacement with the stale copy it is holding. - const firstReplacementContents = `${process.pid}:first-replacement`; - const secondReplacementContents = `${process.pid}:second-replacement`; + let concurrentProcess: typeof import("./runtime-sandbox.js") | null = + null; + let concurrentClaim: ReturnType | null = + null; + let concurrentClaimSettled = false; + + // Recovery pins the dead gate, proves it stale, and — before deciding + // whether the shared path still names that exact pinned gate — finds a + // live replacement already claimed it. The live replacement never + // leaves the shared path, so a third admission racing the same root at + // this exact moment must still see the gate occupied and keep retrying, + // never mistake a momentary gap for an opening. + const replacementGateContents = `${process.pid}:live-replacement`; const recovered = recoveringProcess.prepareAcpxRuntimeSandbox( { binding: fixture.binding, agent: "codex" }, { beforeStaleGateRemoval: async () => { await unlink(gatePath); - await writeFile(gatePath, firstReplacementContents, { - flag: "wx", - }); - }, - beforeStaleGateRestore: async () => { - // Recovery already renamed the shared path away to capture the - // first replacement, so nothing sits at `gatePath` right now. - await writeFile(gatePath, secondReplacementContents, { - flag: "wx", - }); + await writeFile(gatePath, replacementGateContents, { flag: "wx" }); + + vi.resetModules(); + concurrentProcess = await import("./runtime-sandbox.js"); + concurrentClaim = concurrentProcess + .prepareAcpxRuntimeSandbox({ + binding: fixture.binding, + agent: "codex", + }) + .finally(() => { + concurrentClaimSettled = true; + }); + await waitForConcurrentClaimHeadStart(); + // The live replacement still holds the gate, so the concurrent + // claim's own acquisition attempt must still be retrying. + expect(concurrentClaimSettled).toBe(false); }, }, ); await waitForConcurrentClaimHeadStart(); - // Finding anything other than this exact marker proves the restore - // overwrote the gate that claimed the path after it. + expect(concurrentClaimSettled).toBe(false); + // Finding anything other than this exact marker proves recovery — or + // the concurrent claim's own recovery attempt — removed the live + // replacement instead of continuing to retry around it. await expect(readFile(gatePath, "utf8")).resolves.toBe( - secondReplacementContents, + replacementGateContents, ); - // Free the second replacement gate, as its own holder would on - // completing its claim, so the still-retrying recovery call above can - // proceed. + // Free the replacement gate, as its own holder would on completing its + // claim, so both still-retrying calls above can proceed. await unlink(gatePath); - const sandbox = await recovered; + const [sandbox, concurrentSandbox] = await Promise.all([ + recovered, + concurrentClaim!, + ]); expect(sandbox.root).toBe(probe.root); + expect(concurrentSandbox.root).toBe(probe.root); + await concurrentProcess!.removeOwnedAcpxRuntimeSandboxRoot( + concurrentSandbox, + ); await recoveringProcess.removeOwnedAcpxRuntimeSandboxRoot(sandbox); }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index e343ab5aff7..faaea4eb2bb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -401,21 +401,15 @@ export interface AcpxRuntimeSandboxPrepareDependencies { */ afterRootOwned?: () => Promise; /** - * Internal test seam. Runs after stale-gate recovery has read a gate file - * and proved its holder is dead, immediately before recovery renames the - * gate to a private path to inspect and remove it. Lets a test replace + * Internal test seam. Runs once stale-gate recovery has pinned the gate + * currently at the shared path with an extra link and proved its holder + * is dead, immediately before recovery checks whether the shared path + * still names that exact pinned gate and removes it. Lets a test replace * the gate at the shared path in that gap, to prove recovery only ever - * removes the exact gate instance it inspected — never a replacement. + * removes the exact gate instance it pinned — never a replacement, and + * never by disturbing the shared path first. */ beforeStaleGateRemoval?: () => Promise; - /** - * Internal test seam. Runs after stale-gate recovery has renamed a gate - * to a private path and found its bytes do not match the dead gate it - * inspected, immediately before recovery links that captured file back - * onto the shared path. Lets a test create a fresh gate at the shared - * path in that gap, to prove the restore never overwrites it. - */ - beforeStaleGateRestore?: () => Promise; } /** Prepare the private filesystem and environment visible to an ACPX agent. */ @@ -738,7 +732,6 @@ function acpxRuntimeSandboxRootGatePath(root: string): string { interface AcpxRuntimeSandboxRootGateDependencies { beforeStaleGateRemoval?: () => Promise; - beforeStaleGateRestore?: () => Promise; } /** @@ -819,96 +812,93 @@ async function acquireAcpxRuntimeSandboxRootGate( // Removes a gate file left behind by a holder process that no longer exists, // so a hard process crash inside the critical section cannot block every // later admission for this root forever. Never removes a gate whose holder -// is still alive, or one this process cannot prove is dead. +// is still alive, or one this process cannot prove is dead, and never makes +// the shared path briefly name nothing — a live holder's gate must stay +// exclusive at the shared path for as long as that holder keeps it there. // -// Another admission can replace the gate at this same path — release the -// stale one and create a fresh, live one — at any point after this call -// proves the original holder dead. A plain read-then-unlink cannot guard -// against this: whatever this call reads to confirm identity, a later, -// separate unlink call still removes the file the path names at that later -// moment, not the file this call read. Two calls can never be made atomic -// by re-reading closer to the unlink; the gap only shrinks, it does not -// close. +// A plain read-then-unlink cannot inspect and remove safely: whatever this +// call reads to confirm identity, a later, separate unlink call still +// removes the file the path names at that later moment, not the file this +// call read. Two calls can never be made atomic by re-reading closer to the +// unlink; the gap only shrinks, it does not close. An earlier version of +// this function closed that gap by capturing the gate with `rename` before +// inspecting it — but `rename` vacates the shared path for the whole +// inspection, and a fresh claimant that creates a new gate in that gap is +// left believing it holds exclusivity while a delayed restore can still +// silently take its place, or a delayed restore can find the path already +// claimed and abandon the gate it captured, orphaning whichever holder +// created that original gate. // -// So this function never unlinks the shared path directly. It first -// `rename`s the gate to a private path only this call knows, one syscall -// that always moves whatever the shared path currently names — never stale -// information this call read earlier. That syscall is the one and only -// place this call touches the shared path, so there is no later, separate -// step left for a replacement to land in front of. It then inspects the -// private copy at leisure: -// - If the bytes match the dead gate this call proved stale, the private -// copy is that same dead gate; deleting it is always safe, because no -// other admission can name a private path only this call knows. -// - If the bytes differ, this call's `rename` caught a live replacement -// instead. It puts that replacement back with `link`, which — unlike -// `rename` — fails instead of overwriting when the shared path already -// names something else again, so a second replacement can never be -// clobbered by a restore that arrives late. -// -// A device-and-inode check cannot stand in for the byte comparison above: -// deleting a file and immediately creating a new one at the same path can -// reuse the just-freed inode number on some filesystems, so a replacement -// gate can carry the very identity the deleted one had. The byte comparison -// instead keys on the holder pid plus the random per-acquisition token -// `acquireAcpxRuntimeSandboxRootGate` writes; a collision between two -// gates' random tokens is not a practical concern. +// This function instead pins whatever currently occupies the shared path +// with an extra `link` first. Unlike `rename`, `link` adds a second name for +// the same inode without ever removing the first, so the shared path is +// never vacated: a genuinely live holder's gate stays exclusive there for +// the holder's entire critical section, no matter how this call's inspection +// of its own pinned copy turns out. The pin also keeps that exact inode +// alive for as long as this call holds it, so a later identity check against +// it can never be confused by a filesystem reusing a freed inode number for +// an unrelated new file at the same path — the well-known hazard a bare +// device-and-inode check runs into (observed on ext4). Only once this call +// has proved, from its own pinned copy, that the holder is dead does it +// remove the shared name — and only if the shared path still names that +// exact pinned inode at that moment. A live holder that claims the path +// meanwhile always creates a fresh file, so it can never land on this call's +// already-pinned identity, and this call never removes it. async function breakStaleAcpxRuntimeSandboxRootGate( gatePath: string, dependencies: AcpxRuntimeSandboxRootGateDependencies = {}, ): Promise { - let holderPid: number | null = null; - let inspectedContents: string; - try { - const handle = await open( - gatePath, - constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), - ); - try { - const bytes = await readFile(handle); - inspectedContents = bytes.toString("utf8").trim(); - const parsed = Number.parseInt(inspectedContents, 10); - holderPid = Number.isInteger(parsed) && parsed > 0 ? parsed : null; - } finally { - await handle.close(); - } - } catch { - // Missing, unreadable, or malformed: nothing provable to break here. - return; - } - if (holderPid !== null && isAcpxSandboxRootGateHolderAlive(holderPid)) { - return; - } - await dependencies.beforeStaleGateRemoval?.(); - const capturePath = `${gatePath}${ACPX_SANDBOX_ROOT_GATE_REAP_INFIX}${process.pid}-${randomBytes(8).toString("hex")}`; try { - await rename(gatePath, capturePath); + await link(gatePath, capturePath); } catch (error) { - // Gone already, or another recovery call reaped it first: nothing left - // to break. + // Missing already: nothing to break. if (errorCode(error) === "ENOENT") return; throw error; } - - const capturedContents = (await readFile(capturePath)) - .toString("utf8") - .trim(); - if (capturedContents !== inspectedContents) { - // This call's rename caught a live replacement, not the dead gate it - // inspected. Put it back — unless a newer replacement already claims - // the path, in which case leave that one alone and drop the copy this - // call is holding. - await dependencies.beforeStaleGateRestore?.(); + try { + let holderPid: number | null = null; try { - await link(capturePath, gatePath); - } catch (error) { - if (errorCode(error) !== "EEXIST") throw error; + const bytes = await readFile(capturePath); + const contents = bytes.toString("utf8").trim(); + const parsed = Number.parseInt(contents, 10); + holderPid = Number.isInteger(parsed) && parsed > 0 ? parsed : null; + } catch { + // Unreadable or malformed: nothing provable to break here. + return; } + if (holderPid !== null && isAcpxSandboxRootGateHolderAlive(holderPid)) { + return; + } + await dependencies.beforeStaleGateRemoval?.(); + + const pinnedEntry = await lstat(capturePath, { bigint: true }); + const sharedEntry = await lstat(gatePath, { bigint: true }).catch( + (error) => { + if (errorCode(error) === "ENOENT") return null; + throw error; + }, + ); + if ( + sharedEntry !== null && + sharedEntry.dev === pinnedEntry.dev && + sharedEntry.ino === pinnedEntry.ino + ) { + // The shared path still names the exact dead gate this call pinned + // and proved stale: safe to remove. + await unlink(gatePath).catch((error) => { + if (errorCode(error) !== "ENOENT") throw error; + }); + } + // A mismatch means a live holder already claimed the shared path with a + // fresh gate before this call reached its identity check. This call + // never touched the shared path, so that live gate was never disturbed + // and there is nothing to restore. + } finally { + await unlink(capturePath).catch((error) => { + if (errorCode(error) !== "ENOENT") throw error; + }); } - await unlink(capturePath).catch((error) => { - if (errorCode(error) !== "ENOENT") throw error; - }); } function isAcpxSandboxRootGateHolderAlive(pid: number): boolean {