Skip to content

Commit cb523ed

Browse files
authored
🤖 fix: interrupt stream with pending bash tool near-instantly (#478)
## Problem When `InterruptStream` (Ctrl+C) was called during a pending bash tool execution, the stream would hang indefinitely. This was especially problematic for SSH workspaces where long-running commands could block the UI for minutes. ### Root Cause The AI SDK's `for await` loop blocks waiting for the current async iterator operation to complete. When a bash tool is executing, the iterator doesn't yield until `tool.execute()` returns. Even though we call `abortController.abort()`, the bash tool's abort listener was effectively empty: ```typescript abortListener = () => { if (!resolved) { // Runtime handles the actual cancellation ← BUG: Does nothing! // We just need to clean up our side } }; ``` This caused: 1. `cancelStreamSafely()` calls `abortController.abort()` 2. Abort signal propagates but bash tool doesn't resolve 3. AI SDK iterator stays blocked waiting for tool to return 4. `await streamInfo.processingPromise` hangs indefinitely 5. IPC call never returns, UI frozen For SSH workspaces, the SSH runtime's abort handler only kills the local SSH client - the remote command keeps running, making this worse. ## Solution Make the bash tool **actively resolve its promise** when aborted instead of passively waiting: ```typescript abortListener = () => { if (!resolved) { // Immediately resolve with abort error to unblock AI SDK stream teardown(); resolveOnce({ success: false, error: "Command execution was aborted", exitCode: -2, wall_duration_ms: Math.round(performance.now() - startTime), }); } }; ``` This unblocks the chain: - Tool promise resolves immediately with error - AI SDK iterator yields - Stream processing loop detects abort and exits - `processingPromise` resolves - IPC returns instantly ## Testing Added integration test that verifies interrupt completes in < 2 seconds even when a `sleep 60` command is running: ```typescript test("should interrupt stream with pending bash tool call near-instantly", async () => { // Start sleep 60 void sendMessage(workspaceId, "Run this bash command: sleep 60"); await collector.waitForEvent("tool-call-start"); // Measure interrupt time const start = performance.now(); await interruptStream(workspaceId); const duration = performance.now() - start; expect(duration).toBeLessThan(2000); // Must be near-instant }); ``` _Generated with `cmux`_
1 parent 8b6d39a commit cb523ed

File tree

2 files changed

+71
-5
lines changed

2 files changed

+71
-5
lines changed

src/services/tools/bash.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
112112
let exitCode: number | null = null;
113113
let resolved = false;
114114

115+
// Forward-declare teardown function that will be defined below
116+
// eslint-disable-next-line prefer-const
117+
let teardown: () => void;
118+
115119
// Helper to resolve once
116120
const resolveOnce = (result: BashToolResult) => {
117121
if (!resolved) {
@@ -124,13 +128,20 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
124128
}
125129
};
126130

127-
// Set up abort signal listener - cancellation is handled by runtime
131+
// Set up abort signal listener - immediately resolve on abort
128132
let abortListener: (() => void) | null = null;
129133
if (abortSignal) {
130134
abortListener = () => {
131135
if (!resolved) {
132-
// Runtime handles the actual cancellation
133-
// We just need to clean up our side
136+
// Immediately resolve with abort error to unblock AI SDK stream
137+
// The runtime will handle killing the actual process
138+
teardown();
139+
resolveOnce({
140+
success: false,
141+
error: "Command execution was aborted",
142+
exitCode: -2,
143+
wall_duration_ms: Math.round(performance.now() - startTime),
144+
});
134145
}
135146
};
136147
abortSignal.addEventListener("abort", abortListener);
@@ -163,8 +174,8 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
163174
// eslint-disable-next-line prefer-const
164175
let finalize: () => void;
165176

166-
// Helper to tear down streams and readline interfaces
167-
const teardown = () => {
177+
// Define teardown (already declared above)
178+
teardown = () => {
168179
stdoutReader.close();
169180
stderrReader.close();
170181
stdoutNodeStream.destroy();

tests/ipcMain/sendMessage.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,61 @@ describeIntegration("IpcMain sendMessage integration tests", () => {
133133
15000
134134
);
135135

136+
test.concurrent(
137+
"should interrupt stream with pending bash tool call near-instantly",
138+
async () => {
139+
// Setup test environment
140+
const { env, workspaceId, cleanup } = await setupWorkspace(provider);
141+
try {
142+
// Ask the model to run a long-running bash command
143+
// Use explicit instruction to ensure tool call happens
144+
const message = "Use the bash tool to run: sleep 60";
145+
void sendMessageWithModel(env.mockIpcRenderer, workspaceId, message, provider, model);
146+
147+
// Wait for stream to start (more reliable than waiting for tool-call-start)
148+
const collector = createEventCollector(env.sentEvents, workspaceId);
149+
await collector.waitForEvent("stream-start", 10000);
150+
151+
// Give model time to start calling the tool (sleep command should be in progress)
152+
// This ensures we're actually interrupting a running command
153+
await new Promise((resolve) => setTimeout(resolve, 2000));
154+
155+
// Record interrupt time
156+
const interruptStartTime = performance.now();
157+
158+
// Interrupt the stream
159+
const interruptResult = await env.mockIpcRenderer.invoke(
160+
IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM,
161+
workspaceId
162+
);
163+
164+
const interruptDuration = performance.now() - interruptStartTime;
165+
166+
// Should succeed
167+
expect(interruptResult.success).toBe(true);
168+
169+
// Interrupt should complete near-instantly (< 2 seconds)
170+
// This validates that we don't wait for the sleep 60 command to finish
171+
expect(interruptDuration).toBeLessThan(2000);
172+
173+
// Wait for abort event
174+
const abortOrEndReceived = await waitFor(() => {
175+
collector.collect();
176+
const hasAbort = collector
177+
.getEvents()
178+
.some((e) => "type" in e && e.type === "stream-abort");
179+
const hasEnd = collector.hasStreamEnd();
180+
return hasAbort || hasEnd;
181+
}, 5000);
182+
183+
expect(abortOrEndReceived).toBe(true);
184+
} finally {
185+
await cleanup();
186+
}
187+
},
188+
25000
189+
);
190+
136191
test.concurrent(
137192
"should include tokens and timestamp in delta events",
138193
async () => {

0 commit comments

Comments
 (0)