Skip to content

fix(mcp): wait for room on a non-blocking stdout or stderr in tablepro-mcp, and bound every pipe test that could hang - #3142

Merged
datlechin merged 2 commits into
mainfrom
fix/mcp-nonblocking-stdio-writes
Sep 26, 2026
Merged

datlechin merged 2 commits into
mainfrom
fix/mcp-nonblocking-stdio-writes

Conversation

@datlechin

@datlechin datlechin commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Follow-ups to #3137, from its adversarial review. #3137 was merged while this round was in progress, so these changes are a new PR on top of main. All five review findings held when checked against the code.

What was wrong

  1. tablepro-mcp spun at 100% CPU on a non-blocking stdout. BridgeStdout wrote with FileHandle.write(contentsOf:), which retries EAGAIN without waiting. Stdout is non-blocking whenever it shares an open file description with a non-blocking stdin: a terminal opened once and put on 0, 1 and 2, or a host that passes one socket for both.
  2. Two more writes in tablepro-mcp crashed on the same EAGAIN. MCPStderrBridgeLogger and the startup error line (emitFatalJsonRpcError) used the legacy FileHandle.write(_:), which raises instead of retrying. All three writes are in v0.75.0.
  3. Two fix(mcp): keep tablepro-mcp reading a non-blocking stdin and drain every pipe without O_NONBLOCK #3137 tests could still hang or leak.
    • DescriptorReadTests.nextBytesWaitsOnANonBlockingPipe was synchronous and blocked by design, so a nextBytes that treats end of file as "wait again" hung the run.
    • ProcessNativeDumpRunnerTests.lateStderrCallbackLeavesThePipeAlone started a /bin/sh loop that waits for a gate file. If the test failed before creating the gate, the loop outlived the test host.
  4. fix(mcp): keep tablepro-mcp reading a non-blocking stdin and drain every pipe without O_NONBLOCK #3137's body overstated some evidence. Its mutant runs used an earlier revision of the tests, the iOS test job was skipped rather than passed, and the local counts were 87 and 85, not 86 and 84.
  5. 16 narrating /// lines from fix(export): give a dump the TLS options its own client tool takes #3049 were left in two files fix(mcp): keep tablepro-mcp reading a non-blocking stdin and drain every pipe without O_NONBLOCK #3137 edited: CLIToolVersionProbe (9) and ProcessNativeDumpRunnerTests (7).

Measured with a standalone program that writes 256 KiB to a full non-blocking pipe whose reader starts after 1s:

Write CPU during the 1s stall Outcome
FileHandle.write(contentsOf:) (v0.75.0 BridgeStdout) 1,002 ms all bytes, after spinning
FileHandle.write(_:) (v0.75.0 logger and startup line) n/a exit 134, NSFileHandleOperationException ... writeData: Resource temporarily unavailable
DescriptorWrite.allBytes (this PR) 0.39 ms all bytes

What changed

Tests

Helpers

  • BoundedCall runs a call against a 10s deadline and resumes once, from whichever finishes first. It never waits for the call itself, so a call that spins or blocks forever fails the test at the deadline instead of hanging the run. resultOnItsOwnThread runs a synchronous call on its own thread.
  • HeldOpenWriter is rebuilt on it. It used to close the writer at the deadline and then wait for the call to return, which only helps a call that returns once the writer closes. A call that spins at end of file never returned.
  • BackgroundPipeReader reads a pipe to end of file on its own thread after a pause, behind the same deadline.

New tests

  • DescriptorWriteTests/writeWaitsForRoomWithoutSpinning: a 256 KiB write runs on its own thread into a non-blocking pipe whose reader starts after 1s, so the write fills the pipe and has to wait. The test checks that every byte arrives and that the writing thread used under 50 ms of CPU, read from CLOCK_THREAD_CPUTIME_ID.
  • DescriptorWriteTests/writeWithNoReaderThrows: a pipe with no reader is a thrown EPIPE, not a wait.
  • BridgeStdoutTests/nonBlockingStdoutGetsTheWholeLine: a 192 KiB line reaches a non-blocking stdout whole, with its newline, and nothing is logged.
  • BridgeStdoutTests/unwritableStdoutIsLogged: a stdout with no reader logs one error through the bridge's logger.
  • MCPStderrBridgeLoggerTests/fullNonBlockingStderrGetsTheLine: a line logged to a full non-blocking pipe arrives once the reader drains it.

Changed tests

  • lateStderrCallbackLeavesThePipeAlone: defer { runner.cancel() } right after start(). The script's wait is bounded to 1,000 polls and exits 9 if the gate never appeared, so a missing gate fails the exit code check instead of running on. The late callback now runs behind the deadline too.
  • nextBytesWaitsOnANonBlockingPipe is async, and each nextBytes call runs on its own thread behind the deadline. The test ends at the first call that does not return.
  • The same class of hang was in more places in these files, so each got the same treatment. Each was a synchronous call made while a writer was still open, or a read that could wait on a stream that never ends:
    • DescriptorReadTests/emptyNonBlockingPipeThrows
    • PipeReaderTests/callbackWithNothingToReadIsHarmless, which also ends at its first failed call now. Before that, a mutant held it to the 1-minute limit: its later reader.stop() takes the lock the stuck callback holds.
    • PipeReaderTests/stopAtEndOfFileWaitsForTheWriter
    • The late callback in PipeReaderTests/dispatchedCallbackAfterDrainReadsNothing
    • Both BridgeStdinTests stream reads. Under the end-of-file mutant below, nonBlockingStdinReadsUntilEndOfFile used to report no result at all: the run finished in 83s without it.
  • stopsAtTheLimit now closes its writer before reading, and readinessFollowsThePipe reads with a limit of 1. Both changes only remove a way to block. What they assert is unchanged, so no mutant was run for them.

The edits that turn them red

Each row is an xcodebuild test-without-building run of the committed tests against a build with the named edit applied, restored from git afterwards. Three builds each carried several of these edits, chosen so that every test depends on only one edit in its build.

Edit Test Result
DescriptorWrite retries EAGAIN without waiting writeWaitsForRoomWithoutSpinning red: 934 ms of CPU against the 50 ms bound
DescriptorWrite throws EAGAIN writeWaitsForRoomWithoutSpinning red: EAGAIN thrown, 65,536 of 262,144 bytes arrived
same nonBlockingStdoutGetsTheWholeLine red: 65,536 of 196,609 bytes, one error logged
DescriptorWrite waits in poll on EPIPE writeWithNoReaderThrows red at the 10s deadline
BridgeStdout back to v0.75.0 (write(contentsOf:), error written to stderr) unwritableStdoutIsLogged red: the logger got nothing
same nonBlockingStdoutGetsTheWholeLine green: the v0.75.0 write spins but finishes, which is why the CPU check sits on DescriptorWrite
MCPStderrBridgeLogger back to v0.75.0's FileHandle.write(_:) fullNonBlockingStderrGetsTheLine red: the test host aborted
nextBytes waits again at end of file (the review's mutant) nextBytesWaitsOnANonBlockingPipe, nonBlockingStdinReadsUntilEndOfFile red at 10s, nothing hung
availableBytes waits in poll on EAGAIN emptyNonBlockingPipeThrows, callbackWithNothingToReadIsHarmless red at 10s
PipeReader reads before it checks for a consumer dispatchedCallbackAfterDrainReadsNothing red: the late callback took late
PipeReader.stopAtEndOfFile keeps reading at end of file stopAtEndOfFileWaitsForTheWriter red at 10s
ProcessNativeDumpRunner back to #3049's availableData handler and O_NONBLOCK drain lateStderrCallbackLeavesThePipeAlone red: O_NONBLOCK → 4, and late was consumed
bufferedBytes without its poll check bufferedBytesTakeWhatThePipeHolds, dispatchedCallbackAfterDrainReadsNothing, stopWaitsForDeliveryInProgress, lateStderrCallbackLeavesThePipeAlone each red at 10s, none hung

The dump test's child was also checked with a standalone build of the committed runner and the test's exact script. With the deferred cancel(), no /bin/sh was left after the body threw. Without it, which is what a crashed test host looks like, the shell exited on its own after 16s. With the gate never created, the result was exit code 9.

CHANGELOG

Verification

  • verify.sh test on DescriptorReadTests, DescriptorWriteTests, PipeReaderTests, ProcessNativeDumpRunnerTests, BridgeStdinTests, BridgeStdoutTests, MCPStderrBridgeLoggerTests, CLIToolVersionProbeTests, MCPBridgeIntegrationTests, PreConnectHookRunnerTests and SupervisedProcessRunnerTests, at a load average of about 80: the result bundle holds 56 cases, and all 56 passed. The wrapper's log count said 55 executed, because one result line was missing from the log.
  • MCPStdioMessageTransportTests is left out of that list. In an earlier local run the suite stalled for 17 minutes until the host was killed, and 3 of its 12 cases were then reported failed. A sample of the stalled host showed the Copilot language server's LSPTransport.runReadLoop reading through FileHandle.bytes, which holds Foundation's single AsyncBytes queue, and all three failed cases read stdin.bytes. This PR does not touch that class, and only its tests construct it. CI runs it.
  • The build log shows DescriptorWrite.swift and MCPBridgeLogger.swift compiling in the mcp-server target.
  • swiftlint lint --strict on the 15 changed Swift files: 0 violations. check-test-suite-attributes.py and check-log-privacy.py pass.

CI

macOS Tests run 36205384954 at 00a3f9b passed: Detect changes, Package Tests, Build for testing, Unit tests, UI tests 0/3, 1/3 and 2/3, and the macOS Tests Gate. Every touched suite ran in the Unit tests job and passed:

Suite Cases
DescriptorReadTests 8/8
DescriptorWriteTests 2/2
PipeReaderTests 6/6
ProcessNativeDumpRunnerTests 4/4
BridgeStdinTests 2/2
BridgeStdoutTests 2/2
MCPStderrBridgeLoggerTests 1/1
CLIToolVersionProbeTests 4/4
PreConnectHookRunnerTests 1/1
SupervisedProcessRunnerTests 8/8
MCPBridgeIntegrationTests 18/18
MCPStdioMessageTransportTests 12/12

The CPU test took 1.07s on the 3 vCPU runner. Repo Hygiene and Docs passed. The iOS Tests Gate passed, and Run iOS Tests itself was skipped by its change detection.

Not covered

  • MCPStdioMessageTransport still writes with FileHandle.write(contentsOf:) and reads with stdin.bytes. Only its tests construct it.
  • tablepro-imagerender writes its PNG with the legacy FileHandle.standardOutput.write(_:). The app spawns it with a blocking pipe, so the non-blocking case does not arise.
  • AgentCLIProcess.run and ProcessSupervisedRunner, as listed in fix(mcp): keep tablepro-mcp reading a non-blocking stdin and drain every pipe without O_NONBLOCK #3137.
  • No UI flow changed, so there is no TableProUITests automation. CI runs the UI suites.

@datlechin
datlechin force-pushed the fix/mcp-nonblocking-stdio-writes branch from 9619b9a to 00a3f9b Compare September 26, 2026 00:34
@datlechin
datlechin marked this pull request as ready for review September 26, 2026 03:11
@datlechin
datlechin merged commit 32ced10 into main Sep 26, 2026
13 checks passed
@datlechin
datlechin deleted the fix/mcp-nonblocking-stdio-writes branch September 26, 2026 03:11
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.

1 participant