diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index 6ee0ae932..37cb22cf2 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -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) { @@ -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 @@ -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) { diff --git a/src/services/tools/bash.test.ts b/src/services/tools/bash.test.ts index 4ea282d6f..d46478e5a 100644 --- a/src/services/tools/bash.test.ts +++ b/src/services/tools/bash.test.ts @@ -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, diff --git a/src/services/tools/bash.ts b/src/services/tools/bash.ts index 3154ee7d0..e145494ed 100644 --- a/src/services/tools/bash.ts +++ b/src/services/tools/bash.ts @@ -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 }); diff --git a/tests/ipcMain/runtimeExecuteBash.test.ts b/tests/ipcMain/runtimeExecuteBash.test.ts index 96523b2df..4f7d5288b 100644 --- a/tests/ipcMain/runtimeExecuteBash.test.ts +++ b/tests/ipcMain/runtimeExecuteBash.test.ts @@ -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 + ); } ); });