fix: mcp_service - don't drop the last response on backend EOF (#375) - #379
Merged
Conversation
…e#375) HandleStreamPassThrough tore the whole client-facing stream down the instant either leg reported any error, including the normal EOF a one-shot backend gives after answering a single request. That immediate network.Stream.Close call cancels the read side and, per its own documented contract, does not guarantee the response this same goroutine had just handed to Write actually reached the peer - closing right behind a successful write can still lose it in flight, which is the root cause of google#375. The backend leg ending now only half-closes the write side to the client (CloseWrite) and stops relaying backend->client; the read side is left alone until the client itself finishes reading and hangs up, or a genuine transport error occurs, bounded by a 5s drain timeout so a client that never hangs up can't leak the stream. Added a regression test exercising this over a real local QUIC connection (the race is specific to QUIC's stream Close, which cancels the read side over the wire - a TCP+yamux stream's CloseRead is documented as local-only). It passes reliably with the fix; it did not reproduce the race against the old code on loopback across 1500+ iterations, which the PR description explains.
Contributor
There was a problem hiding this comment.
Code Review
This pull request addresses a race condition where in-flight responses could be dropped when a backend closes its connection immediately after responding. It introduces a half-close mechanism on the client-facing stream, allowing the client to finish reading before the stream is fully closed, bounded by a 5-second timeout. A regression test using a real QUIC connection has also been added. Feedback suggests that if writing to the client fails, the error should be propagated to the main goroutine to avoid waiting for the timeout unnecessarily.
…waiting out the drain timeout
aojea
reviewed
Sep 9, 2026
… ended aojea caught a real bug: passThroughDrainTimeout was timed from the start of the whole exchange (the select was reached right after launching both goroutines), not from when the backend leg actually finished - so any session, healthy or not, that happened to run longer than the timeout got killed mid-flight, which is worse than the bug this PR fixes. Adds a backendDone channel closed when the backend-relay goroutine returns; the drain wait now only begins after that signal. Also switches passThroughDrainTimeout from a const to a var so a test can shrink it, and adds a regression test with a backend slower than the (shrunk) timeout that must still succeed.
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.
Summary
Fixes #375.
HandleStreamPassThroughproxies JSON-RPC messages between a client-facing P2P stream and a backend MCP connection. It used to shut the whole thing down -s.Close()- the instant either leg reported any error, including the completely normal case of a one-shot HTTP-style backend answering one request and then closing its side (a cleanEOF, not a failure).network.Stream.Close's own documented contract:The old code skipped exactly that: the backend-read goroutine would
Writethe response to the client, loop back, getEOFfrom the backend, and immediately trigger the deferreds.Close()a few instructions later - closing right behind a write that had already reported success. On the QUIC transport,Close()additionally cancels the read side over the wire (CancelReadsends aSTOP_SENDINGframe to the peer), which a plain TCP+yamux stream'sCloseReadexplicitly does not do (its doc says "Remote is not notified"). That's consistent with the original report: the producer's write reports success, but the response never fully reaches the consumer, which seesEOFinstead.Fix
The backend leg ending now only half-closes our write side to the client (
s.CloseWrite()) and stops the backend->client relay goroutine; it no longer touches the read side or frees the stream. The client leg - the client itself finishing its read and hanging up, or a genuine transport error - is what triggers the finals.Close(). A 5s drain timeout (passThroughDrainTimeout) bounds the wait so a client that never hangs up can't leak the stream indefinitely.Test plan
TestHandleStreamPassThrough_BackendEOFDoesNotDropInFlightResponseingate_test.go, which drives 50 realCallToolround trips over a real local QUIC connection between twoSamNodes (a newstartBareQUICNodehelper -startBareNode's existing TCP+yamux transport can't exercise this: yamux'sCloseReadis local-only per its own doc comment, so it structurally can't reproduce the wire-level race). It passes reliably with the fix.GOMAXPROCS=1), so it is not proof this fixes the exact wire-level interaction seen in the original report - only that the new code followsnetwork.Stream's documented safe-shutdown sequence and behaves correctly across many round trips. Localhost loopback has effectively no latency/jitter, which likely closes the race window that a real multi-host deployment (or a relayed connection) would have.go build ./...,go vet ./..., and the fullinternal/nodesuite pass (the 6 pre-existing unrelated failures in that package are present identically on unmodifiedmain).