What
Client.Connect with a context deadline can block far past that deadline against a server that accepts TCP connections but never responds (black-holed peer). Observed: a 2s deadline returns after ~12s with the default HTTP client in a local test; in production, with connections crossing a NAT that silently dropped packets, a 10s deadline stretched to ~125s (kernel TCP timeout).
The caller's deadline bounds the initialize request itself, but not the error-path cleanup that follows. StreamableClientTransport.Connect detaches the incoming context for the connection's lifecycle (connCtx, cancel := context.WithCancel(xcontext.Detach(ctx)), mcp/streamable.go ~L2045 in v1.7.0). That detachment is intentional and documented for keeping the standalone SSE stream alive past a short connect deadline. However, when initialize fails at the deadline, the cleanup work that runs before Connect returns (the notifications/cancelled write for the abandoned call and session.Close()) issues HTTP requests bound to the detached connection context, not to the caller's expired one. Against a black-holed server those requests block until the HTTP transport or the kernel gives up, so Connect overshoots its deadline by minutes with a default http.Client (no ResponseHeaderTimeout).
Related: #1183 covers the same detached-context ordering problem for Close() specifically. This report is about Connect not honoring its own deadline on the error path.
Repro
func TestConnectOvershootsDeadline(t *testing.T) {
// Accepts TCP connections, never responds to any request.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
}
}()
client := mcp.NewClient(&mcp.Implementation{Name: "c", Version: "0"}, nil)
tr := &mcp.StreamableClientTransport{Endpoint: "http://" + ln.Addr().String()}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
start := time.Now()
_, err = client.Connect(ctx, tr, nil)
elapsed := time.Since(start)
t.Logf("Connect returned after %s, err=%v", elapsed, err)
if elapsed > 4*time.Second {
t.Fatalf("Connect blocked %s past a 2s deadline", elapsed)
}
}
With go-sdk v1.7.0 and Go 1.24 this fails: Connect returned after 12.03s, err=context deadline exceeded; sending "notifications/cancelled": rejected by transport: Post "http://127.0.0.1:...": context deadline exceeded.
Expected
Connect(ctx, ...) returns within (approximately) ctx's deadline regardless of server behavior. Error-path cleanup for a session that never initialized could be best-effort/asynchronous, or bounded by the caller's context rather than the detached connection context.
Workaround
We now run Connect+ListTools in a goroutine and select on the deadline, abandoning the goroutine on timeout, plus a cloned http.Transport with ResponseHeaderTimeout and a short dial timeout (coder/coder#28400).
🤖 Filed by Mux (AI agent) on behalf of @ibetitsmike.
What
Client.Connectwith a context deadline can block far past that deadline against a server that accepts TCP connections but never responds (black-holed peer). Observed: a 2s deadline returns after ~12s with the default HTTP client in a local test; in production, with connections crossing a NAT that silently dropped packets, a 10s deadline stretched to ~125s (kernel TCP timeout).The caller's deadline bounds the initialize request itself, but not the error-path cleanup that follows.
StreamableClientTransport.Connectdetaches the incoming context for the connection's lifecycle (connCtx, cancel := context.WithCancel(xcontext.Detach(ctx)),mcp/streamable.go~L2045 in v1.7.0). That detachment is intentional and documented for keeping the standalone SSE stream alive past a short connect deadline. However, when initialize fails at the deadline, the cleanup work that runs beforeConnectreturns (thenotifications/cancelledwrite for the abandoned call andsession.Close()) issues HTTP requests bound to the detached connection context, not to the caller's expired one. Against a black-holed server those requests block until the HTTP transport or the kernel gives up, soConnectovershoots its deadline by minutes with a defaulthttp.Client(noResponseHeaderTimeout).Related: #1183 covers the same detached-context ordering problem for
Close()specifically. This report is aboutConnectnot honoring its own deadline on the error path.Repro
With go-sdk v1.7.0 and Go 1.24 this fails:
Connect returned after 12.03s, err=context deadline exceeded; sending "notifications/cancelled": rejected by transport: Post "http://127.0.0.1:...": context deadline exceeded.Expected
Connect(ctx, ...)returns within (approximately) ctx's deadline regardless of server behavior. Error-path cleanup for a session that never initialized could be best-effort/asynchronous, or bounded by the caller's context rather than the detached connection context.Workaround
We now run
Connect+ListToolsin a goroutine and select on the deadline, abandoning the goroutine on timeout, plus a clonedhttp.TransportwithResponseHeaderTimeoutand a short dial timeout (coder/coder#28400).