fix(h2): allow stream-bodied requests to multiplex on a busy session - #5538

Merged
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang
Jul 13, 2026
Merged

fix(h2): allow stream-bodied requests to multiplex on a busy session#5538
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang

Conversation

@GiHoon1123

@GiHoon1123GiHoon1123 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This relates to...

Fixes#5524
Fixes#5494

Same root cause for both -- #5494 already has an open fix in #5497 (same core change to busy(), currently blocked on a requested unit test) -- see "Relationship to #5497" below before reviewing.

Rationale

busy() in lib/dispatcher/client-h2.js deferred any request with a stream/async-iterable/FormData body until every other in-flight request on the h2 session completed. If one of those in-flight requests is a long-lived stream (e.g. an open text/event-stream GET), every bodied request queued behind it parks indefinitely. The client also stops reporting kBusy while an h2 context is attached (so the pool keeps multiplexing instead of opening a second connection), so there's no escape route -- for an SSE stream, the hang is permanent.

The two hazards the guard's comments cited are already handled more precisely elsewhere in this file:

  • "can error while other requests are inflight and indirectly error those as well" -- a stream erroring mid-request now only aborts that one stream via the abort() closure (explicit comment there: "We do not destroy the socket as we can continue using the session"); it doesn't touch sibling streams.
  • "cannot be retried... could cause failure" -- canRetryRequestAfterGoAway() already excludes stream/iterable/FormData bodies from the requests it resurrects after a GOAWAY (body == null || isBuffer || isBlobLike only), erroring the rest instead of replaying a partially-consumed body.

Both of those were already in place before this change, so the blanket pre-dispatch guard was redundant.

Relationship to #5497

#5497 removes the same 9 lines for the same reason, opened against #5494 (concurrent POSTs on h2 not running concurrently -- a throughput complaint). #5524 is a more severe symptom of the identical guard: not just serialized, but a permanent deadlock when one of the concurrent requests is a long-lived stream like SSE. I only found #5497 after finishing this fix independently.

This PR adds what #5497 doesn't currently have:

Happy to close this in favor of #5497 if the maintainers would rather continue there -- opening it mainly so the missing test and both scenarios' coverage aren't lost. No preference on which one lands.

Changes

Features

N/A

Bug Fixes

Breaking Changes and Deprecations

N/A -- this only removes a guard that prevented dispatch; nothing that previously worked changes behavior, only what could not previously proceed now can.

Status

busy() deferred any request with a stream/async-iterable/FormData body
until every other in-flight request on the h2 session completed. For a
long-lived stream on the session (e.g. an open text/event-stream GET),
this parks bodied requests indefinitely -- the client also stops
reporting kBusy while an h2 context is attached, so the pool never
opens a second connection either.
The two hazards the guard's comments cited are already handled more
precisely elsewhere: a stream erroring mid-request now only aborts
that one stream (session stays usable, see the abort() closure), and
canRetryRequestAfterGoAway() already excludes stream/iterable/FormData
bodies from the requests it resurrects after a GOAWAY, erroring them
instead of unsafely replaying a partially-consumed body. The blanket
pre-dispatch guard was redundant given those.
Adds a regression test reproducing the exact deadlock: a POST issued
while an SSE GET is held open on the same session now resolves
immediately on the shared session instead of hanging. Fixesnodejs#5524.
nodejs#5494)
Same busy() fix as the previous commit also resolvesnodejs#5494: two
stream-bodied POSTs no longer serialize on the same h2 session, the
second now dispatches while the first is still in flight instead of
waiting for it to complete.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina
mcollina requested a review from metcoder95July 9, 2026 10:03
@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (3c662a5) to head (5081722).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #5538 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37329 37366 +37 =======================================
+ Hits 34883 34919 +36 - Misses 2446 2447 +1 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@metcoder95metcoder95 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tests seems unhappy

The SSE fetch's response body was never read, and after() aborted it
then immediately closed the dispatcher without waiting for the abort
to settle. On a slower teardown (seen on macOS/Windows CI, not Linux),
the abort's socket cleanup can complete after the dispatcher is
already closed and the test considered done, surfacing a late,
listener-less ECONNRESET as an uncaughtException.
Wait for the SSE fetch to settle and explicitly cancel its response
body before closing the dispatcher.
The previous commit's fix (await the aborted fetch, cancel its body
before closing the dispatcher) wasn't the real cause and didn't
resolve it -- reproduced locally under Node 24 (30/30 runs), where the
uncaughtException fires immediately as a side effect of
dispatcher.close() itself, not from a delay-sensitive race (a 2s grace
period in the teardown hook didn't help either).
The actual cause: the client abruptly aborting a still-open SSE stream
before the dispatcher closes tears down the underlying socket by reset
rather than a clean end, and that reset surfaces as a listener-less
ECONNRESET once nothing remains to observe it.
Fix: end the SSE stream from the server side first. That gives the
client a normal stream close to react to, instead of an abrupt one,
before the client-side abort/cancel/close sequence runs. Verified
30/30 locally on Node 24 (where it reproduced 100% before this), and
re-ran the full test:unit suite clean (1415 passed, 0 failed).
@GiHoon1123

Copy link
Copy Markdown
ContributorAuthor

Found the real cause -- it wasn't a timing/wait issue (a 2s grace period didn't help either). The client abruptly aborting the still-open SSE stream before closing the dispatcher tears the socket down by reset instead of a clean end, which surfaced as a listener-less ECONNRESET. Fixed by ending the SSE stream from the server side first, so the client sees a normal close instead.

Reproduced 100% locally under Node 24 (not on 22, which is why I hadn't caught it before) and verified 30/30 after the fix, plus a clean test:unit run.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP/2: fetch POST hangs while an SSE stream is open HTTP2 POST requests over fetch() to the same origin can't run concurrently

4 participants

@GiHoon1123@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(h2): allow stream-bodied requests to multiplex on a busy session - #5538

Merged
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang
Jul 13, 2026
Merged

fix(h2): allow stream-bodied requests to multiplex on a busy session#5538
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang

Conversation

@GiHoon1123

@GiHoon1123GiHoon1123 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This relates to...

Fixes#5524
Fixes#5494

Same root cause for both -- #5494 already has an open fix in #5497 (same core change to busy(), currently blocked on a requested unit test) -- see "Relationship to #5497" below before reviewing.

Rationale

busy() in lib/dispatcher/client-h2.js deferred any request with a stream/async-iterable/FormData body until every other in-flight request on the h2 session completed. If one of those in-flight requests is a long-lived stream (e.g. an open text/event-stream GET), every bodied request queued behind it parks indefinitely. The client also stops reporting kBusy while an h2 context is attached (so the pool keeps multiplexing instead of opening a second connection), so there's no escape route -- for an SSE stream, the hang is permanent.

The two hazards the guard's comments cited are already handled more precisely elsewhere in this file:

  • "can error while other requests are inflight and indirectly error those as well" -- a stream erroring mid-request now only aborts that one stream via the abort() closure (explicit comment there: "We do not destroy the socket as we can continue using the session"); it doesn't touch sibling streams.
  • "cannot be retried... could cause failure" -- canRetryRequestAfterGoAway() already excludes stream/iterable/FormData bodies from the requests it resurrects after a GOAWAY (body == null || isBuffer || isBlobLike only), erroring the rest instead of replaying a partially-consumed body.

Both of those were already in place before this change, so the blanket pre-dispatch guard was redundant.

Relationship to #5497

#5497 removes the same 9 lines for the same reason, opened against #5494 (concurrent POSTs on h2 not running concurrently -- a throughput complaint). #5524 is a more severe symptom of the identical guard: not just serialized, but a permanent deadlock when one of the concurrent requests is a long-lived stream like SSE. I only found #5497 after finishing this fix independently.

This PR adds what #5497 doesn't currently have:

Happy to close this in favor of #5497 if the maintainers would rather continue there -- opening it mainly so the missing test and both scenarios' coverage aren't lost. No preference on which one lands.

Changes

Features

N/A

Bug Fixes

Breaking Changes and Deprecations

N/A -- this only removes a guard that prevented dispatch; nothing that previously worked changes behavior, only what could not previously proceed now can.

Status

busy() deferred any request with a stream/async-iterable/FormData body
until every other in-flight request on the h2 session completed. For a
long-lived stream on the session (e.g. an open text/event-stream GET),
this parks bodied requests indefinitely -- the client also stops
reporting kBusy while an h2 context is attached, so the pool never
opens a second connection either.
The two hazards the guard's comments cited are already handled more
precisely elsewhere: a stream erroring mid-request now only aborts
that one stream (session stays usable, see the abort() closure), and
canRetryRequestAfterGoAway() already excludes stream/iterable/FormData
bodies from the requests it resurrects after a GOAWAY, erroring them
instead of unsafely replaying a partially-consumed body. The blanket
pre-dispatch guard was redundant given those.
Adds a regression test reproducing the exact deadlock: a POST issued
while an SSE GET is held open on the same session now resolves
immediately on the shared session instead of hanging. Fixesnodejs#5524.
nodejs#5494)
Same busy() fix as the previous commit also resolvesnodejs#5494: two
stream-bodied POSTs no longer serialize on the same h2 session, the
second now dispatches while the first is still in flight instead of
waiting for it to complete.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina
mcollina requested a review from metcoder95July 9, 2026 10:03
@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (3c662a5) to head (5081722).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #5538 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37329 37366 +37 =======================================
+ Hits 34883 34919 +36 - Misses 2446 2447 +1 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@metcoder95metcoder95 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tests seems unhappy

The SSE fetch's response body was never read, and after() aborted it
then immediately closed the dispatcher without waiting for the abort
to settle. On a slower teardown (seen on macOS/Windows CI, not Linux),
the abort's socket cleanup can complete after the dispatcher is
already closed and the test considered done, surfacing a late,
listener-less ECONNRESET as an uncaughtException.
Wait for the SSE fetch to settle and explicitly cancel its response
body before closing the dispatcher.
The previous commit's fix (await the aborted fetch, cancel its body
before closing the dispatcher) wasn't the real cause and didn't
resolve it -- reproduced locally under Node 24 (30/30 runs), where the
uncaughtException fires immediately as a side effect of
dispatcher.close() itself, not from a delay-sensitive race (a 2s grace
period in the teardown hook didn't help either).
The actual cause: the client abruptly aborting a still-open SSE stream
before the dispatcher closes tears down the underlying socket by reset
rather than a clean end, and that reset surfaces as a listener-less
ECONNRESET once nothing remains to observe it.
Fix: end the SSE stream from the server side first. That gives the
client a normal stream close to react to, instead of an abrupt one,
before the client-side abort/cancel/close sequence runs. Verified
30/30 locally on Node 24 (where it reproduced 100% before this), and
re-ran the full test:unit suite clean (1415 passed, 0 failed).
@GiHoon1123

Copy link
Copy Markdown
ContributorAuthor

Found the real cause -- it wasn't a timing/wait issue (a 2s grace period didn't help either). The client abruptly aborting the still-open SSE stream before closing the dispatcher tears the socket down by reset instead of a clean end, which surfaced as a listener-less ECONNRESET. Fixed by ending the SSE stream from the server side first, so the client sees a normal close instead.

Reproduced 100% locally under Node 24 (not on 22, which is why I hadn't caught it before) and verified 30/30 after the fix, plus a clean test:unit run.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP/2: fetch POST hangs while an SSE stream is open HTTP2 POST requests over fetch() to the same origin can't run concurrently

4 participants

@GiHoon1123@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(h2): allow stream-bodied requests to multiplex on a busy session - #5538

Merged
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang
Jul 13, 2026
Merged

fix(h2): allow stream-bodied requests to multiplex on a busy session#5538
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang

Conversation

@GiHoon1123

@GiHoon1123GiHoon1123 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This relates to...

Fixes#5524
Fixes#5494

Same root cause for both -- #5494 already has an open fix in #5497 (same core change to busy(), currently blocked on a requested unit test) -- see "Relationship to #5497" below before reviewing.

Rationale

busy() in lib/dispatcher/client-h2.js deferred any request with a stream/async-iterable/FormData body until every other in-flight request on the h2 session completed. If one of those in-flight requests is a long-lived stream (e.g. an open text/event-stream GET), every bodied request queued behind it parks indefinitely. The client also stops reporting kBusy while an h2 context is attached (so the pool keeps multiplexing instead of opening a second connection), so there's no escape route -- for an SSE stream, the hang is permanent.

The two hazards the guard's comments cited are already handled more precisely elsewhere in this file:

  • "can error while other requests are inflight and indirectly error those as well" -- a stream erroring mid-request now only aborts that one stream via the abort() closure (explicit comment there: "We do not destroy the socket as we can continue using the session"); it doesn't touch sibling streams.
  • "cannot be retried... could cause failure" -- canRetryRequestAfterGoAway() already excludes stream/iterable/FormData bodies from the requests it resurrects after a GOAWAY (body == null || isBuffer || isBlobLike only), erroring the rest instead of replaying a partially-consumed body.

Both of those were already in place before this change, so the blanket pre-dispatch guard was redundant.

Relationship to #5497

#5497 removes the same 9 lines for the same reason, opened against #5494 (concurrent POSTs on h2 not running concurrently -- a throughput complaint). #5524 is a more severe symptom of the identical guard: not just serialized, but a permanent deadlock when one of the concurrent requests is a long-lived stream like SSE. I only found #5497 after finishing this fix independently.

This PR adds what #5497 doesn't currently have:

Happy to close this in favor of #5497 if the maintainers would rather continue there -- opening it mainly so the missing test and both scenarios' coverage aren't lost. No preference on which one lands.

Changes

Features

N/A

Bug Fixes

Breaking Changes and Deprecations

N/A -- this only removes a guard that prevented dispatch; nothing that previously worked changes behavior, only what could not previously proceed now can.

Status

busy() deferred any request with a stream/async-iterable/FormData body
until every other in-flight request on the h2 session completed. For a
long-lived stream on the session (e.g. an open text/event-stream GET),
this parks bodied requests indefinitely -- the client also stops
reporting kBusy while an h2 context is attached, so the pool never
opens a second connection either.
The two hazards the guard's comments cited are already handled more
precisely elsewhere: a stream erroring mid-request now only aborts
that one stream (session stays usable, see the abort() closure), and
canRetryRequestAfterGoAway() already excludes stream/iterable/FormData
bodies from the requests it resurrects after a GOAWAY, erroring them
instead of unsafely replaying a partially-consumed body. The blanket
pre-dispatch guard was redundant given those.
Adds a regression test reproducing the exact deadlock: a POST issued
while an SSE GET is held open on the same session now resolves
immediately on the shared session instead of hanging. Fixesnodejs#5524.
nodejs#5494)
Same busy() fix as the previous commit also resolvesnodejs#5494: two
stream-bodied POSTs no longer serialize on the same h2 session, the
second now dispatches while the first is still in flight instead of
waiting for it to complete.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina
mcollina requested a review from metcoder95July 9, 2026 10:03
@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (3c662a5) to head (5081722).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #5538 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37329 37366 +37 =======================================
+ Hits 34883 34919 +36 - Misses 2446 2447 +1 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@metcoder95metcoder95 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tests seems unhappy

The SSE fetch's response body was never read, and after() aborted it
then immediately closed the dispatcher without waiting for the abort
to settle. On a slower teardown (seen on macOS/Windows CI, not Linux),
the abort's socket cleanup can complete after the dispatcher is
already closed and the test considered done, surfacing a late,
listener-less ECONNRESET as an uncaughtException.
Wait for the SSE fetch to settle and explicitly cancel its response
body before closing the dispatcher.
The previous commit's fix (await the aborted fetch, cancel its body
before closing the dispatcher) wasn't the real cause and didn't
resolve it -- reproduced locally under Node 24 (30/30 runs), where the
uncaughtException fires immediately as a side effect of
dispatcher.close() itself, not from a delay-sensitive race (a 2s grace
period in the teardown hook didn't help either).
The actual cause: the client abruptly aborting a still-open SSE stream
before the dispatcher closes tears down the underlying socket by reset
rather than a clean end, and that reset surfaces as a listener-less
ECONNRESET once nothing remains to observe it.
Fix: end the SSE stream from the server side first. That gives the
client a normal stream close to react to, instead of an abrupt one,
before the client-side abort/cancel/close sequence runs. Verified
30/30 locally on Node 24 (where it reproduced 100% before this), and
re-ran the full test:unit suite clean (1415 passed, 0 failed).
@GiHoon1123

Copy link
Copy Markdown
ContributorAuthor

Found the real cause -- it wasn't a timing/wait issue (a 2s grace period didn't help either). The client abruptly aborting the still-open SSE stream before closing the dispatcher tears the socket down by reset instead of a clean end, which surfaced as a listener-less ECONNRESET. Fixed by ending the SSE stream from the server side first, so the client sees a normal close instead.

Reproduced 100% locally under Node 24 (not on 22, which is why I hadn't caught it before) and verified 30/30 after the fix, plus a clean test:unit run.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP/2: fetch POST hangs while an SSE stream is open HTTP2 POST requests over fetch() to the same origin can't run concurrently

4 participants

@GiHoon1123@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(h2): allow stream-bodied requests to multiplex on a busy session - #5538

Merged
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang
Jul 13, 2026
Merged

fix(h2): allow stream-bodied requests to multiplex on a busy session#5538
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang

Conversation

@GiHoon1123

@GiHoon1123GiHoon1123 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This relates to...

Fixes#5524
Fixes#5494

Same root cause for both -- #5494 already has an open fix in #5497 (same core change to busy(), currently blocked on a requested unit test) -- see "Relationship to #5497" below before reviewing.

Rationale

busy() in lib/dispatcher/client-h2.js deferred any request with a stream/async-iterable/FormData body until every other in-flight request on the h2 session completed. If one of those in-flight requests is a long-lived stream (e.g. an open text/event-stream GET), every bodied request queued behind it parks indefinitely. The client also stops reporting kBusy while an h2 context is attached (so the pool keeps multiplexing instead of opening a second connection), so there's no escape route -- for an SSE stream, the hang is permanent.

The two hazards the guard's comments cited are already handled more precisely elsewhere in this file:

  • "can error while other requests are inflight and indirectly error those as well" -- a stream erroring mid-request now only aborts that one stream via the abort() closure (explicit comment there: "We do not destroy the socket as we can continue using the session"); it doesn't touch sibling streams.
  • "cannot be retried... could cause failure" -- canRetryRequestAfterGoAway() already excludes stream/iterable/FormData bodies from the requests it resurrects after a GOAWAY (body == null || isBuffer || isBlobLike only), erroring the rest instead of replaying a partially-consumed body.

Both of those were already in place before this change, so the blanket pre-dispatch guard was redundant.

Relationship to #5497

#5497 removes the same 9 lines for the same reason, opened against #5494 (concurrent POSTs on h2 not running concurrently -- a throughput complaint). #5524 is a more severe symptom of the identical guard: not just serialized, but a permanent deadlock when one of the concurrent requests is a long-lived stream like SSE. I only found #5497 after finishing this fix independently.

This PR adds what #5497 doesn't currently have:

Happy to close this in favor of #5497 if the maintainers would rather continue there -- opening it mainly so the missing test and both scenarios' coverage aren't lost. No preference on which one lands.

Changes

Features

N/A

Bug Fixes

Breaking Changes and Deprecations

N/A -- this only removes a guard that prevented dispatch; nothing that previously worked changes behavior, only what could not previously proceed now can.

Status

busy() deferred any request with a stream/async-iterable/FormData body
until every other in-flight request on the h2 session completed. For a
long-lived stream on the session (e.g. an open text/event-stream GET),
this parks bodied requests indefinitely -- the client also stops
reporting kBusy while an h2 context is attached, so the pool never
opens a second connection either.
The two hazards the guard's comments cited are already handled more
precisely elsewhere: a stream erroring mid-request now only aborts
that one stream (session stays usable, see the abort() closure), and
canRetryRequestAfterGoAway() already excludes stream/iterable/FormData
bodies from the requests it resurrects after a GOAWAY, erroring them
instead of unsafely replaying a partially-consumed body. The blanket
pre-dispatch guard was redundant given those.
Adds a regression test reproducing the exact deadlock: a POST issued
while an SSE GET is held open on the same session now resolves
immediately on the shared session instead of hanging. Fixesnodejs#5524.
nodejs#5494)
Same busy() fix as the previous commit also resolvesnodejs#5494: two
stream-bodied POSTs no longer serialize on the same h2 session, the
second now dispatches while the first is still in flight instead of
waiting for it to complete.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina
mcollina requested a review from metcoder95July 9, 2026 10:03
@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (3c662a5) to head (5081722).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #5538 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37329 37366 +37 =======================================
+ Hits 34883 34919 +36 - Misses 2446 2447 +1 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@metcoder95metcoder95 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tests seems unhappy

The SSE fetch's response body was never read, and after() aborted it
then immediately closed the dispatcher without waiting for the abort
to settle. On a slower teardown (seen on macOS/Windows CI, not Linux),
the abort's socket cleanup can complete after the dispatcher is
already closed and the test considered done, surfacing a late,
listener-less ECONNRESET as an uncaughtException.
Wait for the SSE fetch to settle and explicitly cancel its response
body before closing the dispatcher.
The previous commit's fix (await the aborted fetch, cancel its body
before closing the dispatcher) wasn't the real cause and didn't
resolve it -- reproduced locally under Node 24 (30/30 runs), where the
uncaughtException fires immediately as a side effect of
dispatcher.close() itself, not from a delay-sensitive race (a 2s grace
period in the teardown hook didn't help either).
The actual cause: the client abruptly aborting a still-open SSE stream
before the dispatcher closes tears down the underlying socket by reset
rather than a clean end, and that reset surfaces as a listener-less
ECONNRESET once nothing remains to observe it.
Fix: end the SSE stream from the server side first. That gives the
client a normal stream close to react to, instead of an abrupt one,
before the client-side abort/cancel/close sequence runs. Verified
30/30 locally on Node 24 (where it reproduced 100% before this), and
re-ran the full test:unit suite clean (1415 passed, 0 failed).
@GiHoon1123

Copy link
Copy Markdown
ContributorAuthor

Found the real cause -- it wasn't a timing/wait issue (a 2s grace period didn't help either). The client abruptly aborting the still-open SSE stream before closing the dispatcher tears the socket down by reset instead of a clean end, which surfaced as a listener-less ECONNRESET. Fixed by ending the SSE stream from the server side first, so the client sees a normal close instead.

Reproduced 100% locally under Node 24 (not on 22, which is why I hadn't caught it before) and verified 30/30 after the fix, plus a clean test:unit run.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP/2: fetch POST hangs while an SSE stream is open HTTP2 POST requests over fetch() to the same origin can't run concurrently

4 participants

@GiHoon1123@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(h2): allow stream-bodied requests to multiplex on a busy session - #5538

Merged
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang
Jul 13, 2026
Merged

fix(h2): allow stream-bodied requests to multiplex on a busy session#5538
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang

Conversation

@GiHoon1123

@GiHoon1123GiHoon1123 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This relates to...

Fixes#5524
Fixes#5494

Same root cause for both -- #5494 already has an open fix in #5497 (same core change to busy(), currently blocked on a requested unit test) -- see "Relationship to #5497" below before reviewing.

Rationale

busy() in lib/dispatcher/client-h2.js deferred any request with a stream/async-iterable/FormData body until every other in-flight request on the h2 session completed. If one of those in-flight requests is a long-lived stream (e.g. an open text/event-stream GET), every bodied request queued behind it parks indefinitely. The client also stops reporting kBusy while an h2 context is attached (so the pool keeps multiplexing instead of opening a second connection), so there's no escape route -- for an SSE stream, the hang is permanent.

The two hazards the guard's comments cited are already handled more precisely elsewhere in this file:

  • "can error while other requests are inflight and indirectly error those as well" -- a stream erroring mid-request now only aborts that one stream via the abort() closure (explicit comment there: "We do not destroy the socket as we can continue using the session"); it doesn't touch sibling streams.
  • "cannot be retried... could cause failure" -- canRetryRequestAfterGoAway() already excludes stream/iterable/FormData bodies from the requests it resurrects after a GOAWAY (body == null || isBuffer || isBlobLike only), erroring the rest instead of replaying a partially-consumed body.

Both of those were already in place before this change, so the blanket pre-dispatch guard was redundant.

Relationship to #5497

#5497 removes the same 9 lines for the same reason, opened against #5494 (concurrent POSTs on h2 not running concurrently -- a throughput complaint). #5524 is a more severe symptom of the identical guard: not just serialized, but a permanent deadlock when one of the concurrent requests is a long-lived stream like SSE. I only found #5497 after finishing this fix independently.

This PR adds what #5497 doesn't currently have:

Happy to close this in favor of #5497 if the maintainers would rather continue there -- opening it mainly so the missing test and both scenarios' coverage aren't lost. No preference on which one lands.

Changes

Features

N/A

Bug Fixes

Breaking Changes and Deprecations

N/A -- this only removes a guard that prevented dispatch; nothing that previously worked changes behavior, only what could not previously proceed now can.

Status

busy() deferred any request with a stream/async-iterable/FormData body
until every other in-flight request on the h2 session completed. For a
long-lived stream on the session (e.g. an open text/event-stream GET),
this parks bodied requests indefinitely -- the client also stops
reporting kBusy while an h2 context is attached, so the pool never
opens a second connection either.
The two hazards the guard's comments cited are already handled more
precisely elsewhere: a stream erroring mid-request now only aborts
that one stream (session stays usable, see the abort() closure), and
canRetryRequestAfterGoAway() already excludes stream/iterable/FormData
bodies from the requests it resurrects after a GOAWAY, erroring them
instead of unsafely replaying a partially-consumed body. The blanket
pre-dispatch guard was redundant given those.
Adds a regression test reproducing the exact deadlock: a POST issued
while an SSE GET is held open on the same session now resolves
immediately on the shared session instead of hanging. Fixesnodejs#5524.
nodejs#5494)
Same busy() fix as the previous commit also resolvesnodejs#5494: two
stream-bodied POSTs no longer serialize on the same h2 session, the
second now dispatches while the first is still in flight instead of
waiting for it to complete.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina
mcollina requested a review from metcoder95July 9, 2026 10:03
@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (3c662a5) to head (5081722).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #5538 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37329 37366 +37 =======================================
+ Hits 34883 34919 +36 - Misses 2446 2447 +1 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@metcoder95metcoder95 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tests seems unhappy

The SSE fetch's response body was never read, and after() aborted it
then immediately closed the dispatcher without waiting for the abort
to settle. On a slower teardown (seen on macOS/Windows CI, not Linux),
the abort's socket cleanup can complete after the dispatcher is
already closed and the test considered done, surfacing a late,
listener-less ECONNRESET as an uncaughtException.
Wait for the SSE fetch to settle and explicitly cancel its response
body before closing the dispatcher.
The previous commit's fix (await the aborted fetch, cancel its body
before closing the dispatcher) wasn't the real cause and didn't
resolve it -- reproduced locally under Node 24 (30/30 runs), where the
uncaughtException fires immediately as a side effect of
dispatcher.close() itself, not from a delay-sensitive race (a 2s grace
period in the teardown hook didn't help either).
The actual cause: the client abruptly aborting a still-open SSE stream
before the dispatcher closes tears down the underlying socket by reset
rather than a clean end, and that reset surfaces as a listener-less
ECONNRESET once nothing remains to observe it.
Fix: end the SSE stream from the server side first. That gives the
client a normal stream close to react to, instead of an abrupt one,
before the client-side abort/cancel/close sequence runs. Verified
30/30 locally on Node 24 (where it reproduced 100% before this), and
re-ran the full test:unit suite clean (1415 passed, 0 failed).
@GiHoon1123

Copy link
Copy Markdown
ContributorAuthor

Found the real cause -- it wasn't a timing/wait issue (a 2s grace period didn't help either). The client abruptly aborting the still-open SSE stream before closing the dispatcher tears the socket down by reset instead of a clean end, which surfaced as a listener-less ECONNRESET. Fixed by ending the SSE stream from the server side first, so the client sees a normal close instead.

Reproduced 100% locally under Node 24 (not on 22, which is why I hadn't caught it before) and verified 30/30 after the fix, plus a clean test:unit run.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP/2: fetch POST hangs while an SSE stream is open HTTP2 POST requests over fetch() to the same origin can't run concurrently

4 participants

@GiHoon1123@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(h2): allow stream-bodied requests to multiplex on a busy session - #5538

Merged
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang
Jul 13, 2026
Merged

fix(h2): allow stream-bodied requests to multiplex on a busy session#5538
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang

Conversation

@GiHoon1123

@GiHoon1123GiHoon1123 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This relates to...

Fixes#5524
Fixes#5494

Same root cause for both -- #5494 already has an open fix in #5497 (same core change to busy(), currently blocked on a requested unit test) -- see "Relationship to #5497" below before reviewing.

Rationale

busy() in lib/dispatcher/client-h2.js deferred any request with a stream/async-iterable/FormData body until every other in-flight request on the h2 session completed. If one of those in-flight requests is a long-lived stream (e.g. an open text/event-stream GET), every bodied request queued behind it parks indefinitely. The client also stops reporting kBusy while an h2 context is attached (so the pool keeps multiplexing instead of opening a second connection), so there's no escape route -- for an SSE stream, the hang is permanent.

The two hazards the guard's comments cited are already handled more precisely elsewhere in this file:

  • "can error while other requests are inflight and indirectly error those as well" -- a stream erroring mid-request now only aborts that one stream via the abort() closure (explicit comment there: "We do not destroy the socket as we can continue using the session"); it doesn't touch sibling streams.
  • "cannot be retried... could cause failure" -- canRetryRequestAfterGoAway() already excludes stream/iterable/FormData bodies from the requests it resurrects after a GOAWAY (body == null || isBuffer || isBlobLike only), erroring the rest instead of replaying a partially-consumed body.

Both of those were already in place before this change, so the blanket pre-dispatch guard was redundant.

Relationship to #5497

#5497 removes the same 9 lines for the same reason, opened against #5494 (concurrent POSTs on h2 not running concurrently -- a throughput complaint). #5524 is a more severe symptom of the identical guard: not just serialized, but a permanent deadlock when one of the concurrent requests is a long-lived stream like SSE. I only found #5497 after finishing this fix independently.

This PR adds what #5497 doesn't currently have:

Happy to close this in favor of #5497 if the maintainers would rather continue there -- opening it mainly so the missing test and both scenarios' coverage aren't lost. No preference on which one lands.

Changes

Features

N/A

Bug Fixes

Breaking Changes and Deprecations

N/A -- this only removes a guard that prevented dispatch; nothing that previously worked changes behavior, only what could not previously proceed now can.

Status

busy() deferred any request with a stream/async-iterable/FormData body
until every other in-flight request on the h2 session completed. For a
long-lived stream on the session (e.g. an open text/event-stream GET),
this parks bodied requests indefinitely -- the client also stops
reporting kBusy while an h2 context is attached, so the pool never
opens a second connection either.
The two hazards the guard's comments cited are already handled more
precisely elsewhere: a stream erroring mid-request now only aborts
that one stream (session stays usable, see the abort() closure), and
canRetryRequestAfterGoAway() already excludes stream/iterable/FormData
bodies from the requests it resurrects after a GOAWAY, erroring them
instead of unsafely replaying a partially-consumed body. The blanket
pre-dispatch guard was redundant given those.
Adds a regression test reproducing the exact deadlock: a POST issued
while an SSE GET is held open on the same session now resolves
immediately on the shared session instead of hanging. Fixesnodejs#5524.
nodejs#5494)
Same busy() fix as the previous commit also resolvesnodejs#5494: two
stream-bodied POSTs no longer serialize on the same h2 session, the
second now dispatches while the first is still in flight instead of
waiting for it to complete.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina
mcollina requested a review from metcoder95July 9, 2026 10:03
@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (3c662a5) to head (5081722).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #5538 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37329 37366 +37 =======================================
+ Hits 34883 34919 +36 - Misses 2446 2447 +1 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@metcoder95metcoder95 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tests seems unhappy

The SSE fetch's response body was never read, and after() aborted it
then immediately closed the dispatcher without waiting for the abort
to settle. On a slower teardown (seen on macOS/Windows CI, not Linux),
the abort's socket cleanup can complete after the dispatcher is
already closed and the test considered done, surfacing a late,
listener-less ECONNRESET as an uncaughtException.
Wait for the SSE fetch to settle and explicitly cancel its response
body before closing the dispatcher.
The previous commit's fix (await the aborted fetch, cancel its body
before closing the dispatcher) wasn't the real cause and didn't
resolve it -- reproduced locally under Node 24 (30/30 runs), where the
uncaughtException fires immediately as a side effect of
dispatcher.close() itself, not from a delay-sensitive race (a 2s grace
period in the teardown hook didn't help either).
The actual cause: the client abruptly aborting a still-open SSE stream
before the dispatcher closes tears down the underlying socket by reset
rather than a clean end, and that reset surfaces as a listener-less
ECONNRESET once nothing remains to observe it.
Fix: end the SSE stream from the server side first. That gives the
client a normal stream close to react to, instead of an abrupt one,
before the client-side abort/cancel/close sequence runs. Verified
30/30 locally on Node 24 (where it reproduced 100% before this), and
re-ran the full test:unit suite clean (1415 passed, 0 failed).
@GiHoon1123

Copy link
Copy Markdown
ContributorAuthor

Found the real cause -- it wasn't a timing/wait issue (a 2s grace period didn't help either). The client abruptly aborting the still-open SSE stream before closing the dispatcher tears the socket down by reset instead of a clean end, which surfaced as a listener-less ECONNRESET. Fixed by ending the SSE stream from the server side first, so the client sees a normal close instead.

Reproduced 100% locally under Node 24 (not on 22, which is why I hadn't caught it before) and verified 30/30 after the fix, plus a clean test:unit run.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP/2: fetch POST hangs while an SSE stream is open HTTP2 POST requests over fetch() to the same origin can't run concurrently

4 participants

@GiHoon1123@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(h2): allow stream-bodied requests to multiplex on a busy session - #5538

Merged
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang
Jul 13, 2026
Merged

fix(h2): allow stream-bodied requests to multiplex on a busy session#5538
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang

Conversation

@GiHoon1123

@GiHoon1123GiHoon1123 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This relates to...

Fixes#5524
Fixes#5494

Same root cause for both -- #5494 already has an open fix in #5497 (same core change to busy(), currently blocked on a requested unit test) -- see "Relationship to #5497" below before reviewing.

Rationale

busy() in lib/dispatcher/client-h2.js deferred any request with a stream/async-iterable/FormData body until every other in-flight request on the h2 session completed. If one of those in-flight requests is a long-lived stream (e.g. an open text/event-stream GET), every bodied request queued behind it parks indefinitely. The client also stops reporting kBusy while an h2 context is attached (so the pool keeps multiplexing instead of opening a second connection), so there's no escape route -- for an SSE stream, the hang is permanent.

The two hazards the guard's comments cited are already handled more precisely elsewhere in this file:

  • "can error while other requests are inflight and indirectly error those as well" -- a stream erroring mid-request now only aborts that one stream via the abort() closure (explicit comment there: "We do not destroy the socket as we can continue using the session"); it doesn't touch sibling streams.
  • "cannot be retried... could cause failure" -- canRetryRequestAfterGoAway() already excludes stream/iterable/FormData bodies from the requests it resurrects after a GOAWAY (body == null || isBuffer || isBlobLike only), erroring the rest instead of replaying a partially-consumed body.

Both of those were already in place before this change, so the blanket pre-dispatch guard was redundant.

Relationship to #5497

#5497 removes the same 9 lines for the same reason, opened against #5494 (concurrent POSTs on h2 not running concurrently -- a throughput complaint). #5524 is a more severe symptom of the identical guard: not just serialized, but a permanent deadlock when one of the concurrent requests is a long-lived stream like SSE. I only found #5497 after finishing this fix independently.

This PR adds what #5497 doesn't currently have:

Happy to close this in favor of #5497 if the maintainers would rather continue there -- opening it mainly so the missing test and both scenarios' coverage aren't lost. No preference on which one lands.

Changes

Features

N/A

Bug Fixes

Breaking Changes and Deprecations

N/A -- this only removes a guard that prevented dispatch; nothing that previously worked changes behavior, only what could not previously proceed now can.

Status

busy() deferred any request with a stream/async-iterable/FormData body
until every other in-flight request on the h2 session completed. For a
long-lived stream on the session (e.g. an open text/event-stream GET),
this parks bodied requests indefinitely -- the client also stops
reporting kBusy while an h2 context is attached, so the pool never
opens a second connection either.
The two hazards the guard's comments cited are already handled more
precisely elsewhere: a stream erroring mid-request now only aborts
that one stream (session stays usable, see the abort() closure), and
canRetryRequestAfterGoAway() already excludes stream/iterable/FormData
bodies from the requests it resurrects after a GOAWAY, erroring them
instead of unsafely replaying a partially-consumed body. The blanket
pre-dispatch guard was redundant given those.
Adds a regression test reproducing the exact deadlock: a POST issued
while an SSE GET is held open on the same session now resolves
immediately on the shared session instead of hanging. Fixesnodejs#5524.
nodejs#5494)
Same busy() fix as the previous commit also resolvesnodejs#5494: two
stream-bodied POSTs no longer serialize on the same h2 session, the
second now dispatches while the first is still in flight instead of
waiting for it to complete.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina
mcollina requested a review from metcoder95July 9, 2026 10:03
@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (3c662a5) to head (5081722).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #5538 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37329 37366 +37 =======================================
+ Hits 34883 34919 +36 - Misses 2446 2447 +1 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@metcoder95metcoder95 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tests seems unhappy

The SSE fetch's response body was never read, and after() aborted it
then immediately closed the dispatcher without waiting for the abort
to settle. On a slower teardown (seen on macOS/Windows CI, not Linux),
the abort's socket cleanup can complete after the dispatcher is
already closed and the test considered done, surfacing a late,
listener-less ECONNRESET as an uncaughtException.
Wait for the SSE fetch to settle and explicitly cancel its response
body before closing the dispatcher.
The previous commit's fix (await the aborted fetch, cancel its body
before closing the dispatcher) wasn't the real cause and didn't
resolve it -- reproduced locally under Node 24 (30/30 runs), where the
uncaughtException fires immediately as a side effect of
dispatcher.close() itself, not from a delay-sensitive race (a 2s grace
period in the teardown hook didn't help either).
The actual cause: the client abruptly aborting a still-open SSE stream
before the dispatcher closes tears down the underlying socket by reset
rather than a clean end, and that reset surfaces as a listener-less
ECONNRESET once nothing remains to observe it.
Fix: end the SSE stream from the server side first. That gives the
client a normal stream close to react to, instead of an abrupt one,
before the client-side abort/cancel/close sequence runs. Verified
30/30 locally on Node 24 (where it reproduced 100% before this), and
re-ran the full test:unit suite clean (1415 passed, 0 failed).
@GiHoon1123

Copy link
Copy Markdown
ContributorAuthor

Found the real cause -- it wasn't a timing/wait issue (a 2s grace period didn't help either). The client abruptly aborting the still-open SSE stream before closing the dispatcher tears the socket down by reset instead of a clean end, which surfaced as a listener-less ECONNRESET. Fixed by ending the SSE stream from the server side first, so the client sees a normal close instead.

Reproduced 100% locally under Node 24 (not on 22, which is why I hadn't caught it before) and verified 30/30 after the fix, plus a clean test:unit run.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP/2: fetch POST hangs while an SSE stream is open HTTP2 POST requests over fetch() to the same origin can't run concurrently

4 participants

@GiHoon1123@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(h2): allow stream-bodied requests to multiplex on a busy session - #5538

Merged
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang
Jul 13, 2026
Merged

fix(h2): allow stream-bodied requests to multiplex on a busy session#5538
mcollina merged 4 commits into
nodejs:mainfrom
GiHoon1123:fix-issue-5524-h2-sse-post-hang

Conversation

@GiHoon1123

@GiHoon1123GiHoon1123 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This relates to...

Fixes#5524
Fixes#5494

Same root cause for both -- #5494 already has an open fix in #5497 (same core change to busy(), currently blocked on a requested unit test) -- see "Relationship to #5497" below before reviewing.

Rationale

busy() in lib/dispatcher/client-h2.js deferred any request with a stream/async-iterable/FormData body until every other in-flight request on the h2 session completed. If one of those in-flight requests is a long-lived stream (e.g. an open text/event-stream GET), every bodied request queued behind it parks indefinitely. The client also stops reporting kBusy while an h2 context is attached (so the pool keeps multiplexing instead of opening a second connection), so there's no escape route -- for an SSE stream, the hang is permanent.

The two hazards the guard's comments cited are already handled more precisely elsewhere in this file:

  • "can error while other requests are inflight and indirectly error those as well" -- a stream erroring mid-request now only aborts that one stream via the abort() closure (explicit comment there: "We do not destroy the socket as we can continue using the session"); it doesn't touch sibling streams.
  • "cannot be retried... could cause failure" -- canRetryRequestAfterGoAway() already excludes stream/iterable/FormData bodies from the requests it resurrects after a GOAWAY (body == null || isBuffer || isBlobLike only), erroring the rest instead of replaying a partially-consumed body.

Both of those were already in place before this change, so the blanket pre-dispatch guard was redundant.

Relationship to #5497

#5497 removes the same 9 lines for the same reason, opened against #5494 (concurrent POSTs on h2 not running concurrently -- a throughput complaint). #5524 is a more severe symptom of the identical guard: not just serialized, but a permanent deadlock when one of the concurrent requests is a long-lived stream like SSE. I only found #5497 after finishing this fix independently.

This PR adds what #5497 doesn't currently have:

Happy to close this in favor of #5497 if the maintainers would rather continue there -- opening it mainly so the missing test and both scenarios' coverage aren't lost. No preference on which one lands.

Changes

Features

N/A

Bug Fixes

Breaking Changes and Deprecations

N/A -- this only removes a guard that prevented dispatch; nothing that previously worked changes behavior, only what could not previously proceed now can.

Status

busy() deferred any request with a stream/async-iterable/FormData body
until every other in-flight request on the h2 session completed. For a
long-lived stream on the session (e.g. an open text/event-stream GET),
this parks bodied requests indefinitely -- the client also stops
reporting kBusy while an h2 context is attached, so the pool never
opens a second connection either.
The two hazards the guard's comments cited are already handled more
precisely elsewhere: a stream erroring mid-request now only aborts
that one stream (session stays usable, see the abort() closure), and
canRetryRequestAfterGoAway() already excludes stream/iterable/FormData
bodies from the requests it resurrects after a GOAWAY, erroring them
instead of unsafely replaying a partially-consumed body. The blanket
pre-dispatch guard was redundant given those.
Adds a regression test reproducing the exact deadlock: a POST issued
while an SSE GET is held open on the same session now resolves
immediately on the shared session instead of hanging. Fixesnodejs#5524.
nodejs#5494)
Same busy() fix as the previous commit also resolvesnodejs#5494: two
stream-bodied POSTs no longer serialize on the same h2 session, the
second now dispatches while the first is still in flight instead of
waiting for it to complete.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina
mcollina requested a review from metcoder95July 9, 2026 10:03
@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (3c662a5) to head (5081722).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #5538 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37329 37366 +37 =======================================
+ Hits 34883 34919 +36 - Misses 2446 2447 +1 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@metcoder95metcoder95 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tests seems unhappy

The SSE fetch's response body was never read, and after() aborted it
then immediately closed the dispatcher without waiting for the abort
to settle. On a slower teardown (seen on macOS/Windows CI, not Linux),
the abort's socket cleanup can complete after the dispatcher is
already closed and the test considered done, surfacing a late,
listener-less ECONNRESET as an uncaughtException.
Wait for the SSE fetch to settle and explicitly cancel its response
body before closing the dispatcher.
The previous commit's fix (await the aborted fetch, cancel its body
before closing the dispatcher) wasn't the real cause and didn't
resolve it -- reproduced locally under Node 24 (30/30 runs), where the
uncaughtException fires immediately as a side effect of
dispatcher.close() itself, not from a delay-sensitive race (a 2s grace
period in the teardown hook didn't help either).
The actual cause: the client abruptly aborting a still-open SSE stream
before the dispatcher closes tears down the underlying socket by reset
rather than a clean end, and that reset surfaces as a listener-less
ECONNRESET once nothing remains to observe it.
Fix: end the SSE stream from the server side first. That gives the
client a normal stream close to react to, instead of an abrupt one,
before the client-side abort/cancel/close sequence runs. Verified
30/30 locally on Node 24 (where it reproduced 100% before this), and
re-ran the full test:unit suite clean (1415 passed, 0 failed).
@GiHoon1123

Copy link
Copy Markdown
ContributorAuthor

Found the real cause -- it wasn't a timing/wait issue (a 2s grace period didn't help either). The client abruptly aborting the still-open SSE stream before closing the dispatcher tears the socket down by reset instead of a clean end, which surfaced as a listener-less ECONNRESET. Fixed by ending the SSE stream from the server side first, so the client sees a normal close instead.

Reproduced 100% locally under Node 24 (not on 22, which is why I hadn't caught it before) and verified 30/30 after the fix, plus a clean test:unit run.

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP/2: fetch POST hangs while an SSE stream is open HTTP2 POST requests over fetch() to the same origin can't run concurrently

4 participants

@GiHoon1123@codecov-commenter@mcollina@metcoder95