If a spawned MCP server dies (or closes its stdout) during startup — before the client's initialize handshake finishes — Client.connect(transport:) never returns and one core sits pinned at 100%.
What I see: a stdio server that exits immediately on launch. ps shows the host process in state R, and sample <pid> shows the spin inside closure #1 in Client.connect → AsyncThrowingStream.Iterator.next().
The message-handling loop in Sources/MCP/Client/Client.swift is (trimmed):
task = Task {
guard let connection = self.connection else { return }
repeat {
if Task.isCancelled { break }
do {
let stream = await connection.receive()
for try await data in stream {
if Task.isCancelled { break }
// decode + dispatch
}
} catch { break }
} while true
}
// ...
return try await _initialize()
When the transport hits EOF, the stream from receive() finishes, the for try await completes without throwing, and while true immediately loops back and calls receive() again, which returns another already-finished stream. Nothing sleeps or exits on end-of-stream, so it spins. Meanwhile connect has moved on to await _initialize(), whose response never arrives because the server is gone, so connect is parked forever on a continuation nothing resumes.
Minimal repro: spawn /usr/bin/true via Process with two Pipes, wrap the pipe fds in a StdioTransport(input:output:), build a Client, and try await client.connect(transport:). Just re-ran this against v0.12.1: connect has neither returned nor thrown 8 seconds in, and ps samples the process at ~100% CPU for the whole wait (96.6 / 100.0 / 100.0 / 100.0 over four 1.5s samples).
Two changes would fix it:
- The loop should stop (or back off) when the underlying stream finishes rather than re-polling a finished stream — a finished transport stream means the connection is gone.
connect / _initialize should then fail with a connection-closed error instead of hanging.
For what it's worth, my workaround is to race connect against a timeout and call client.disconnect() on expiry — disconnect() cancels that task and resumes the pending initialize with an error, so connect throws instead of hanging. Separately I had to set signal(SIGPIPE, SIG_IGN), because writing to the dead server's stdin was raising SIGPIPE and taking down the whole host process. I'd be happy to turn the loop-termination part into a PR if that's welcome.
If a spawned MCP server dies (or closes its stdout) during startup — before the client's
initializehandshake finishes —Client.connect(transport:)never returns and one core sits pinned at 100%.What I see: a stdio server that exits immediately on launch.
psshows the host process in stateR, andsample <pid>shows the spin insideclosure #1 in Client.connect→AsyncThrowingStream.Iterator.next().The message-handling loop in
Sources/MCP/Client/Client.swiftis (trimmed):When the transport hits EOF, the stream from
receive()finishes, thefor try awaitcompletes without throwing, andwhile trueimmediately loops back and callsreceive()again, which returns another already-finished stream. Nothing sleeps or exits on end-of-stream, so it spins. Meanwhileconnecthas moved on toawait _initialize(), whose response never arrives because the server is gone, soconnectis parked forever on a continuation nothing resumes.Minimal repro: spawn
/usr/bin/trueviaProcesswith twoPipes, wrap the pipe fds in aStdioTransport(input:output:), build aClient, andtry await client.connect(transport:). Just re-ran this against v0.12.1:connecthas neither returned nor thrown 8 seconds in, andpssamples the process at ~100% CPU for the whole wait (96.6 / 100.0 / 100.0 / 100.0 over four 1.5s samples).Two changes would fix it:
connect/_initializeshould then fail with a connection-closed error instead of hanging.For what it's worth, my workaround is to race
connectagainst a timeout and callclient.disconnect()on expiry —disconnect()cancels that task and resumes the pending initialize with an error, soconnectthrows instead of hanging. Separately I had to setsignal(SIGPIPE, SIG_IGN), because writing to the dead server's stdin was raising SIGPIPE and taking down the whole host process. I'd be happy to turn the loop-termination part into a PR if that's welcome.