Skip to content

StdioTransport.send() still interleaves concurrent sends under EAGAIN backpressure — #252 was closed without a merged fix #263

Description

@onetamer

Summary

StdioTransport.send() can splice the bytes of one JSON-RPC frame into the
middle of another when two sends run concurrently and the first hits stdout
backpressure. This corrupts the newline-delimited framing for any client
reading the stream.

This is the same defect reported in #252 (2026-07-02). That issue was closed
by its reporter the next day with no linked commit or PR, and the code on
main today is unchanged — the bug is still present. Filing fresh with an
independent, in-the-wild reproduction.

Root cause

StdioTransport is an actor, but send(_:) contains an await inside its
write loop — the EAGAIN backpressure sleep:

} catch let error where MCPError.isResourceTemporarilyUnavailable(error) {
    try await Task.sleep(for: .milliseconds(10))   // ← actor reentrancy point
    continue
}

Actor isolation guarantees mutual exclusion only between suspension
points. While one send is suspended in that sleep mid-frame, a second
send enters the actor and writes its complete frame into the middle of the
first one.

Observed in the wild

Version 0.12.1, macOS 15 (Darwin 25.5), a stdio MCP server whose
initialize result carries a ~1.9 KB instructions string. A pipelining
client sent initialize + a tools/call back-to-back; the captured stdout
shows the tool response spliced mid-word into the initialize result:

...has not made any bucket visi{"id":2,"jsonrpc":"2.0","result":{...}}
ble to agents yet...

Both frames are unparseable to a line-delimited JSON reader at that point.
Any client that issues parallel tool calls (several do) can trigger this
whenever a response exceeds what the pipe accepts in one write — no
misbehaving client required.

Reproduction sketch

Same shape as #252's: create the transport over a pipe the reader drains
slowly, start a send large enough to hit EAGAIN (hundreds of KB, or a small
pipe), and issue a second small send while the first is suspended. The small
frame lands inside the large one.

Suggested fix

Serialize sends so one completes before the next begins — either drop to a
blocking write for the remainder of a frame, or chain sends FIFO. We shipped
the FIFO as a wrapper in our server and it eliminates the corruption:

private var lastSend: Task<Void, Never>?

func send(_ data: Data) async throws {
    let previous = lastSend
    let task = Task<Void, Error> { [base] in
        await previous?.value
        try await base.send(data)
    }
    lastSend = Task { try? await task.value }
    try await task.value
}

The same pattern inlined into StdioTransport.send (chain before the write
loop) would fix it at the source. Happy to turn this into a PR if that's
welcome.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions