Skip to content

node:http2: free a request's slot when its stream closes on the wire, abort request({ signal }) from JS only - #42763

Open
robobun wants to merge 10 commits into
mainfrom
robobun/38daadc0/http2-signal-abort-drain
Open

robobun wants to merge 10 commits into
mainfrom
robobun/38daadc0/http2-signal-abort-drain

Conversation

@robobun

@robobun robobun commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A client is at the peer's SETTINGS_MAX_CONCURRENT_STREAMS, with requests queued for a free slot, and destroys an open request. The next HEADERS go out before that RST_STREAM. A node server answers GOAWAY, and each other request fails (ERR_HTTP2_GOAWAY_SESSION, ERR_HTTP2_STREAM_ERROR).
  • Cause: _destroy (src/js/node/http2.ts) sends the RST_STREAM from a setImmediate. The slot was released on 'close', one loop turn earlier.
  • request(headers, { signal }): an abort while a write waits for window emits 'drain'. The native listener (SignalRef, src/runtime/api/bun/h2_frame_parser.rs) called the queued write's callback with no error.

Fix

  • The slot is released where the native parser reports the stream closed: the streamError, aborted and streamEnd (state 7) handlers. end_stream writes the RST_STREAM frame before it dispatches.
  • _destroy queues the RST_STREAM before any listener runs.
  • The native signal handling is removed. The JS listener abortRequestStream already destroys the stream, as node does.
  • Verified: test/js/node/http2/node-http2-request-signal.test.ts (23 tests, main fails 16). Also all of test/js/node/http2/ and the vendored test-http2-*.js.

Background

  • The peer counts a stream against its limit until it gets RST_STREAM, or until both sides sent END_STREAM.
  • Considered a release one setImmediate after 'close': it only happens to run after the RST_STREAM. The native report is exact.

Downsides

  • A signal abort writes its RST_STREAM one loop turn later than on main, as destroy() does.
  • request(), write() and abort() in one tick send HEADERS, DATA, RST_STREAM. Main sent HEADERS, RST_STREAM.
  • A response that nothing reads no longer holds its slot, as in node.
Notes

A request given up at the limit, against node's own server. The client starts limit + 3 requests on one session: limit go out and 3 wait for a slot. The caller of request 1 then gives it up. The peer is a node v26.3.0 http2.createServer({ settings: { maxConcurrentStreams: limit } }) in a second process. nghttp2 ends the session with GOAWAY when a HEADERS frame exceeds the limit.

request 1 is given up with main this PR node v26.3.0 as the client
destroy() GOAWAY, no other caller gets its answer each other caller gets its answer each other caller gets its answer
destroy(err) GOAWAY answered answered
setTimeout(ms, () => destroy()) GOAWAY answered answered
pipeline() with a source that fails GOAWAY answered answered
close() answered answered answered
a signal abort answered answered answered
nothing (the control) answered answered answered

"main" is bun 1.4.3-canary.1 (367d939, a release build) and a debug build of 13a98b0. Both give the same result at limit 1 and at limit 100. Against a raw peer, main writes HEADERS 1, HEADERS 3, RST_STREAM 1 for destroy(). This PR and node write HEADERS 1, RST_STREAM 1, HEADERS 3. The test file has the 14 cases (7 ways, limits 1 and 100). They wait for conditions and not for time: the peer holds the first limit answers until the client resets one of them.

Merged with main at 13a98b0. One file conflicted, h2_frame_parser.rs. #42682 removed is_stream_aborted. #43933 moved the stream's native signal listener to AbortHandle (SignalRef, attach_signal, abort_stream_for_signal). This PR removes that listener, so the merge deletes those three. tsc --noEmit -p src/js/tsconfig.json passes. The merge and the new tests did not go through another self-review round, because the source diff against main is the same change as before.

Cost per request, from the diff. One closure and one 'close' listener fewer (#trackActiveRequest). A { signal } request has one Box<SignalRef>, one parser ref and one AbortHandle registration fewer. Each of the three native close handlers has one more call with a flag test. test-http2-forget-closed-streams.js (10000 requests in a row) on the debug build, run alone: main 94 s and 116 s, this PR 90 s, 96 s and 100 s. perf and valgrind are not installed in the container that ran this, so there is no instruction count.

This is the request({ signal }) path that #42717 names as excluded. That PR fixes the same 'drain' bug for the session teardown paths and does not touch this code.

Other node mismatches of the native path. The same deletion fixes them, and the new test covers the first two:

  • stream.end(body), then an abort while the body is still blocked: main emits 'aborted' and sets stream.aborted. Native looked at its own stream state (OPEN, END_STREAM not sent yet). Node and _destroy look at writableState.ending.
  • A signal that is not a native AbortSignal (any event target with an aborted property): main throws ERR_INVALID_ARG_TYPE from native request(). Node accepts it (validateAbortSignal plus addAbortListener).
  • An abort listener that was added to the signal before request() saw stream.aborted === true. In node it sees a live stream.

Removed with it. StreamState::IDLE was constructed only in the native pre-aborted branch, which JS never reached (JS handles a pre-aborted signal before it calls native). H2FrameParser::finalize freed the streams early at VM shutdown only to release the parser refs that SignalRef held. A Stream holds no parser ref now, so Drop frees the streams once the refcount reaches zero. process.exit() from inside a stream 'data' callback with open streams, with BUN_DESTRUCT_VM_ON_EXIT=1 and detect_leaks=1: no leak report.

Checked against node v26.3.0 with a raw h2c peer, after the change. RST_STREAM(CANCEL) reaches the wire, 'aborted' fires once, rstCode is 8, the 'error' is an AbortError whose cause is the signal reason, the abort listener count on the signal returns to 0 after 'close', an abort before connect sends nothing, two streams on one signal each send their RST_STREAM, write() after the abort returns false with ERR_STREAM_DESTROYED, abort followed by session.destroy() sends only GOAWAY.

Wire differences that remain, all shared with stream.destroy(err) on main. With abort(); session.close() in one tick the order is GOAWAY, RST_STREAM (node: RST_STREAM, GOAWAY). With request(); write(); abort() in one tick the corked chunk is flushed, so the peer sees HEADERS, DATA, RST_STREAM. Main's signal path sent HEADERS, RST_STREAM. Node sends nothing, because nghttp2 cancels the HEADERS that it has not sent yet.

The slot release, {status of the queued request} with maxConcurrentStreams: 1 (node gives 200 in every row):

the open request is cancelled by main native deletion alone this PR
the signal 200 REFUSED_STREAM 200
destroy(err) REFUSED_STREAM REFUSED_STREAM 200
close(NGHTTP2_CANCEL) 200 200 200

The same holds when user code sends the next request from the 'close', 'error' or 'aborted' listener of the cancelled one.

Where the slot is released. nghttp2 frees the slot when the stream closes on the wire. The native side reports exactly that through three handlers (streamError, aborted, streamEnd with state 7), which is where #connections is decremented. The release and the flush of queued requests happen there now, and the 'close' listener per request is gone. end_stream (the JS rstStream() call) used to dispatch before it wrote the RST_STREAM frame. The engine's own resets write first, and end_stream does the same now, so a request that the handler submits follows the RST_STREAM. The slot is taken before the native request() call, because that call can report the stream closed (invalid options, memory limit), and it is given back if the call throws. An earlier version of this PR released the slot one setImmediate after 'close', which only happened to run after the one that sends the RST_STREAM (review comment by @Jarred-Sumner).

One effect of the exact signal, the same in node v26.3.0: a request whose response ended frees its slot at once, even if nothing reads the body. On main the queued request waits until the body is consumed (covered by the test).

_destroy queues the RST_STREAM first. _destroy computed the code and queued the RST_STREAM after it emitted 'aborted'. A listener that throws stopped it there (destroy.ts catches the throw and still emits 'error' and 'close'), so the stream was never reset: the native stream stayed open, the peer kept counting it, #connections stayed up and a graceful session.close() could not finish. This is the same on main for every stream.destroy(). With the release tied to the native close, a queued request would also wait forever (found in review). _destroy now sets the code and queues the RST_STREAM before any listener runs, like node's closeStream(). stream.rstCode therefore has its final value inside 'aborted' listeners, as in node: 8, 2 and 0 for a signal abort, destroy(err) and destroy(). On main it is 8 for the signal path (set by the native listener) and undefined for the other two. Node v26.3.0 itself does not send the RST_STREAM when an 'aborted' listener throws, and never submits the queued request. The test for this case is skipped under node.

Not fixed here. A peer RST_STREAM for a stream with a flow-control-blocked write, on a session whose transport is a JS Duplex (createConnection on the client, server.emit('connection', duplex) on the server), also emits 'drain': drains 1, accepted 32, node 0 and 0. On a native socket it does not, because the tick queue runs between the 'aborted'/streamError dispatch and clean_queue. There on_stream_reset dispatches first and the JS handler defers the destroy with process.nextTick, so it needs its own change.

The test file. 23 tests. A debug build of main (13a98b0) fails 16. The 7 that pass there: the signal abort with a queued request against the raw peer (the native listener wrote the RST_STREAM at once), and close(), the signal abort and the control against the node peer at both limits. Node v26.3.0 passes 22 and skips the throwing 'aborted' listener case.

Suites on the debug+ASAN build, after the merge. The 12 files in test/js/node/http2/: 619 pass, 6 skip, 0 fail. The vendored test-http2-*.js through scripts/runner.node.ts: 262 of 263 pass. test-http2-forget-closed-streams.js ran past the runner's 180 s limit while the other tests ran beside it. Run alone it passes, on main and on this PR (the times are above). grpc-js test-retry, test-deadline, test-server-errors, test-call-propagation and test-idle-timer pass. The test file also passes with BUN_DESTRUCT_VM_ON_EXIT=1, detect_leaks=1 and BUN_JSC_validateExceptionChecks=1. It passes 12 of 12 runs on the debug build, 18 of 18 with six runs at once, and 20 of 20 on node.

Self-reviewed: 6 concerns raised, 5 addressed (the RST_STREAM order for queued requests, test waits that could hang, assertion order, peer sockets left open, the shutdown path), 1 named above as a remaining wire difference. The test helper also rejects its waits when the connection ends first, so a regression fails with the frames that the peer saw.


[human-review] gate passed · iteration 3 · 3 files touched

fails on main (without fix)
ASAN without fix: 7 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2-request-signal.test.ts"
bun test v1.4.3 (09bb54630)

test/js/node/http2/node-http2-request-signal.test.ts:
192 |       await closed;
193 |       // The peer reads what was written, then sees the connection close.
194 |       session.destroy();
195 |       const wire = await rstCode;
196 | 
197 |       assert.deepStrictEqual(
                   ^
AssertionError: Expected values to be strictly deep-equal:
+ actual - expected

  {
+   accepted: 32,
-   accepted: 0,
    backpressured: true,
+   drains: 1,
-   drains: 0,
    events: [
      'aborted',
      'error AbortError',
      'close'
    ],

 generatedMessage: true,
     actual: {
  backpressured: true,
  drains: 1,
  accepted: 32,
  events: [ "aborted", "error AbortError", "close" ],
  rstCodeInAborted: 8,
  rstCode: 8,
  wire: 8,
},
   expected: {
  backpressured: true,
  drains: 0,
  accepted: 0,
  events: [ "aborted", "error AbortError", "close" ],
  rstCodeInAborted: 8,
  rstCode: 8,
  wire: 8,
},
   operator: "deepStrictEqual",
       diff: "simple
... (truncated)

release without fix: 2 FAILED
bun test v1.4.3-canary.1 (041e6819f)

test/js/node/http2/node-http2-request-signal.test.ts:
192 |       await closed;
193 |       // The peer reads what was written, then sees the connection close.
194 |       session.destroy();
195 |       const wire = await rstCode;
196 | 
197 |       assert.deepStrictEqual(
                   ^
AssertionError: Expected values to be strictly deep-equal:
+ actual - expected
... Skipped lines

  {
    accepted: 0,
    backpressured: true,
    drains: 0,
    events: [
...
    rstCode: 8,
+   rstCodeInAborted: undefined,
-   rstCodeInAborted: 8,
    wire: 8
  }

 generatedMessage: true,
     actual: {
  backpressured: true,
  drains: 0,
  accepted: 0,
  events: [ "aborted", "error AbortError", "close" ],
  rstCodeInAborted: undefined,
  rstCode: 8,
  wire: 8,
},
   expected: {
  backpressured: true,
  drains: 0,
  accepted: 0,
  events: [ "aborted", "error AbortError", "close" ],
  rstCodeInAborted: 8,
  rstCode: 8,
  wire: 8,
},
   operator: "deepStrictEqual",
       diff: "simple",
       code: "ERR_ASSERTION"

      at /workspace/bun/test/js/node/http2/node-http2-request-signal.test.ts:197:14
      at node:test:1445:26
      at exe
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2-request-signal.test.ts"
bun test v1.4.3 (09bb54630)

test/js/node/http2/node-http2-request-signal.test.ts:
(pass) session.request(headers, { signal }) > abort with a flow-control-blocked write emits no 'drain' and the stream accepts no more writes [723.86ms]
(pass) session.request(headers, { signal }) > abort after end() is not reported as 'aborted' [107.10ms]
(pass) session.request(headers, { signal }) > accepts any event target with an 'aborted' property as the signal [72.00ms]
(pass) a request queued behind SETTINGS_MAX_CONCURRENT_STREAMS is sent when the open request is closed on the wire > after the RST_STREAM, when a signal aborts the open request [111.02ms]
(pass) a request queued behind SETTINGS_MAX_CONCURRENT_STREAMS is sent when the open request is closed on the wire > after the RST_STREAM, when destroy(err) closes the open request [77.51ms]
(pass) a request queued behind SETTINGS_MAX_CONCURRENT_STREAMS is sent when the open request is closed on the wire > after the RST_STREAM, when a diagnostics
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 651ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/139] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/139] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (32 fields)
Found 
... (truncated)
diff hotspot
src/js/node/http2.ts                               |  70 ++--
 src/runtime/api/bun/h2_frame_parser.rs             | 150 +--------
 .../node/http2/node-http2-request-signal.test.ts   | 372 +++++++++++++++++++++
 3 files changed, 410 insertions(+), 182 deletions(-)

gate history · 4 passed · 0 rejected · iteration 3

evidence per changed file
file                                                  reads  edits  tests
src/js/node/http2.ts                                     19      9     42
src/runtime/api/bun/h2_frame_parser.rs                   17      9     42
test/js/node/http2/node-http2-request-signal.test.ts      3      7     41

@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: c6bde3fa-fab8-42e5-a849-227b0d89708b

📥 Commits

Reviewing files that changed from the base of the PR and between cf8754c and 041e681.

📒 Files selected for processing (3)
  • src/js/node/http2.ts
  • src/runtime/api/bun/h2_frame_parser.rs
  • test/js/node/http2/node-http2-request-signal.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

Changes

The HTTP/2 parser no longer manages AbortSignal state. Client request-slot reuse waits for native stream teardown. Raw h2c tests verify cancellation events, reset codes, abort reasons, and request ordering.

HTTP/2 cancellation flow

Layer / File(s) Summary
Stream cancellation state
src/runtime/api/bun/h2_frame_parser.rs
Removes signal listeners, signal cleanup, signal-based request handling, and the IDLE stream state. Stream abortion now depends on closed streams with CANCEL reset codes.
Queued request ordering
src/js/node/http2.ts
Reserves request slots before submission and releases them after native close, error, or abort events. Queued requests flush after slot release. Submission failures release reservations immediately.
Request cancellation validation
test/js/node/http2/node-http2-request-signal.test.ts
Adds raw h2c helpers and tests for flow-control cancellation, event behavior, abort reasons, reset codes, writes, drains, and concurrent-stream ordering.

Suggested reviewers: cirospaciari

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 041e6

No confirmed merge-blocking risk remains in the HTTP/2 cancellation and request-slot changes.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies both main changes: releasing request slots when streams close on the wire and handling request-signal aborts in JavaScript. It is longer than ideal but remains specific an…
Description check ✅ Passed The description explains the problem, cause, fix, trade-offs, and verification results. It does not use the template’s exact headings, but it provides the required change summary and verification deta…

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

@robobun

robobun commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: merged with main (13a98b0), one more test block added. The diff is ready for review. All review threads are resolved.

Two reports are reproduced here. Both are in test/js/node/http2/node-http2-request-signal.test.ts. Run it with bun bd test test/js/node/http2/node-http2-request-signal.test.ts or node --test test/js/node/http2/node-http2-request-signal.test.ts. With src/ at main it fails 16 of 23 tests.

1. A caller gives its request up while the session is at the peer's limit. The client starts limit + 3 requests on one session. limit go out and 3 wait for a slot. 20 ms later the caller of request 1 gives it up. The peer is a node http2 server in a second process. Run bun h2-destroy-at-limit.cjs <how> 1 node.

h2-destroy-at-limit.cjs
// usage: bun h2-destroy-at-limit.cjs [how = destroy | destroy-error | timeout-destroy | pipeline | close | abort | none] [limit = 1] [runtime of the peer = node]
// (loopback only; the peer is a plain node:http2 server in a second process; it allows <limit> streams at a time: SETTINGS_MAX_CONCURRENT_STREAMS)
// A node:http2 client starts limit + 3 numbered requests on one session: <limit> go out, 3 wait for a free slot. 20 ms later the caller of request 1 gives it up.
// expected (node): RST_STREAM for request 1 goes out, THEN the next waiting request: everybody else gets its own answer: "RESULT: ok", exit 0.
// actual (bun, how = destroy ...): the waiting request's HEADERS go out BEFORE the RST_STREAM: the peer counts one stream too many and ends the whole session.
const http2 = require("node:http2"), { spawn } = require("node:child_process"), { pipeline, Readable } = require("node:stream");
const [how = "destroy", limitS = "1", peer = "node"] = process.argv.slice(2), limit = +limitS;
if (how === "server") {
  const s = http2.createServer({ settings: { maxConcurrentStreams: limit } });
  s.on("session", x => x.on("error", () => {}));
  s.on("stream", (st, h) => { st.on("error", () => {}); st.resume(); setTimeout(() => { if (!st.destroyed) { st.respond({ ":status": 200 }); st.end("answer-" + h[":path"].slice(1)); } }, 60); });
  s.listen(0, "127.0.0.1", () => console.log("PORT " + s.address().port));
} else {
  const srv = spawn(peer, [__filename, "server", limitS], { stdio: ["ignore", "pipe", "ignore"] });
  srv.stdout.once("data", d => {
    const c = http2.connect("http://127.0.0.1:" + /PORT (\d+)/.exec(String(d))[1]), got = {}, N = limit + 3; let session = "alive";
    c.on("error", e => (session = e.code)); c.on("goaway", code => (session = "GOAWAY " + code));
    c.on("remoteSettings", () => {
      for (let n = 1; n <= N; n++) {
        const ac = new AbortController(), post = n === 1 && how === "pipeline";
        const r = c.request({ ":path": "/" + n, ":method": post ? "POST" : "GET" }, n === 1 && how === "abort" ? { signal: ac.signal } : {}); let body = ""; got[n] = "never settled";
        r.on("data", x => (body += x)); r.on("end", () => (got[n] = body || "no body")); r.on("error", e => (got[n] = e.code || e.message)); r.on("close", () => { if (got[n] === "never settled") got[n] = "closed"; });
        if (post) pipeline(new Readable({ read() { setTimeout(() => this.destroy(new Error("source failed")), 20); } }), r, () => {}); else r.end();
        if (n !== 1) continue;
        if (how === "timeout-destroy") r.setTimeout(20, () => r.destroy());
        else setTimeout(() => { if (how === "destroy") r.destroy(); else if (how === "destroy-error") r.destroy(new Error("gave up")); else if (how === "close") r.close(); else if (how === "abort") ac.abort(); }, 20);
      }
    });
    setTimeout(() => {
      const others = Object.keys(got).filter(n => n !== "1"), lost = others.filter(n => got[n] !== "answer-" + n), kinds = [...new Set(lost.map(n => got[n]))];
      console.log(`how ${how}, limit ${limit}: request 1: ${got[1]}; the session: ${session}`);
      console.log(`the other callers: ${others.length - lost.length} of ${others.length} got their own answer` + (lost.length ? "; the rest: " + kinds.join(", ") : ""));
      console.log(lost.length ? "RESULT: one caller gave its request up and other callers lost theirs" : "RESULT: ok"); srv.kill("SIGKILL"); process.exit(lost.length ? 1 : 0);
    }, 900);
  });
}
how bun 1.4.3-canary.1+367d939d9 this branch (debug build) node v26.3.0 as the client
destroy, destroy-error, timeout-destroy, pipeline exit 1. The session gets GOAWAY and 0 of 3 other callers get their answer (ERR_HTTP2_GOAWAY_SESSION) exit 0 exit 0
close, abort, none exit 0 exit 0 exit 0

On this branch each of the seven ways gives exit 0 in 3 of 3 runs. The script waits 900 ms in total. That is too short for limit 100 on a debug build, so the test file waits for conditions and covers the seven ways at limits 1 and 100.

2. A signal aborts request(headers, { signal }) while a write is blocked on flow control. Save the script as repro.js and run it with bun repro.js and with node repro.js. It connects to a raw h2c peer that never sends WINDOW_UPDATE, writes more than one window, and aborts once the window is used up.

const http2 = require("node:http2");
const net = require("node:net");
const frame = (t, fl, sid, p = Buffer.alloc(0)) => { const b = Buffer.alloc(9 + p.length); b.writeUIntBE(p.length, 0, 3); b[3] = t; b[4] = fl; b.writeUInt32BE(sid >>> 0, 5); p.copy(b, 9); return b; };
const PREFACE = Buffer.from("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n");
let exhausted; const windowExhausted = new Promise(r => (exhausted = r));
const server = net.createServer(s => {
  let buf = Buffer.alloc(0), pre = false, got = 0;
  s.on("error", () => {});
  s.on("data", d => {
    buf = Buffer.concat([buf, d]);
    if (!pre) { if (buf.length < PREFACE.length) return; pre = true; buf = buf.subarray(PREFACE.length); s.write(frame(4, 0, 0)); }
    while (buf.length >= 9) {
      const len = buf.readUIntBE(0, 3); if (buf.length < 9 + len) break;
      const t = buf[3], fl = buf[4]; buf = buf.subarray(9 + len);
      if (t === 4 && !(fl & 1)) s.write(frame(4, 1, 0));
      if (t === 0 && (got += len) >= 65535) exhausted();
    }
  });
});
server.listen(0, "127.0.0.1", () => {
  const session = http2.connect("http://127.0.0.1:" + server.address().port);
  session.on("error", () => {});
  session.once("remoteSettings", async () => {
    const ac = new AbortController();
    const stream = session.request({ ":path": "/u", ":method": "POST" }, { signal: ac.signal });
    stream.on("error", () => {});
    let drains = 0, accepted = 0;
    const backpressured = !stream.write(Buffer.alloc(65535 + 32768, 0x41));
    stream.on("drain", () => { drains++; for (let b = 32; b > 0 && stream.write(Buffer.alloc(16384)); b--) accepted++; });
    await windowExhausted;
    stream.on("close", () => setImmediate(() => { console.log(JSON.stringify({ backpressured, drains, accepted })); process.exit(0); }));
    ac.abort();
  });
});
runtime output
node v26.3.0 {"backpressured":true,"drains":0,"accepted":0}
bun 1.4.3-canary.1+367d939d9 {"backpressured":true,"drains":1,"accepted":32}
this branch (debug build) {"backpressured":true,"drains":0,"accepted":0}

@robobun

robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:40 PM PT - Oct 5th, 2026

✅ @robobun, your commit b386b070013547b90b14b1c87332a964b32a088e passed in Build #123494! 🎉


🧪   To try this PR locally:

bunx bun-pr 42763

That installs a local version of the PR into your bun-42763 executable, so you can run:

bun-42763 --bun

The native H2FrameParser also listened to the AbortSignal of a request.
That listener ran before the JS abort listener. It closed the native
stream and dropped the queued DATA frames, and this completed the held
Writable write callback with success while the JS stream was still live.
Writable then emitted 'drain'. Later writes reached the closed native
stream and were reported as successful.

Remove the native signal handling. The JS listener already destroys the
stream with an AbortError, like node, and _destroy sends
RST_STREAM(CANCEL). The stream is destroyed before the queued frames are
dropped, so Writable skips 'drain' and rejects later writes.

The native path also emitted 'aborted' for a stream whose writable side
had ended, rejected a signal that is not a native AbortSignal, and ran
before abort listeners that were added before request(). Node does none
of these.
destroy() and close() send RST_STREAM from a setImmediate that is queued
before 'close'. The client released the SETTINGS_MAX_CONCURRENT_STREAMS
slot on 'close', so the HEADERS frame of the next queued request reached
the peer before the RST_STREAM. The peer still counted the first stream
as open and refused the new one with REFUSED_STREAM.

Release the slot one setImmediate after 'close'. The native signal
listener used to write RST_STREAM at once, which hid this for
request({ signal }). stream.destroy() had the problem before.
@robobun
robobun force-pushed the robobun/38daadc0/http2-signal-abort-drain branch from 60c3241 to 02eec28 Compare September 15, 2026 00:08
Comment thread src/js/node/http2.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

…the connection ends

The test helper's wireLength(n) and windowExhausted now reject when the
peer socket closes or the session errors or closes, so a regression fails
with the frames that were seen and does not wait for the timeout.
Comment thread src/js/node/http2.ts Resolved
_destroy queues the RST_STREAM and then the slot release, in that order,
with two setImmediate calls in one place. The session no longer needs a
'close' listener per request that relies on the order of two unrelated
setImmediate calls.
Comment thread src/js/node/http2.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.

Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no new issues

No new issues were found in this update; 2 findings from earlier reviews are still open above.

Still open from earlier reviews (2):

  • 🔴 src/js/node/http2.ts:2667 — A throwing 'aborted' listener now leaks the concurrency slot, so requests queued behind SETTINGS_MAX_CONCURRENT_STREAMS…
  • 🔴 src/js/node/http2.ts:6295 — A http2.client.stream.start diagnostics_channel subscriber that destroys the stream leaks a concurrency slot, so later…

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

The release from Http2Stream#_destroy lost the slot in two cases. A
listener that throws inside _destroy ('aborted') skipped the release. A
diagnostics_channel subscriber of http2.client.stream.start that destroys
the request ran _destroy before the request was counted.

'close' fires in both cases. The listener releases the slot one
setImmediate later, which is after the setImmediate that sends the
RST_STREAM. Add a test for the diagnostics_channel case.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the latest push and found no new issues. Moving the slot release back to a 'close' listener (cf8754c) addresses both open findings — 'close' is emitted from destroy.ts via nextTick even when _destroy throws from emit("aborted") and even when destroy() ran before #trackActiveRequest registered the listener — and the new diagnostics_channel test covers the second case. Given the scope (native abort path removed, RST_STREAM/slot-release ordering), a human look is still worthwhile.

What was reviewed

  • #trackActiveRequest: the once("close", …) + setImmediate release fires on every destroy path, including a throw between emit("aborted") and the RST_STREAM scheduling.
  • Native deletions: StreamState::IDLE had no remaining producer after the pre-aborted branch was removed; the finalize stream-free loop only existed to drop SignalRef's parser refs.
  • Test helper: wireLength/windowExhausted now reject with the observed frames when the connection ends first, so a regression fails instead of hanging.
Extended reasoning...

Overview

This PR removes the native AbortSignal handling for http2.connect().request({ signal }) from src/runtime/api/bun/h2_frame_parser.rs (~144 lines: SignalRef, attach_signal, abort_stream, StreamState::IDLE, the shutdown free loop) and relies on the existing JS abortRequestStream → stream.destroy(new AbortError()) path in src/js/node/http2.ts. To keep RST_STREAM on the wire before the next queued request's HEADERS, #trackActiveRequest now defers the concurrency-slot release by one setImmediate after 'close'. A 326-line dual-runtime test file exercises abort-during-flow-control-block, wire ordering under SETTINGS_MAX_CONCURRENT_STREAMS: 1, and non-native signal objects.

Security risks

None identified. The change removes native code rather than adding it, and the JS path already existed. No new user-controlled input reaches native. The test server binds port: 0 on localhost only.

Level of scrutiny

Moderate-to-high. This is node:* compat where event ordering and wire framing are contract, and the PR description names remaining wire-order differences from Node (GOAWAY/RST_STREAM ordering, corked DATA before RST_STREAM) that are shared with stream.destroy(err) on main. The native deletion also removes a VM-shutdown cleanup loop and a stream state variant, so lifecycle correctness needs a human eye. The two 🔴 findings from the earlier review were both slot-leak regressions introduced by an intermediate approach (release scheduled inside _destroy); cf8754c reverts to the base-branch shape ('close' listener) with only a setImmediate added, which restores the base-branch guarantee that the release survives any throw inside _destroy and any pre-registration destroy().

Other factors

The PR author added a targeted test for the diagnostics_channel case raised earlier, and the test helper now rejects its waiters on connection close (the earlier optional finding). The PR description reports the full test/js/node/http2/ suite and 261 vendored test-http2-*.js passing on debug+ASAN, plus 30/30 runs under BUN_DESTRUCT_VM_ON_EXIT=1 with leak detection. The remaining wire-order differences are explicitly documented as pre-existing and shared with the non-signal destroy() path.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

setImmediate is not the precise indicator of completion here.

@robobun

robobun commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed. The second setImmediate only happens to run after the one that sends the RST_STREAM. It does not know that the frame is written.

The exact signal exists already: the native side reports a closed stream through the streamError, aborted and streamEnd (state 7) handlers. These are the places that decrement #connections. I am moving the slot release there, so a slot is free when the RST_STREAM is written or when both sides sent END_STREAM, like nghttp2 counts it. end_stream dispatches before it writes the RST_STREAM frame today (the engine's own resets write first), so I will make it write first. I will push when the http2 suites pass.

…closed

A setImmediate after 'close' only happened to run after the one that
sends the RST_STREAM. The exact signal is the native close notification:
the streamError, aborted and streamEnd (state 7) handlers, which already
decrement #connections. Release the SETTINGS_MAX_CONCURRENT_STREAMS slot
there and submit the queued requests. nghttp2 counts the same way.

end_stream dispatched before it wrote the RST_STREAM frame. It now writes
the frame first, like the engine's own resets, so a request that the
handler submits follows the RST_STREAM on the wire.

The slot is taken before the native request() call, because that call can
report the stream closed. A request whose response ended no longer holds
its slot until something reads the body.
@robobun

robobun commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 041e681.

  • The slot is released in the three handlers through which the native side reports a closed stream (streamError, aborted, streamEnd with state 7), next to #connections--. The queued requests are submitted from there. The 'close' listener and both setImmediate tricks are gone.
  • end_stream now writes the RST_STREAM frame before it dispatches, as the engine's own resets do. A request that the handler submits therefore follows the RST_STREAM on the wire.
  • The slot is taken before the native request() call, because that call can report the stream closed, and it is given back if the call throws.

One more case now matches node: a request whose response ended frees its slot at once, even if nothing reads the body. On main the queued request waits until the body is consumed. The test file has a case for it (7 tests, main fails 6, node v26.3.0 passes 7). All of test/js/node/http2/, the 261 vendored test-http2-*.js and the grpc-js suites I ran pass on the debug build.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.

Comment thread src/js/node/http2.ts Resolved
… runs

Http2Stream#_destroy computed the code and queued the RST_STREAM after it
emitted 'aborted'. A listener that throws stopped _destroy there, so the
RST_STREAM was never sent: the native stream stayed open, the peer kept
counting it, and the session could not finish a graceful close. With the
slot release tied to the native close, a queued request also waited
forever.

Set the code and queue the RST_STREAM first, like node's closeStream().
stream.rstCode now has its final value inside 'aborted' listeners, as in
node.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

This PR also fixes a second report. A response that ended, but that nothing reads, keeps its maxConcurrentStreams slot on main. Each later request() stays pending. Node v26.3.0 answers it.

Repro with a real http2.createServer: two unread responses fill the limit of 2, then a third request is made.

const http2 = require("node:http2");
const server = http2.createServer({ settings: { maxConcurrentStreams: 2 } });
server.on("stream", stream => {
  stream.on("error", () => {});
  stream.resume();
  stream.on("end", () => {
    stream.respond({ ":status": 200 });
    stream.end("response-body");
  });
});
server.listen(0, () => {
  const client = http2.connect(`http://127.0.0.1:${server.address().port}`);
  client.on("error", e => console.log("client session error", e.code));
  client.on("remoteSettings", async () => {
    for (let i = 0; i < 2; i++) {
      const req = client.request({ ":path": "/", ":method": "POST" });
      req.on("error", () => {});
      req.end("body"); // the response is never read
    }
    await new Promise(r => setTimeout(r, 800));
    const third = client.request({ ":path": "/third", ":method": "POST" });
    let thirdStatus = null;
    third.on("response", h => (thirdStatus = h[":status"]));
    third.on("error", () => {});
    third.resume();
    third.end("x");
    await new Promise(r => setTimeout(r, 800));
    console.log(JSON.stringify({ thirdPending: third.pending, thirdStatus }));
    process.exit(0);
  });
});

Output, debug builds:

  • main (367d939): {"thirdPending":true,"thirdStatus":null}
  • main merged with this branch (353e366): {"thirdPending":false,"thirdStatus":200}, the same as node v26.3.0

The branch now conflicts with main in src/runtime/api/bun/h2_frame_parser.rs. #42682 removed is_stream_aborted and edited SignalRef there. In the local merge I removed both. test/js/node/http2/node-http2-request-signal.test.ts passes 8 of 8 on that merge.

Conflicts: src/runtime/api/bun/h2_frame_parser.rs

main removed is_stream_aborted (#42682) and moved the stream's native
AbortSignal listener to AbortHandle (#43933). This branch removes that
listener, so SignalRef, attach_signal and abort_stream_for_signal are
deleted with it.
One caller gives its open request up while other requests wait for a
slot. The peer is node's own http2 server, which ends the session when a
client exceeds its SETTINGS_MAX_CONCURRENT_STREAMS. The request is given
up in seven ways, at a limit of 1 and of 100. Each other caller must get
its own answer.

Also checks the wire order for a plain destroy().
@robobun robobun changed the title node:http2: abort a request({ signal }) stream from JS only node:http2: free a request's slot when its stream closes on the wire, abort request({ signal }) from JS only Oct 6, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment on lines +363 to +365
setting(SETTINGS_MAX_CONCURRENT_STREAMS, 1),
{ respond: true },
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): On a CI lane without a node binary, all 14 peer-limit tests in this file now fail instead of being skipped. before throws "node executable not found" at test/js/node/http2/node-http2-request-signal.test.ts:429 when Bun.which("node") returns null. The sibling node-http2-client-close.test.ts:334 skips its node spawn when node is missing and on musl, where node segfaults and the runner fails the file for the core dump. Fix: gate the node-peer describe with skip when node is not found (and on musl like the sibling), so the first two describes still run everywhere.

Why this was flagged

The third describe spawns the system node as the HTTP/2 peer. In before at test/js/node/http2/node-http2-request-signal.test.ts:363-365, Bun.which("node") returning null makes the hook throw new Error("node executable not found"), which fails every limit ${limit}, ${how} test (2 limits x 7 ways = 14 tests) rather than skipping them. REVIEW.md asks tests needing a system binary to skipIf when it is unavailable. The existing test/js/node/http2/node-http2-client-close.test.ts:334 does exactly that ({ skip: !node || isMusl }) and also documents that Alpine's node segfaults and the CI runner fails a file for any new core dump; this new file spawns node on the musl lane unconditionally. On the base branch this file does not exist, so no http2 test fails for a missing node binary.

Verification: nit — triggers when Bun.which("node") returns null. test/js/node/http2/node-http2-request-signal.test.ts:428-429 throws "node executable not found" inside the before hook of the third describe, so a missing node makes every limit ${limit}, ${how} test (14 tests) fail rather than skip. The sibling node-http2-client-close.test.ts:325-334 does gate its node spawn with { skip: !node || isMusl }.

Comment thread src/js/node/http2.ts
Comment on lines +6371 to +6372
req[bunHTTP2StreamStatus] &= ~StreamState.HoldsRequestSlot;
this.#activeRequestCount--;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): The flush-path catch at src/js/node/http2.ts:6371-6372 re-implements #releaseRequestSlot without its flag guard, so a native request() that dispatches onStreamError and then throws decrements #activeRequestCount twice. The count then goes below the real number of open streams and the client submits more requests than the peer's SETTINGS_MAX_CONCURRENT_STREAMS allows, which the peer answers with REFUSED_STREAM. Fix: release the slot through one guarded helper at every site (the HoldsRequestSlot check must run before the decrement), e.g. split #releaseRequestSlot into a guarded decrement and the flush so the loop can call the decrement without re-entering itself.

Why this was flagged

Native request() in src/runtime/api/bun/h2_frame_parser.rs:6944-6968 dispatches onStreamError at 6948 when over the session memory limit, and only after that can fail with ? at 6959 (binary_type.to_js(...)). The JS streamError handler at src/js/node/http2.ts:5074 calls #releaseRequestSlot, which clears HoldsRequestSlot and decrements #activeRequestCount (6319-6322). When the same native call then throws, the #flushPendingRequests catch at 6371-6372 clears the flag again and decrements #activeRequestCount unconditionally, while the immediate-path catch at 6302 goes through the guarded #releaseRequestSlot. The counter is now one below the number of streams the peer counts, so the gate at 6343 lets one extra request through and the peer refuses it. On the base branch the slot was released once by the 'close' listener, so no double decrement was possible. The trigger is an allocation failure in that native path, so it is rare; the duplicated two-line block is the same logic as the helper minus its guard and should share it.

Verification: src/js/node/http2.ts:6370-6372 decrements #activeRequestCount unconditionally, unlike #releaseRequestSlot which returns early when the flag is already clear. The streamError handler (:5071-5076) calls #releaseRequestSlot, so if native request() dispatches onStreamError and then returns Err, the count is decremented twice. The only trigger is JSC OOM at h2_frame_parser.rs:6959.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants