From df13c13771f6ef9add6e654d76041e1cfb524423 Mon Sep 17 00:00:00 2001 From: jinku Date: Wed, 29 Jul 2026 00:02:20 -0700 Subject: [PATCH] fix: fall back to the liveness probe when identity is denied validateProcessIdentity() collapsed "the probe was denied" into "the process is dead", the same defect #79 reported for isProcessAlive(). Inside a Codex workspace-write sandbox `spawnSync ps` fails with EPERM unconditionally, so every job that recorded a pidIdentity would be auto-reaped while still running. It is latent today only because claude-cli.mjs swallows the same ps failure at spawn time and persists pidIdentity: null. Treat EPERM/EACCES from the identity probe as "identity unknowable" and defer to isProcessAlive(), which still reports ESRCH for a dead PID. Genuine mismatches and absent PIDs stay classified as not alive, so PID-reuse detection is only given up when identity cannot be established at all. Closes #83 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/lib/process.mjs | 14 +++++++++++--- tests/process.test.mjs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/scripts/lib/process.mjs b/scripts/lib/process.mjs index 0e91d78..448dbbf 100644 --- a/scripts/lib/process.mjs +++ b/scripts/lib/process.mjs @@ -155,10 +155,18 @@ export function getProcessIdentity(pid) { } } -export function validateProcessIdentity(pid, expectedIdentity) { +export function validateProcessIdentity(pid, expectedIdentity, options = {}) { + const identityImpl = options.identityImpl ?? getProcessIdentity; try { - return getProcessIdentity(pid) === expectedIdentity; - } catch { + return identityImpl(pid) === expectedIdentity; + } catch (error) { + // A denied probe (sandboxed `ps`, unreadable /proc entry) means the identity + // cannot be established, not that the process is gone. Fall back to the + // zero-signal liveness probe, which still reports ESRCH for a dead PID. + // PID-reuse detection is only given up when identity is unknowable at all. + if (error?.code === "EPERM" || error?.code === "EACCES") { + return isProcessAlive(pid, options); + } return false; } } diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 07b1ecf..bf94afc 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -387,4 +387,42 @@ describe("validateProcessIdentity", () => { it("returns false for non-existent PID", () => { assert.equal(validateProcessIdentity(99999999, "any"), false); }); + + it("falls back to the liveness probe when the identity probe is denied", () => { + const deniedIdentity = () => { + throw Object.assign(new Error("spawnSync ps EPERM"), { code: "EPERM" }); + }; + + assert.equal( + validateProcessIdentity(12345, "recorded-identity", { + identityImpl: deniedIdentity, + killImpl: () => { + throw Object.assign(new Error("operation not permitted"), { code: "EPERM" }); + }, + }), + true, + ); + + assert.equal( + validateProcessIdentity(12345, "recorded-identity", { + identityImpl: deniedIdentity, + killImpl: () => { + throw Object.assign(new Error("no such process"), { code: "ESRCH" }); + }, + }), + false, + ); + }); + + it("keeps a failed identity probe that is not a denial classified as dead", () => { + assert.equal( + validateProcessIdentity(12345, "recorded-identity", { + identityImpl: () => { + throw new Error("ps: exit=1"); + }, + killImpl: () => true, + }), + false, + ); + }); });