Public: anyone can read this; the crew can change it.

A cancelled crew run leaves no process behind: a grandchild the run forked is gone within 2 s #10704

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10698-a-cancelled-crew-run-leaves-no-process-b → main 1 commit fork

  • Merged. scout/10698-a-cancelled-crew-run-leaves-no-process-b was merged into main as c95cc7f.

Review

Sign in to review this change.
1 file changed +133 −0
test/crew-cancel-group.test.ts added +133
@@ -0,0 +1,133 @@
1 /**
2 * A cancelled crew run leaves no process behind (#10698). `runClaude`
3 * starts the agent `detached`, in a group of its own, and a cancel signals
4 * that whole group (`signalGroup`), so a tool call the agent forked — and
5 * whatever that forked in turn — ends with it, not five seconds later when
6 * the reader gives up, and not never.
7 *
8 * The stand-in agent forks a background shell that holds its stdout open
9 * (as a tool call left running does) and that shell forks a long `sleep`.
10 * Signalling the agent alone would leave both alive past the 2 s bound:
11 * the open pipe keeps the run waiting until its 5 s read deadline.
12 */
13 import { afterAll, describe, expect, test } from "bun:test";
14 import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
15 import { mkdtemp, rm } from "node:fs/promises";
16 import { tmpdir } from "node:os";
17 import { join } from "node:path";
18 import { runClaude } from "../src/crew/claude.ts";
19 import { sandboxPolicy } from "../src/crew/sandbox.ts";
20
21 const dir = await mkdtemp(join(tmpdir(), "forge-cancel-group-"));
22 const started: number[] = [];
23 afterAll(async () => {
24 for (const pid of started)
25 try {
26 process.kill(pid, "SIGKILL");
27 } catch {
28 // gone, as it should be
29 }
30 await rm(dir, { recursive: true, force: true });
31 });
32
33 /** Alive and not a zombie waiting for its new parent to reap it. */
34 function alive(pid: number): boolean {
35 try {
36 process.kill(pid, 0);
37 } catch {
38 return false;
39 }
40 try {
41 const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
42 const state = stat.slice(stat.lastIndexOf(")") + 2)[0];
43 return state !== "Z" && state !== "X";
44 } catch {
45 return false;
46 }
47 }
48
49 /** Polls, never a fixed sleep: true once every pid is gone, false at the deadline. */
50 async function allGoneWithin(pids: readonly number[], ms: number): Promise<boolean> {
51 const deadline = Date.now() + ms;
52 while (Date.now() < deadline) {
53 if (pids.every((pid) => !alive(pid))) return true;
54 await Bun.sleep(20);
55 }
56 return pids.every((pid) => !alive(pid));
57 }
58
59 async function waitFor(file: string, ms: number): Promise<number> {
60 const deadline = Date.now() + ms;
61 while (Date.now() < deadline) {
62 if (existsSync(file)) {
63 const pid = Number(readFileSync(file, "utf8").trim());
64 if (Number.isInteger(pid) && pid > 1) return pid;
65 }
66 await Bun.sleep(20);
67 }
68 throw new Error(`${file} never named a pid`);
69 }
70
71 describe.skipIf(process.platform !== "linux")("a cancelled crew run", () => {
72 test("takes the agent's forked child and that child's own child with it within 2 s", async () => {
73 const binary = join(dir, "agent");
74 const childFile = join(dir, "child.pid");
75 const grandFile = join(dir, "grand.pid");
76 const agentFile = join(dir, "agent.pid");
77 // The child keeps stdout open and waits on its own sleeping child; the
78 // grandchild's pid is written last, so both exist once it is there.
79 writeFileSync(
80 binary,
81 `#!/bin/sh
82 IFS= read -r first
83 echo $$ > ${agentFile}
84 sh -c 'sleep 300 & echo $$ > ${childFile}; echo $! > ${grandFile}.tmp; mv ${grandFile}.tmp ${grandFile}; wait' &
85 echo '{"type":"system","subtype":"init","session_id":"s1","model":"fake"}'
86 sleep 300
87 `,
88 );
89 chmodSync(binary, 0o755);
90
91 const abort = new AbortController();
92 const run = runClaude({
93 binary,
94 cwd: dir,
95 prompt: "Do the thing.",
96 appendSystemPrompt: "",
97 budgetUsd: 1,
98 env: {},
99 sandbox: sandboxPolicy({ dataDir: dir, agentName: "bot", urls: [] }),
100 timeoutMs: 60_000,
101 log: () => {},
102 signal: abort.signal,
103 settleMs: 1_000,
104 });
105
106 const grand = await waitFor(grandFile, 10_000);
107 const child = await waitFor(childFile, 1_000);
108 const agent = await waitFor(agentFile, 1_000);
109 const pids = [agent, child, grand];
110 started.push(...pids);
111 expect(pids.every(alive)).toBe(true);
112
113 abort.abort();
114 expect(await allGoneWithin(pids, 2_000)).toBe(true);
115
116 const outcome = await run;
117 expect(outcome.ok).toBe(false);
118 expect(outcome.reason).toBe("cancelled");
119 // Reaped too, not left as zombies: no pid answers signal 0.
120 const deadline = Date.now() + 2_000;
121 const answers = (): number[] =>
122 pids.filter((pid) => {
123 try {
124 process.kill(pid, 0);
125 return true;
126 } catch {
127 return false;
128 }
129 });
130 while (answers().length > 0 && Date.now() < deadline) await Bun.sleep(20);
131 expect(answers()).toEqual([]);
132 }, 20_000);
133 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs