Skip to content

fix(upstream): detect a dead stdio transport and respawn the process (RC4-STDIO-001) - #1489

Merged
github-actions[bot] merged 3 commits into
mainfrom
claude/fix-stdio-dead-transport-recovery
Oct 3, 2026
Merged

github-actions[bot] merged 3 commits into
mainfrom
claude/fix-stdio-dead-transport-recovery

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 3, 2026

Copy link
Copy Markdown
Member

Pull Request

Description

Fixes RC4-STDIO-001, from the codex QA of v0.70.0-rc.4. The bug also exists in rc.2. When a stdio upstream's child process dies, every call fails with transport error: transport closed, but REST keeps reporting the server connected, healthy and usable, and it is never respawned. Only a manual restart recovered it.

Cause. After the child dies, mcp-go's stdio reader hits EOF and every request returns transport.ErrTransportClosed. isConnectionError in internal/upstream/managed/client.go had no marker for that error, so:

  • the health loop logged it as "timeout (high activity), ignoring";
  • a failed tools/call took the not-a-connection-error branch.

SetError was therefore never called, and the backoff reconnect, which only runs from StateError, never fired.

Fix (managed layer only):

  • isDeadTransportError: matches ErrTransportClosed, io.ErrClosedPipe and os.ErrClosed through the error chain. Its text fallback counts only when the text sits inside a wrapped transport.Error, so a healthy server's JSON-RPC error text can't trigger it. io.EOF/io.ErrUnexpectedEOF are deliberately excluded, because HTTP/SSE transports wrap request EOFs that way on live connections.
  • Effect: a dead transport is now a hard connection error and is not treated as a transient health-check error. The first failed ping or call moves the server to Error, and the existing ShouldRetry backoff and tryReconnect respawn the process.
  • Generation guard: both the call path (recordDeadConnection) and the health path record the error only while the client is still Ready on the same connection generation, serialized under epochMu. A ping or call that races a deliberate Disconnect can't flip a reset client to Error, and a burst of calls on one dead transport costs only one retry.
  • No new risks: the reconnect path is unchanged, the managed Disconnect is never called from the health goroutine (no self-join), and backoff and the supervisor behave as before.

Testing

  • I have tested these changes locally

  • I have added/updated tests that prove my fix is effective or my feature works

  • All existing tests pass

  • Tests:

    • New: dead_transport_test.go covers the classification table (including negatives for JSON-RPC text and HTTP EOF), health check flips to Error, a call marks Error, a burst counts one retry, and a call after Disconnect stays Disconnected.
    • New: dead_transport_fixture_test.go uses a real mcpfixture child: SIGKILL, assert it leaves Ready, assert it respawns to a new instance_id.
    • All of these failed before the fix.
  • Race suites: go test -race passes for ./internal/upstream/... and ./internal/runtime/supervisor/.... The fixture test passed with -count=3 and -count=5.

  • Live probe on a real core: within 5s of SIGKILLing the stdio child, REST shows connected=false, unhealthy. By 30s a new child was spawned and the server was healthy again.

  • Review: zcode round 1 raised three mediums (HTTP EOF, JSON-RPC text, health-path epoch). All are fixed in 603614463.

  • Known low follow-ups, not in this PR:

    • runListToolsAsLeader, GetCachedToolCount and GetPrompt still call SetError on connection errors with no epoch guard.
    • The epoch branch of the recordDeadConnection guard has no dedicated test.

🤖 Generated with Claude Code

…(RC4-STDIO-001)

Cause: when a stdio upstream's child process dies, mcp-go's stdio reader
hits EOF and closes the transport; every later request returns
transport.ErrTransportClosed ("transport error: transport closed").
The managed client's isConnectionError matched none of that text, so:
- the health-loop ping logged it as "timeout (high activity), ignoring"
  and never flipped the state machine to Error;
- a failed tools/call took the "not a connection error" branch.
The server therefore kept reporting Ready/healthy with every call failing,
and the backoff reconnect (which only runs from StateError) never fired.

Fix:
- add isDeadTransportError (ErrTransportClosed, io.EOF/ErrUnexpectedEOF,
  io.ErrClosedPipe, os.ErrClosed via errors.Is, plus text fallbacks for
  flattened chains; bare "EOF" text deliberately not matched) and treat it
  as a hard connection error in isConnectionError and as non-transient in
  isTransientHealthCheckError, so one failed ping or call flips the server
  to Error and the existing ShouldRetry backoff + tryReconnect respawns it.
- route the call-path verdict through recordDeadConnection, which applies
  SetError only while the client is still Ready on the same connection
  generation (under epochMu, like the ambiguous-call probe): a call racing
  a deliberate Disconnect cannot flip it to Error, and a burst of calls on
  one dead transport charges one retry instead of inflating the backoff.

Tests: classification table, health-ping and tools/call flips to Error,
burst/one-retry and post-disconnect guards, and an end-to-end test that
SIGKILLs a real cmd/mcpfixture stdio child and asserts the client leaves
Ready and reconnects to a new fixture instance (fails without the fix).
…verdict by epoch

Follow-ups from the zcode review of the RC4-STDIO-001 fix:

- isDeadTransportError no longer matches io.EOF / io.ErrUnexpectedEOF. The
  classifier is shared by all protocols and net/http wraps those for a single
  truncated/reset response on a live HTTP/SSE upstream, which must keep the
  normal flap tolerance. A stdio child's death surfaces as ErrTransportClosed
  (mcp-go's reader converts its EOF), so stdio coverage is unchanged.
- The text fallbacks ("transport closed", "file already closed", closed pipe)
  now only count after mcp-go's "transport error: " prefix, so a live
  server's JSON-RPC error message echoing errno text cannot evict it.
- performHealthCheck applies its SetError through the same epoch/epochMu
  guard as the call path (setErrorIfCurrentConnection): a ping that failed
  because Disconnect closed the transport no longer flips the freshly Reset
  client to Error.

Tests: HTTP-shaped EOF chains and JSON-RPC errors echoing the markers are
not dead transports; HTTP EOF ping stays Ready; tool error echoing
"file already closed" stays Ready; ping racing Disconnect stays
Disconnected with retryCount 0. All fail on the previous commit.
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: claude/fix-stdio-dead-transport-recovery

Available Artifacts

  • archive-darwin-amd64 (31 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (27 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (27 MB)
  • installer-dmg-darwin-arm64 (24 MB)
  • smart-mcp-proxymcpproxy-goM38J4E.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 37143826254 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

codecov-commenter commented Oct 3, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 93.33333% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/upstream/managed/client.go 93.10% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

opencode review of RC4-STDIO-001:
- ListTools, the tool-count refresh and GetPrompt now record a connection
  error through setErrorIfCurrentConnection with the epoch captured before
  the request, like the call and health paths: concurrent failures on one
  dead transport mark it once, and a failure from a connection Disconnect
  already replaced never flips the reset client to Error.
- tools/call reads its connection epoch after the admission wait, so a call
  queued across a reconnect is charged to the connection it actually ran
  on instead of being discarded as stale.
- the text fallback now matches only mcp-go's typed *transport.Error or a
  message that starts with its prefix, not server-supplied JSON-RPC text
  that merely contains the phrase.
- test the epoch arm of the guard on its own.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0045fb9
Status: ✅  Deploy successful!
Preview URL: https://96e155ce.mcpproxy-docs.pages.dev
Branch Preview URL: https://claude-fix-stdio-dead-transp.mcpproxy-docs.pages.dev

View logs

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved (Model B): Paperclip review verdicts = ACCEPT and qa-gate green at this head SHA. Arming auto-merge; GitHub merges when all required checks pass.

@github-actions
github-actions Bot enabled auto-merge (squash) October 3, 2026 18:22
@github-actions
github-actions Bot merged commit 48ad26e into main Oct 3, 2026
41 checks passed
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.

2 participants