Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions scripts/lib/process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/process.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", {
Expand Down