Skip to content

Client.connect busy-loops at 100% CPU when the transport reaches EOF before initialize completes #256

Description

@magicnight

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.connectAsyncThrowingStream.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions