Skip to content
Merged
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
25 changes: 23 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,19 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
# npm intermittently fails to install platform-specific optional deps
# (rolldown native bindings; npm/cli#4828), reddening healthy builds.
# Retry with a clean node_modules, keeping the lockfile for determinism.
- name: Install dependencies
shell: bash
run: |
for attempt in 1 2 3; do
if npm ci; then exit 0; fi
echo "::warning::npm ci failed (attempt $attempt); retrying"
rm -rf node_modules
sleep 5
done
exit 1
- run: npm run build
- run: npm test
- run: npm pack --dry-run
Expand All @@ -41,7 +53,16 @@ jobs:
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- name: Install dependencies
shell: bash
run: |
for attempt in 1 2 3; do
if npm ci; then exit 0; fi
echo "::warning::npm ci failed (attempt $attempt); retrying"
rm -rf node_modules
sleep 5
done
exit 1
- run: npm run build
- run: npm test
- run: npm publish --provenance --access public
Expand Down
37 changes: 37 additions & 0 deletions src/__fixtures__/win-pid-stub/child.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env node
// Stub that stands in for Claude's real node process. It records its own pid
// and writes a Claude-style session file, so a test can verify both:
// 1. the pid node-pty reports for the spawned wrapper vs this real pid, and
// 2. that a PidWatcher discovers this session file by cwd+recency even when
// node-pty's reported pid points at a wrapper (the issue-#1 fix).
//
// POSIX: Claude is a `#!/usr/bin/env node` script, so node-pty execs node in
// place and the reported pid IS this pid. Windows: Claude is launched via
// claude.cmd, so node-pty reports the cmd.exe wrapper pid and this node runs
// as a grandchild with a DIFFERENT pid — the root cause of issue #1.
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";

const pidFile = process.env.CLARP_PROBE_PIDFILE;
if (pidFile) writeFileSync(pidFile, String(process.pid));

const sessionsDir = process.env.CLARP_PROBE_SESSIONS_DIR;
if (sessionsDir) {
mkdirSync(sessionsDir, { recursive: true });
writeFileSync(
join(sessionsDir, `${process.pid}.json`),
JSON.stringify({
pid: process.pid,
sessionId: "probe-session",
cwd: process.env.CLARP_PROBE_CWD || process.cwd(),
kind: "interactive",
status: "idle",
updatedAt: Date.now(),
}),
);
}

process.stdout.write("stub-child-ready\n");
// Stay alive briefly so the test can read node-pty's reported pid and run the
// PidWatcher (whose liveness check requires this process to still exist).
setTimeout(() => process.exit(0), 6000);
5 changes: 5 additions & 0 deletions src/__fixtures__/win-pid-stub/wrapper.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@echo off
REM Mirrors how Claude Code installs on Windows: a .cmd shim that invokes node.
REM node-pty/ConPTY runs this through cmd.exe, so the real node process below is
REM a grandchild with a pid that differs from node-pty's reported pid.
node "%~dp0child.mjs"
224 changes: 223 additions & 1 deletion src/pid-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { PidWatcher, type PidFileData } from "./pid-watcher.js";
import { PidWatcher, getParentPid, type PidFileData } from "./pid-watcher.js";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
Expand Down Expand Up @@ -147,3 +147,225 @@ describe("PidWatcher", () => {
expect(transcriptPath).toBeNull();
});
});

// When node-pty reports a wrapper's pid (Windows claude.cmd, POSIX shim) the
// reported pid file never appears, so PidWatcher must discover Claude's real
// session file by matching cwd + recency. The reported pid (WRAPPER_PID) is
// deliberately one whose file is never written.
describe("PidWatcher session discovery (wrapper-pid case)", () => {
const WRAPPER_PID = 4242;
const REAL_PID = process.pid; // a guaranteed-alive "grandchild" pid
let tmpDir: string;
let sessionsDir: string;
let startedAt: number;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pid-discovery-test-"));
sessionsDir = path.join(tmpDir, ".claude", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
startedAt = Date.now();
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

function writeSession(pid: number, data: Partial<PidFileData>): void {
const full: PidFileData = {
pid,
sessionId: `sess-${pid}`,
cwd: "/work/project",
kind: "interactive",
updatedAt: startedAt + 1000,
...data,
};
fs.writeFileSync(path.join(sessionsDir, `${pid}.json`), JSON.stringify(full));
}

function makeWatcher(cwd: string): PidWatcher {
return new PidWatcher(WRAPPER_PID, { onStatusChange: () => {} }, tmpDir, {
cwd,
startedAt,
getParentPid: (pid) => (pid === REAL_PID ? WRAPPER_PID : null),
});
}

it("discovers the real session file by cwd when the reported pid file is absent", () => {
writeSession(REAL_PID, { cwd: "/work/project", sessionId: "real-session" });
const watcher = makeWatcher("/work/project");
expect(watcher.getSessionId()).toBe("real-session");
});

it("ignores session files for a different cwd", () => {
writeSession(REAL_PID, { cwd: "/some/other/dir", sessionId: "other" });
const watcher = makeWatcher("/work/project");
expect(watcher.getSessionId()).toBeNull();
});

it("ignores a stale prior-run file (updatedAt before start)", () => {
writeSession(REAL_PID, { updatedAt: startedAt - 60_000, sessionId: "stale" });
const watcher = makeWatcher("/work/project");
expect(watcher.getSessionId()).toBeNull();
});

it("ignores a session whose process is no longer alive", () => {
// pid 0 fails the liveness/validity guard deterministically.
writeSession(0, { cwd: "/work/project", sessionId: "dead" });
const watcher = makeWatcher("/work/project");
expect(watcher.getSessionId()).toBeNull();
});

it("reports status changes from the discovered file via polling", () => {
vi.useFakeTimers();
try {
writeSession(REAL_PID, { cwd: "/work/project", status: "busy", sessionId: "live" });
const changes: string[] = [];
const watcher = new PidWatcher(
WRAPPER_PID,
{ onStatusChange: (status) => changes.push(status) },
tmpDir,
{
cwd: "/work/project",
startedAt,
getParentPid: (pid) => (pid === REAL_PID ? WRAPPER_PID : null),
},
);
watcher.start();
expect(changes).toEqual(["busy"]);
writeSession(REAL_PID, { cwd: "/work/project", status: "idle", sessionId: "live" });
vi.advanceTimersByTime(600);
expect(changes).toEqual(["busy", "idle"]);
watcher.stop();
} finally {
vi.useRealTimers();
}
});

it("matches cwd case-insensitively and across separators on Windows", () => {
if (process.platform !== "win32") return;
writeSession(REAL_PID, { cwd: "C:\\Work\\Project", sessionId: "win" });
const watcher = makeWatcher("c:/work/project");
expect(watcher.getSessionId()).toBe("win");
});
});

// When two clarp runs launch Claude from the same cwd through a wrapper, both
// scan the shared sessions directory. PidWatcher must adopt only the session
// whose process descends from the wrapper IT launched, and refuse rather than
// guess if ancestry can't disambiguate.
describe("PidWatcher concurrent same-cwd disambiguation", () => {
const WRAPPER_PID = 500;
let tmpDir: string;
let sessionsDir: string;
let startedAt: number;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pid-concurrent-test-"));
sessionsDir = path.join(tmpDir, ".claude", "sessions");
fs.mkdirSync(sessionsDir, { recursive: true });
startedAt = Date.now();
});
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });

function writeSession(pid: number, sessionId: string, when: number): void {
fs.writeFileSync(
path.join(sessionsDir, `${pid}.json`),
JSON.stringify({ pid, sessionId, cwd: "/shared/dir", kind: "interactive", updatedAt: when }),
);
}

// Wrapper 500 -> our Claude 1001; the other run is wrapper 600 -> Claude 1002.
const parents: Record<number, number> = { 1001: 500, 1002: 600 };

function makeWatcher(opts?: {
startedAt?: number;
parents?: Record<number, number | null>;
onWarning?: (message: string) => void;
}): PidWatcher {
const lookup = opts?.parents ?? parents;
return new PidWatcher(WRAPPER_PID, { onStatusChange: () => {} }, tmpDir, {
cwd: "/shared/dir",
startedAt: opts?.startedAt ?? startedAt,
isPidAlive: () => true,
getParentPid: (pid) => lookup[pid] ?? null,
});
}

function makeWatcherWithWarning(opts: {
startedAt: number;
parents: Record<number, number | null>;
warnings: string[];
}): PidWatcher {
return new PidWatcher(WRAPPER_PID, {
onStatusChange: () => {},
onWarning: (message) => opts.warnings.push(message),
}, tmpDir, {
cwd: "/shared/dir",
startedAt: opts.startedAt,
isPidAlive: () => true,
getParentPid: (pid) => opts.parents[pid] ?? null,
});
}

it("adopts the session descended from our wrapper, not the concurrent run's", () => {
writeSession(1002, "theirs", startedAt + 2000); // newer, but not ours
writeSession(1001, "ours", startedAt + 1000);
expect(makeWatcher().getSessionId()).toBe("ours");
});

it("refuses to adopt when no candidate descends from our wrapper", () => {
writeSession(1002, "theirs", startedAt + 1000); // descends from 600, not 500
fs.writeFileSync(
path.join(sessionsDir, "9003.json"),
JSON.stringify({ pid: 9003, sessionId: "alsotheirs", cwd: "/shared/dir", kind: "interactive", updatedAt: startedAt + 1500 }),
);
expect(makeWatcher().getSessionId()).toBeNull();
});

it("waits during the grace window instead of adopting a sole foreign candidate", () => {
writeSession(1002, "theirs", startedAt + 1000);
const watcher = makeWatcher();

expect(watcher.getSessionId()).toBeNull();

writeSession(1001, "ours", startedAt + 2000);
expect(watcher.getSessionId()).toBe("ours");
});

it("keeps refusing a positively foreign candidate after the grace window", () => {
writeSession(1002, "theirs", startedAt + 1000);
const watcher = makeWatcher({
startedAt: Date.now() - 11_000,
parents: { 1002: 600, 600: 1 },
});

expect(watcher.getSessionId()).toBeNull();
});

it("degrades to newest candidate after grace when ancestry is indeterminate", () => {
const warnings: string[] = [];
writeSession(1002, "theirs", startedAt + 1000);
writeSession(1003, "newer", startedAt + 2000);
const watcher = makeWatcherWithWarning({
startedAt: Date.now() - 11_000,
parents: { 1002: null, 1003: null },
warnings,
});

expect(watcher.getSessionId()).toBe("newer");
expect(warnings).toHaveLength(1);
});
});

describe("getParentPid", () => {
it("resolves the current process's parent on this platform", () => {
// Verifies the real platform implementation (incl. Windows PowerShell) on
// each CI OS without needing concurrent spawns.
expect(getParentPid(process.pid)).toBe(process.ppid);
});

it("returns null for an invalid pid", () => {
expect(getParentPid(0)).toBeNull();
expect(getParentPid(-1)).toBeNull();
});
});
Loading
Loading