Skip to content

Commit 30a9d89

Browse files
authored
Merge pull request #379 from fer-marino/fix/mcp-passthrough-eof-race
fix: mcp_service - don't drop the last response on backend EOF (#375)
2 parents 5f75029 + 57b5c53 commit 30a9d89

2 files changed

Lines changed: 297 additions & 30 deletions

File tree

internal/node/gate_test.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ package node
1616

1717
import (
1818
"context"
19+
"net/http"
1920
"net/http/httptest"
21+
"strings"
2022
"testing"
2123
"time"
2224

@@ -313,3 +315,216 @@ func TestHandleMCPStream_ForwarderRoutesCalls(t *testing.T) {
313315
t.Errorf("forwarder did not pass un-namespaced name; got %q", tc.Text)
314316
}
315317
}
318+
319+
// startBareQUICNode is startBareNode over the QUIC transport instead of TCP.
320+
//
321+
// The distinction matters for TestHandleStreamPassThrough_BackendEOFDoesNotDropInFlightResponse
322+
// below: go-libp2p's QUIC-backed network.Stream.Close cancels the read side
323+
// over the wire (CancelRead sends a QUIC STOP_SENDING frame to the peer),
324+
// while the yamux stream used over startBareNode's plain TCP transport does
325+
// the equivalent locally only - its CloseRead doc says plainly "Remote is
326+
// not notified." A stream backed by TCP+yamux structurally cannot exercise
327+
// the wire-level race this test is after; only a real QUIC connection can.
328+
func startBareQUICNode(t *testing.T, ctx context.Context) (*SamNode, func()) {
329+
t.Helper()
330+
dir := t.TempDir()
331+
store, err := NewStore(dir)
332+
if err != nil {
333+
t.Fatal(err)
334+
}
335+
336+
priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1)
337+
if err != nil {
338+
t.Fatal(err)
339+
}
340+
node, err := NewSamNode(Options{
341+
PrivKey: priv,
342+
RouterAddrs: nil,
343+
Store: store,
344+
MeshID: "test-mesh",
345+
DiscoveryInterval: "1s",
346+
ListenAddrs: []string{"/ip4/127.0.0.1/udp/0/quic-v1"},
347+
EnableRelay: false,
348+
NodeConfig: &NodeConfigComplete{},
349+
KeyGracePeriod: 24 * time.Hour,
350+
AllowLoopback: true,
351+
MonitorBootstrap: 2 * time.Minute,
352+
MonitorInterval: 1 * time.Minute,
353+
})
354+
if err != nil {
355+
t.Fatal(err)
356+
}
357+
node.BiscuitTimeout = 500 * time.Millisecond
358+
if err := node.Start(ctx); err != nil {
359+
t.Fatal(err)
360+
}
361+
362+
cleanup := func() {
363+
_ = node.Teardown()
364+
_ = store.Close()
365+
}
366+
return node, cleanup
367+
}
368+
369+
// TestHandleStreamPassThrough_BackendEOFDoesNotDropInFlightResponse is a
370+
// regression test for google/sam#375.
371+
//
372+
// A backend that answers a single request and then closes its side of the
373+
// connection - a normal EOF for a one-shot HTTP-style backend, not a
374+
// failure - used to make HandleStreamPassThrough tear the whole
375+
// client-facing stream down immediately via network.Stream.Close. Close's
376+
// own documented contract says it "does not guarantee receipt of the data";
377+
// closing right behind a Write that had just reported success raced the
378+
// response off the wire, and the caller in the original report sometimes
379+
// saw EOF in its place. See the comment on the drain logic in
380+
// HandleStreamPassThrough for the fix.
381+
//
382+
// The race is timing-dependent, so this repeats the call many times over a
383+
// real QUIC connection (see startBareQUICNode) rather than asserting on a
384+
// single attempt.
385+
func TestHandleStreamPassThrough_BackendEOFDoesNotDropInFlightResponse(t *testing.T) {
386+
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
387+
defer cancel()
388+
389+
// A payload closer in size to the 834-byte response in the original
390+
// report than a trivial "ok" would be - small messages are far more
391+
// likely to always win the race regardless of the bug.
392+
wantText := strings.Repeat("x", 700)
393+
upstream := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
394+
srv := mcp.NewServer(&mcp.Implementation{Name: "fake", Version: "0.0.1"}, nil)
395+
srv.AddTool(&mcp.Tool{Name: "echo", Description: "echo", InputSchema: map[string]any{"type": "object"}},
396+
func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
397+
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: wantText}}}, nil
398+
})
399+
return srv
400+
}, nil))
401+
defer upstream.Close()
402+
403+
nodeA, cleanupA := startBareQUICNode(t, ctx)
404+
defer cleanupA()
405+
nodeB, cleanupB := startBareQUICNode(t, ctx)
406+
defer cleanupB()
407+
408+
svc := &MCPService{baseService: baseService{
409+
info: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "svc"},
410+
backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: upstream.URL},
411+
}}
412+
if err := svc.Init(ctx); err != nil {
413+
t.Fatalf("MCPService.Init: %v", err)
414+
}
415+
nodeA.services.insertService(svc)
416+
t.Cleanup(func() { _ = svc.Teardown() })
417+
418+
nodeA.Host.SetStreamHandler(testMCPProtocol, func(s network.Stream) {
419+
nodeA.HandleMCPStream(s, RequestContext{Target: "svc"})
420+
})
421+
422+
if err := nodeB.Host.Connect(ctx, peer.AddrInfo{ID: nodeA.Host.ID(), Addrs: nodeA.Host.Addrs()}); err != nil {
423+
t.Fatalf("connect: %v", err)
424+
}
425+
426+
const iterations = 50
427+
for i := 0; i < iterations; i++ {
428+
func() {
429+
s, err := nodeB.Host.NewStream(ctx, nodeA.Host.ID(), testMCPProtocol)
430+
if err != nil {
431+
t.Fatalf("iteration %d: NewStream: %v", i, err)
432+
}
433+
defer func() { _ = s.Close() }()
434+
435+
client := mcp.NewClient(&mcp.Implementation{Name: "tc", Version: "0.0.1"}, nil)
436+
session, err := client.Connect(ctx, NewStreamTransport(s), nil)
437+
if err != nil {
438+
t.Fatalf("iteration %d: client.Connect: %v", i, err)
439+
}
440+
defer func() { _ = session.Close() }()
441+
442+
res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "echo", Arguments: map[string]any{}})
443+
if err != nil {
444+
t.Fatalf("iteration %d: CallTool: %v", i, err)
445+
}
446+
tc, ok := res.Content[0].(*mcp.TextContent)
447+
if !ok || tc.Text != wantText {
448+
t.Fatalf("iteration %d: got %v, want text of length %d", i, res.Content, len(wantText))
449+
}
450+
}()
451+
}
452+
}
453+
454+
// TestHandleStreamPassThrough_SlowBackendDoesNotHitDrainTimeout is a
455+
// regression test for review feedback from aojea on google/sam#379: the
456+
// drain wait was timed from the start of the whole exchange, not from when
457+
// the backend leg actually finished, so any session - healthy or not -
458+
// that happened to run longer than passThroughDrainTimeout got killed
459+
// mid-flight. Shrinks passThroughDrainTimeout for the test so proving this
460+
// doesn't need a multi-second sleep; startBareNode's plain TCP transport is
461+
// enough here, since this is about goroutine timing, not the QUIC-specific
462+
// Close semantics TestHandleStreamPassThrough_BackendEOFDoesNotDropInFlightResponse
463+
// covers.
464+
func TestHandleStreamPassThrough_SlowBackendDoesNotHitDrainTimeout(t *testing.T) {
465+
oldTimeout := passThroughDrainTimeout
466+
passThroughDrainTimeout = 50 * time.Millisecond
467+
t.Cleanup(func() { passThroughDrainTimeout = oldTimeout })
468+
469+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
470+
defer cancel()
471+
472+
// Several times passThroughDrainTimeout: under the bug, the stream was
473+
// torn down well before the backend ever answered.
474+
const backendDelay = 300 * time.Millisecond
475+
upstream := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
476+
srv := mcp.NewServer(&mcp.Implementation{Name: "slow", Version: "0.0.1"}, nil)
477+
srv.AddTool(&mcp.Tool{Name: "slow_echo", Description: "slow", InputSchema: map[string]any{"type": "object"}},
478+
func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
479+
time.Sleep(backendDelay)
480+
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "ok"}}}, nil
481+
})
482+
return srv
483+
}, nil))
484+
defer upstream.Close()
485+
486+
nodeA, cleanupA := startBareNode(t, ctx)
487+
defer cleanupA()
488+
nodeB, cleanupB := startBareNode(t, ctx)
489+
defer cleanupB()
490+
491+
svc := &MCPService{baseService: baseService{
492+
info: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "slow-svc"},
493+
backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: upstream.URL},
494+
}}
495+
if err := svc.Init(ctx); err != nil {
496+
t.Fatalf("MCPService.Init: %v", err)
497+
}
498+
nodeA.services.insertService(svc)
499+
t.Cleanup(func() { _ = svc.Teardown() })
500+
501+
nodeA.Host.SetStreamHandler(testMCPProtocol, func(s network.Stream) {
502+
nodeA.HandleMCPStream(s, RequestContext{Target: "slow-svc"})
503+
})
504+
505+
if err := nodeB.Host.Connect(ctx, peer.AddrInfo{ID: nodeA.Host.ID(), Addrs: nodeA.Host.Addrs()}); err != nil {
506+
t.Fatalf("connect: %v", err)
507+
}
508+
509+
s, err := nodeB.Host.NewStream(ctx, nodeA.Host.ID(), testMCPProtocol)
510+
if err != nil {
511+
t.Fatalf("NewStream: %v", err)
512+
}
513+
defer func() { _ = s.Close() }()
514+
515+
client := mcp.NewClient(&mcp.Implementation{Name: "tc", Version: "0.0.1"}, nil)
516+
session, err := client.Connect(ctx, NewStreamTransport(s), nil)
517+
if err != nil {
518+
t.Fatalf("client.Connect: %v", err)
519+
}
520+
defer func() { _ = session.Close() }()
521+
522+
res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "slow_echo", Arguments: map[string]any{}})
523+
if err != nil {
524+
t.Fatalf("CallTool: %v (a healthy exchange slower than passThroughDrainTimeout must not be killed for that alone)", err)
525+
}
526+
tc, ok := res.Content[0].(*mcp.TextContent)
527+
if !ok || tc.Text != "ok" {
528+
t.Fatalf("got %v, want text %q", res.Content, "ok")
529+
}
530+
}

internal/node/mcp_service.go

Lines changed: 82 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,18 @@ var preflightMethodsUnsupportedByPassThrough = map[string]bool{
140140
"server/discover": true,
141141
}
142142

143+
// passThroughDrainTimeout bounds how long HandleStreamPassThrough keeps a
144+
// client-facing stream open after the backend leg has ended, waiting for the
145+
// client to finish reading the last response and hang up on its own. See the
146+
// comment on the drain logic in HandleStreamPassThrough for why this wait
147+
// exists at all.
148+
//
149+
// A var, not a const, so a test can shrink it - the countdown only starts
150+
// once the backend leg is done, so shrinking it does not affect an
151+
// in-progress exchange, only how long a stalled client is tolerated for
152+
// after that.
153+
var passThroughDrainTimeout = 5 * time.Second
154+
143155
// HandleStreamPassThrough connects to the backend and proxies JSON-RPC messages.
144156
func (m *MCPService) HandleStreamPassThrough(s network.Stream) {
145157
defer func() {
@@ -148,23 +160,14 @@ func (m *MCPService) HandleStreamPassThrough(s network.Stream) {
148160
}
149161
}()
150162

151-
var backendTransport mcp.Transport
152-
var closeTransport func()
153-
154163
backendTransport, err := m.backendTransport()
155164
if err != nil {
156165
logger.Errorf("[MCPService] %s: %v", m.info.Name, err)
157166
return
158167
}
159-
closeTransport = func() {} // fresh per stream for URL; shared bridge is never closed
160168

161169
ctx, cancel := context.WithCancel(context.Background())
162170
defer cancel()
163-
defer func() {
164-
if closeTransport != nil {
165-
closeTransport()
166-
}
167-
}()
168171

169172
backendConn, err := backendTransport.Connect(ctx)
170173
if err != nil {
@@ -180,49 +183,98 @@ func (m *MCPService) HandleStreamPassThrough(s network.Stream) {
180183
return
181184
}
182185

183-
// Dumb pipe: Proxy JSON-RPC messages between client and backend
184-
errc := make(chan error, 2)
186+
// Dumb pipe: proxy JSON-RPC messages between client and backend.
187+
//
188+
// The two legs are not symmetric on shutdown. A backend answering one
189+
// request and then hanging up (a clean EOF, typical of a one-shot
190+
// HTTP-style backend transport) is normal completion, not a failure of
191+
// the client-facing side of the pipe - but closing s in reaction to it
192+
// used to tear down both directions immediately (network.Stream.Close
193+
// implies CancelRead), including the read side and, per that method's
194+
// own documented contract, without waiting for the response this same
195+
// goroutine had just handed to Write to actually reach the peer. Close
196+
// "does not guarantee receipt of the data"; the documented safe sequence
197+
// is CloseWrite, then wait for the peer to finish reading (or hang up),
198+
// then Close. That race is the root cause of google/sam#375: the
199+
// producer's write reports success, but the immediate teardown right
200+
// behind it can still lose the response in flight, and the consumer
201+
// sees EOF instead.
202+
//
203+
// So the backend leg ending only half-closes our write side to the
204+
// client (CloseWrite: no more responses are coming, but nothing already
205+
// in flight is discarded) and stops relaying backend->client; it leaves
206+
// the read side alone. The client leg - the client itself finishing the
207+
// read and hanging up, or a genuine transport error - is what triggers
208+
// the final s.Close() at the top of this function. passThroughDrainTimeout
209+
// bounds that wait, but only once the backend leg is actually done
210+
// (backendDone below) - it is not a cap on the whole exchange, or a
211+
// slow-but-healthy session would be killed mid-flight for no better
212+
// reason than having taken a while.
213+
//
214+
// clientErrc is buffered for 2, not 1: a client write failure below is
215+
// also a client-leg error (the stream to the client is dead, so there is
216+
// nothing left to drain for), and with both goroutines able to send, an
217+
// unlucky interleaving where the main select has already consumed one
218+
// value could otherwise leave the second sender blocked forever.
219+
clientErrc := make(chan error, 2)
220+
backendDone := make(chan struct{})
221+
222+
go func() {
223+
defer close(backendDone)
224+
for {
225+
msg, err := backendConn.Read(ctx)
226+
if err != nil {
227+
logger.Debugf("[MCPService] %s: backend read error: %v", m.info.Name, err)
228+
if cwErr := s.CloseWrite(); cwErr != nil {
229+
logger.Debugf("[MCPService] %s: failed to close write side to client: %v", m.info.Name, cwErr)
230+
}
231+
return
232+
}
233+
if err := clientConn.Write(ctx, msg); err != nil {
234+
logger.Debugf("[MCPService] %s: client write error: %v", m.info.Name, err)
235+
clientErrc <- err
236+
return
237+
}
238+
}
239+
}()
185240

186241
go func() {
187242
for {
188243
msg, err := clientConn.Read(ctx)
189244
if err != nil {
190245
logger.Debugf("[MCPService] %s: client read error: %v", m.info.Name, err)
191-
errc <- err
246+
clientErrc <- err
192247
return
193248
}
194249
if req, ok := msg.(*jsonrpc.Request); ok && preflightMethodsUnsupportedByPassThrough[req.Method] {
195250
resp := &jsonrpc.Response{ID: req.ID, Error: &jsonrpc.Error{Code: jsonrpc.CodeMethodNotFound, Message: req.Method + " is not supported by this pass-through proxy"}}
196251
if werr := clientConn.Write(ctx, resp); werr != nil {
197252
logger.Debugf("[MCPService] %s: failed to reject %s: %v", m.info.Name, req.Method, werr)
198-
errc <- werr
253+
clientErrc <- werr
199254
return
200255
}
201256
continue
202257
}
203258
if err := backendConn.Write(ctx, msg); err != nil {
204259
logger.Debugf("[MCPService] %s: backend write error: %v", m.info.Name, err)
205-
errc <- err
260+
clientErrc <- err
206261
return
207262
}
208263
}
209264
}()
210265

211-
go func() {
212-
for {
213-
msg, err := backendConn.Read(ctx)
214-
if err != nil {
215-
logger.Debugf("[MCPService] %s: backend read error: %v", m.info.Name, err)
216-
errc <- err
217-
return
218-
}
219-
if err := clientConn.Write(ctx, msg); err != nil {
220-
logger.Debugf("[MCPService] %s: client write error: %v", m.info.Name, err)
221-
errc <- err
222-
return
223-
}
224-
}
225-
}()
266+
// No timeout here: the exchange runs for as long as both legs are
267+
// making progress. Only once the backend leg ends (backendDone) does a
268+
// bounded wait for the client to also finish begin, below.
269+
select {
270+
case <-clientErrc:
271+
return
272+
case <-backendDone:
273+
}
226274

227-
<-errc
275+
select {
276+
case <-clientErrc:
277+
case <-time.After(passThroughDrainTimeout):
278+
logger.Debugf("[MCPService] %s: client did not hang up within %v of the backend finishing; closing", m.info.Name, passThroughDrainTimeout)
279+
}
228280
}

0 commit comments

Comments
 (0)