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
9 changes: 6 additions & 3 deletions src/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,8 @@ export class SSHRuntime implements Runtime {
abortSignal,
});

await stream.stdin.close();
// Command doesn't use stdin - abort to close immediately without waiting
await stream.stdin.abort();
const exitCode = await stream.exitCode;

if (exitCode !== 0) {
Expand Down Expand Up @@ -999,7 +1000,8 @@ export class SSHRuntime implements Runtime {
abortSignal,
});

await checkStream.stdin.close();
// Command doesn't use stdin - abort to close immediately without waiting
await checkStream.stdin.abort();
const checkExitCode = await checkStream.exitCode;

// Handle check results
Expand Down Expand Up @@ -1072,7 +1074,8 @@ export class SSHRuntime implements Runtime {
abortSignal,
});

await stream.stdin.close();
// Command doesn't use stdin - abort to close immediately without waiting
await stream.stdin.abort();
const exitCode = await stream.exitCode;

if (exitCode !== 0) {
Expand Down
3 changes: 2 additions & 1 deletion src/services/tools/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,8 @@ describe("bash tool", () => {
const startTime = performance.now();

// cat without input should complete immediately
// This used to hang because cat would wait for stdin
// This used to hang because stdin.close() would wait for acknowledgment
// Fixed by using stdin.abort() for immediate closure
const args: BashToolArgs = {
script: "echo test | cat",
timeout_secs: 5,
Expand Down
8 changes: 5 additions & 3 deletions src/services/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,11 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => {
abortSignal.addEventListener("abort", abortListener);
}

// Close stdin immediately - we don't need to send any input
// This is critical: not closing stdin can cause the runtime to wait forever
execStream.stdin.close().catch(() => {
// Force-close stdin immediately - we don't need to send any input
// Use abort() instead of close() for immediate, synchronous closure
// close() is async and waits for acknowledgment, which can hang over SSH
// abort() immediately marks stream as errored and releases locks
execStream.stdin.abort().catch(() => {
// Ignore errors - stream might already be closed
});

Expand Down
77 changes: 77 additions & 0 deletions tests/ipcMain/runtimeExecuteBash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,83 @@ describeIntegration("Runtime Bash Execution", () => {
},
type === "ssh" ? TEST_TIMEOUT_SSH_MS : TEST_TIMEOUT_LOCAL_MS
);

test.concurrent(
"should not hang on commands that read stdin without input",
async () => {
const env = await createTestEnvironment();
const tempGitRepo = await createTempGitRepo();

try {
// Setup provider
await setupProviders(env.mockIpcRenderer, {
anthropic: {
apiKey: getApiKey("ANTHROPIC_API_KEY"),
},
});

// Create workspace
const branchName = generateBranchName("bash-stdin");
const runtimeConfig = getRuntimeConfig(branchName);
const { workspaceId, cleanup } = await createWorkspaceWithInit(
env,
tempGitRepo,
branchName,
runtimeConfig,
true, // waitForInit
type === "ssh"
);

try {
// Create a test file with JSON content
await sendMessageAndWait(
env,
workspaceId,
'Run bash: echo \'{"test": "data"}\' > /tmp/test.json',
HAIKU_MODEL,
BASH_ONLY
);

// Test command that pipes file through stdin-reading command (jq)
// This would hang forever if stdin.close() was used instead of stdin.abort()
// Regression test for: https://github.com/coder/cmux/issues/503
const startTime = Date.now();
const events = await sendMessageAndWait(
env,
workspaceId,
"Run bash with 3s timeout: cat /tmp/test.json | jq '.'",
HAIKU_MODEL,
BASH_ONLY,
15000 // 15s max wait - should complete in < 5s
);
const duration = Date.now() - startTime;

// Extract response text
const responseText = extractTextFromEvents(events);

// Verify command completed successfully (not timeout)
expect(responseText).toContain("test");
expect(responseText).toContain("data");

// Verify command completed quickly (not hanging until timeout)
// Should complete in under 5 seconds for SSH, 3 seconds for local
const maxDuration = type === "ssh" ? 8000 : 5000;
expect(duration).toBeLessThan(maxDuration);

// Verify bash tool was called
const toolCallStarts = events.filter((e: any) => e.type === "tool-call-start");
const bashCalls = toolCallStarts.filter((e: any) => e.toolName === "bash");
expect(bashCalls.length).toBeGreaterThan(0);
} finally {
await cleanup();
}
} finally {
await cleanupTempGitRepo(tempGitRepo);
await cleanupTestEnvironment(env);
}
},
type === "ssh" ? TEST_TIMEOUT_SSH_MS : TEST_TIMEOUT_LOCAL_MS
);
}
);
});