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
- 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.
- 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:
- Create a pipe; construct
StdioTransport(input: <unused pipe>, output: <pipe write end>), connect().
- Do not drain the read end yet (forces backpressure).
Task { try await transport.send(bigMessage) } where bigMessage is ~300KB of JSON.
- Sleep ~150ms (the big send is now suspended in the EAGAIN retry), then
Task { try await transport.send(smallMessage) }.
- 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.)
Summary
StdioTransportis an actor, butsend()awaits inside its EAGAIN retry loop (try await Task.sleep(for: .milliseconds(10))). Actor re-entrancy admits a secondsend()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
output.writereturns partial and eventually hitsEAGAIN, suspending in the retry sleep.send()on the same transport during that suspension — e.g. a periodic server-side notification (in our case, a 2s liveness notification racing largeCallToolresponses).Reproduction
Deterministic in <1s, no MCP server needed:
StdioTransport(input: <unused pipe>, output: <pipe write end>),connect().Task { try await transport.send(bigMessage) }wherebigMessageis ~300KB of JSON.Task { try await transport.send(smallMessage) }.\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
sendbehind the previous one:(We ship this as a wrapper actor that chains sends in front of an inner
StdioTransportand it eliminated the timeouts entirely; happy to send a PR if the approach looks right.)