From 2c29504c75db93dedc93235934ffc8a86192c98e Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 14 Aug 2026 17:28:54 +0700 Subject: [PATCH] test(lanes): stop lane-lifecycle from leaking real tmux + claude processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/__tests__/lane-lifecycle.test.js | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index d4403c2..a67bbea 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -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(); } }); });