Skip to content

StdioTransport.send() can interleave concurrent sends under EAGAIN backpressure, corrupting newline framing #252

Description

@obj-p

Summary

StdioTransport is an actor, but send() awaits inside its EAGAIN retry loop (try await Task.sleep(for: .milliseconds(10))). Actor re-entrancy admits a second send() at that suspension point, so when a message larger than the pipe buffer backs up mid-write, a concurrent message's bytes are spliced into the middle of it. The newline framing is corrupted, the peer drops what it can't decode, and the request awaiting the corrupted response times out with no error surfaced on either side.

Conditions

  1. A message larger than the pipe buffer (~64KB on Darwin) — e.g. a tool response containing base64 image data — so output.write returns partial and eventually hits EAGAIN, suspending in the retry sleep.
  2. Any concurrent send() on the same transport during that suspension — e.g. a periodic server-side notification (in our case, a 2s liveness notification racing large CallTool responses).

Reproduction

Deterministic in <1s, no MCP server needed:

  1. Create a pipe; construct StdioTransport(input: <unused pipe>, output: <pipe write end>), connect().
  2. Do not drain the read end yet (forces backpressure).
  3. Task { try await transport.send(bigMessage) } where bigMessage is ~300KB of JSON.
  4. Sleep ~150ms (the big send is now suspended in the EAGAIN retry), then Task { try await transport.send(smallMessage) }.
  5. Await both sends, drain the pipe, split on \n: the small message's bytes appear inside the big message's frame — neither line parses as JSON.

Observed in production as intermittent, silent request timeouts under load (client sees no response; server logs show the handler completed normally), on swift-sdk 0.7.x.

Suggested fix

Serialize the writes so a frame's bytes always land contiguously — e.g. chain each send behind the previous one:

private var sendChain: Task<Void, Swift.Error>?

public func send(_ message: Data) async throws {
    let previous = sendChain
    let task = Task {
        _ = try? await previous?.value
        try await writeFrame(message) // the existing write+EAGAIN-retry loop
    }
    sendChain = task // no suspension between reading and writing sendChain
    try await task.value
}

(We ship this as a wrapper actor that chains sends in front of an inner StdioTransport and it eliminated the timeouts entirely; happy to send a PR if the approach looks right.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions