Skip to content

Commit b37edd7

Browse files
authored
Merge pull request #349 from raymondginger2018-sudo/pr/bash-close-event-hang
fix(core): stop the bash tool from hanging forever when a child holds the pipe
2 parents 0ee6643 + 698d9e0 commit b37edd7

2 files changed

Lines changed: 318 additions & 37 deletions

File tree

‎packages/core/src/tests/tool-handlers.test.ts‎

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import childProcess from "node:child_process";
2+
import { syncBuiltinESMExports } from "node:module";
3+
import { EventEmitter } from "node:events";
4+
import { PassThrough } from "node:stream";
5+
import { killProcessTree } from "../common/process-tree";
16
import { afterEach, test } from "node:test";
27
import assert from "node:assert/strict";
38
import * as fs from "fs";
49
import * as os from "os";
510
import * as path from "path";
6-
import { setTimeout as delay } from "node:timers/promises";
11+
import { setTimeout as delay, setImmediate as nextTurn } from "node:timers/promises";
712
import type { BackgroundProcessCompletion, ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor";
813
import { handleBashTool } from "../tools/bash-handler";
914
import { handleEditTool } from "../tools/edit-handler";
@@ -105,6 +110,186 @@ test("Bash timeout control can extend the active command deadline", async () =>
105110
assert.equal(result.metadata?.timeoutMs, 1000);
106111
});
107112

113+
for (const stream of ["stdout", "stderr"]) {
114+
test(`Bash bounds draining when a descendant holds ${stream}`, { timeout: 8_000 }, async () => {
115+
const workspace = createTempWorkspace();
116+
const exits: Array<string | number> = [];
117+
const chunks: string[] = [];
118+
let pid: number | undefined;
119+
let control: ProcessTimeoutControl | undefined;
120+
let revoked = 0;
121+
const startedAt = Date.now();
122+
try {
123+
const result = await handleBashTool(
124+
{ command: `sleep 30 ${stream === "stdout" ? "2>/dev/null" : ">/dev/null"} & printf 'hi\\n'` },
125+
createContext(`bash-held-${stream}`, workspace, {
126+
bashTimeoutMs: 1_000,
127+
bashMinTimeoutMs: 1,
128+
onProcessStart: (value) => {
129+
pid = value as number;
130+
},
131+
onProcessStdout: (_pid, chunk) => chunks.push(chunk),
132+
onProcessExit: (value) => exits.push(value),
133+
onProcessTimeoutControl: (_pid, value) => {
134+
if (value) control = value;
135+
else revoked++;
136+
},
137+
})
138+
);
139+
assert.ok(Date.now() - startedAt < 6_000);
140+
assert.equal(result.ok, true);
141+
assert.equal(result.metadata?.timedOut, false);
142+
assert.equal(result.metadata?.exitCode, 0);
143+
assert.match(result.output ?? "", /hi/);
144+
assert.match(result.output ?? "", /Output streams did not close/);
145+
assert.equal(exits.length, 1);
146+
assert.equal(revoked, 1);
147+
const info = control!.getInfo();
148+
assert.deepEqual(control!.setTimeoutMs(1), info);
149+
const count = chunks.length;
150+
await delay(50);
151+
assert.equal(chunks.length, count);
152+
assert.equal(exits.length, 1);
153+
} finally {
154+
if (pid) killProcessTree(pid, "SIGKILL");
155+
}
156+
});
157+
}
158+
159+
test("Bash drains delayed output and preserves a failing shell exit", { timeout: 5_000 }, async () => {
160+
const result = await handleBashTool(
161+
{ command: "(sleep 0.2; printf 'late-out'; printf 'late-err' >&2) & exit 7" },
162+
createContext("bash-drain-failure", createTempWorkspace())
163+
);
164+
assert.equal(result.ok, false);
165+
assert.equal(result.metadata?.exitCode, 7);
166+
assert.match(result.output ?? "", /late-out/);
167+
assert.match(result.output ?? "", /late-err/);
168+
assert.doesNotMatch(result.output ?? "", /Output streams did not close/);
169+
});
170+
171+
for (const lateEvent of ["close", "exit", "none"]) {
172+
test(`Bash timeout stays failed with late event: ${lateEvent}`, async (t) => {
173+
const child = Object.assign(new EventEmitter(), {
174+
pid: 12345,
175+
stdout: new PassThrough(),
176+
stderr: new PassThrough(),
177+
});
178+
const spawnMock = t.mock.method(childProcess, "spawn", () => child);
179+
// Simulate an unsuccessful kill without touching any real process.
180+
const killMock = t.mock.method(process, "kill", () => {
181+
throw new Error("ESRCH");
182+
});
183+
const taskkillMock = t.mock.method(childProcess, "spawnSync", () => ({ status: 128 }));
184+
syncBuiltinESMExports();
185+
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
186+
let exits = 0;
187+
let revocations = 0;
188+
const chunks: string[] = [];
189+
try {
190+
let completed = false;
191+
const promise = handleBashTool(
192+
{ command: "ignored" },
193+
createContext("bash-timeout-race", createTempWorkspace(), {
194+
bashTimeoutMs: 100,
195+
bashMinTimeoutMs: 1,
196+
onProcessExit: () => {
197+
exits++;
198+
},
199+
onProcessTimeoutControl: (_pid, control) => {
200+
if (!control) revocations++;
201+
},
202+
onProcessStdout: (_pid, chunk) => {
203+
chunks.push(chunk);
204+
},
205+
})
206+
).then((value) => {
207+
completed = true;
208+
return value;
209+
});
210+
const captured = "before" + "x".repeat(35_000);
211+
child.stdout.write(captured);
212+
t.mock.timers.tick(100);
213+
assert.ok(killMock.mock.callCount() > 0);
214+
t.mock.timers.tick(1_500);
215+
if (lateEvent !== "none") child.emit("exit", 0, null);
216+
if (lateEvent === "close") child.emit("close", 0, null);
217+
t.mock.timers.tick(500);
218+
await nextTurn();
219+
assert.equal(completed, true, "late exit must not extend timeout grace");
220+
const result = await promise;
221+
assert.equal(result.ok, false);
222+
assert.equal(result.error, "Command timed out.");
223+
assert.equal(result.metadata?.timedOut, true);
224+
assert.equal(result.metadata?.exitCode, null);
225+
assert.equal(result.metadata?.signal, null);
226+
assert.match(result.output ?? "", /before/);
227+
assert.equal(result.metadata?.truncated, true);
228+
if (lateEvent !== "close") assert.match(result.output ?? "", /Output streams did not close/);
229+
child.emit("exit", 0, null);
230+
child.emit("close", 0, null);
231+
child.stdout.emit("data", "after");
232+
t.mock.timers.tick(10_000);
233+
assert.equal(exits, 1);
234+
assert.equal(revocations, 1);
235+
assert.deepEqual(chunks, [captured]);
236+
} finally {
237+
spawnMock.mock.restore();
238+
killMock.mock.restore();
239+
taskkillMock.mock.restore();
240+
t.mock.timers.reset();
241+
syncBuiltinESMExports();
242+
}
243+
});
244+
}
245+
246+
for (const replacement of ["deleted", "file"]) {
247+
test(`Bash falls back when cached cwd is ${replacement}`, async () => {
248+
const workspace = createTempWorkspace();
249+
const subdir = path.join(workspace, "child");
250+
fs.mkdirSync(subdir);
251+
const context = createContext(`bash-cwd-${replacement}`, workspace);
252+
// Native realpath also expands Windows 8.3 aliases (RUNNER~1 vs runneradmin).
253+
const changed = await handleBashTool({ command: "cd child" }, context);
254+
assert.equal(changed.ok, true);
255+
assert.equal(fs.realpathSync.native(String(changed.metadata?.cwd)), fs.realpathSync.native(subdir));
256+
const retained = await handleBashTool({ command: "pwd" }, context);
257+
assert.equal(fs.realpathSync.native(String(retained.metadata?.startCwd)), fs.realpathSync.native(subdir));
258+
fs.rmdirSync(subdir);
259+
if (replacement === "file") fs.writeFileSync(subdir, "not a directory");
260+
const result = await handleBashTool({ command: "pwd" }, context);
261+
assert.equal(result.ok, true);
262+
assert.equal(fs.realpathSync.native(String(result.metadata?.startCwd)), fs.realpathSync.native(workspace));
263+
});
264+
}
265+
266+
test(
267+
"Bash preserves Git Bash virtual mount cwd as a native Windows directory",
268+
{ skip: process.platform !== "win32" },
269+
async () => {
270+
const context = createContext("bash-virtual-cwd", createTempWorkspace());
271+
const changed = await handleBashTool({ command: "cd /tmp && pwd -W" }, context);
272+
assert.equal(changed.ok, true);
273+
const nativeCwd = String(changed.metadata?.cwd);
274+
assert.equal(path.isAbsolute(nativeCwd), true);
275+
assert.equal(fs.statSync(nativeCwd).isDirectory(), true);
276+
assert.equal(fs.realpathSync.native(nativeCwd), fs.realpathSync.native((changed.output ?? "").trim()));
277+
278+
const retained = await handleBashTool({ command: "pwd -W" }, context);
279+
assert.equal(retained.ok, true);
280+
assert.equal(fs.realpathSync.native(String(retained.metadata?.startCwd)), fs.realpathSync.native(nativeCwd));
281+
assert.equal(fs.realpathSync.native((retained.output ?? "").trim()), fs.realpathSync.native(nativeCwd));
282+
}
283+
);
284+
285+
test("Bash reports an invalid project root as a spawn failure", { timeout: 3_000 }, async () => {
286+
const root = path.join(createTempWorkspace(), "missing");
287+
const result = await handleBashTool({ command: "pwd" }, createContext("bash-invalid-root", root));
288+
assert.equal(result.ok, false);
289+
assert.match(result.error ?? "", /ENOENT/);
290+
assert.equal(result.metadata?.timedOut, false);
291+
});
292+
108293
test("Bash can run commands in the background and report completion output", async () => {
109294
const workspace = createTempWorkspace();
110295
let completion: BackgroundProcessCompletion | null = null;

0 commit comments

Comments
 (0)