fix(runner): release the sandbox lease on late admission failure and close the gate-recovery capture race

A later admission stage (credential, command, or runtime) that failed
after sandbox acquisition never released the sandbox's durable lease,
permanently pinning the marker and blocking a later admission from
reusing or removing the root. Release the lease through the same path
a live host's own close uses, once every other resource has closed.

Stale-gate recovery captured the shared gate path with a rename before
it could tell whether the gate was truly dead. If a live holder
replaced the gate before the rename, and a third admission claimed the
path before recovery's restore, two admissions could enter the
same-root critical section at once. Pin the gate with an extra link
instead, so the shared path is never vacated during inspection, and
remove it only when the shared path still names the exact pinned,
proven-dead gate.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Priya RamanandPaperclip committed 2026-09-03 10:49:16 +00:00
1 parent c67d4f1a7d
commit 4e20fb8ac1
4 files changed
+274 -130

No files matched your search

@@ -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 = "";
@@ -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<void> | null;
timer: ReturnType<typeof setTimeout> | 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<T>(
function retainAbortedRuntimeAdmissionCleanup(input: {
pendingRuntime: Promise<AcpxRuntimePort>;
credential: AcpxProviderLifetimeLease | null;
sandbox: AcpxRuntimeSandbox | null;
reason: string;
failedAdmissionCleanupTransfer: Promise<void>;
}): 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<void> {
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<AggregateError | null> {
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;
@@ -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<typeof prepareAcpxRuntimeSandbox> | 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);
});
});
@@ -401,21 +401,15 @@ export interface AcpxRuntimeSandboxPrepareDependencies {
*/
afterRootOwned?: () => Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
}
/** Prepare the private filesystem and environment visible to an ACPX agent. */
@@ -738,7 +732,6 @@ function acpxRuntimeSandboxRootGatePath(root: string): string {
interface AcpxRuntimeSandboxRootGateDependencies {
beforeStaleGateRemoval?: () => Promise<void>;
beforeStaleGateRestore?: () => Promise<void>;
}
/**
@@ -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<void> {
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 {