mcp: bound streamable Close DELETE so a silent peer cannot hang teardown (#1184)

Close issued a synchronous session-termination DELETE on the connection
context and cancelled that context only after `Do` returned. A reachable
but silent peer could stall `Close()` until the TCP timeout.

Give the DELETE its own short timeout.

RED on current main: `TestCloseHangsOnBlackHoledDelete` failed with
`Close() blocked on a black-holing DELETE`.

GREEN: `go test ./mcp/ -count=1 -timeout 120s -run
'TestStreamableClient|TestCloseHangs|TestCallCancellation_FastReturn'`

Fixes #1183

Made with [Cursor](https://cursor.com)

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
This commit is contained in:
Lee 2026-08-21 18:31:12 +08:00 • committed by GitHub
commit 3d6450fccf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -2016,6 +2016,11 @@ const (
reconnectGrowFactor = 1.5
// reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely.
reconnectMaxDelay = 30 * time.Second
// closeDeleteTimeout bounds the session-termination DELETE sent by Close.
// That request is best-effort: a peer that accepts the connection but never
// answers must not be able to hold teardown open until the TCP timeout.
// See issue #1183.
closeDeleteTimeout = 5 * time.Second
)
var (
@ -2767,7 +2772,9 @@ func (c *streamableClientConn) Close() error {
// No session was established (e.g. the server is stateless),
// so there is nothing to delete.
} else {
req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil)
reqCtx, stop := context.WithTimeout(c.ctx, closeDeleteTimeout)
defer stop()
req, err := http.NewRequestWithContext(reqCtx, http.MethodDelete, c.url, nil)
if err != nil {
c.closeErr = err
} else {