Skip to content

Commit 25fb385

Browse files
authored
🤖 fix: check abort signal in bash tool stream consumption (#541)
Fixes bash tool hanging when interrupted during execution by properly handling abort signals in stream consumption. ## Problem When a new message arrives while a bash command is executing, the tool should abort quickly. However, after PR #537 removed the 10ms wait workaround, the bash tool would hang if the abort signal fired while `reader.read()` was blocked waiting for data. This was especially noticeable over SSH or with commands producing continuous output. The issue: `consumeStream()` didn't listen for abort events, so when the process was killed but streams hadn't closed yet, `reader.read()` stayed blocked indefinitely. ## Solution Register an abort event listener that immediately cancels the reader when abort fires: ```typescript const abortHandler = () => reader.cancel().catch(() => {}); abortSignal?.addEventListener('abort', abortHandler); ``` This interrupts `reader.read()` mid-operation rather than checking abort before each read (which has a race condition). ## Testing - Added unit test verifying abort completes in < 2s with continuous output - All 44 bash unit tests pass - All 8 executeBash integration tests pass - All 963 unit tests pass _Generated with `cmux`_
1 parent 4a54146 commit 25fb385

File tree

2 files changed

+93
-0
lines changed

2 files changed

+93
-0
lines changed

src/services/tools/bash.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,66 @@ fi
11511151

11521152
expect(remainingProcesses).toBe(0);
11531153
});
1154+
1155+
it("should abort quickly when command produces continuous output", async () => {
1156+
using testEnv = createTestBashTool();
1157+
const tool = testEnv.tool;
1158+
1159+
// Create AbortController to simulate user interruption
1160+
const abortController = new AbortController();
1161+
1162+
// Command that produces slow, continuous output
1163+
// The key is it keeps running, so the abort happens while reader.read() is waiting
1164+
const args: BashToolArgs = {
1165+
script: `
1166+
# Produce continuous output slowly (prevents hitting truncation limits)
1167+
for i in {1..1000}; do
1168+
echo "Output line $i"
1169+
sleep 0.1
1170+
done
1171+
`,
1172+
timeout_secs: 120,
1173+
};
1174+
1175+
// Start the command
1176+
const resultPromise = tool.execute!(args, {
1177+
...mockToolCallOptions,
1178+
abortSignal: abortController.signal,
1179+
}) as Promise<BashToolResult>;
1180+
1181+
// Wait for output to start (give it time to produce a few lines)
1182+
await new Promise((resolve) => setTimeout(resolve, 250));
1183+
1184+
// Abort the operation while it's still producing output
1185+
const abortTime = Date.now();
1186+
abortController.abort();
1187+
1188+
// Wait for the result with a timeout to detect hangs
1189+
const timeoutPromise = new Promise((_, reject) =>
1190+
setTimeout(() => reject(new Error("Test timeout - tool did not abort quickly")), 5000)
1191+
);
1192+
1193+
const result = (await Promise.race([resultPromise, timeoutPromise])) as BashToolResult;
1194+
const duration = Date.now() - abortTime;
1195+
1196+
// Command should be aborted
1197+
expect(result.success).toBe(false);
1198+
if (!result.success) {
1199+
// Error should mention abort or indicate the process was killed
1200+
const errorText = result.error.toLowerCase();
1201+
expect(
1202+
errorText.includes("abort") ||
1203+
errorText.includes("killed") ||
1204+
errorText.includes("signal") ||
1205+
result.exitCode === -1
1206+
).toBe(true);
1207+
}
1208+
1209+
// CRITICAL: Tool should return quickly after abort (< 2s)
1210+
// This is the regression test - without checking abort signal in consumeStream(),
1211+
// the tool hangs until the streams close (which can take a long time)
1212+
expect(duration).toBeLessThan(2000);
1213+
});
11541214
});
11551215

11561216
describe("SSH runtime redundant cd detection", () => {

src/services/tools/bash.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,16 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
300300
const reader = stream.getReader();
301301
const decoder = new TextDecoder("utf-8");
302302
let carry = "";
303+
304+
// Set up abort handler to cancel reader when abort signal fires
305+
// This interrupts reader.read() if it's blocked, preventing hangs
306+
const abortHandler = () => {
307+
reader.cancel().catch(() => {
308+
/* ignore - reader may already be closed */
309+
});
310+
};
311+
abortSignal?.addEventListener("abort", abortHandler);
312+
303313
try {
304314
while (true) {
305315
if (truncationState.fileTruncated) {
@@ -336,6 +346,9 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
336346
if (truncationState.fileTruncated) break;
337347
}
338348
} finally {
349+
// Clean up abort listener
350+
abortSignal?.removeEventListener("abort", abortHandler);
351+
339352
// Flush decoder for any trailing bytes and emit the last line (if any)
340353
try {
341354
const tail = decoder.decode();
@@ -358,6 +371,15 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
358371
try {
359372
[exitCode] = await Promise.all([execStream.exitCode, consumeStdout, consumeStderr]);
360373
} catch (err: unknown) {
374+
// Check if this was an abort
375+
if (abortSignal?.aborted) {
376+
return {
377+
success: false,
378+
error: "Command execution was aborted",
379+
exitCode: -1,
380+
wall_duration_ms: Math.round(performance.now() - startTime),
381+
};
382+
}
361383
return {
362384
success: false,
363385
error: `Failed to execute command: ${err instanceof Error ? err.message : String(err)}`,
@@ -366,6 +388,17 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
366388
};
367389
}
368390

391+
// Check if command was aborted (exitCode will be EXIT_CODE_ABORTED = -997)
392+
// This can happen if abort signal fired after Promise.all resolved but before we check
393+
if (abortSignal?.aborted) {
394+
return {
395+
success: false,
396+
error: "Command execution was aborted",
397+
exitCode: -1,
398+
wall_duration_ms: Math.round(performance.now() - startTime),
399+
};
400+
}
401+
369402
// Round to integer to preserve tokens
370403
const wall_duration_ms = Math.round(performance.now() - startTime);
371404

0 commit comments

Comments
 (0)