fix(h2): allow stream-bodied requests to multiplex on a busy session#5538
fix(h2): allow stream-bodied requests to multiplex on a busy session#5538GiHoon1123 wants to merge 4 commits into
Conversation
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. Fixes nodejs#5524.
nodejs#5494) Same busy() fix as the previous commit also resolves nodejs#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.
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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).
|
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 |
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()inlib/dispatcher/client-h2.jsdeferred 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 opentext/event-streamGET), every bodied request queued behind it parks indefinitely. The client also stops reportingkBusywhile 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:
abort()closure (explicit comment there: "We do not destroy the socket as we can continue using the session"); it doesn't touch sibling streams.canRetryRequestAfterGoAway()already excludes stream/iterable/FormData bodies from the requests it resurrects after a GOAWAY (body == null || isBuffer || isBlobLikeonly), 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:
mcollinarequested on feat(h2): enable concurrency on idempotent requests #5497 (still outstanding as of this PR) -- covering HTTP2 POST requests overfetch()to the same origin can't run concurrently #5494's own scenario directly: a second stream-bodied POST now dispatches while the first is still in flight instead of waiting for it to complete.fetchPOST hangs while an SSE stream is open #5524 deadlock scenario (SSE held open + bodied POST hangs forever, not just serializes).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
fetchPOST hangs while an SSE stream is open #5524: a bodiedfetch()/request()call on an h2 session no longer waits forever behind an open long-lived stream (e.g. SSE) on the same session.fetch()to the same origin can't run concurrently #5494: concurrent bodiedfetch()/request()calls on the same h2 session now dispatch concurrently instead of serializing.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