From 0abbe97e916909b716cf1b52985196972f61e2fc Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Wed, 29 Jul 2026 17:34:34 -0700 Subject: [PATCH 01/18] fix(state): make lifecycle lock timeouts side-effect free Signed-off-by: Ho Lim --- .../state/mcp-lifecycle-lock-acquisition.ts | 61 +++++++++++++++---- test/mcp-lifecycle-lock.test.ts | 53 +++++++++++++++- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/src/lib/state/mcp-lifecycle-lock-acquisition.ts b/src/lib/state/mcp-lifecycle-lock-acquisition.ts index b1606c16667..c8c21984f8f 100644 --- a/src/lib/state/mcp-lifecycle-lock-acquisition.ts +++ b/src/lib/state/mcp-lifecycle-lock-acquisition.ts @@ -43,6 +43,8 @@ export interface McpLifecycleLockOptions { pollIntervalMs?: number; timeoutMs?: number; corruptLockGraceMs?: number; + /** Monotonic clock override used by deterministic deadline tests. */ + monotonicNow?: () => number; } interface HeldLockLease { @@ -72,10 +74,10 @@ function classifyObservedMcpLifecycleLock( sandboxName: string, corruptLockGraceMs: number, corruptTracker: CorruptGenerationTracker, + now: number, ): McpLifecycleLockDisposition { if (!observation.owner || observation.owner.sandboxName !== sandboxName) { const generation = `${observation.dev}:${observation.ino}:${observation.mtimeMs}`; - const now = performance.now(); if (corruptTracker.generation !== generation) { corruptTracker.generation = generation; corruptTracker.firstSeenAt = now; @@ -98,22 +100,31 @@ async function tryReapStaleLock( sandboxName: string, corruptLockGraceMs: number, corruptTracker: CorruptGenerationTracker, + monotonicNow: () => number, + assertBeforeDeadline: () => void, ): Promise { const reaperPath = `${lockPath}.reaper`; const reaperToken = crypto.randomUUID(); const reaperOwner = createMcpLifecycleLockOwner(sandboxName, reaperToken); + assertBeforeDeadline(); if (!(await writeMcpLifecycleLockCandidateAndLink(reaperPath, reaperOwner))) return false; try { const latest = await readMcpLifecycleLockObservation(lockPath); if (!latest) return true; if ( - classifyObservedMcpLifecycleLock(latest, sandboxName, corruptLockGraceMs, corruptTracker) !== - "stale" + classifyObservedMcpLifecycleLock( + latest, + sandboxName, + corruptLockGraceMs, + corruptTracker, + monotonicNow(), + ) !== "stale" ) { return false; } + assertBeforeDeadline(); return reclaimStaleMcpLifecycleLockGeneration(lockPath, latest); } finally { await safelyReleaseMcpLifecycleLock(reaperPath, reaperToken); @@ -130,23 +141,27 @@ async function acquireMcpLifecycleLock( options.corruptLockGraceMs, DEFAULT_CORRUPT_LOCK_GRACE_MS, ); + const monotonicNow = options.monotonicNow ?? (() => performance.now()); const lockPath = getMcpLifecycleLockPath(sandboxName, options.stateDir); await fs.promises.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700, }); - const startedAt = performance.now(); + const deadline = monotonicNow() + timeoutMs; const corruptMainTracker: CorruptGenerationTracker = { generation: null, firstSeenAt: 0 }; const corruptReaperTracker: CorruptGenerationTracker = { generation: null, firstSeenAt: 0 }; let lastOwnerPid: number | null = null; + const assertBeforeDeadline = () => { + if (monotonicNow() < deadline) return; + const ownerSuffix = lastOwnerPid ? ` (owner pid ${lastOwnerPid})` : ""; + throw new Error( + `Timed out waiting for the sandbox mutation lock for '${sandboxName}'${ownerSuffix}. Another lifecycle, policy, channel, shields, or snapshot operation is still running.`, + ); + }; + for (;;) { - if (performance.now() - startedAt >= timeoutMs) { - const ownerSuffix = lastOwnerPid ? ` (owner pid ${lastOwnerPid})` : ""; - throw new Error( - `Timed out waiting for the sandbox mutation lock for '${sandboxName}'${ownerSuffix}. Another lifecycle, policy, channel, shields, or snapshot operation is still running.`, - ); - } + assertBeforeDeadline(); const reaperPath = `${lockPath}.reaper`; const reaperObservation = await readMcpLifecycleLockObservation(reaperPath); @@ -156,11 +171,13 @@ async function acquireMcpLifecycleLock( sandboxName, corruptLockGraceMs, corruptReaperTracker, + monotonicNow(), ); if (reaperDisposition === "stale") { // The reaper has the same atomic, PID-identified owner format as the // main lock. A SIGKILL at any point in stale-lock cleanup is therefore // recoverable without age-expiring a legitimate long operation. + assertBeforeDeadline(); await reclaimStaleMcpLifecycleLockGeneration(reaperPath, reaperObservation); continue; } @@ -170,13 +187,22 @@ async function acquireMcpLifecycleLock( resetCorruptGenerationTracker(corruptReaperTracker); if (!(await mcpLifecycleLockPathExists(reaperPath))) { + assertBeforeDeadline(); const token = crypto.randomUUID(); const owner = createMcpLifecycleLockOwner(sandboxName, token); if (await writeMcpLifecycleLockCandidateAndLink(lockPath, owner)) { // A stale-lock reaper may have appeared between our pre-check and the // atomic link. Do not enter the critical section until that generation // gate has gone away. - if (!(await mcpLifecycleLockPathExists(reaperPath))) return { lockPath, token }; + if (!(await mcpLifecycleLockPathExists(reaperPath))) { + try { + assertBeforeDeadline(); + } catch (error) { + await safelyReleaseMcpLifecycleLock(lockPath, token); + throw error; + } + return { lockPath, token }; + } await safelyReleaseMcpLifecycleLock(lockPath, token); } } @@ -190,9 +216,20 @@ async function acquireMcpLifecycleLock( sandboxName, corruptLockGraceMs, corruptMainTracker, + monotonicNow(), ) === "stale" ) { - if (await tryReapStaleLock(lockPath, sandboxName, corruptLockGraceMs, corruptMainTracker)) { + assertBeforeDeadline(); + if ( + await tryReapStaleLock( + lockPath, + sandboxName, + corruptLockGraceMs, + corruptMainTracker, + monotonicNow, + assertBeforeDeadline, + ) + ) { continue; } } diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 79599b94ed5..688a85399c4 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -10,6 +10,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { McpLifecycleLockOptions } from "../src/lib/state/mcp-lifecycle-lock"; import "./helpers/mcp-lifecycle-lock-properties"; type LifecycleLockModule = typeof import("../src/lib/state/mcp-lifecycle-lock"); @@ -24,7 +25,7 @@ const currentPidNamespaceIdentity = lifecycleLock.readMcpLockPidNamespaceIdentit let stateDir: string; const children = new Set(); -function options(overrides: Record = {}) { +function options(overrides: Partial = {}) { return { stateDir, pollIntervalMs: 5, @@ -448,6 +449,56 @@ const releasePath = process.argv[3]; expect(fs.existsSync(lockPath)).toBe(false); }); + it("preserves a corrupt lock when observation crosses the acquisition deadline", async () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, '{"version":1,"sandboxName":"alpha"'); + let nowCalls = 0; + + await expect( + lifecycleLock.withMcpLifecycleLock( + "alpha", + () => undefined, + options({ + timeoutMs: 30, + corruptLockGraceMs: 100, + monotonicNow: () => { + nowCalls += 1; + if (nowCalls <= 4) return 0; + if (nowCalls <= 6) return 10; + return 200; + }, + }), + ), + ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); + + expect(fs.readFileSync(lockPath, "utf8")).toContain('"sandboxName":"alpha"'); + }); + + it("does not enter the critical section when lock publication crosses the deadline", async () => { + let nowCalls = 0; + let entered = false; + + await expect( + lifecycleLock.withMcpLifecycleLock( + "alpha", + () => { + entered = true; + }, + options({ + timeoutMs: 30, + monotonicNow: () => { + nowCalls += 1; + return nowCalls <= 3 ? 0 : 100; + }, + }), + ), + ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); + + expect(entered).toBe(false); + expect(fs.existsSync(lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir))).toBe(false); + }); + it("recovers a reaper whose owner was killed during stale-lock cleanup", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); const reaperPath = `${lockPath}.reaper`; From 19f17ff941696f7c927b451ded7392669c913105 Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Wed, 29 Jul 2026 17:42:43 -0700 Subject: [PATCH 02/18] test(state): keep lifecycle deadline proof linear Signed-off-by: Ho Lim --- test/mcp-lifecycle-lock.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 688a85399c4..155f04e0bc8 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -453,6 +453,7 @@ const releasePath = process.argv[3]; const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); fs.writeFileSync(lockPath, '{"version":1,"sandboxName":"alpha"'); + const nowValues = [0, 0, 0, 0, 10, 10, 200]; let nowCalls = 0; await expect( @@ -462,12 +463,7 @@ const releasePath = process.argv[3]; options({ timeoutMs: 30, corruptLockGraceMs: 100, - monotonicNow: () => { - nowCalls += 1; - if (nowCalls <= 4) return 0; - if (nowCalls <= 6) return 10; - return 200; - }, + monotonicNow: () => nowValues[Math.min(nowCalls++, nowValues.length - 1)], }), ), ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); From de98a7da6992ad4d6cc5afb9401e8445beaa4ade Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Mon, 3 Aug 2026 15:52:56 -0700 Subject: [PATCH 03/18] fix(state): restore stale locks after deadline Signed-off-by: Ho Lim --- .../state/mcp-lifecycle-lock-acquisition.ts | 8 +- src/lib/state/mcp-lifecycle-lock-storage.ts | 26 ++++-- test/mcp-lifecycle-lock.test.ts | 85 +++++++++++++++++++ 3 files changed, 111 insertions(+), 8 deletions(-) diff --git a/src/lib/state/mcp-lifecycle-lock-acquisition.ts b/src/lib/state/mcp-lifecycle-lock-acquisition.ts index c8c21984f8f..01e289f716d 100644 --- a/src/lib/state/mcp-lifecycle-lock-acquisition.ts +++ b/src/lib/state/mcp-lifecycle-lock-acquisition.ts @@ -125,7 +125,7 @@ async function tryReapStaleLock( } assertBeforeDeadline(); - return reclaimStaleMcpLifecycleLockGeneration(lockPath, latest); + return reclaimStaleMcpLifecycleLockGeneration(lockPath, latest, assertBeforeDeadline); } finally { await safelyReleaseMcpLifecycleLock(reaperPath, reaperToken); } @@ -178,7 +178,11 @@ async function acquireMcpLifecycleLock( // main lock. A SIGKILL at any point in stale-lock cleanup is therefore // recoverable without age-expiring a legitimate long operation. assertBeforeDeadline(); - await reclaimStaleMcpLifecycleLockGeneration(reaperPath, reaperObservation); + await reclaimStaleMcpLifecycleLockGeneration( + reaperPath, + reaperObservation, + assertBeforeDeadline, + ); continue; } await sleep(pollIntervalMs); diff --git a/src/lib/state/mcp-lifecycle-lock-storage.ts b/src/lib/state/mcp-lifecycle-lock-storage.ts index ad5d7c0b5c7..a9da682b6f2 100644 --- a/src/lib/state/mcp-lifecycle-lock-storage.ts +++ b/src/lib/state/mcp-lifecycle-lock-storage.ts @@ -97,9 +97,22 @@ export async function safelyReleaseMcpLifecycleLock( await reclaimStaleMcpLifecycleLockGeneration(lockPath, observation); } +async function restoreClaimedMcpLifecycleLockGeneration( + targetPath: string, + quarantinePath: string, +): Promise { + try { + await fs.promises.link(quarantinePath, targetPath); + await fs.promises.rm(quarantinePath, { force: true }); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") throw error; + } +} + export async function reclaimStaleMcpLifecycleLockGeneration( targetPath: string, expected: LockObservation, + assertAfterClaim?: () => void, ): Promise { const quarantinePath = `${targetPath}.reclaim-${process.pid}-${crypto.randomUUID()}`; try { @@ -122,6 +135,12 @@ export async function reclaimStaleMcpLifecycleLockGeneration( claimed.ino === expected.ino : claimed?.owner?.token === expectedToken; if (claimedExpectedGeneration) { + try { + assertAfterClaim?.(); + } catch (error) { + await restoreClaimedMcpLifecycleLockGeneration(targetPath, quarantinePath); + throw error; + } await fs.promises.rm(quarantinePath, { force: true, recursive: true }); return true; } @@ -131,12 +150,7 @@ export async function reclaimStaleMcpLifecycleLockGeneration( // quarantine name. If another generation already occupies the canonical // path, preserve the displaced owner record for diagnosis rather than ever // deleting an owner we did not claim. - try { - await fs.promises.link(quarantinePath, targetPath); - await fs.promises.rm(quarantinePath, { force: true }); - } catch (error) { - if (!isErrnoException(error) || error.code !== "EEXIST") throw error; - } + await restoreClaimedMcpLifecycleLockGeneration(targetPath, quarantinePath); return false; } diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 155f04e0bc8..6407cfa38e9 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -495,6 +495,91 @@ const releasePath = process.argv[3]; expect(fs.existsSync(lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir))).toBe(false); }); + it("restores a stale main lock when reclamation crosses the deadline", async () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + lockPath, + `${JSON.stringify({ + version: 1, + sandboxName: "alpha", + pid: 2_147_483_647, + processIdentity: "dead-process", + hostIdentity: currentHostIdentity, + pidNamespaceIdentity: currentPidNamespaceIdentity, + token: "stale-main-token", + acquiredAt: "2026-01-01T00:00:00.000Z", + })}\n`, + ); + const rename = fs.promises.rename.bind(fs.promises); + let now = 0; + const renameSpy = vi.spyOn(fs.promises, "rename").mockImplementation(async (from, to) => { + await rename(from, to); + if (String(from) === lockPath) now = 100; + }); + let entered = false; + + try { + await expect( + lifecycleLock.withMcpLifecycleLock( + "alpha", + () => { + entered = true; + }, + options({ timeoutMs: 30, monotonicNow: () => now }), + ), + ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); + } finally { + renameSpy.mockRestore(); + } + + expect(entered).toBe(false); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("stale-main-token"); + }); + + it("restores a stale reaper when reclamation crosses the deadline", async () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + const reaperPath = `${lockPath}.reaper`; + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + reaperPath, + `${JSON.stringify({ + version: 1, + sandboxName: "alpha", + pid: 2_147_483_647, + processIdentity: "dead-reaper", + hostIdentity: currentHostIdentity, + pidNamespaceIdentity: currentPidNamespaceIdentity, + token: "stale-reaper-token", + acquiredAt: "2026-01-01T00:00:00.000Z", + })}\n`, + ); + const rename = fs.promises.rename.bind(fs.promises); + let now = 0; + const renameSpy = vi.spyOn(fs.promises, "rename").mockImplementation(async (from, to) => { + await rename(from, to); + if (String(from) === reaperPath) now = 100; + }); + let entered = false; + + try { + await expect( + lifecycleLock.withMcpLifecycleLock( + "alpha", + () => { + entered = true; + }, + options({ timeoutMs: 30, monotonicNow: () => now }), + ), + ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); + } finally { + renameSpy.mockRestore(); + } + + expect(entered).toBe(false); + expect(JSON.parse(fs.readFileSync(reaperPath, "utf8")).token).toBe("stale-reaper-token"); + }); + it("recovers a reaper whose owner was killed during stale-lock cleanup", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); const reaperPath = `${lockPath}.reaper`; From 9102bfd68f3812a45c30b0fb7d5655084e2eadea Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Mon, 3 Aug 2026 16:07:14 -0700 Subject: [PATCH 04/18] fix(state): avoid reclaiming lock directories Signed-off-by: Ho Lim --- .../state/mcp-lifecycle-lock-acquisition.ts | 14 +++++------ .../state/mcp-lifecycle-lock-identity.test.ts | 2 +- src/lib/state/mcp-lifecycle-lock-identity.ts | 6 ++++- src/lib/state/mcp-lifecycle-lock-storage.ts | 25 ++++++++++++++++--- test/helpers/mcp-lifecycle-lock-properties.ts | 2 +- test/mcp-lifecycle-lock.test.ts | 25 +++++++++++++++++-- 6 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/lib/state/mcp-lifecycle-lock-acquisition.ts b/src/lib/state/mcp-lifecycle-lock-acquisition.ts index 01e289f716d..252b6a3d6d8 100644 --- a/src/lib/state/mcp-lifecycle-lock-acquisition.ts +++ b/src/lib/state/mcp-lifecycle-lock-acquisition.ts @@ -76,7 +76,10 @@ function classifyObservedMcpLifecycleLock( corruptTracker: CorruptGenerationTracker, now: number, ): McpLifecycleLockDisposition { - if (!observation.owner || observation.owner.sandboxName !== sandboxName) { + if ( + (!observation.owner || observation.owner.sandboxName !== sandboxName) && + observation.reclaimable + ) { const generation = `${observation.dev}:${observation.ino}:${observation.mtimeMs}`; if (corruptTracker.generation !== generation) { corruptTracker.generation = generation; @@ -87,12 +90,9 @@ function classifyObservedMcpLifecycleLock( } resetCorruptGenerationTracker(corruptTracker); // The wall-clock arguments are irrelevant for a structurally valid owner. - return classifyMcpLifecycleLock( - observation, - sandboxName, - observation.mtimeMs, - corruptLockGraceMs, - ); + return observation.owner === null + ? "wait" + : classifyMcpLifecycleLock(observation, sandboxName, observation.mtimeMs, corruptLockGraceMs); } async function tryReapStaleLock( diff --git a/src/lib/state/mcp-lifecycle-lock-identity.test.ts b/src/lib/state/mcp-lifecycle-lock-identity.test.ts index 79f359a2bab..5d336ba0d4f 100644 --- a/src/lib/state/mcp-lifecycle-lock-identity.test.ts +++ b/src/lib/state/mcp-lifecycle-lock-identity.test.ts @@ -73,7 +73,7 @@ function owner( } function observation(lockOwner: McpLifecycleLockOwner | null, mtimeMs = 0): LockObservation { - return { owner: lockOwner, mtimeMs, dev: 10, ino: 20 }; + return { owner: lockOwner, mtimeMs, dev: 10, ino: 20, reclaimable: true }; } function probes( diff --git a/src/lib/state/mcp-lifecycle-lock-identity.ts b/src/lib/state/mcp-lifecycle-lock-identity.ts index b8e864ea4aa..48855c8844d 100644 --- a/src/lib/state/mcp-lifecycle-lock-identity.ts +++ b/src/lib/state/mcp-lifecycle-lock-identity.ts @@ -30,6 +30,8 @@ export interface LockObservation { mtimeMs: number; dev: number; ino: number; + /** Directories cannot be restored with the no-overwrite hard-link protocol. */ + reclaimable: boolean; } export type McpLifecycleLockDisposition = "active" | "stale" | "wait"; @@ -198,7 +200,9 @@ export function classifyMcpLifecycleLock( ): McpLifecycleLockDisposition { const { owner } = observation; if (!owner || owner.sandboxName !== sandboxName) { - return nowMs - observation.mtimeMs >= corruptLockGraceMs ? "stale" : "wait"; + return observation.reclaimable && nowMs - observation.mtimeMs >= corruptLockGraceMs + ? "stale" + : "wait"; } // The lock coordinates local CLI processes, not independent hosts or PID // namespaces. Never use this process's PID table to reap a foreign owner; diff --git a/src/lib/state/mcp-lifecycle-lock-storage.ts b/src/lib/state/mcp-lifecycle-lock-storage.ts index a9da682b6f2..d311f832d21 100644 --- a/src/lib/state/mcp-lifecycle-lock-storage.ts +++ b/src/lib/state/mcp-lifecycle-lock-storage.ts @@ -46,7 +46,13 @@ export async function readMcpLifecycleLockObservation( try { const stat = await fs.promises.lstat(lockPath); if (!stat.isFile() || stat.isSymbolicLink()) { - return { owner: null, mtimeMs: stat.mtimeMs, dev: stat.dev, ino: stat.ino }; + return { + owner: null, + mtimeMs: stat.mtimeMs, + dev: stat.dev, + ino: stat.ino, + reclaimable: !stat.isDirectory(), + }; } } catch (statError) { if (isErrnoException(statError) && statError.code === "ENOENT") return null; @@ -58,7 +64,13 @@ export async function readMcpLifecycleLockObservation( try { const stat = await handle.stat(); if (!stat.isFile()) { - return { owner: null, mtimeMs: stat.mtimeMs, dev: stat.dev, ino: stat.ino }; + return { + owner: null, + mtimeMs: stat.mtimeMs, + dev: stat.dev, + ino: stat.ino, + reclaimable: !stat.isDirectory(), + }; } try { const parsed: unknown = JSON.parse(await handle.readFile("utf8")); @@ -67,9 +79,16 @@ export async function readMcpLifecycleLockObservation( mtimeMs: stat.mtimeMs, dev: stat.dev, ino: stat.ino, + reclaimable: true, }; } catch { - return { owner: null, mtimeMs: stat.mtimeMs, dev: stat.dev, ino: stat.ino }; + return { + owner: null, + mtimeMs: stat.mtimeMs, + dev: stat.dev, + ino: stat.ino, + reclaimable: true, + }; } } finally { await handle.close(); diff --git a/test/helpers/mcp-lifecycle-lock-properties.ts b/test/helpers/mcp-lifecycle-lock-properties.ts index 693932cc864..83961b2a041 100644 --- a/test/helpers/mcp-lifecycle-lock-properties.ts +++ b/test/helpers/mcp-lifecycle-lock-properties.ts @@ -42,7 +42,7 @@ function owner( } function observation(lockOwner: McpLifecycleLockOwner | null, mtimeMs = 0): LockObservation { - return { owner: lockOwner, mtimeMs, dev: 1, ino: 1 }; + return { owner: lockOwner, mtimeMs, dev: 1, ino: 1, reclaimable: true }; } function probes( diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 6407cfa38e9..ae9a2dbe397 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -515,7 +515,10 @@ const releasePath = process.argv[3]; let now = 0; const renameSpy = vi.spyOn(fs.promises, "rename").mockImplementation(async (from, to) => { await rename(from, to); - if (String(from) === lockPath) now = 100; + switch (String(from)) { + case lockPath: + now = 100; + } }); let entered = false; @@ -558,7 +561,10 @@ const releasePath = process.argv[3]; let now = 0; const renameSpy = vi.spyOn(fs.promises, "rename").mockImplementation(async (from, to) => { await rename(from, to); - if (String(from) === reaperPath) now = 100; + switch (String(from)) { + case reaperPath: + now = 100; + } }); let entered = false; @@ -580,6 +586,21 @@ const releasePath = process.argv[3]; expect(JSON.parse(fs.readFileSync(reaperPath, "utf8")).token).toBe("stale-reaper-token"); }); + it("does not reclaim a corrupt directory at the lock path", async () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + fs.mkdirSync(lockPath, { recursive: true }); + + await expect( + lifecycleLock.withMcpLifecycleLock( + "alpha", + () => undefined, + options({ timeoutMs: 30, corruptLockGraceMs: 1 }), + ), + ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); + + expect(fs.lstatSync(lockPath).isDirectory()).toBe(true); + }); + it("recovers a reaper whose owner was killed during stale-lock cleanup", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); const reaperPath = `${lockPath}.reaper`; From e495e8d029d0ff1cdb62cdef455bad2b9e78add1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 3 Aug 2026 17:27:34 -0700 Subject: [PATCH 05/18] test(state): add lifecycle issue references Signed-off-by: Prekshi Vyas --- test/mcp-lifecycle-lock.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index ae9a2dbe397..e55a0be9c9e 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -449,7 +449,7 @@ const releasePath = process.argv[3]; expect(fs.existsSync(lockPath)).toBe(false); }); - it("preserves a corrupt lock when observation crosses the acquisition deadline", async () => { + it("preserves a corrupt lock when observation crosses the acquisition deadline (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); fs.writeFileSync(lockPath, '{"version":1,"sandboxName":"alpha"'); @@ -471,7 +471,7 @@ const releasePath = process.argv[3]; expect(fs.readFileSync(lockPath, "utf8")).toContain('"sandboxName":"alpha"'); }); - it("does not enter the critical section when lock publication crosses the deadline", async () => { + it("does not enter the critical section when lock publication crosses the deadline (#7858)", async () => { let nowCalls = 0; let entered = false; @@ -495,7 +495,7 @@ const releasePath = process.argv[3]; expect(fs.existsSync(lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir))).toBe(false); }); - it("restores a stale main lock when reclamation crosses the deadline", async () => { + it("restores a stale main lock when reclamation crosses the deadline (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); fs.writeFileSync( @@ -540,7 +540,7 @@ const releasePath = process.argv[3]; expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("stale-main-token"); }); - it("restores a stale reaper when reclamation crosses the deadline", async () => { + it("restores a stale reaper when reclamation crosses the deadline (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); const reaperPath = `${lockPath}.reaper`; fs.mkdirSync(path.dirname(lockPath), { recursive: true }); @@ -586,7 +586,7 @@ const releasePath = process.argv[3]; expect(JSON.parse(fs.readFileSync(reaperPath, "utf8")).token).toBe("stale-reaper-token"); }); - it("does not reclaim a corrupt directory at the lock path", async () => { + it("does not reclaim a corrupt directory at the lock path (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); fs.mkdirSync(lockPath, { recursive: true }); From c1428570a9b867ef911afd0132a9a10fa38cd868 Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Tue, 4 Aug 2026 11:44:21 -0700 Subject: [PATCH 06/18] test: keep lifecycle lock timing setup linear Signed-off-by: Ho Lim --- test/mcp-lifecycle-lock.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index e9abd8eed42..5664076358e 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -680,7 +680,7 @@ const releasePath = process.argv[3]; let now = 0; const renameSpy = vi.spyOn(fs.promises, "rename").mockImplementation(async (from, to) => { await rename(from, to); - if (String(from) === lockPath) now = 100; + now = String(from) === lockPath ? 100 : now; }); let entered = false; From 8a33683a8950508396a69bb74d20f64186164bc6 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 21:36:05 -0400 Subject: [PATCH 07/18] test(shields): isolate deadline snapshot reuse Signed-off-by: Julie Yaunches --- src/lib/shields/index.test.ts | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index 82d8a23e898..9f8ed310dd2 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -592,42 +592,25 @@ describe("shields — unit logic", () => { expect(appliedPolicy).not.toContain("mcp_bridge_alpha"); }); - it("auto-restore applies a snapshot with no managed MCP entries when policy staging is unavailable (#7952)", async () => { - const sandboxName = "openclaw"; - const processToken = "d".repeat(32); + it("deadline restore reuses an unchanged snapshot without temporary storage when no managed MCP entries exist (#7952)", async () => { const snapshotPath = path.join(stateDir(), "policy-snapshot-no-managed-mcp.yaml"); fs.mkdirSync(stateDir(), { recursive: true }); fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n"); - writeState(sandboxName, { - shieldsDown: true, - shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: [], - }); - writeMarker(sandboxName, { - pid: 2_147_483_647, - sandboxName, - snapshotPath, - restoreAt: new Date(Date.now() - 1_000).toISOString(), - processToken, - }); - vi.spyOn(process, "kill").mockImplementation(routeProcessKill); - const { applyShieldsPolicySnapshot } = await loadShieldsModule(); - const { buildPolicySetCommand } = await import("../policy"); const createTempDirectory = vi.spyOn(fs, "mkdtempSync").mockImplementation(() => { throw Object.assign(new Error("ENOSPC: simulated temporary storage full"), { code: "ENOSPC", }); }); + const { buildDeadlineRuntimeManagedMcpPolicy } = await import("./permissive-runtime"); - const result = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { - transitionProcessToken: processToken, - deadlineAuthoritative: true, - expiredTimerRecovery: true, + const result = buildDeadlineRuntimeManagedMcpPolicy(snapshotPath, { + managedMcpPolicies: [], + snapshotManagedPolicyKeys: [], + readBasePolicy: () => fs.readFileSync(snapshotPath, "utf-8"), }); - expect(result.status).toBe(0); + expect(result).toEqual({ path: snapshotPath, omissions: [] }); expect(createTempDirectory).not.toHaveBeenCalled(); - expect(buildPolicySetCommand).toHaveBeenCalledWith(snapshotPath, sandboxName); }); it("shieldsStatus warns and stays DOWN when inline recovery fails", async () => { From 2123889e29210c06d9845d0b7a7421f5924ec027 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 21:54:57 -0400 Subject: [PATCH 08/18] fix(lock): await stale generation reclamation Signed-off-by: Julie Yaunches --- src/lib/state/mcp-lifecycle-lock-acquisition.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/state/mcp-lifecycle-lock-acquisition.ts b/src/lib/state/mcp-lifecycle-lock-acquisition.ts index f05f7ed328d..0ebdd715108 100644 --- a/src/lib/state/mcp-lifecycle-lock-acquisition.ts +++ b/src/lib/state/mcp-lifecycle-lock-acquisition.ts @@ -324,7 +324,9 @@ async function tryReapStaleMainLock( return false; } assertBeforeDeadline(); - return reclaimStaleMcpLifecycleLockGeneration(lockPath, latest, assertBeforeDeadline); + // Keep the reaper generation held until reclamation or restoration settles. + // Returning the promise directly would enter the async finally first. + return await reclaimStaleMcpLifecycleLockGeneration(lockPath, latest, assertBeforeDeadline); } finally { await safelyReleaseMcpLifecycleLock(reaperPath, reaperToken); } From 35138fa135d167eeed3e6e332bc523ac2811ed85 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 22:13:00 -0400 Subject: [PATCH 09/18] test(e2e): wait for gateway fixture readiness Signed-off-by: Julie Yaunches --- .../messaging-compatible-endpoint-helpers.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/e2e/support/messaging-compatible-endpoint-helpers.test.ts b/test/e2e/support/messaging-compatible-endpoint-helpers.test.ts index faa1072a067..e251dce637c 100644 --- a/test/e2e/support/messaging-compatible-endpoint-helpers.test.ts +++ b/test/e2e/support/messaging-compatible-endpoint-helpers.test.ts @@ -67,11 +67,17 @@ describe("messaging compatible endpoint helper coverage", () => { "signals only a start-time-matched owned gateway process (#6352)", async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-owned-gateway-pid-")); - const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - argv0: "openshell-gateway[nemoclaw=nemoclaw;port=8080]", - stdio: "ignore", - }); + const child = spawn( + process.execPath, + ["-e", "process.stdout.write('ready\\n'); setInterval(() => {}, 1000)"], + { + argv0: "openshell-gateway[nemoclaw=nemoclaw;port=8080]", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + const childReady = once(child.stdout, "data"); await once(child, "spawn"); + await childReady; const childExit = once(child, "exit"); const pid = child.pid; expect(pid).toBeTypeOf("number"); From 33b70bf994c7718f526e64043ea6888c557ce83f Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 22:26:57 -0400 Subject: [PATCH 10/18] test(lock): make corrupt containment deterministic Signed-off-by: Julie Yaunches --- test/mcp-lifecycle-lock.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 5664076358e..cb189a453fb 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -145,9 +145,17 @@ describe("MCP lifecycle lock", () => { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); fs.writeFileSync(targetPath, target); fs.symlinkSync(targetPath, lockPath); + let monotonicNow = 0; await expect( - lifecycleLock.withMcpLifecycleLock("alpha", () => "acquired", options({ timeoutMs: 50 })), + lifecycleLock.withMcpLifecycleLock( + "alpha", + () => "acquired", + options({ + corruptLockGraceMs: 1, + monotonicNow: () => monotonicNow++, + }), + ), ).rejects.toThrow(/containment is active/); expect(fs.readFileSync(targetPath, "utf8")).toBe(target); expect(fs.lstatSync(lockPath).isSymbolicLink()).toBe(true); @@ -170,13 +178,15 @@ describe("MCP lifecycle lock", () => { server.listen(lockPath, resolve); }); expect(fs.lstatSync(lockPath).isSocket()).toBe(true); + let monotonicNow = 0; try { await expect( lifecycleLock.withMcpLifecycleLock("alpha", () => "acquired", { ...options(), stateDir: shortStateDir, - timeoutMs: 50, + corruptLockGraceMs: 1, + monotonicNow: () => monotonicNow++, }), ).rejects.toThrow(/containment is active/); expect(fs.lstatSync(lockPath).isSocket()).toBe(true); From 782d16a8146606cc1f1db1ee9f59ad6301462f87 Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Tue, 4 Aug 2026 19:52:23 -0700 Subject: [PATCH 11/18] test(state): make lifecycle timing deterministic Signed-off-by: Ho Lim --- test/mcp-lifecycle-lock.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 5664076358e..7ff97185ce3 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -145,9 +145,18 @@ describe("MCP lifecycle lock", () => { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); fs.writeFileSync(targetPath, target); fs.symlinkSync(targetPath, lockPath); + const nowValues = [0, 0, 0, 0, 11]; + let nowCalls = 0; await expect( - lifecycleLock.withMcpLifecycleLock("alpha", () => "acquired", options({ timeoutMs: 50 })), + lifecycleLock.withMcpLifecycleLock( + "alpha", + () => "acquired", + options({ + timeoutMs: 50, + monotonicNow: () => nowValues[Math.min(nowCalls++, nowValues.length - 1)], + }), + ), ).rejects.toThrow(/containment is active/); expect(fs.readFileSync(targetPath, "utf8")).toBe(target); expect(fs.lstatSync(lockPath).isSymbolicLink()).toBe(true); @@ -736,12 +745,18 @@ const releasePath = process.argv[3]; it("does not reclaim a corrupt directory at the lock path (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); fs.mkdirSync(lockPath, { recursive: true }); + const nowValues = [0, 0, 0, 0, 100]; + let nowCalls = 0; await expect( lifecycleLock.withMcpLifecycleLock( "alpha", () => undefined, - options({ timeoutMs: 30, corruptLockGraceMs: 1 }), + options({ + timeoutMs: 30, + corruptLockGraceMs: 1, + monotonicNow: () => nowValues[Math.min(nowCalls++, nowValues.length - 1)], + }), ), ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); From 4c8103a46d4e3bbe285b816ec3998745b78c88b1 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 4 Aug 2026 22:24:02 -0700 Subject: [PATCH 12/18] test(state): make legacy lock recovery deterministic --- src/lib/state/mcp-lifecycle-lock-acquisition.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/state/mcp-lifecycle-lock-acquisition.test.ts b/src/lib/state/mcp-lifecycle-lock-acquisition.test.ts index d53edcec57c..3fc05bc7d7d 100644 --- a/src/lib/state/mcp-lifecycle-lock-acquisition.test.ts +++ b/src/lib/state/mcp-lifecycle-lock-acquisition.test.ts @@ -129,9 +129,12 @@ describe("MCP lifecycle lock acquisition", () => { it("does not strand asynchronous recovery behind an expired legacy marker", async () => { writeTimerMarker(undefined, new Date(Date.now() - 1_000).toISOString()); - await expect(withMcpLifecycleLock(SANDBOX_NAME, () => "entered", options())).resolves.toBe( - "entered", - ); + await expect( + withMcpLifecycleLock(SANDBOX_NAME, () => "entered", { + ...options(), + monotonicNow: () => 0, + }), + ).resolves.toBe("entered"); }); it("does not strand synchronous recovery behind an expired legacy short-token marker", () => { From cf076ce61f0199baee01d35bca827bc2479fb292 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 5 Aug 2026 00:05:20 -0700 Subject: [PATCH 13/18] fix(state): enforce sync lock deadlines Signed-off-by: Prekshi Vyas --- .../state/mcp-lifecycle-lock-acquisition.ts | 72 +++++++++++++++---- src/lib/state/mcp-lifecycle-lock-storage.ts | 26 +++++-- test/mcp-lifecycle-lock.test.ts | 64 +++++++++++++++++ 3 files changed, 142 insertions(+), 20 deletions(-) diff --git a/src/lib/state/mcp-lifecycle-lock-acquisition.ts b/src/lib/state/mcp-lifecycle-lock-acquisition.ts index 0ebdd715108..f421c27018f 100644 --- a/src/lib/state/mcp-lifecycle-lock-acquisition.ts +++ b/src/lib/state/mcp-lifecycle-lock-acquisition.ts @@ -221,7 +221,7 @@ function classifyObservedMcpLifecycleLock( sandboxName: string, corruptLockGraceMs: number, corruptTracker: CorruptGenerationTracker, - now = performance.now(), + now: number, ): McpLifecycleLockDisposition { if ( (!observation.owner || observation.owner.sandboxName !== sandboxName) && @@ -338,6 +338,8 @@ function tryReapStaleMainLockSync( stateDir: string, corruptLockGraceMs: number, corruptTracker: CorruptGenerationTracker, + monotonicNow: () => number, + assertBeforeDeadline: () => void, ): boolean { const containmentPath = committedContainmentPath(lockPath); const deadlinePath = `${lockPath}.deadline`; @@ -352,9 +354,11 @@ function tryReapStaleMainLockSync( const reaperPath = `${lockPath}.reaper`; const reaperToken = crypto.randomUUID(); const reaperOwner = createMcpLifecycleLockOwner(sandboxName, reaperToken, takeoverToken); + assertBeforeDeadline(); if (!writeMcpLifecycleLockCandidateAndLinkSync(reaperPath, reaperOwner)) return false; try { + assertBeforeDeadline(); if ( mcpLifecycleLockPathExistsSync(containmentPath) || mcpLifecycleLockPathExistsSync(deadlinePath) || @@ -366,8 +370,13 @@ function tryReapStaleMainLockSync( if (!latest) return true; if ( !isValidMainOwnerForSandbox(latest, sandboxName) || - classifyObservedMcpLifecycleLock(latest, sandboxName, corruptLockGraceMs, corruptTracker) !== - "stale" + classifyObservedMcpLifecycleLock( + latest, + sandboxName, + corruptLockGraceMs, + corruptTracker, + monotonicNow(), + ) !== "stale" ) { return false; } @@ -379,6 +388,7 @@ function tryReapStaleMainLockSync( return false; } if (latest.owner?.shieldsTakeoverToken || currentTakeoverToken) { + assertBeforeDeadline(); ensureDurableContainmentForStaleGenerationSync( lockPath, sandboxName, @@ -388,7 +398,8 @@ function tryReapStaleMainLockSync( ); return false; } - return reclaimStaleMcpLifecycleLockGenerationSync(lockPath, latest); + assertBeforeDeadline(); + return reclaimStaleMcpLifecycleLockGenerationSync(lockPath, latest, assertBeforeDeadline); } finally { safelyReleaseMcpLifecycleLockSync(reaperPath, reaperToken); } @@ -585,24 +596,27 @@ function acquireMcpLifecycleLockSync( const lockPath = getMcpLifecycleLockPath(sandboxName, options.stateDir); fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 }); - const startedAt = performance.now(); + const monotonicNow = options.monotonicNow ?? (() => performance.now()); + const deadline = monotonicNow() + timeoutMs; const corruptMainTracker: CorruptGenerationTracker = { generation: null, firstSeenAt: 0 }; const corruptReaperTracker: CorruptGenerationTracker = { generation: null, firstSeenAt: 0 }; const corruptDeadlineTracker: CorruptGenerationTracker = { generation: null, firstSeenAt: 0 }; let lastOwnerPid: number | null = null; + const assertBeforeDeadline = () => { + if (monotonicNow() < deadline) return; + throw new Error( + `Timed out waiting for sandbox mutation lock for '${sandboxName}'${ + lastOwnerPid ? ` (owner PID ${lastOwnerPid})` : "" + }`, + ); + }; for (;;) { const containmentPath = committedContainmentPath(lockPath); const containment = readMcpLifecycleLockObservationSync(containmentPath); if (containment) { throw committedContainmentActiveError(sandboxName, lockPath, containment); } - if (performance.now() - startedAt >= timeoutMs) { - throw new Error( - `Timed out waiting for sandbox mutation lock for '${sandboxName}'${ - lastOwnerPid ? ` (owner PID ${lastOwnerPid})` : "" - }`, - ); - } + assertBeforeDeadline(); const deadlinePath = `${lockPath}.deadline`; const deadlineObservation = readMcpLifecycleLockObservationSync(deadlinePath); @@ -612,8 +626,10 @@ function acquireMcpLifecycleLockSync( sandboxName, corruptLockGraceMs, corruptDeadlineTracker, + monotonicNow(), ); if (deadlineDisposition === "stale") { + assertBeforeDeadline(); ensureDurableContainmentForStaleGenerationSync( lockPath, sandboxName, @@ -636,8 +652,10 @@ function acquireMcpLifecycleLockSync( sandboxName, corruptLockGraceMs, corruptReaperTracker, + monotonicNow(), ); if (reaperDisposition === "stale") { + assertBeforeDeadline(); ensureDurableContainmentForStaleGenerationSync( lockPath, sandboxName, @@ -665,6 +683,7 @@ function acquireMcpLifecycleLockSync( const token = crypto.randomUUID(); const shieldsTakeoverToken = readShieldsTimerTakeoverToken(sandboxName, options.stateDir); const owner = createMcpLifecycleLockOwner(sandboxName, token, shieldsTakeoverToken); + assertBeforeDeadline(); if (writeMcpLifecycleLockCandidateAndLinkSync(lockPath, owner)) { if ( !mcpLifecycleLockPathExistsSync(containmentPath) && @@ -673,6 +692,12 @@ function acquireMcpLifecycleLockSync( !isShieldsTimerDeadlineExpired(sandboxName, options.stateDir) && readShieldsTimerTakeoverToken(sandboxName, options.stateDir) === shieldsTakeoverToken ) { + try { + assertBeforeDeadline(); + } catch (error) { + safelyReleaseMcpLifecycleLockSync(lockPath, token); + throw error; + } return { lockPath, token, @@ -692,18 +717,23 @@ function acquireMcpLifecycleLockSync( sandboxName, corruptLockGraceMs, corruptMainTracker, + monotonicNow(), ) === "stale" ) { if (isValidMainOwnerForSandbox(observation, sandboxName)) { + assertBeforeDeadline(); tryReapStaleMainLockSync( lockPath, sandboxName, options.stateDir, corruptLockGraceMs, corruptMainTracker, + monotonicNow, + assertBeforeDeadline, ); continue; } + assertBeforeDeadline(); ensureDurableContainmentForStaleGenerationSync( lockPath, sandboxName, @@ -919,6 +949,7 @@ async function acquireDeadlineFence( sandboxName, corruptLockGraceMs, corruptTracker, + performance.now(), ) === "stale" ) { ensureDurableContainmentForStaleGenerationSync( @@ -1034,6 +1065,7 @@ function acquireDeadlineFenceSync( sandboxName, corruptLockGraceMs, corruptTracker, + performance.now(), ) === "stale" ) { ensureDurableContainmentForStaleGenerationSync( @@ -1087,7 +1119,13 @@ async function clearDeadlineProtectedPath( const observed = await readMcpLifecycleLockObservation(targetPath); if (!observed) return; - const disposition = classifyObservedMcpLifecycleLock(observed, sandboxName, 0, corruptTracker); + const disposition = classifyObservedMcpLifecycleLock( + observed, + sandboxName, + 0, + corruptTracker, + performance.now(), + ); const owner = observed.owner; const exactLocalOwner = owner?.sandboxName === sandboxName && @@ -1182,7 +1220,13 @@ function clearDeadlineProtectedPathSync( const observed = readMcpLifecycleLockObservationSync(targetPath); if (!observed) return; - const disposition = classifyObservedMcpLifecycleLock(observed, sandboxName, 0, corruptTracker); + const disposition = classifyObservedMcpLifecycleLock( + observed, + sandboxName, + 0, + corruptTracker, + performance.now(), + ); const owner = observed.owner; const exactLocalOwner = owner?.sandboxName === sandboxName && diff --git a/src/lib/state/mcp-lifecycle-lock-storage.ts b/src/lib/state/mcp-lifecycle-lock-storage.ts index a66976a1812..43c0599e381 100644 --- a/src/lib/state/mcp-lifecycle-lock-storage.ts +++ b/src/lib/state/mcp-lifecycle-lock-storage.ts @@ -205,6 +205,18 @@ async function restoreClaimedMcpLifecycleLockGeneration( } } +function restoreClaimedMcpLifecycleLockGenerationSync( + targetPath: string, + quarantinePath: string, +): void { + try { + fs.linkSync(quarantinePath, targetPath); + fs.rmSync(quarantinePath, { force: true }); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") throw error; + } +} + export async function reclaimStaleMcpLifecycleLockGeneration( targetPath: string, expected: LockObservation, @@ -253,6 +265,7 @@ export async function reclaimStaleMcpLifecycleLockGeneration( export function reclaimStaleMcpLifecycleLockGenerationSync( targetPath: string, expected: LockObservation, + assertAfterClaim?: () => void, ): boolean { const quarantinePath = `${targetPath}.reclaim-${process.pid}-${crypto.randomUUID()}`; try { @@ -272,16 +285,17 @@ export function reclaimStaleMcpLifecycleLockGenerationSync( claimed.ino === expected.ino : claimed?.owner?.token === expectedToken; if (claimedExpectedGeneration) { + try { + assertAfterClaim?.(); + } catch (error) { + restoreClaimedMcpLifecycleLockGenerationSync(targetPath, quarantinePath); + throw error; + } fs.rmSync(quarantinePath, { force: true, recursive: true }); return true; } - try { - fs.linkSync(quarantinePath, targetPath); - fs.rmSync(quarantinePath, { force: true }); - } catch (error) { - if (!isErrnoException(error) || error.code !== "EEXIST") throw error; - } + restoreClaimedMcpLifecycleLockGenerationSync(targetPath, quarantinePath); return false; } diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 7ff97185ce3..3fee3d5c6df 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -669,6 +669,31 @@ const releasePath = process.argv[3]; expect(fs.existsSync(lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir))).toBe(false); }); + it("does not enter the synchronous critical section when lock publication crosses the deadline (#7858)", () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + const linkSync = fs.linkSync.bind(fs); + let now = 0; + const linkSpy = vi.spyOn(fs, "linkSync").mockImplementation((from, to) => { + linkSync(from, to); + if (String(to) === lockPath) now = 100; + }); + const operation = vi.fn(); + + try { + expect(() => + lifecycleLock.withMcpLifecycleLockSync("alpha", operation, { + ...options({ timeoutMs: 30 }), + monotonicNow: () => now, + }), + ).toThrow("Timed out waiting for sandbox mutation lock"); + } finally { + linkSpy.mockRestore(); + } + + expect(operation).not.toHaveBeenCalled(); + expect(fs.existsSync(lockPath)).toBe(false); + }); + it("restores a stale main lock when reclamation crosses the deadline (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); @@ -711,6 +736,45 @@ const releasePath = process.argv[3]; expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("stale-main-token"); }); + it("restores a stale main lock when synchronous reclamation crosses the deadline (#7858)", () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + lockPath, + `${JSON.stringify({ + version: 1, + sandboxName: "alpha", + pid: 2_147_483_647, + processIdentity: "dead-process", + hostIdentity: currentHostIdentity, + pidNamespaceIdentity: currentPidNamespaceIdentity, + token: "stale-main-token", + acquiredAt: "2026-01-01T00:00:00.000Z", + })}\n`, + ); + const renameSync = fs.renameSync.bind(fs); + let now = 0; + const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { + renameSync(from, to); + if (String(from) === lockPath) now = 100; + }); + const operation = vi.fn(); + + try { + expect(() => + lifecycleLock.withMcpLifecycleLockSync("alpha", operation, { + ...options({ timeoutMs: 30 }), + monotonicNow: () => now, + }), + ).toThrow("Timed out waiting for sandbox mutation lock"); + } finally { + renameSpy.mockRestore(); + } + + expect(operation).not.toHaveBeenCalled(); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("stale-main-token"); + }); + it("preserves a stale reaper when observation crosses the deadline (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); const reaperPath = `${lockPath}.reaper`; From 3b40c9054636719d711073608956dbb9dbac2b13 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 5 Aug 2026 00:13:24 -0700 Subject: [PATCH 14/18] test(state): keep sync deadline tests linear Signed-off-by: Prekshi Vyas --- test/mcp-lifecycle-lock.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 3fee3d5c6df..494ad21e0b8 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -675,7 +675,7 @@ const releasePath = process.argv[3]; let now = 0; const linkSpy = vi.spyOn(fs, "linkSync").mockImplementation((from, to) => { linkSync(from, to); - if (String(to) === lockPath) now = 100; + now = String(to) === lockPath ? 100 : now; }); const operation = vi.fn(); @@ -756,7 +756,7 @@ const releasePath = process.argv[3]; let now = 0; const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => { renameSync(from, to); - if (String(from) === lockPath) now = 100; + now = String(from) === lockPath ? 100 : now; }); const operation = vi.fn(); From 9cca0f3b9925d6731ac19d5ec757dcda5d643766 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Wed, 5 Aug 2026 08:07:45 -0700 Subject: [PATCH 15/18] test(state): stabilize lifecycle lock timing budget Signed-off-by: Charan Jagwani --- src/lib/state/mcp-lifecycle-lock-acquisition.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/state/mcp-lifecycle-lock-acquisition.test.ts b/src/lib/state/mcp-lifecycle-lock-acquisition.test.ts index 3fc05bc7d7d..bb72667d5d1 100644 --- a/src/lib/state/mcp-lifecycle-lock-acquisition.test.ts +++ b/src/lib/state/mcp-lifecycle-lock-acquisition.test.ts @@ -30,7 +30,7 @@ function options() { return { stateDir, pollIntervalMs: 1, - timeoutMs: 20, + timeoutMs: 1_000, corruptLockGraceMs: 1, }; } From d2e1fbd9e5fda75754909e6dc6eca73cf0ff9ccf Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 5 Aug 2026 13:17:33 -0700 Subject: [PATCH 16/18] fix(state): enforce lock acquisition deadline Signed-off-by: Carlos Villela --- .../state/mcp-lifecycle-lock-acquisition.ts | 22 ++- test/mcp-lifecycle-lock.test.ts | 161 +++++++++++++++++- 2 files changed, 175 insertions(+), 8 deletions(-) diff --git a/src/lib/state/mcp-lifecycle-lock-acquisition.ts b/src/lib/state/mcp-lifecycle-lock-acquisition.ts index f421c27018f..2f97e18a5c0 100644 --- a/src/lib/state/mcp-lifecycle-lock-acquisition.ts +++ b/src/lib/state/mcp-lifecycle-lock-acquisition.ts @@ -172,6 +172,7 @@ function ensureDurableContainmentForStaleGenerationSync( stateDir: string, observation: LockObservation, reason: string, + assertAuthority?: () => void, ): void { const containmentPath = committedContainmentPath(lockPath); if (mcpLifecycleLockPathExistsSync(containmentPath)) return; @@ -184,6 +185,7 @@ function ensureDurableContainmentForStaleGenerationSync( sandboxName, readShieldsTimerTakeoverToken(sandboxName, stateDir), `${reason}; contained generation ${generation}`, + assertAuthority, ); } catch (error) { if (mcpLifecycleLockPathExistsSync(containmentPath)) return; @@ -320,6 +322,7 @@ async function tryReapStaleMainLock( stateDir, latest, "A timer-bound sandbox mutation owner exited before durable containment was committed", + assertBeforeDeadline, ); return false; } @@ -395,6 +398,7 @@ function tryReapStaleMainLockSync( stateDir, latest, "A timer-bound sandbox mutation owner exited before durable containment was committed", + assertBeforeDeadline, ); return false; } @@ -408,7 +412,7 @@ function tryReapStaleMainLockSync( async function acquireMcpLifecycleLock( sandboxName: string, options: McpLifecycleLockOptions, -): Promise { +): Promise void }> { const pollIntervalMs = positiveInteger(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS); const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS); const corruptLockGraceMs = positiveInteger( @@ -461,6 +465,7 @@ async function acquireMcpLifecycleLock( stateDir, deadlineObservation, "An auto-restore deadline owner exited before its recovery operation completed", + assertBeforeDeadline, ); continue; } @@ -487,6 +492,7 @@ async function acquireMcpLifecycleLock( stateDir, reaperObservation, "A stale-lock reaper exited before cleanup completed", + assertBeforeDeadline, ); continue; } @@ -534,6 +540,7 @@ async function acquireMcpLifecycleLock( return { lockPath, token, + assertBeforeDeadline, ...(shieldsTakeoverToken ? { shieldsTakeoverToken } : {}), }; } @@ -573,6 +580,7 @@ async function acquireMcpLifecycleLock( stateDir, observation, "A sandbox mutation owner exited before its descendants could be proven contained", + assertBeforeDeadline, ); continue; } @@ -586,7 +594,7 @@ async function acquireMcpLifecycleLock( function acquireMcpLifecycleLockSync( sandboxName: string, options: McpLifecycleLockOptions & { stateDir: string }, -): AcquiredMcpLifecycleLock { +): AcquiredMcpLifecycleLock & { assertBeforeDeadline: () => void } { const pollIntervalMs = positiveInteger(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS); const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS); const corruptLockGraceMs = positiveInteger( @@ -636,6 +644,7 @@ function acquireMcpLifecycleLockSync( options.stateDir, deadlineObservation, "An auto-restore deadline owner exited before its recovery operation completed", + assertBeforeDeadline, ); continue; } @@ -662,6 +671,7 @@ function acquireMcpLifecycleLockSync( options.stateDir, reaperObservation, "A stale-lock reaper exited before cleanup completed", + assertBeforeDeadline, ); continue; } @@ -701,6 +711,7 @@ function acquireMcpLifecycleLockSync( return { lockPath, token, + assertBeforeDeadline, ...(shieldsTakeoverToken ? { shieldsTakeoverToken } : {}), }; } @@ -740,6 +751,7 @@ function acquireMcpLifecycleLockSync( options.stateDir, observation, "A sandbox mutation owner exited before its descendants could be proven contained", + assertBeforeDeadline, ); continue; } @@ -1662,7 +1674,10 @@ export function withMcpLifecycleLockSync( context.set(lockPath, lease); let retainOwnedGate = false; try { - return heldLocks.run(context, operation); + return heldLocks.run(context, () => { + acquired.assertBeforeDeadline(); + return operation(); + }); } catch (error) { retainOwnedGate = Boolean(acquired.shieldsTakeoverToken) && @@ -1715,6 +1730,7 @@ export async function withMcpLifecycleLock( return heldLocks.run(context, async () => { let retainOwnedGate = false; try { + acquired.assertBeforeDeadline(); return await operation(); } catch (error) { retainOwnedGate = diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 9d7ff408143..a318b075478 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -650,7 +650,91 @@ const releasePath = process.argv[3]; expect(fs.existsSync(`${lockPath}.containment`)).toBe(false); }); - it("does not enter the critical section when lock publication crosses the deadline (#7858)", async () => { + it("rolls back containment when publication crosses the asynchronous acquisition deadline (#7858)", async () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + const deadlinePath = `${lockPath}.deadline`; + const containmentPath = `${lockPath}.containment`; + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + deadlinePath, + `${JSON.stringify({ + version: 1, + sandboxName: "alpha", + pid: 2_147_483_647, + processIdentity: "dead-deadline", + hostIdentity: currentHostIdentity, + pidNamespaceIdentity: currentPidNamespaceIdentity, + token: "stale-deadline-token", + acquiredAt: "2026-01-01T00:00:00.000Z", + })}\n`, + ); + const linkSync = fs.linkSync.bind(fs); + let now = 0; + const linkSpy = vi.spyOn(fs, "linkSync").mockImplementation((from, to) => { + linkSync(from, to); + now = String(to) === containmentPath ? 100 : now; + }); + const operation = vi.fn(); + + try { + await expect( + lifecycleLock.withMcpLifecycleLock("alpha", operation, { + ...options({ timeoutMs: 30 }), + monotonicNow: () => now, + }), + ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); + } finally { + linkSpy.mockRestore(); + } + + expect(operation).not.toHaveBeenCalled(); + expect(JSON.parse(fs.readFileSync(deadlinePath, "utf8")).token).toBe("stale-deadline-token"); + expect(fs.existsSync(containmentPath)).toBe(false); + }); + + it("rolls back containment when publication crosses the synchronous acquisition deadline (#7858)", () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + const deadlinePath = `${lockPath}.deadline`; + const containmentPath = `${lockPath}.containment`; + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + deadlinePath, + `${JSON.stringify({ + version: 1, + sandboxName: "alpha", + pid: 2_147_483_647, + processIdentity: "dead-deadline", + hostIdentity: currentHostIdentity, + pidNamespaceIdentity: currentPidNamespaceIdentity, + token: "stale-deadline-token", + acquiredAt: "2026-01-01T00:00:00.000Z", + })}\n`, + ); + const linkSync = fs.linkSync.bind(fs); + let now = 0; + const linkSpy = vi.spyOn(fs, "linkSync").mockImplementation((from, to) => { + linkSync(from, to); + now = String(to) === containmentPath ? 100 : now; + }); + const operation = vi.fn(); + + try { + expect(() => + lifecycleLock.withMcpLifecycleLockSync("alpha", operation, { + ...options({ timeoutMs: 30 }), + monotonicNow: () => now, + }), + ).toThrow("Timed out waiting for sandbox mutation lock"); + } finally { + linkSpy.mockRestore(); + } + + expect(operation).not.toHaveBeenCalled(); + expect(JSON.parse(fs.readFileSync(deadlinePath, "utf8")).token).toBe("stale-deadline-token"); + expect(fs.existsSync(containmentPath)).toBe(false); + }); + + it("does not enter the critical section when lock publication crosses the acquisition deadline (#7858)", async () => { let nowCalls = 0; let entered = false; @@ -671,7 +755,74 @@ const releasePath = process.argv[3]; expect(fs.existsSync(lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir))).toBe(false); }); - it("does not enter the synchronous critical section when lock publication crosses the deadline (#7858)", () => { + it("does not invoke the asynchronous callback after the acquisition deadline (#7858)", async () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + let now = 0; + let handoffScheduled = false; + const operation = vi.fn(); + + await expect( + lifecycleLock.withMcpLifecycleLock("alpha", operation, { + ...options({ timeoutMs: 30 }), + monotonicNow: () => { + if (!handoffScheduled && fs.existsSync(lockPath)) { + handoffScheduled = true; + queueMicrotask(() => { + const owner = JSON.parse(fs.readFileSync(lockPath, "utf8")); + fs.unlinkSync(lockPath); + fs.writeFileSync( + lockPath, + `${JSON.stringify({ ...owner, token: "async-replacement-token" })}\n`, + ); + now = 100; + }); + } + return now; + }, + }), + ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); + + expect(handoffScheduled).toBe(true); + expect(operation).not.toHaveBeenCalled(); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("async-replacement-token"); + }); + + it("does not invoke the synchronous callback after the acquisition deadline (#7858)", () => { + const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); + let acquisitionPublished = false; + let replacementPublished = false; + const operation = vi.fn(); + + expect(() => + lifecycleLock.withMcpLifecycleLockSync("alpha", operation, { + ...options({ timeoutMs: 30 }), + monotonicNow: () => { + if (!fs.existsSync(lockPath)) return 0; + if (!acquisitionPublished) { + acquisitionPublished = true; + return 0; + } + if (!replacementPublished) { + const owner = JSON.parse(fs.readFileSync(lockPath, "utf8")); + fs.unlinkSync(lockPath); + fs.writeFileSync( + lockPath, + `${JSON.stringify({ ...owner, token: "sync-replacement-token" })}\n`, + ); + replacementPublished = true; + } + return 100; + }, + }), + ).toThrow("Timed out waiting for sandbox mutation lock"); + + expect(acquisitionPublished).toBe(true); + expect(replacementPublished).toBe(true); + expect(operation).not.toHaveBeenCalled(); + expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("sync-replacement-token"); + }); + + it("does not enter the synchronous critical section when lock publication crosses the acquisition deadline (#7858)", () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); const linkSync = fs.linkSync.bind(fs); let now = 0; @@ -696,7 +847,7 @@ const releasePath = process.argv[3]; expect(fs.existsSync(lockPath)).toBe(false); }); - it("restores a stale main lock when reclamation crosses the deadline (#7858)", async () => { + it("restores a stale main lock when reclamation crosses the acquisition deadline (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); fs.writeFileSync( @@ -738,7 +889,7 @@ const releasePath = process.argv[3]; expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("stale-main-token"); }); - it("restores a stale main lock when synchronous reclamation crosses the deadline (#7858)", () => { + it("restores a stale main lock when synchronous reclamation crosses the acquisition deadline (#7858)", () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); fs.writeFileSync( @@ -777,7 +928,7 @@ const releasePath = process.argv[3]; expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("stale-main-token"); }); - it("preserves a stale reaper when observation crosses the deadline (#7858)", async () => { + it("preserves a stale reaper when observation crosses the acquisition deadline (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); const reaperPath = `${lockPath}.reaper`; fs.mkdirSync(path.dirname(lockPath), { recursive: true }); From 23d9fc2c33641394e40d2dd36e2c6cf89bc30f86 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 5 Aug 2026 14:07:20 -0700 Subject: [PATCH 17/18] docs(state): clarify lifecycle lock wording Signed-off-by: Carlos Villela --- src/lib/state/mcp-lifecycle-lock-acquisition.ts | 3 +-- src/lib/state/mcp-lifecycle-lock-identity.ts | 2 +- test/mcp-lifecycle-lock.test.ts | 8 ++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib/state/mcp-lifecycle-lock-acquisition.ts b/src/lib/state/mcp-lifecycle-lock-acquisition.ts index 2f97e18a5c0..14292054740 100644 --- a/src/lib/state/mcp-lifecycle-lock-acquisition.ts +++ b/src/lib/state/mcp-lifecycle-lock-acquisition.ts @@ -327,8 +327,7 @@ async function tryReapStaleMainLock( return false; } assertBeforeDeadline(); - // Keep the reaper generation held until reclamation or restoration settles. - // Returning the promise directly would enter the async finally first. + // Await reclamation so the finally block releases the reaper generation after reclamation or restoration completes. return await reclaimStaleMcpLifecycleLockGeneration(lockPath, latest, assertBeforeDeadline); } finally { await safelyReleaseMcpLifecycleLock(reaperPath, reaperToken); diff --git a/src/lib/state/mcp-lifecycle-lock-identity.ts b/src/lib/state/mcp-lifecycle-lock-identity.ts index 26e0686c0c3..3b49bb2dba5 100644 --- a/src/lib/state/mcp-lifecycle-lock-identity.ts +++ b/src/lib/state/mcp-lifecycle-lock-identity.ts @@ -32,7 +32,7 @@ export interface LockObservation { mtimeMs: number; dev: number; ino: number; - /** Directories cannot be restored with the no-overwrite hard-link protocol. */ + /** A directory cannot be restored with a hard link. */ reclaimable: boolean; } diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index a318b075478..83c832d0a10 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -650,7 +650,7 @@ const releasePath = process.argv[3]; expect(fs.existsSync(`${lockPath}.containment`)).toBe(false); }); - it("rolls back containment when publication crosses the asynchronous acquisition deadline (#7858)", async () => { + it("rolls back containment when publication crosses the acquisition deadline in the asynchronous path (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); const deadlinePath = `${lockPath}.deadline`; const containmentPath = `${lockPath}.containment`; @@ -692,7 +692,7 @@ const releasePath = process.argv[3]; expect(fs.existsSync(containmentPath)).toBe(false); }); - it("rolls back containment when publication crosses the synchronous acquisition deadline (#7858)", () => { + it("rolls back containment when publication crosses the acquisition deadline in the synchronous path (#7858)", () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); const deadlinePath = `${lockPath}.deadline`; const containmentPath = `${lockPath}.containment`; @@ -755,7 +755,7 @@ const releasePath = process.argv[3]; expect(fs.existsSync(lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir))).toBe(false); }); - it("does not invoke the asynchronous callback after the acquisition deadline (#7858)", async () => { + it("does not invoke the callback after the acquisition deadline in the asynchronous path (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); let now = 0; let handoffScheduled = false; @@ -787,7 +787,7 @@ const releasePath = process.argv[3]; expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("async-replacement-token"); }); - it("does not invoke the synchronous callback after the acquisition deadline (#7858)", () => { + it("does not invoke the callback after the acquisition deadline in the synchronous path (#7858)", () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); let acquisitionPublished = false; let replacementPublished = false; From 4f8e3b8c6287491471e6bc50a6d43f9e1dea76ef Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 5 Aug 2026 15:43:38 -0700 Subject: [PATCH 18/18] test(state): extract lifecycle lock deadline clocks Signed-off-by: Carlos Villela --- .../mcp-lifecycle-lock-deadline-clock.ts | 68 +++++++++++++++++++ test/mcp-lifecycle-lock.test.ts | 50 +++----------- 2 files changed, 79 insertions(+), 39 deletions(-) create mode 100644 test/helpers/mcp-lifecycle-lock-deadline-clock.ts diff --git a/test/helpers/mcp-lifecycle-lock-deadline-clock.ts b/test/helpers/mcp-lifecycle-lock-deadline-clock.ts new file mode 100644 index 00000000000..d7b01e560b9 --- /dev/null +++ b/test/helpers/mcp-lifecycle-lock-deadline-clock.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +type AsynchronousReplacementClock = { + readonly handoffScheduled: () => boolean; + readonly monotonicNow: () => number; +}; + +type SynchronousReplacementClock = { + readonly acquisitionPublished: () => boolean; + readonly monotonicNow: () => number; + readonly replacementPublished: () => boolean; +}; + +function replaceLockGeneration(lockPath: string, token: string): void { + const owner = JSON.parse(fs.readFileSync(lockPath, "utf8")) as Record; + fs.unlinkSync(lockPath); + fs.writeFileSync(lockPath, `${JSON.stringify({ ...owner, token })}\n`); +} + +export function createAsynchronousLockReplacementClock( + lockPath: string, + replacementToken: string, +): AsynchronousReplacementClock { + let now = 0; + let handoffScheduled = false; + + return { + handoffScheduled: () => handoffScheduled, + monotonicNow: () => { + if (!handoffScheduled && fs.existsSync(lockPath)) { + handoffScheduled = true; + queueMicrotask(() => { + replaceLockGeneration(lockPath, replacementToken); + now = 100; + }); + } + return now; + }, + }; +} + +export function createSynchronousLockReplacementClock( + lockPath: string, + replacementToken: string, +): SynchronousReplacementClock { + let acquisitionPublished = false; + let replacementPublished = false; + + return { + acquisitionPublished: () => acquisitionPublished, + monotonicNow: () => { + if (!fs.existsSync(lockPath)) return 0; + if (!acquisitionPublished) { + acquisitionPublished = true; + return 0; + } + if (!replacementPublished) { + replaceLockGeneration(lockPath, replacementToken); + replacementPublished = true; + } + return 100; + }, + replacementPublished: () => replacementPublished, + }; +} diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 83c832d0a10..0cf90e0d54e 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -11,6 +11,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as lifecycleLock from "../src/lib/state/mcp-lifecycle-lock"; +import { + createAsynchronousLockReplacementClock, + createSynchronousLockReplacementClock, +} from "./helpers/mcp-lifecycle-lock-deadline-clock"; import "./helpers/mcp-lifecycle-lock-properties"; const requireDist = createRequire(import.meta.url); @@ -757,67 +761,35 @@ const releasePath = process.argv[3]; it("does not invoke the callback after the acquisition deadline in the asynchronous path (#7858)", async () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); - let now = 0; - let handoffScheduled = false; + const clock = createAsynchronousLockReplacementClock(lockPath, "async-replacement-token"); const operation = vi.fn(); await expect( lifecycleLock.withMcpLifecycleLock("alpha", operation, { ...options({ timeoutMs: 30 }), - monotonicNow: () => { - if (!handoffScheduled && fs.existsSync(lockPath)) { - handoffScheduled = true; - queueMicrotask(() => { - const owner = JSON.parse(fs.readFileSync(lockPath, "utf8")); - fs.unlinkSync(lockPath); - fs.writeFileSync( - lockPath, - `${JSON.stringify({ ...owner, token: "async-replacement-token" })}\n`, - ); - now = 100; - }); - } - return now; - }, + monotonicNow: clock.monotonicNow, }), ).rejects.toThrow("Timed out waiting for the sandbox mutation lock"); - expect(handoffScheduled).toBe(true); + expect(clock.handoffScheduled()).toBe(true); expect(operation).not.toHaveBeenCalled(); expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("async-replacement-token"); }); it("does not invoke the callback after the acquisition deadline in the synchronous path (#7858)", () => { const lockPath = lifecycleLock.getMcpLifecycleLockPath("alpha", stateDir); - let acquisitionPublished = false; - let replacementPublished = false; + const clock = createSynchronousLockReplacementClock(lockPath, "sync-replacement-token"); const operation = vi.fn(); expect(() => lifecycleLock.withMcpLifecycleLockSync("alpha", operation, { ...options({ timeoutMs: 30 }), - monotonicNow: () => { - if (!fs.existsSync(lockPath)) return 0; - if (!acquisitionPublished) { - acquisitionPublished = true; - return 0; - } - if (!replacementPublished) { - const owner = JSON.parse(fs.readFileSync(lockPath, "utf8")); - fs.unlinkSync(lockPath); - fs.writeFileSync( - lockPath, - `${JSON.stringify({ ...owner, token: "sync-replacement-token" })}\n`, - ); - replacementPublished = true; - } - return 100; - }, + monotonicNow: clock.monotonicNow, }), ).toThrow("Timed out waiting for sandbox mutation lock"); - expect(acquisitionPublished).toBe(true); - expect(replacementPublished).toBe(true); + expect(clock.acquisitionPublished()).toBe(true); + expect(clock.replacementPublished()).toBe(true); expect(operation).not.toHaveBeenCalled(); expect(JSON.parse(fs.readFileSync(lockPath, "utf8")).token).toBe("sync-replacement-token"); });