Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/services/tools/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,66 @@ fi

expect(remainingProcesses).toBe(0);
});

it("should abort quickly when command produces continuous output", async () => {
using testEnv = createTestBashTool();
const tool = testEnv.tool;

// Create AbortController to simulate user interruption
const abortController = new AbortController();

// Command that produces slow, continuous output
// The key is it keeps running, so the abort happens while reader.read() is waiting
const args: BashToolArgs = {
script: `
# Produce continuous output slowly (prevents hitting truncation limits)
for i in {1..1000}; do
echo "Output line $i"
sleep 0.1
done
`,
timeout_secs: 120,
};

// Start the command
const resultPromise = tool.execute!(args, {
...mockToolCallOptions,
abortSignal: abortController.signal,
}) as Promise<BashToolResult>;

// Wait for output to start (give it time to produce a few lines)
await new Promise((resolve) => setTimeout(resolve, 250));

// Abort the operation while it's still producing output
const abortTime = Date.now();
abortController.abort();

// Wait for the result with a timeout to detect hangs
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Test timeout - tool did not abort quickly")), 5000)
);

const result = (await Promise.race([resultPromise, timeoutPromise])) as BashToolResult;
const duration = Date.now() - abortTime;

// Command should be aborted
expect(result.success).toBe(false);
if (!result.success) {
// Error should mention abort or indicate the process was killed
const errorText = result.error.toLowerCase();
expect(
errorText.includes("abort") ||
errorText.includes("killed") ||
errorText.includes("signal") ||
result.exitCode === -1
).toBe(true);
}

// CRITICAL: Tool should return quickly after abort (< 2s)
// This is the regression test - without checking abort signal in consumeStream(),
// the tool hangs until the streams close (which can take a long time)
expect(duration).toBeLessThan(2000);
});
});

describe("SSH runtime redundant cd detection", () => {
Expand Down
33 changes: 33 additions & 0 deletions src/services/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,16 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
const reader = stream.getReader();
const decoder = new TextDecoder("utf-8");
let carry = "";

// Set up abort handler to cancel reader when abort signal fires
// This interrupts reader.read() if it's blocked, preventing hangs
const abortHandler = () => {
reader.cancel().catch(() => {
/* ignore - reader may already be closed */
});
};
abortSignal?.addEventListener("abort", abortHandler);

try {
while (true) {
if (truncationState.fileTruncated) {
Expand Down Expand Up @@ -336,6 +346,9 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
if (truncationState.fileTruncated) break;
}
} finally {
// Clean up abort listener
abortSignal?.removeEventListener("abort", abortHandler);

// Flush decoder for any trailing bytes and emit the last line (if any)
try {
const tail = decoder.decode();
Expand All @@ -358,6 +371,15 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
try {
[exitCode] = await Promise.all([execStream.exitCode, consumeStdout, consumeStderr]);
} catch (err: unknown) {
// Check if this was an abort
if (abortSignal?.aborted) {
return {
success: false,
error: "Command execution was aborted",
exitCode: -1,
wall_duration_ms: Math.round(performance.now() - startTime),
};
}
return {
success: false,
error: `Failed to execute command: ${err instanceof Error ? err.message : String(err)}`,
Expand All @@ -366,6 +388,17 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
};
}

// Check if command was aborted (exitCode will be EXIT_CODE_ABORTED = -997)
// This can happen if abort signal fired after Promise.all resolved but before we check
if (abortSignal?.aborted) {
return {
success: false,
error: "Command execution was aborted",
exitCode: -1,
wall_duration_ms: Math.round(performance.now() - startTime),
};
}

// Round to integer to preserve tokens
const wall_duration_ms = Math.round(performance.now() - startTime);

Expand Down