Skip to content

Commit 2e8a408

Browse files
fix(transport): exit ContinuousListening goroutine on context cancellation (#790)
The goroutine spawned by WithContinuousListening() in Start() only selected on c.initialized and c.closed, ignoring ctx.Done(). If initialization never succeeded and Close() was not called, the goroutine leaked indefinitely. Add ctx.Done() as a third select case so that cancelling the context passed to Start() is sufficient to release the goroutine, consistent with how the SSE transport handles cancellation. Fixes goroutine leak when Initialize fails and Close() is not called.
1 parent 9a96404 commit 2e8a408

2 files changed

Lines changed: 51 additions & 0 deletions

File tree

client/transport/streamable_http.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ func (c *StreamableHTTP) Start(ctx context.Context) error {
191191
c.listenForever(ctx)
192192
case <-c.closed:
193193
return
194+
case <-ctx.Done():
195+
return
194196
}
195197
}()
196198
}

client/transport/streamable_http_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"net/http/httptest"
1111
"net/url"
12+
"runtime"
1213
"strings"
1314
"sync"
1415
"testing"
@@ -1324,6 +1325,54 @@ func TestSendRequestSSEStreamStaysOpen(t *testing.T) {
13241325
require.NotNil(t, resp2)
13251326
}
13261327

1328+
// TestContinuousListeningGoroutineExitsOnContextCancel verifies that the goroutine
1329+
// spawned by WithContinuousListening exits when the context passed to Start() is
1330+
// cancelled, even if Initialize never succeeds.
1331+
func TestContinuousListeningGoroutineExitsOnContextCancel(t *testing.T) {
1332+
origRetryInterval := retryInterval
1333+
retryInterval = 10 * time.Millisecond
1334+
t.Cleanup(func() { retryInterval = origRetryInterval })
1335+
1336+
// Server that always rejects requests, so Initialize never succeeds.
1337+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1338+
http.Error(w, `{"jsonrpc":"2.0","id":"1","error":{"code":-32600,"message":"Bad Request"}}`, http.StatusBadRequest)
1339+
}))
1340+
defer srv.Close()
1341+
1342+
const cycles = 5
1343+
before := runtime.NumGoroutine()
1344+
1345+
for range cycles {
1346+
trans, err := NewStreamableHTTP(srv.URL, WithContinuousListening())
1347+
require.NoError(t, err)
1348+
1349+
startCtx, cancel := context.WithCancel(context.Background())
1350+
1351+
err = trans.Start(startCtx)
1352+
require.NoError(t, err)
1353+
1354+
// Attempt Initialize — it will fail because the server returns 400.
1355+
initReq := JSONRPCRequest{
1356+
JSONRPC: "2.0",
1357+
ID: mcp.NewRequestId(int64(1)),
1358+
Method: "initialize",
1359+
}
1360+
_, err = trans.SendRequest(startCtx, initReq)
1361+
require.Error(t, err)
1362+
1363+
// Cancel the context. This should be sufficient to release the goroutine
1364+
// spawned in Start(), without requiring an explicit Close() call.
1365+
cancel()
1366+
}
1367+
1368+
// Poll until goroutines settle, to avoid a flaky single-snapshot check.
1369+
allowed := 1
1370+
require.Eventually(t, func() bool {
1371+
runtime.Gosched()
1372+
return runtime.NumGoroutine()-before <= allowed
1373+
}, 5*time.Second, 50*time.Millisecond, "goroutines leaked beyond allowed=%d (ran %d cycles)", allowed, cycles)
1374+
}
1375+
13271376
// TestSendRequestSSEStreamStaysOpenWithContinuousListening is the same as above but
13281377
// with WithContinuousListening enabled — the GET SSE connection hangs forever on
13291378
// a stateless server, and we verify it doesn't block POST requests.

0 commit comments

Comments
 (0)