Skip to content

Commit bf483f8

Browse files
Merge pull request #1068 from corbitsdev/cl-7990-reap-leaked-shell-children-so-interrupt-and-close-cannot
fix(subagent): reap shell children so interrupt and close settle
2 parents f9948ca + 613cda0 commit bf483f8

6 files changed

Lines changed: 541 additions & 20 deletions

File tree

‎src/agent/background-shell-tool.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,81 @@ describe("background shell through the agent toolset", () => {
129129
}
130130
});
131131

132+
test("shell_collect with an aborted signal releases as running without killing the child", async () => {
133+
const toolset = await createAgentToolset({
134+
cwd: process.cwd(),
135+
permissionGate: gate(process.cwd()),
136+
});
137+
try {
138+
const started = await toolset.dynamicRunner.run(
139+
{
140+
id: "a-start",
141+
name: "run_shell",
142+
arguments: {
143+
command: "sleep 60",
144+
background: true,
145+
},
146+
},
147+
new AbortController().signal,
148+
);
149+
const { shell_id } = JSON.parse(String(started.content)) as {
150+
shell_id: string;
151+
};
152+
const aborted = new AbortController();
153+
aborted.abort(new Error("interrupted by interrupt_agent"));
154+
const out = await toolset.dynamicRunner.run(
155+
{
156+
id: "a-collect",
157+
name: "shell_collect",
158+
arguments: { shell_id, action: "collect", wait_ms: 60_000 },
159+
},
160+
aborted.signal,
161+
);
162+
expect(JSON.parse(String(out.content))).toMatchObject({
163+
status: "running",
164+
});
165+
// A live-signal collect still sees the child running: the abort above
166+
// released the waiter instead of killing the process. Had the abort
167+
// killed it, this would already report completed.
168+
const stillThere = await toolset.dynamicRunner.run(
169+
{
170+
id: "a-collect2",
171+
name: "shell_collect",
172+
arguments: { shell_id, action: "collect" },
173+
},
174+
new AbortController().signal,
175+
);
176+
expect(JSON.parse(String(stillThere.content))).toMatchObject({
177+
status: "running",
178+
});
179+
// And the child is still killable: cancel settles it as completed.
180+
const cancelled = await toolset.dynamicRunner.run(
181+
{
182+
id: "a-cancel",
183+
name: "shell_collect",
184+
arguments: { shell_id, action: "cancel" },
185+
},
186+
new AbortController().signal,
187+
);
188+
expect(JSON.parse(String(cancelled.content))).toMatchObject({
189+
status: "cancelling",
190+
});
191+
const final = await toolset.dynamicRunner.run(
192+
{
193+
id: "a-collect3",
194+
name: "shell_collect",
195+
arguments: { shell_id, action: "collect", wait_ms: 5_000 },
196+
},
197+
new AbortController().signal,
198+
);
199+
expect(JSON.parse(String(final.content))).toMatchObject({
200+
status: "completed",
201+
});
202+
} finally {
203+
await toolset.dispose();
204+
}
205+
});
206+
132207
test("toolset dispose kills every live background process group", async () => {
133208
const token = `ic_toolset_dispose_${randomUUID()}`;
134209
const toolset = await createAgentToolset({

‎src/agent/background-shell-tool.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ export function createShellCollectTool(
7979
) {
8080
return {
8181
definition: shellCollectDefinition,
82-
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
82+
handler: async (
83+
rawArgs: Record<string, unknown>,
84+
signal?: AbortSignal,
85+
): Promise<string> => {
8386
const parsed = ShellCollectArgs(rawArgs);
8487
if (parsed instanceof type.errors) {
8588
return "Error: shell_collect requires shell_id (string) and action ('collect' | 'cancel').";
@@ -91,7 +94,11 @@ export function createShellCollectTool(
9194
}
9295
return JSON.stringify({ shell_id, status: "cancelling" });
9396
}
94-
const snapshot = await registry.collect(shell_id, parsed.wait_ms ?? 0);
97+
const snapshot = await registry.collect(
98+
shell_id,
99+
parsed.wait_ms ?? 0,
100+
signal,
101+
);
95102
if (snapshot.state === "running") {
96103
return JSON.stringify({ shell_id, status: "running" });
97104
}

‎src/shell/background-shell.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,53 @@ describe("background shell registry", () => {
122122
const after = await registry.collect(started.id, 0);
123123
expect(after.state).toBe("not-found");
124124
});
125+
126+
test("collect with an aborted signal releases as running without killing the child", async () => {
127+
const token = `ic_bg_abort_${randomUUID()}`;
128+
const registry = createBackgroundShellRegistry();
129+
const started = registry.start({
130+
command: `bash -c 'exec -a ${token} sleep 600'`,
131+
cwd: tmpCwd,
132+
});
133+
if ("error" in started) throw new Error(started.error);
134+
try {
135+
const aborted = new AbortController();
136+
aborted.abort(new Error("interrupted by interrupt_agent"));
137+
const snapshot = await registry.collect(
138+
started.id,
139+
60_000,
140+
aborted.signal,
141+
);
142+
expect(snapshot.state).toBe("running");
143+
const stillThere = await registry.collect(started.id, 0);
144+
expect(stillThere.state).toBe("running");
145+
const probe = spawnSync("pgrep", ["-f", token], { encoding: "utf8" });
146+
expect(probe.status).toBe(0);
147+
} finally {
148+
registry.disposeAll("test done");
149+
}
150+
});
151+
152+
test("releaseWaiters wakes a parked collect without killing the child", async () => {
153+
const token = `ic_bg_release_${randomUUID()}`;
154+
const registry = createBackgroundShellRegistry();
155+
const started = registry.start({
156+
command: `bash -c 'exec -a ${token} sleep 600'`,
157+
cwd: tmpCwd,
158+
});
159+
if ("error" in started) throw new Error(started.error);
160+
try {
161+
const pending = registry.collect(started.id, 60_000);
162+
await new Promise((r) => setTimeout(r, 100));
163+
registry.releaseWaiters();
164+
const snapshot = await pending;
165+
expect(snapshot.state).toBe("running");
166+
const stillThere = await registry.collect(started.id, 0);
167+
expect(stillThere.state).toBe("running");
168+
const probe = spawnSync("pgrep", ["-f", token], { encoding: "utf8" });
169+
expect(probe.status).toBe(0);
170+
} finally {
171+
registry.disposeAll("test done");
172+
}
173+
});
125174
});

‎src/shell/background-shell.ts‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,14 @@ export type BackgroundShellSnapshot =
6464

6565
export interface BackgroundShellRegistry {
6666
start: (args: StartBackgroundShellArgs) => { id: string } | { error: string };
67-
collect: (id: string, waitMs?: number) => Promise<BackgroundShellSnapshot>;
67+
collect: (
68+
id: string,
69+
waitMs?: number,
70+
signal?: AbortSignal,
71+
) => Promise<BackgroundShellSnapshot>;
6872
cancel: (id: string) => boolean;
6973
disposeAll: (reason: string) => void;
74+
releaseWaiters: () => void;
7075
runningCount: () => number;
7176
}
7277

@@ -151,14 +156,33 @@ export function createBackgroundShellRegistry(
151156
const collect = async (
152157
id: string,
153158
waitMs = 0,
159+
signal?: AbortSignal,
154160
): Promise<BackgroundShellSnapshot> => {
155161
const done = completed.get(id);
156162
if (done !== undefined) return { state: "completed", exit: done };
157163
if (!running.has(id)) return { state: "not-found" };
158164
if (waitMs > 0) {
165+
// An already-aborted collect releases immediately as still-running:
166+
// interrupt must not park the session on a live descendant, and must
167+
// not kill it either — the child belongs to the still-alive session.
168+
if (signal?.aborted === true) return { state: "running" };
159169
await new Promise<void>((resolve) => {
160-
exitWaiters.set(id, resolve);
161-
setTimeout(resolve, waitMs);
170+
const timer = setTimeout(() => {
171+
exitWaiters.delete(id);
172+
signal?.removeEventListener("abort", onAbort);
173+
resolve();
174+
}, waitMs);
175+
const onAbort = (): void => {
176+
clearTimeout(timer);
177+
exitWaiters.delete(id);
178+
resolve();
179+
};
180+
signal?.addEventListener("abort", onAbort, { once: true });
181+
exitWaiters.set(id, () => {
182+
clearTimeout(timer);
183+
signal?.removeEventListener("abort", onAbort);
184+
resolve();
185+
});
162186
}).finally(() => exitWaiters.delete(id));
163187
const finished = completed.get(id);
164188
if (finished !== undefined) return { state: "completed", exit: finished };
@@ -173,12 +197,22 @@ export function createBackgroundShellRegistry(
173197
return true;
174198
};
175199

200+
/**
201+
* Wake every parked `collect` waiter as still-running without touching the
202+
* children. Interrupt paths call this so a live descendant releases the
203+
* session instead of wedging teardown; close paths use `disposeAll`, which
204+
* wakes waiters and then kills the trees.
205+
*/
206+
const releaseWaiters = (): void => {
207+
for (const wake of exitWaiters.values()) wake();
208+
exitWaiters.clear();
209+
};
210+
176211
const disposeAll = (reason: string): void => {
177212
for (const child of running.values()) killProcessTree(child);
178213
running.clear();
179214
completed.clear();
180-
for (const wake of exitWaiters.values()) wake();
181-
exitWaiters.clear();
215+
releaseWaiters();
182216
// onExit is intentionally not fired for disposed shells: the session is
183217
// gone, so there is no later turn to deliver to (`reason` is for callers
184218
// that log it).
@@ -190,6 +224,7 @@ export function createBackgroundShellRegistry(
190224
collect,
191225
cancel,
192226
disposeAll,
227+
releaseWaiters,
193228
runningCount: () => running.size,
194229
};
195230
}

0 commit comments

Comments
 (0)