Skip to content

fix(stdio): drain subprocess stderr and bound requests - #80

Open
morozsm wants to merge 1 commit into
tbxark:masterfrom
morozsm:fix/stdio-stderr-deadlock
Open

fix(stdio): drain subprocess stderr and bound requests#80
morozsm wants to merge 1 commit into
tbxark:masterfrom
morozsm:fix/stdio-stderr-deadlock

Conversation

@morozsm

@morozsm morozsm commented Aug 19, 2026

Copy link
Copy Markdown

A stdio downstream that logs to stderr eventually stops responding, permanently, and nothing reports an error.

The stderr deadlock

Start() gives the subprocess a StderrPipe() that nothing ever reads: mcp-go only exposes it through client.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 inside write(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 pingFailureThreshold probes 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. TestStdioStderrDoesNotDeadlock reproduces 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.

timeout was ignored for stdio

timeout 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 (TestParseMCPClientConfigV2/stdio_ignores_unusable_timeout).

TestStdioToolCallIsBounded covers this with a fixture tool that accepts a call and never answers.

Notes

  • Verified against a real-world Python stdio server that had been taking its proxy down: the failing call now returns a proper error in 0.6s and the server stays reachable, where previously the whole downstream was lost until a restart.
  • The underlying unread-stderr pipe belongs to mcp-go; draining it here fixes it for this proxy without waiting on that.
  • go build, go vet, gofmt and go test ./... pass; golangci-lint run --no-config reports the same 10 pre-existing errcheck issues as master.

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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@morozsm

morozsm commented Aug 19, 2026

Copy link
Copy Markdown
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 client.GetStderr accessor works with mcp-go as it ships today, and stays correct if the library later drains the pipe itself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant