@@ -16,7 +16,9 @@ package node
1616
1717import (
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+ }
0 commit comments