fix(connections): read every subprocess pipe through one reader that cannot raise or read after it stops - #3133
Merged
Merged
Conversation
…cannot raise or read after it stops
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.
The
Unit testsjob of run 36150062875 (attempt 1, PR #3130) aborted the test host withNSFileHandleOperationException: -[NSConcreteFileHandle availableData]: unknown error, raised inside the stderrreadabilityHandlerofProcessNativeDumpRunner. XCTest pinned it onAppSettingsJsonExtractorTests/testNpgsql()only because that case was running when the process died. Attempt 2 passed. The same handler runs for every backup and restore driven bymysqldump,pg_dumpand the rest, so the app could die the same way.What was wrong
The xcresult's
StandardOutputAndStandardError.txthas the whole sequence, 52ms afterProcessNativeDumpRunnerTestspassed:Read failure 35 is
EAGAIN, which a read only gets from a non-blocking descriptor. The one thing that made this pipe non-blocking wasdrainStderr, added by #3049 (unreleased): the termination handler clearedreadabilityHandler, setO_NONBLOCKon the read end and read it empty understderrLock. Clearing the handler cancels the dispatch source but does not recall a callback Foundation has already dispatched. That callback waited onstderrLockfor the drain, then calledavailableDataon a pipe that was now empty, non-blocking, and still held open by a writer (the test's surviving grandchild, or any child the app is spawning at that moment).availableDataturns every failed read into an Objective-C exception, and one unwinding through Swift ends the process. The "unknown error" iserrnoclobbered by Foundation's own log call before it built the exception.Two faults, then: a read API that raises instead of reporting, and a teardown that lets a late callback read a descriptor the owner has already drained.
LSPTransport.stop()has the second one in its other form: it cleared the stderr handler and then closed the handle, so a callback already dispatched reads a closed handle, which raises too.FileHandle.read(upToCount:)throws instead of raising, but it cannot stand in foravailableData. Measured on a pipe: with 6 bytes waiting and the writer open,read(upToCount: 65_536)blocked until killed, because it waits for the whole count or end of file; on a non-blocking pipe it read the 6 bytes, hitEAGAIN, threw, and discarded them.What changed
DescriptorRead(Core/Process): oneread(2)of whatever has arrived,EINTRretried, empty at end of file, a failure thrown asPOSIXError.hasInputWithoutWaitingaskspoll(2)whether a read would return at once, so a drain never has to setO_NONBLOCKon a descriptor another reader shares.PipeReader(Core/Process): owns a read end'sreadabilityHandler. Every read happens under one lock and only while a consumer is installed. Both ways of stopping clear the handler and then remove the consumer under that lock, so once they return nothing is reading and nothing will: a callback Foundation dispatched earlier finds no consumer and returns without touching the descriptor.stop(drainingUpTo:)first hands over what the pipe holds, up to a limit, without waiting on a writer.stopAtEndOfFile()reads until every writer has closed. A read error is logged and ends reading;EAGAINis a callback with nothing to read and keeps it going; end of file clears the handler, whereavailableDataleft Foundation calling back with empty data until someone cleared it.ProcessNativeDumpRunner:stop(drainingUpTo: stderrByteCap), the bounded drain it already had, now withoutO_NONBLOCK.drainStderrand its duplicated doc paragraph are gone.ProcessSupervisedRunner:stopAtEndOfFile(), which is whatreadToEnd()did.ingestLockis gone; the reader's lock gives the fix(connections): keep a helper process last stderr line when it exits right after writing it #2613 guarantee that no line reaches the stream afterfinishcloses it.PreConnectHookRunner:stop(drainingUpTo: pipeCapacity). It had no drain, so a script that wrote its reason and exited at once could come back asPre-connect script failed with exit code 3and no message.LSPTransport.stop(): stops the reader before closing the handle.BridgeStdin(thetablepro-mcphelper): reads stdin throughDescriptorRead, and a failed read is logged and ends the stream instead of aborting the helper.DescriptorRead.swiftjoins themcp-servertarget inproject.yml.MCPBridgeIntegrationTests' harness had the same handler shape in the test host, and now usesPipeReader.Why not the other shapes
availableDataand only fencing the late callback would fix this ordering and leave the next closed or non-blocking descriptor fatal. Replacing only the read would leaveLSPTransportclosing a handle a callback may still be reading, which is exactly the descriptor-reuse hazard a rawread(2)would otherwise walk into.DispatchIOstream channels close the descriptor safely, but give no "take what is buffered now and stop" for a pipe a grandchild keeps open, which is the drain the dump runner needs.ProcessSupervisedRunnerkeeps reading to end of file. A bounded drain would stop itsterminationfrom waiting on a helper that inherited stderr (measured: 5.01s against 0.008s with asleep 5 &helper), but that wait is also what keeps a command that backgrounds its own helper counted as running, so changing it is a product decision and not part of this fix.Verification
Standalone harnesses compile the real source file (at
origin/mainand at this branch) withswiftc -Oand amain.swift, 16 runs at a time:origin/mainProcessNativeDumpRunnerPreConnectHookRunnerProcessSupervisedRunnerLSPTransportThe harness reproduced CI's exact signature, including
Encountered read failure 35and the same 16-frame stack. Instrumenting main's runner showed the mechanism: in 16,000 runs, 12 callbacks ran after the drain, all 12 on a non-blocking descriptor, 6 of them on an empty pipe. TheLSPTransportwindow is real but too narrow to hit in 64,000 stops; it changed for the shape, not a measured crash.DescriptorReadTests(6): arrived bytes returned without waiting, the limit, end of file,EAGAINandEBADFthrown,pollreadiness.PipeReaderTests(6): order and self-stop at end of file; a callback dispatched before a drain and run after it reads nothing (the CI ordering) and leaves the descriptor blocking; a callback with nothing to read keeps the reader going; the bounded drain; the end-of-file drain;stopwaits for a delivery in progress.PreConnectHookRunnerTests: 64 failing scripts, 16 at a time, each error carries the script's message. Againstorigin/main's runner it failed 3 runs out of 3.verify.sh buildPASS (both the app andmcp-servercompile the new files).verify.sh test DescriptorReadTests PipeReaderTests SupervisedProcessRunnerTests ProcessNativeDumpRunnerTests MCPBridgeIntegrationTests TunnelCommandManagerTests CloudSQLProxyManagerTests CloudflareTunnelManagerTests PreConnectHookRunnerTests: 66/66 PASS (xcresult counts).swiftlint lint --stricton every changed Swift file: 0 violations.check-log-privacy.py,check-test-suite-attributes.py,audit-refactor-health.sh --check: pass.One test fact worth keeping: end of file is not immediate after closing a pipe's write end in a process that is spawning children. With four threads launching
/usr/bin/true, 14 of 20,000 closes were not yet end of file at once (0 of 20,000 without them), so the readiness test waits for it.Not covered
FileHandlereads that raise on error (readDataToEndOfFile,readData(ofLength:)) remain inGzipProcess,CLIExecutableFinder,AgentAuthenticator,PrivilegedShell,CellImageRenderer,AWSCredentialResolverand a few plugins. Each reads a pipe synchronously after its process exits, with no handler racing it, so none has this failure shape. They are left for their own change.LSPTransport.writeMessageuses the raisingFileHandle.write(_:), which can abort onEPIPEonce the server has exited. Same exception class, a different path, not changed here.TableProUITestsautomation.