Skip to content

Conversation

@ammar-agent
Copy link
Collaborator

@ammar-agent ammar-agent commented Nov 8, 2025

The bash tool had an arbitrary 10ms wait after process exit, hoping readline would finish draining buffered output. This was non-deterministic and caused race conditions over SSH.

Replaced Node.js Readable + readline conversion with direct Web Streams consumption. Now we await Promise.all([exitCode, consumeStdout, consumeStderr]) — deterministic completion, no timing assumptions.

Uses TextDecoder streaming mode to preserve UTF-8 boundaries across chunk splits and handles both \n and \r\n line endings correctly.

Generated with cmux

## Problem

Commands like `grep -RIn ... | head -n 200` would hang over SSH runtime,
not respecting timeouts. Previous fix (PR #504) using stdin.abort() addressed
a symptom but not the root cause.

## Root Cause

bash.ts waited for THREE conditions before finalizing:
- exitCode !== null
- stdoutEnded (stream 'close' event)
- stderrEnded (stream 'close' event)

Over SSH with ControlMaster multiplexing, stream 'close' events don't
propagate reliably. The 50ms grace period fallback was insufficient, allowing
hangs on commands that take longer or during high SSH latency.

## Solution

**Single source of truth: process exit triggers all cleanup**

Created DisposableProcess wrapper that:
1. Registers cleanup callbacks (stream destruction, etc.)
2. Auto-executes cleanup when process exits
3. Makes it IMPOSSIBLE to wait for stream events that never arrive

Key changes:
- SSHRuntime.exec(): Use DisposableProcess, cancel streams on exit
- LocalRuntime.exec(): Same pattern for consistency
- bash.ts: Simplified from complex multi-condition wait to simple `await exitCode`

## Benefits

- ✅ Fixes the hang completely
- ✅ Simpler: Removed ~130 LoC of complex finalization logic
- ✅ Faster: No 50ms grace period on every command
- ✅ Correct by construction: Process lifetime bounds all resources

## Impact

**Invariant**: When exitCode resolves, streams are already destroyed.

**Proof**: DisposableProcess registers cleanup on process 'close' event,
which fires before exitCode promise resolves. Therefore: exitCode resolved ⟹
streams destroyed. No race conditions possible.

## Testing

- All 955 unit tests pass
- Added regression test for grep|head pattern
- bash.ts tests validate no hangs
- LocalRuntime/SSHRuntime tests pass

---
_Generated with `cmux`_
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

When a process exits via signal (e.g., segfault, kill $$), exitCode is null
but signalCode is set. Check both before calling kill() to avoid ESRCH errors.

Also wrap kill() in try/catch as an additional safeguard for TOCTOU race
where process exits between check and kill call.
The previous approach cancelled streams in DisposableProcess cleanup, but this
was too early - bash.ts was still reading from them. Streams close naturally
when the process exits, so no explicit cancellation is needed.

The key fix is that bash.ts now awaits exitCode (which resolves after process
exits), then immediately cleans up readline interfaces and Node streams. This
prevents waiting for stream 'close' events that don't propagate over SSH.
DisposableProcess should NOT auto-call dispose() on process 'close' event.
This was running cleanup before bash.ts finished reading streams.

Dispose is only called explicitly via timeout/abort handlers. Process streams
close naturally when process exits, so no forced cleanup is needed.
After process exits, readline interfaces may still have buffered data to
process. Add a small 10ms delay before destroying streams to allow readline
to finish processing. This prevents empty output in tests.
The previous approach used a 10ms delay to allow readline interfaces to
process buffered data after process exit. This was a timing-based workaround
that wasn't truly correct-by-construction.

## Problem

When a process exits, there's a race between:
1. Process 'exit' event
2. Readline 'close' events (stdout and stderr)

For commands like 'grep | head', the stdout stream closes early (when head
exits), which triggers readline 'close' BEFORE the process 'exit' event.
The 10ms delay hoped to bridge this gap, but it's not deterministic.

## Solution

Wait for all three events concurrently using Promise.all():
- exitCode (process exit)
- stdoutClosed (readline finished processing stdout)
- stderrClosed (readline finished processing stderr)

This ensures we never return results before readline has finished processing
all buffered data, without relying on arbitrary timing delays.

## Testing

- All 955 unit tests pass
- All bash integration tests pass, including the grep|head regression test
- Both local and SSH runtime tests pass
- No timing-based workarounds

---
_Generated with `cmux`_
Extracted three helper functions to improve clarity and reduce duplication:

1. **validateScript()** - Centralizes all input validation (empty script, sleep
   commands, redundant cd). Returns error result or null.

2. **createLineHandler()** - Unified line processing logic for both stdout and
   stderr. Eliminates 84 lines of duplicate code. Enforces truncation limits
   consistently.

3. **formatResult()** - Handles all result formatting based on exit code and
   truncation state. Simplifies the main execution flow.

## Benefits

- **-80 LoC of duplication removed** (stdout/stderr handlers)
- **Clearer separation of concerns** - validation, processing, formatting
- **Easier to test** - helpers are pure functions
- **More maintainable** - single source of truth for line processing logic

## Testing

- All 955 unit tests pass
- Static checks pass (lint + typecheck + format)
- No behavior changes, pure refactor

---
_Generated with `cmux`_
- Replace readline + Node streams with direct Web Streams readers
- Read stdout/stderr in parallel; await exitCode + drains via Promise.all
- Deterministic line splitting with TextDecoder streaming (handles UTF-8 boundaries)
- Early cancel on hard limits; no arbitrary sleeps
- Simplifies code and resolves SSH close-event race

_Generated with `cmux`_
@ammario ammario merged commit 4a54146 into main Nov 9, 2025
13 checks passed
@ammario ammario deleted the bash-hang branch November 9, 2025 16:26
ammario pushed a commit that referenced this pull request Nov 9, 2025
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`_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants