test(lanes): stop lane-lifecycle from leaking real tmux + claude processes
Four cases in lane-lifecycle.test.js call /start without stubbing PATH, so they spawn the real system `claude` binary in a real tmux session to simulate a stuck/live run. Each then mocks tmux's own exec calls to fake has-session/kill-session for the app's checks, but never touches the real spawned process — the mock only fools the app, not the OS. Two of these leaked past every prior test run undetected (ccam-lane-22, ccam-lane-24), surfacing in the dashboard's live "Dashboard runs" list with no DB record and a garbage started_at, and reappearing in a Workspace split pane pointed at a deleted temp directory. Stub a lightweight fake `claude` on PATH (same pattern already used correctly elsewhere in this file) instead of spawning the real CLI, and explicitly kill the real tmux session in each test's teardown since the app-level mock never reaches the OS process.
This commit is contained in:
@@ -80,6 +80,29 @@ function makeRunChild({ exitsOnKill }) {
|
||||
return child;
|
||||
}
|
||||
|
||||
// Puts a fake `claude` binary on PATH so a real `/start` spawns a real tmux
|
||||
// session running THIS script instead of the system Claude Code CLI. Tests
|
||||
// that mock tmux's own exec calls (to simulate a stuck/live session) still
|
||||
// spawn this real process underneath — without the stub, that spawn launches
|
||||
// the actual `claude` binary and, because the mock replaces the app's own
|
||||
// kill-session call, the real process is never actually terminated, leaking
|
||||
// a live tmux session + CLI process for good. Returns the restore function.
|
||||
function stubClaudeBinary(name) {
|
||||
const bin = path.join(ROOT, `${name}-bin`);
|
||||
const claude = path.join(bin, "claude");
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
claude,
|
||||
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
|
||||
);
|
||||
fs.chmodSync(claude, 0o755);
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
||||
return () => {
|
||||
process.env.PATH = originalPath;
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForProvisioning(id) {
|
||||
const deadline = Date.now() + 5000;
|
||||
let response;
|
||||
@@ -818,6 +841,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
fs.writeFileSync(sentinel, "still here\n");
|
||||
|
||||
// Start a run for the lane
|
||||
const restorePath = stubClaudeBinary("await-timeout");
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
|
||||
assert.equal(started.status, 200);
|
||||
const runId = started.body.lane.run_id;
|
||||
@@ -848,6 +872,15 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
// The mocked kill-session above only fools the app's own check — the
|
||||
// real tmux session + claude stub spawned above is still alive and
|
||||
// must be killed for real, or it leaks past this test run.
|
||||
try {
|
||||
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
restorePath();
|
||||
}
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
});
|
||||
@@ -889,6 +922,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
const lane = await createManagedLane("start-twice");
|
||||
|
||||
// Start a run for the lane
|
||||
const restorePath = stubClaudeBinary("start-twice");
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
|
||||
assert.equal(started.status, 200);
|
||||
const runId = started.body.lane.run_id;
|
||||
@@ -915,6 +949,15 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(after.body.lane.run_id, runId);
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
// The real tmux session behind the "first" run is never reset/killed
|
||||
// in this test, mocked or otherwise — kill it for real so it doesn't
|
||||
// leak past this test run.
|
||||
try {
|
||||
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
restorePath();
|
||||
}
|
||||
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
@@ -1134,6 +1177,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
const lane = await adoptedLane("release-moved-on");
|
||||
|
||||
// Create a run for this lane.
|
||||
const restorePath = stubClaudeBinary("release-moved-on");
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
const runId = started.body.lane.run_id;
|
||||
@@ -1161,6 +1205,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
assert.equal(after.status, "running");
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
// The app never calls kill-session here (healing preserves the "live"
|
||||
// run) — kill the real tmux session directly so it doesn't leak.
|
||||
try {
|
||||
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
restorePath();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1169,6 +1221,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
const lane = await adoptedLane("release-stale-run");
|
||||
|
||||
// Start a run for this lane.
|
||||
const restorePath = stubClaudeBinary("release-stale-run");
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||
const runId = started.body.lane.run_id;
|
||||
@@ -1195,6 +1248,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
assert.equal(after.status, "idle", "status should be idle after run is gone");
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
// The app believes the session is already gone and never calls
|
||||
// kill-session — kill the real tmux session directly so it doesn't leak.
|
||||
try {
|
||||
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
restorePath();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user