Skip to content

fix: cancel context before closing body to prevent HTTP/2 hang#769

Merged
ezynda3 merged 6 commits intomark3labs:mainfrom
samkeet:fix/http2-close-before-cancel
Apr 4, 2026
Merged

fix: cancel context before closing body to prevent HTTP/2 hang#769
ezynda3 merged 6 commits intomark3labs:mainfrom
samkeet:fix/http2-close-before-cancel

Conversation

@samkeet
Copy link
Copy Markdown
Contributor

@samkeet samkeet commented Mar 27, 2026

Description

SendRequest, SendNotification, and sendResponseToServer can hang indefinitely on HTTP/2 when the MCP server leaves the SSE stream open after sending its JSON-RPC response.

The root cause is Go's LIFO defer ordering: resp.Body.Close() runs before cancel(). On HTTP/2, Close() blocks in a select waiting for cs.donec (stream cleanup) or cs.ctx.Done() (context cancellation). When cc.wmu is contended, cs.donec may never close — and ctx.Done() hasn't fired because cancel() is still waiting its turn.

Fix

Call cancel() before resp.Body.Close() so the context is already canceled when Close() reaches the blocking select:

// Before (hangs):
defer cancel()
defer resp.Body.Close()

// After (returns immediately):
defer func() { cancel(); resp.Body.Close() }()

Applied in 3 locations: SendRequest, SendNotification, sendResponseToServer.

Early-return paths call cancel() explicitly before returning.

Test

Includes TestStreamableHTTP_CloseBlocksBeforeCancel — a deterministic reproduction using a mock transport that simulates the HTTP/2 Close() blocking behavior. Without the fix it hangs for 5s then fails; with the fix it passes in <5ms.

All existing tests pass with -race.

Fixes #768

Summary by CodeRabbit

  • Bug Fixes
    • Fixed HTTP/2 Server-Sent Events shutdown hangs by optimizing the order of context cancellation and response resource cleanup.
  • Tests
    • Added regression test to ensure streaming response cleanup completes properly without hangs.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The changes address an HTTP/2 stream cleanup hang by reordering context cancellation relative to response body closure. In StreamableHTTP, cancellation is now called explicitly on error paths and executes before body closure on successful requests. A regression test is added to verify this ordering prevents blocking behavior.

Changes

Cohort / File(s) Summary
Cancellation and cleanup ordering
client/transport/streamable_http.go
Modified SendRequest, SendNotification, and sendResponseToServer to call cancel() explicitly on error paths and reorder cleanup to execute context cancellation before resp.Body.Close() in a single deferred function, preventing HTTP/2 hang conditions.
Regression test for HTTP/2 cleanup hang
client/transport/streamable_http_bodyclose_test.go
Added deterministic HTTP/2 SSE shutdown hang simulator with h2BodySimulator and blockingSSEReader types; includes mockH2Transport and test TestStreamableHTTP_CloseBlocksBeforeCancel to verify SendRequest completes within timeout when response body close blocks on context cancellation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested labels

type: bug

Suggested reviewers

  • ezynda3
  • pottekkat
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: reordering context cancellation before body closure to prevent HTTP/2 hangs.
Description check ✅ Passed The description covers the problem, fix, testing approach, and issue reference, but lacks explicit type-of-change and checklist selections from the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
client/transport/streamable_http_bodyclose_test.go (1)

88-96: Potential goroutine leak: select {} blocks indefinitely.

The empty select {} at line 91 blocks the goroutine forever with no escape path. While this simulates the intended SSE stream behavior, if h2BodySimulator.Close() is never called (e.g., due to a test panic), the goroutine will leak.

This is acceptable for a focused regression test, but consider adding a context or timeout for safer cleanup if this test grows in scope.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/streamable_http_bodyclose_test.go` around lines 88 - 96, The
empty select in blockingSSEReader.Read blocks forever and can leak a goroutine;
add a cancellable signal (e.g., a done chan struct{} or a context.Context field
on blockingSSEReader) and have the test harness (h2BodySimulator.Close or
equivalent) close/cancel it; then change Read (in blockingSSEReader.Read) to
block on that channel/context (select { case <-r.done: return 0, io.EOF; case
<-...: }) instead of select {} so the reader can be unblocked and the goroutine
cleaned up when Close is called.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/transport/streamable_http.go`:
- Around line 287-291: The nil-response branch currently returns the surrounding
err which is guaranteed to be nil there; update the resp==nil handling so it
cancels the context and returns a proper non-nil error (e.g., "failed to send
request: no response received" or similar) instead of fmt.Errorf("failed to send
request: %w", err); locate the resp check and cancel() call in
streamable_http.go and replace the return to return a descriptive fmt.Errorf,
ensuring callers never receive (nil, nil).

---

Nitpick comments:
In `@client/transport/streamable_http_bodyclose_test.go`:
- Around line 88-96: The empty select in blockingSSEReader.Read blocks forever
and can leak a goroutine; add a cancellable signal (e.g., a done chan struct{}
or a context.Context field on blockingSSEReader) and have the test harness
(h2BodySimulator.Close or equivalent) close/cancel it; then change Read (in
blockingSSEReader.Read) to block on that channel/context (select { case
<-r.done: return 0, io.EOF; case <-...: }) instead of select {} so the reader
can be unblocked and the goroutine cleaned up when Close is called.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a962a104-2b85-4903-96bd-f61108590bf9

📥 Commits

Reviewing files that changed from the base of the PR and between 4713d74 and cc9e4a1.

📒 Files selected for processing (2)
  • client/transport/streamable_http.go
  • client/transport/streamable_http_bodyclose_test.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
client/transport/streamable_http_bodyclose_test.go (2)

110-112: Unused t *testing.T field.

The t field in mockH2Transport is never used within RoundTrip. Consider removing it or using it for diagnostic logging if needed.

♻️ Suggested fix
 type mockH2Transport struct {
-	t *testing.T
 }

And update the instantiation at line 168:

mockTransport := &mockH2Transport{}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/streamable_http_bodyclose_test.go` around lines 110 - 112,
The struct mockH2Transport contains an unused field t *testing.T; remove that
field from the type definition and update any instantiations (e.g.,
mockTransport := &mockH2Transport{}) so they no longer attempt to set t; verify
RoundTrip (method on mockH2Transport) compiles and uses no testing.T reference
after the field is removed.

167-207: Consider adding transport.Close() cleanup.

The test creates a StreamableHTTP transport but doesn't close it after the test completes. While this may not cause issues in this specific test since the mock transport doesn't hold real resources, adding cleanup would be more robust and consistent with production usage patterns.

♻️ Suggested fix
 	require.NoError(t, err)
 	require.NoError(t, transport.Start(context.Background()))
+	defer transport.Close()
 
 	done := make(chan struct{})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/streamable_http_bodyclose_test.go` around lines 167 - 207,
The test TestStreamableHTTP_CloseBlocksBeforeCancel creates a StreamableHTTP via
NewStreamableHTTP but never calls transport.Close(); update the test to ensure
transport is cleaned up by calling transport.Close() (preferably via defer right
after Start succeeds) to mirror real usage and release any resources; refer to
the transport variable returned by NewStreamableHTTP (and the StreamableHTTP
type) and add a defer transport.Close() immediately after require.NoError(t,
transport.Start(...)) so the transport is always closed even on test failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@client/transport/streamable_http_bodyclose_test.go`:
- Around line 110-112: The struct mockH2Transport contains an unused field t
*testing.T; remove that field from the type definition and update any
instantiations (e.g., mockTransport := &mockH2Transport{}) so they no longer
attempt to set t; verify RoundTrip (method on mockH2Transport) compiles and uses
no testing.T reference after the field is removed.
- Around line 167-207: The test TestStreamableHTTP_CloseBlocksBeforeCancel
creates a StreamableHTTP via NewStreamableHTTP but never calls
transport.Close(); update the test to ensure transport is cleaned up by calling
transport.Close() (preferably via defer right after Start succeeds) to mirror
real usage and release any resources; refer to the transport variable returned
by NewStreamableHTTP (and the StreamableHTTP type) and add a defer
transport.Close() immediately after require.NoError(t, transport.Start(...)) so
the transport is always closed even on test failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 80679271-b2b2-4722-8983-7b9a8fa10bfc

📥 Commits

Reviewing files that changed from the base of the PR and between cc9e4a1 and d08d858.

📒 Files selected for processing (2)
  • client/transport/streamable_http.go
  • client/transport/streamable_http_bodyclose_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/transport/streamable_http.go

samkeet added a commit to samkeet/mcp-go that referenced this pull request Mar 28, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
path/to/your/file.go (1)

7-7: Prefer a sentinel error (and wrap only when propagating underlying errors).

Returning fmt.Errorf("some error") directly makes errors.Is checks impossible for callers. Define a package-level sentinel and return it here.

Suggested refactor
+var ErrSomeFunction = errors.New("some error")
+
 func SomeFunction() error {
     ...
     if someCondition { 
         return nil
     }
-    return fmt.Errorf("some error")
+    return ErrSomeFunction
 }

As per coding guidelines, return sentinel errors and use wrapped errors for contextual propagation (fmt.Errorf("context: %w", err)).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@path/to/your/file.go` at line 7, Define a package-level sentinel error (e.g.,
var ErrSome = errors.New("some error")) and replace the direct fmt.Errorf("some
error") return with returning ErrSome from the function; only use
fmt.Errorf("context: %w", err) to wrap when propagating an underlying error, and
reference ErrSome in callers with errors.Is to detect this condition.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@path/to/your/file.go`:
- Around line 1-2: Add proper GoDoc comments for exported functions by adding
comments that start with the function name (e.g., "SomeFunction ..." and
"SomeOtherFunction ...") immediately above their declarations, remove the
unnecessary inline comment inside SomeFunction, and ensure SomeFunction returns
a nil error explicitly (return nil) so the function signature is satisfied;
update the comment text to be concise and descriptive and place it directly
above the corresponding function declarations.
- Around line 3-4: Replace the placeholder ellipses (...) with real code or
remove them so the file compiles, define or replace the undefined someCondition
used in the if statement (or change the if to a valid condition) so it no longer
references an unknown identifier, add the "fmt" import to the file so fmt.Errorf
is available, and update the GoDoc comments for the exported functions
SomeFunction and SomeOtherFunction to start with the function names (e.g., "//
SomeFunction ..." and "// SomeOtherFunction ..."); also remove the unnecessary
inline comment that was on line 5.

---

Nitpick comments:
In `@path/to/your/file.go`:
- Line 7: Define a package-level sentinel error (e.g., var ErrSome =
errors.New("some error")) and replace the direct fmt.Errorf("some error") return
with returning ErrSome from the function; only use fmt.Errorf("context: %w",
err) to wrap when propagating an underlying error, and reference ErrSome in
callers with errors.Is to detect this condition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d8d220d9-6baa-48b5-bf9e-e90b55e0b2be

📥 Commits

Reviewing files that changed from the base of the PR and between d08d858 and 1a93b65.

📒 Files selected for processing (1)
  • path/to/your/file.go

@ezynda3
Copy link
Copy Markdown
Contributor

ezynda3 commented Mar 31, 2026

@samkeet please fix linting errors and tests and address any coderabbit comments.

@ezynda3 ezynda3 added the status: needs submitter response Waiting for feedback from issue opener label Mar 31, 2026
samkeet and others added 5 commits April 1, 2026 11:41
On HTTP/2, resp.Body.Close() blocks in a select waiting for stream cleanup
(cs.donec) or context cancellation (cs.ctx.Done()). When cc.wmu is contended,
cs.donec may never close, making ctx.Done() the only exit path.

The previous defer ordering (LIFO) ran Close() before cancel(), so ctx.Done()
never fired — causing an indefinite hang when the MCP server leaves the SSE
stream open after sending its response (allowed by the spec: SHOULD, not MUST).

Fix: call cancel() before resp.Body.Close() in all three locations:
- SendRequest
- SendNotification
- sendResponseToServer

Includes a deterministic reproduction test using a mock transport that
simulates the HTTP/2 Close() blocking behavior.

Fixes mark3labs#768
Branch-Creation-Time: 2026-03-27T18:18:02+0000
…in test

- Return descriptive error when resp is nil instead of wrapping a nil err
- Add done channel to blockingSSEReader so goroutines are cleanly unblocked
  when h2BodySimulator.Close() is called, preventing leaks on test panics
- Delete path/to/your/file.go (placeholder that caused typecheck lint failure)
- Delete revert_commit.md (stray file)
- Remove unused t *testing.T field from mockH2Transport struct
- Add defer transport.Close() after Start() in regression test
samkeet added a commit to samkeet/mcp-go that referenced this pull request Apr 1, 2026
@samkeet samkeet force-pushed the fix/http2-close-before-cancel branch from 8fe631d to 4b3d0bd Compare April 1, 2026 18:41
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
client/transport/streamable_http_bodyclose_test.go (1)

138-138: Consider handling the json.Marshal error.

While marshaling a simple map is unlikely to fail, silently ignoring the error is not idiomatic. In test code, you can either check the error or use a fatal assertion.

♻️ Suggested fix
-	data, _ := json.Marshal(rpcResp)
+	data, err := json.Marshal(rpcResp)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal JSON-RPC response: %w", err)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/transport/streamable_http_bodyclose_test.go` at line 138, Handle the
possible error returned by json.Marshal(rpcResp) instead of ignoring it: capture
the error from json.Marshal (when creating variable data from rpcResp) and in
the test assert/fail on error (e.g., call t.Fatalf or use your test helper like
require.NoError(t, err)) so failures in marshaling surface rather than being
silently ignored.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@client/transport/streamable_http_bodyclose_test.go`:
- Line 138: Handle the possible error returned by json.Marshal(rpcResp) instead
of ignoring it: capture the error from json.Marshal (when creating variable data
from rpcResp) and in the test assert/fail on error (e.g., call t.Fatalf or use
your test helper like require.NoError(t, err)) so failures in marshaling surface
rather than being silently ignored.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b345dd6e-cde7-4a55-9ce8-e992f660dd2b

📥 Commits

Reviewing files that changed from the base of the PR and between 8fe631d and 4b3d0bd.

📒 Files selected for processing (2)
  • client/transport/streamable_http.go
  • client/transport/streamable_http_bodyclose_test.go
✅ Files skipped from review due to trivial changes (1)
  • client/transport/streamable_http.go

@samkeet samkeet requested a review from ezynda3 April 1, 2026 18:56
@samkeet
Copy link
Copy Markdown
Contributor Author

samkeet commented Apr 1, 2026

@ezynda3 Resolved the comments from CodeRabbit. Ready for review again. Thank you!

@samkeet samkeet force-pushed the fix/http2-close-before-cancel branch from 5bb71d2 to 7d14161 Compare April 2, 2026 16:42
ezynda3 pushed a commit that referenced this pull request Apr 4, 2026
…d HTTP/2 hangs (#780)

* fix: cancel context before closing body to prevent HTTP/2 hang

On HTTP/2, resp.Body.Close() blocks in a select waiting for stream cleanup
(cs.donec) or context cancellation (cs.ctx.Done()). When cc.wmu is contended,
cs.donec may never close, making ctx.Done() the only exit path.

The previous defer ordering (LIFO) ran Close() before cancel(), so ctx.Done()
never fired — causing an indefinite hang when the MCP server leaves the SSE
stream open after sending its response (allowed by the spec: SHOULD, not MUST).

Fix: call cancel() before resp.Body.Close() in all three locations:
- SendRequest
- SendNotification
- sendResponseToServer

Includes a deterministic reproduction test using a mock transport that
simulates the HTTP/2 Close() blocking behavior.

Fixes #768
Branch-Creation-Time: 2026-03-27T18:18:02+0000

* fix: address review feedback — nil-response error and goroutine leak in test

- Return descriptive error when resp is nil instead of wrapping a nil err
- Add done channel to blockingSSEReader so goroutines are cleanly unblocked
  when h2BodySimulator.Close() is called, preventing leaks on test panics

* Add bug fixes and docstring updates from PR #769

* Revert commit 1a93b65

* fix: address coderabbit review — remove stray files and clean up test

- Delete path/to/your/file.go (placeholder that caused typecheck lint failure)
- Delete revert_commit.md (stray file)
- Remove unused t *testing.T field from mockH2Transport struct
- Add defer transport.Close() after Start() in regression test

* fix: handle json.Marshal error in mockH2Transport.RoundTrip

* fix: make readSSE context-aware to prevent HTTP/2 hangs

readSSE uses bufio.ReadString which is blocking I/O that does not respect
context cancellation. When an SSE stream is open but idle (e.g. stateless
MCP servers that never send data on GET connections), ReadString blocks
indefinitely. The select{case <-ctx.Done()} check only runs between reads,
not during a blocked read.

Fix: spawn a goroutine that closes the reader when ctx is cancelled. This
causes ReadString to return immediately with an error, allowing readSSE
to exit promptly.

Also fix createGETConnectionToServer to use the same cancel-before-close
pattern as SendRequest and SendNotification, preventing HTTP/2 body drain
hangs on shutdown.
Branch-Creation-Time: 2026-04-01T23:12:16+0000
@ezynda3 ezynda3 merged commit 29a711c into mark3labs:main Apr 4, 2026
1 check passed
@samkeet samkeet deleted the fix/http2-close-before-cancel branch April 6, 2026 16:31
@samkeet samkeet restored the fix/http2-close-before-cancel branch April 6, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: needs submitter response Waiting for feedback from issue opener

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: SendRequest hangs indefinitely on HTTP/2 when server leaves SSE stream open after response

2 participants