fix(mcp): read pipe data before waitUntilExit to prevent deadlock#292
Conversation
Move readDataToEndOfFile() calls ahead of process.waitUntilExit() in ShellTool. When a child process writes more than the kernel pipe buffer (~64 KB on macOS), the write() blocks until someone drains the pipe. If the parent calls waitUntilExit() first, nobody is draining, so both parent and child block indefinitely. Reading pipes before waiting is the standard POSIX pattern and eliminates the deadlock. Add a regression test that generates 128 KB of output (via head -c 131072 /dev/zero | base64). Without the fix the test deadlocks (confirmed via 30-second timeout kill); with the fix it completes in ~0.08 seconds.
|
ClawSweeper status: review started. I am starting a fresh review of this pull request: fix(mcp): read pipe data before waitUntilExit to prevent deadlock This is item 1/1 in the current shard. Shard 0/1. This placeholder means the worker is alive and reading the current context. I will edit this same comment with the actual review when the claws are done clicking. Crustacean status: shell secured, claws on keyboard, evidence pebbles being sorted. |
|
Maintainer proof on exact head Verdict: ready to land. The reported deadlock is real, but the original patch only fixed the stdout-only case: reading stdout and stderr sequentially can still deadlock when stderr fills while stdout remains open. I pushed Red/green evidence:
Exact-head validation:
Provenance is clear: the blocking order was introduced with the original ShellTool in |
What Problem This Solves
ShellTool.execute()callsprocess.waitUntilExit()before reading from the stdout/stderr pipes. When a child command writes more than the kernel pipe buffer (~64 KB on macOS),write()blocks until someone drains the pipe. Because the parent is stuck inwaitUntilExit(), nobody drains, and both parent and child block indefinitely.Any shell command producing moderate output (a long
ls,catof a config file, build logs) can trigger this.Call chain
The child is blocked in
write()waiting for the pipe to drain, while the parent is blocked inwaitUntilExit()waiting for the child to exit.Bug origin
Introduced in 95ecdd4 (2025-11-14) in the initial
PeekabooAgentRuntimemodule creation. Present for ~8 months.Evidence
Fix
Move
readDataToEndOfFile()calls beforeprocess.waitUntilExit(). This is the standard POSIX pattern: drain the pipe first, then wait for the child to exit.do { try process.run() - process.waitUntilExit() - + // Read pipe data BEFORE waitUntilExit to avoid deadlock. let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit()Red-green verification
Red step (without fix):
timeout 30 swift test --filter "Shell tool does not deadlock"-> exit code 124 (killed by timeout; test deadlocked as expected).Green step (with fix): same test -> passed in 0.077 seconds.
Regression test
The test generates 128 KB of output (
head -c 131072 /dev/zero | base64), exceeding the ~64 KB pipe buffer. Without the fix, the test deadlocks. With the fix, it completes in under a second.Prior art
The same
Processpipe buffer deadlock pattern has been fixed in other Swift projects:Python's
subprocessdocumentation explicitly warns against this pattern: "This will deadlock when using stdout=PIPE or stderr=PIPE and the child process generates enough output to a pipe such that it blocks waiting for the OS pipe buffer to accept more data."Real behavior proof
Command tested:
head -c 131072 /dev/zero | base64via ShellTool MCP interfaceEnvironment: macOS, Swift Testing framework, PeekabooCore test suite
Proof type: Red-green regression test
Red (without fix): Test deadlocks; killed by 30-second timeout (exit 124)
Green (with fix): Test passes in 0.077 seconds; output size >100,000 characters verified
Reproducibility: Deterministic. Any command producing >64 KB output triggers the deadlock on the unfixed code.