Skip to content

fix(h2): settle a request whose stream is cancelled - #5607

Merged
mcollina merged 1 commit into
v7.xfrom
fix/h2-stream-cancel-v7
Jul 30, 2026
Merged

fix(h2): settle a request whose stream is cancelled#5607
mcollina merged 1 commit into
v7.xfrom
fix/h2-stream-cancel-v7

Conversation

@mcollina

Copy link
Copy Markdown
Member

A request over HTTP/2 was only ever completed from the stream's 'end', 'error' or 'timeout'. There is one case that produces none of them, and it strands the request forever.

What Node reports per reset code

Client sends a GET, server RSTs the stream before responding (pure node:http2, no undici):

RST NO_ERROR        events=[end, close]
RST PROTOCOL_ERROR  events=[error, close]
RST INTERNAL_ERROR  events=[error, close]
RST REFUSED_STREAM  events=[error, close]
RST CANCEL          events=[close]          ← nothing to act on

CANCEL is the code Node itself uses when a stream is destroyed locally, so an incoming RST_STREAM(CANCEL) is treated as a plain teardown rather than an error. Destroying the stream also unenrolls its timeout, so no 'timeout' follows either — stream.setTimeout() is silently cancelled.

The result, with headersTimeout and bodyTimeout both set to 500ms:

undici 7.29.0: never settled — no error, no timeout after 5008ms
with this fix: UND_ERR_INFO: HTTP/2: stream closed before the response was complete after 48ms

No response, no error, no timeout, regardless of the configured timeouts — the caller's promise simply never settles and the request stays in the running window for the life of the client.

This is reachable against ordinary infrastructure: a proxy cancels its upstream stream whenever its own downstream client goes away.

Change

Settle the request from 'close' when nothing else has, mirroring what the 'error' handler already does. The guard is !request.aborted && !request.completed, so normal completions and existing error paths are untouched.

Tests

test/http2-stream-cancel.js covers both directions: cancelled before the response (must reject) and cancelled after the headers arrive (must still complete normally). Note the first test hangs rather than failing on the current branch — that is the bug.

test/+(http2|h2)*.js: 53 pass. Full unit suite: 1306 pass, 0 fail.

A seeded chaos harness over the same code goes from requests that never settle to stuck=0 across every seed tried (7, 1, 42, 99).

Still outstanding

That harness still reports queue drift on this branch — running stays above zero with nothing in flight — because the abort path settles the caller without advancing kRunningIdx, and the queue is head-of-line only (client[kQueue][client[kRunningIdx]++] = null) while h2 completes out of order. That predates this change and is not affected by it, but it means a long-lived client can still accumulate phantom running slots. Happy to look at it separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01A49JamgF2TkZHu5h58ChUM

RST_STREAM(CANCEL) received before the response is the one reset code Node
reports as a bare 'close' on the client stream:

  RST NO_ERROR        -> 'end', 'close'
  RST PROTOCOL_ERROR  -> 'error', 'close'
  RST INTERNAL_ERROR  -> 'error', 'close'
  RST REFUSED_STREAM  -> 'error', 'close'
  RST CANCEL          -> 'close'          <- nothing to act on

Destroying the stream also unenrolls its timeout, so no 'timeout' follows
either. Since a request was only ever completed from 'end', 'error' or
'timeout', a cancelled stream left it in the running window forever and its
caller never heard back -- no response, no error, no timeout, whatever
headersTimeout and bodyTimeout were set to.

Proxies send this upstream whenever their own downstream client goes away,
so it is reachable against ordinary infrastructure.

Settle the request from 'close' when nothing else has, mirroring what the
'error' handler already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A49JamgF2TkZHu5h58ChUM
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.16%. Comparing base (8d347dd) to head (d6151ac).

Additional details and impacted files
@@            Coverage Diff             @@
##             v7.x    #5607      +/-   ##
==========================================
- Coverage   93.17%   93.16%   -0.01%     
==========================================
  Files         112      112              
  Lines       36751    36759       +8     
==========================================
+ Hits        34241    34247       +6     
- Misses       2510     2512       +2     

☔ 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.

@mcollina
mcollina merged commit c26f943 into v7.x Jul 30, 2026
35 of 37 checks passed
@mcollina
mcollina deleted the fix/h2-stream-cancel-v7 branch July 30, 2026 09:43
Sign up for free to 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.

3 participants