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
Conversation
…o-mcp, and bound every pipe test that could hang
…bound the stdin stream reads
datlechin
force-pushed
the
fix/mcp-nonblocking-stdio-writes
branch
from
September 26, 2026 00:34
9619b9a to
00a3f9b
Compare
datlechin
marked this pull request as ready for review
September 26, 2026 03:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
tablepro-mcpspun at 100% CPU on a non-blocking stdout.BridgeStdoutwrote withFileHandle.write(contentsOf:), which retriesEAGAINwithout 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.tablepro-mcpcrashed on the sameEAGAIN.MCPStderrBridgeLoggerand the startup error line (emitFatalJsonRpcError) used the legacyFileHandle.write(_:), which raises instead of retrying. All three writes are in v0.75.0.DescriptorReadTests.nextBytesWaitsOnANonBlockingPipewas synchronous and blocked by design, so anextBytesthat treats end of file as "wait again" hung the run.ProcessNativeDumpRunnerTests.lateStderrCallbackLeavesThePipeAlonestarted a/bin/shloop that waits for a gate file. If the test failed before creating the gate, the loop outlived the test host.///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) andProcessNativeDumpRunnerTests(7).Measured with a standalone program that writes 256 KiB to a full non-blocking pipe whose reader starts after 1s:
FileHandle.write(contentsOf:)(v0.75.0BridgeStdout)FileHandle.write(_:)(v0.75.0 logger and startup line)NSFileHandleOperationException ... writeData: Resource temporarily unavailableDescriptorWrite.allBytes(this PR)What changed
DescriptorWrite.allBytes(_:to:), the write-side mirror ofDescriptorRead.nextBytes. It writes withwrite(2)until every byte is out. OnEAGAINit waits inpoll(POLLOUT)with no timeout and writes the rest,EINTRis retried, and any other error is thrown. It compiles intotablepro-mcpas well as the app.BridgeStdoutwrites through it. It now takes the bridge's logger, and a failed write is logged there ("Writing stdout failed") instead of going straight to stderr.MCPStderrBridgeLoggerandemitFatalJsonRpcErrorwrite through it too. The logger takes an internalinit(descriptor:)so a test can hand it a pipe;init()still writes to stderr.CLIToolVersionProbeandProcessNativeDumpRunnerTests. The measurement one of them carried: a harness mirroring the dump runner captured nothing in 2 of 300 runs of a tool that exits at once (Backup dump fails - Error Message - unknown variable 'ssl-mode=PREFERRED' #3046). The older doc comments inProcessNativeDumpRunner(feat(export): DuckDB backup, a table picker and multi-database dumps (#2485) #2649) andSupervisedProcessRunner(feat(connections): reach a database through a tunnel command #2638) are left alone: they shipped long ago and are outside this follow-up.Tests
Helpers
BoundedCallruns 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.resultOnItsOwnThreadruns a synchronous call on its own thread.HeldOpenWriteris 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.BackgroundPipeReaderreads 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 fromCLOCK_THREAD_CPUTIME_ID.DescriptorWriteTests/writeWithNoReaderThrows: a pipe with no reader is a thrownEPIPE, 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 afterstart(). 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.nextBytesWaitsOnANonBlockingPipeis async, and eachnextBytescall runs on its own thread behind the deadline. The test ends at the first call that does not return.DescriptorReadTests/emptyNonBlockingPipeThrowsPipeReaderTests/callbackWithNothingToReadIsHarmless, which also ends at its first failed call now. Before that, a mutant held it to the 1-minute limit: its laterreader.stop()takes the lock the stuck callback holds.PipeReaderTests/stopAtEndOfFileWaitsForTheWriterPipeReaderTests/dispatchedCallbackAfterDrainReadsNothingBridgeStdinTestsstream reads. Under the end-of-file mutant below,nonBlockingStdinReadsUntilEndOfFileused to report no result at all: the run finished in 83s without it.stopsAtTheLimitnow closes its writer before reading, andreadinessFollowsThePipereads 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-buildingrun 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.DescriptorWriteretriesEAGAINwithout waitingwriteWaitsForRoomWithoutSpinningDescriptorWritethrowsEAGAINwriteWaitsForRoomWithoutSpinningEAGAINthrown, 65,536 of 262,144 bytes arrivednonBlockingStdoutGetsTheWholeLineDescriptorWritewaits inpollonEPIPEwriteWithNoReaderThrowsBridgeStdoutback to v0.75.0 (write(contentsOf:), error written to stderr)unwritableStdoutIsLoggednonBlockingStdoutGetsTheWholeLineDescriptorWriteMCPStderrBridgeLoggerback to v0.75.0'sFileHandle.write(_:)fullNonBlockingStderrGetsTheLinenextByteswaits again at end of file (the review's mutant)nextBytesWaitsOnANonBlockingPipe,nonBlockingStdinReadsUntilEndOfFileavailableByteswaits inpollonEAGAINemptyNonBlockingPipeThrows,callbackWithNothingToReadIsHarmlessPipeReaderreads before it checks for a consumerdispatchedCallbackAfterDrainReadsNothinglatePipeReader.stopAtEndOfFilekeeps reading at end of filestopAtEndOfFileWaitsForTheWriterProcessNativeDumpRunnerback to #3049'savailableDatahandler andO_NONBLOCKdrainlateStderrCallbackLeavesThePipeAloneO_NONBLOCK → 4, andlatewas consumedbufferedByteswithout itspollcheckbufferedBytesTakeWhatThePipeHolds,dispatchedCallbackAfterDrainReadsNothing,stopWaitsForDeliveryInProgress,lateStderrCallbackLeavesThePipeAloneThe 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/shwas 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
tablepro-mcpusing a full CPU core, or crashing, when its standard output or error was non-blocking." All three writes shipped in v0.75.0, as the table above shows.Verification
verify.sh teston 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.MCPStdioMessageTransportTestsis 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'sLSPTransport.runReadLoopreading throughFileHandle.bytes, which holds Foundation's single AsyncBytes queue, and all three failed cases readstdin.bytes. This PR does not touch that class, and only its tests construct it. CI runs it.DescriptorWrite.swiftandMCPBridgeLogger.swiftcompiling in themcp-servertarget.swiftlint lint --stricton the 15 changed Swift files: 0 violations.check-test-suite-attributes.pyandcheck-log-privacy.pypass.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:
The CPU test took 1.07s on the 3 vCPU runner. Repo Hygiene and Docs passed. The iOS Tests Gate passed, and
Run iOS Testsitself was skipped by its change detection.Not covered
MCPStdioMessageTransportstill writes withFileHandle.write(contentsOf:)and reads withstdin.bytes. Only its tests construct it.tablepro-imagerenderwrites its PNG with the legacyFileHandle.standardOutput.write(_:). The app spawns it with a blocking pipe, so the non-blocking case does not arise.AgentCLIProcess.runandProcessSupervisedRunner, as listed in fix(mcp): keep tablepro-mcp reading a non-blocking stdin and drain every pipe without O_NONBLOCK #3137.TableProUITestsautomation. CI runs the UI suites.