From c4dbf1b0537d169a5580aa45678cb12eb6a1d281 Mon Sep 17 00:00:00 2001 From: Behruz Nassre Esfahani <20915308+behruznassre@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:07:48 -0700 Subject: [PATCH] fix: keep a process's identity valid across exec on macOS On macOS the process identity was `ps -o lstart=,comm=`. A legitimate exec keeps the PID and start time but changes comm, so a CC_PLUGIN_CODEX_CLAUDE_BIN shell wrapper that execs Claude failed validateProcessIdentity while alive, and reapStaleJobs marked the running job failed (#105). The identity is now the start time alone, which a reused PID cannot share. An identity recorded in the old lstart+comm form still matches on its start time, so a job running while the plugin updates is not reaped. Fixes #105 Co-Authored-By: Claude Opus 5.5 --- scripts/lib/process.mjs | 22 +++++++++++++++++++--- tests/process.test.mjs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/scripts/lib/process.mjs b/scripts/lib/process.mjs index 448dbbf..a733246 100644 --- a/scripts/lib/process.mjs +++ b/scripts/lib/process.mjs @@ -141,11 +141,14 @@ export function formatCommandFailure(result) { /** * Get stable process identity for PID reuse detection. - * Returns a string that is immutable for the process lifetime. + * Returns a string that is immutable for the process lifetime: the process + * start time, which a reused PID cannot share. On macOS this is `lstart` alone; + * `comm` is left out because a legitimate `exec` (a shell wrapper launching + * Claude) changes it while the process lives on. */ export function getProcessIdentity(pid) { if (process.platform === 'darwin') { - const row = runCommandChecked('ps', ['-o', 'lstart=,comm=', '-p', String(pid)]); + const row = runCommandChecked('ps', ['-o', 'lstart=', '-p', String(pid)]); return row.stdout.trim(); } else { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); @@ -155,10 +158,23 @@ export function getProcessIdentity(pid) { } } +/** + * Whether a probed identity matches a recorded one. Up to v1.5.0 the macOS + * identity was `lstart` followed by `comm`; a job recorded then is matched on its + * start time, so updating the plugin does not reap a job that is still running. + */ +function identityMatches(actual, expected) { + if (actual === expected) return true; + return typeof expected === "string" + && actual.length > 0 + && expected.startsWith(actual) + && /^\s+\S/.test(expected.slice(actual.length)); +} + export function validateProcessIdentity(pid, expectedIdentity, options = {}) { const identityImpl = options.identityImpl ?? getProcessIdentity; try { - return identityImpl(pid) === expectedIdentity; + return identityMatches(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 diff --git a/tests/process.test.mjs b/tests/process.test.mjs index bf94afc..9180ead 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -4,6 +4,8 @@ */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; import { runCommand, @@ -414,6 +416,43 @@ describe("validateProcessIdentity", () => { ); }); + // #105: a shell wrapper (CC_PLUGIN_CODEX_CLAUDE_BIN) that execs Claude keeps + // its PID and start time, but on macOS `comm` changed, so the live job was + // judged dead and auto-reaped. + it("still validates a process after it execs another program", { skip: process.platform === "win32" }, async () => { + const script = `printf pre; read -r go; exec "${NODE_BIN}" -e 'process.stdout.write("ready"); setTimeout(() => {}, 10000)'`; + const child = spawn("/bin/sh", ["-c", script], { stdio: ["pipe", "pipe", "ignore"] }); + try { + const [pre] = await once(child.stdout, "data"); + assert.equal(String(pre), "pre"); + const beforeExec = getProcessIdentity(child.pid); + child.stdin.end("go\n"); + const [ready] = await once(child.stdout, "data"); + assert.equal(String(ready), "ready"); + assert.equal(isProcessAlive(child.pid), true); + assert.equal(validateProcessIdentity(child.pid, beforeExec), true); + } finally { + child.kill("SIGTERM"); + } + }); + + it("rejects a recorded identity with a different start time", () => { + assert.equal( + validateProcessIdentity(12345, "Mon Oct 26 09:04:16 2026", { + identityImpl: () => "Mon Oct 26 09:04:17 2026", + }), + false, + ); + }); + + it("matches an identity recorded with comm (v1.5.0 and earlier) on its start time", () => { + const identityImpl = () => "Mon Oct 26 09:04:16 2026"; + assert.equal(validateProcessIdentity(12345, "Mon Oct 26 09:04:16 2026 /bin/zsh", { identityImpl }), true); + assert.equal(validateProcessIdentity(12345, "Mon Oct 26 09:04:15 2026 /bin/zsh", { identityImpl }), false); + assert.equal(validateProcessIdentity(12345, "Mon Oct 26 09:04:16 20260", { identityImpl }), false); + assert.equal(validateProcessIdentity(12345, " anything", { identityImpl: () => "" }), false); + }); + it("keeps a failed identity probe that is not a denial classified as dead", () => { assert.equal( validateProcessIdentity(12345, "recorded-identity", {