fix(stdio): drain subprocess stderr and bound requests - #80
Open
morozsm wants to merge 1 commit into
Open
Conversation
A stdio downstream is given a StderrPipe that nothing ever reads: mcp-go only exposes it through client.GetStderr, and the proxy never called it. An OS pipe holds about 64KB, so any server that logs to stderr runs fine until that buffer fills and then blocks in write(2) forever. Nothing surfaces an error when this happens, because from the proxy's side the server has merely gone quiet. One stdio channel carries every request, so the wedged write also stops the keepalive ping being answered; after pingFailureThreshold probes the client is marked unhealthy and stays that way for every caller until the proxy restarts. Ordinary logging is enough to trigger it — no misbehaving tool required. Drain stderr into the proxy's own log at debug level, where a downstream's complaints are useful rather than fatal. Also honour `timeout` for stdio. It was parsed for sse and streamable-http and silently dropped for stdio, so a call the server accepted and never answered waited forever. The sse and streamable-http clients carry their timeout inside the transport; the stdio transport has no equivalent, so the bound is applied to the context of each forwarded call. A sub-millisecond value is still discarded rather than failing startup, keeping the existing tolerance for a `timeout` copied in from a Claude config. Both failures are covered by e2e tests that hang without the fix.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Author
|
For reference, the underlying unread-stderr pipe is now filed upstream in mcp-go: mark3labs/mcp-go#956, with a standalone reproduction that needs no proxy (https://gist.github.com/morozsm/b53fdd8dd032ece7413cca5e2becc765). This PR does not depend on that being fixed — draining via the existing |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A stdio downstream that logs to stderr eventually stops responding, permanently, and nothing reports an error.
The stderr deadlock
Start()gives the subprocess aStderrPipe()that nothing ever reads: mcp-go only exposes it throughclient.GetStderr, and the proxy never calls it. An OS pipe holds about 64KB, so a server that writes to stderr works fine until that buffer fills and then blocks insidewrite(2)forever.From the proxy's side the server has merely gone quiet, so there is no error to log. One stdio channel carries every request, so the wedged write also stops the keepalive ping being answered; after
pingFailureThresholdprobes the client is marked unhealthy and stays that way for every caller until the proxy restarts.Ordinary logging is enough to trigger this — no misbehaving tool required.
TestStdioStderrDoesNotDeadlockreproduces it with a fixture tool that writes 256KB to stderr; without the fix the call never returns.The fix drains stderr into the proxy's own log at debug level, where a downstream's complaints are useful rather than fatal.
timeoutwas ignored for stdiotimeoutwas parsed forsseandstreamable-httpand silently dropped forstdio, so a call the server accepted and never answered waited forever. The sse and streamable-http clients carry their timeout inside the transport; the stdio transport has no equivalent, so the bound is applied to the context of each forwarded call.A sub-millisecond value is still discarded rather than failing startup, keeping the existing tolerance for a
timeoutcopied in from a Claude config (TestParseMCPClientConfigV2/stdio_ignores_unusable_timeout).TestStdioToolCallIsBoundedcovers this with a fixture tool that accepts a call and never answers.Notes
go build,go vet,gofmtandgo test ./...pass;golangci-lint run --no-configreports the same 10 pre-existing errcheck issues as master.