Skip to content

node:http2: never send a PRIORITY field and validate options.parent, like node - #43544

Closed
robobun wants to merge 5 commits into
mainfrom
robobun/b3b424a2/h2-request-parent-zero
Closed

robobun wants to merge 5 commits into
mainfrom
robobun/b3b424a2/h2-request-parent-zero

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • request(headers, { parent: 0 }) fails with ERR_HTTP2_STREAM_ERROR: Stream closed with error code NGHTTP2_INTERNAL_ERROR. The next request fails with ERR_HTTP2_SESSION_ERROR: Session closed with error code 9. node v26.3.0 answers both with 200.
  • The cause is parent <= 0 in H2FrameParser::request() (src/runtime/api/bun/h2_frame_parser.rs:6974). It runs after the header block is in the HPACK encoder, and no HEADERS frame follows. A negative or NaN parent takes the same path, where node throws RangeError ERR_OUT_OF_RANGE (src/js/node/http2.ts:5962 sets no minimum).
  • bun also writes a PRIORITY field for exclusive: true, a non-zero parent or a weight (h2_frame_parser.rs:7178). node v26.3.0 never does: its nghttp2 1.69.0 discards the priority spec, so every request HEADERS frame has flags 0x05.

Fix

  • The native HEADERS writer no longer reads exclusive, parent or weight, and never writes a PRIORITY field. request() and respond() share that writer, so both match node.
  • request() calls validateNumber(options.parent, "options.parent", 0), like node's setAndValidatePriorityOptions.
  • Deleted, with no other user: StreamPriority, UInt31WithReserved::to_uint32, HeadersFrameFlags::PRIORITY and the per-stream weight field. state.weight reports 16, as node does.
  • Verified: test/js/node/http2/node-http2.test.js (5 new tests, all fail on bun 1.4.3-canary), the rest of test/js/node/http2/, and node's 261 test-http2-*.js files. The two wire-level scenarios pass under node v26.3.0 (Notes).

Background

  • One HPACK encoder (RFC 7541) serves all streams of a session, and the peer's decoder mirrors each table insert. A block that was encoded but never sent leaves the tables different. The peer answers the next block with GOAWAY(COMPRESSION_ERROR), code 9.
  • A HEADERS frame can carry a PRIORITY field (RFC 7540 6.2): an exclusive bit, a stream dependency (parent) and a weight.
  • RFC 9113 deprecated this scheme, and nghttp2 removed it. node still validates the options.
Notes

History of this PR. The first version kept the PRIORITY field and only made parent: 0 legal (no field for the default dependency, and no self-dependency on the wire). A maintainer asked to match node instead, which sends no PRIORITY field at all. The current version does that, and it removes the code the first version changed.

Repro for parent (bun file.mjs and node file.mjs):

import http2 from "node:http2";
const srv = http2.createServer();
srv.on("stream", st => { st.respond(); st.end("ok"); });
srv.on("sessionError", () => {});
await new Promise(r => srv.listen(0, "127.0.0.1", r));
function one(c, options) {
  try {
    const req = c.request({ ":path": "/", "x-a": "1" }, options);
    return new Promise(resolve => {
      req.on("error", e => resolve(`stream error ${e.code}: ${e.message}`));
      req.on("response", h => { req.resume(); req.on("close", () => resolve(`response ${h[":status"]}`)); });
    });
  } catch (e) { return Promise.resolve(`THROW ${e.name} ${e.code} ${e.message}`); }
}
for (const parent of [-1, 0, NaN, -Infinity, -0, -0.5, 1.5, 3, 2 ** 31 - 1, 2 ** 31, 2 ** 32, Infinity]) {
  const c = http2.connect("http://127.0.0.1:" + srv.address().port);
  const sessionErrors = [];
  c.on("error", e => sessionErrors.push(e.code + ": " + e.message));
  await new Promise(r => c.on("connect", r));
  const first = await one(c, { parent });
  const second = await one(c, undefined);
  console.log(`parent: ${Object.is(parent, -0) ? "-0" : parent} first: ${first} | then plain: ${second} | session errors: ${JSON.stringify(sessionErrors)}`);
  c.destroy();
}
srv.close();

node v26.3.0, and bun with this change (the output is the same, message text included):

parent: -1 first: THROW RangeError ERR_OUT_OF_RANGE The value of "options.parent" is out of range. It must be >= 0. Received -1 | then plain: response 200 | session errors: []
parent: 0 first: response 200 | then plain: response 200 | session errors: []
parent: NaN first: THROW RangeError ERR_OUT_OF_RANGE The value of "options.parent" is out of range. It must be >= 0. Received NaN | then plain: response 200 | session errors: []
parent: -Infinity first: THROW RangeError ERR_OUT_OF_RANGE ... Received -Infinity | then plain: response 200 | session errors: []
parent: -0 first: response 200 | then plain: response 200 | session errors: []
parent: -0.5 first: THROW RangeError ERR_OUT_OF_RANGE ... Received -0.5 | then plain: response 200 | session errors: []
(every other value: response 200, response 200)

bun 1.4.3-canary.1+367d939d9, for -1, 0, NaN, -Infinity, -0 and -0.5:

first: stream error ERR_HTTP2_STREAM_ERROR: Stream closed with error code NGHTTP2_INTERNAL_ERROR | then plain: stream error ERR_HTTP2_SESSION_ERROR: Session closed with error code 9 | session errors: ["ERR_HTTP2_SESSION_ERROR: Session closed with error code 9"]

What goes on the wire. A raw TCP peer that records the flags of each request HEADERS frame (a GET, so END_STREAM | END_HEADERS = 0x05 with no PRIORITY):

options node v26.3.0 bun before bun with this change
none 0x05 0x05 0x05
{ parent: 0 } 0x05 no frame, stream reset 0x05
{ exclusive: true }, { parent: 0, exclusive: true } 0x05 0x25, E=1, dependency 0, weight byte 0 0x05
{ parent: 1 }, { exclusive: true, parent: 3 } 0x05 0x25 with that dependency, weight byte 0 0x05
{ parent: 2 ** 31 - 1, exclusive: true } 0x05 0x25 0x05

The new tests, in describe("http2 priority options"):

  1. request() with parent 0, -0, 0 with exclusive: true, 1.5, 2 ** 31 and Infinity gets a 200 from an http2.createServer() peer each time. A plain request on the same session then gets a 200, and the session emits no error.
  2. request() with parent -1, -0.5, -Infinity and NaN throws RangeError ERR_OUT_OF_RANGE with node's message, before and after 'connect'. A plain request on the same session then gets a 200.
  3. A raw TCP server records the flags of each request HEADERS frame. Every option shape in the table gives 0x05.
  4. On a session with paddingStrategy ALIGNED or MAX, a request with { exclusive: true, parent: 3 } and then a plain request both get a 200.
  5. A raw TCP client sends six requests. The server answers with respond() and { parent: 0 }, { parent: -1 }, { parent: 3 }, { weight: 0 }, { weight: 16 }, { exclusive: true }. Every response HEADERS frame arrives, and none has the PRIORITY flag.

Tests 3 and 5 as one standalone script (node:assert, raw sockets) print the same under node v26.3.0 and under this branch:

request(): every HEADERS frame has flags 0x05 [5,5,5,5,5,5,5]
respond(): no response HEADERS frame has the PRIORITY flag [false,false,false,false,false,false]

Defects of the PRIORITY field that go away with it. None of them needs its own fix now:

  • On a session that pads its frames, the field went out before the Pad Length octet. RFC 7540 6.2 puts Pad Length first. http2.connect(url, { paddingStrategy: PADDING_STRATEGY_MAX }) followed by request(h, { exclusive: true }) ended the session with error code 9, and ALIGNED ended it with error code 1. Test 4 covers this.
  • The weight byte was always 0 (weight 1, the lowest), because request() deletes options.weight (DEP0194). The default weight is 16.
  • A parent equal to the stream's own id put a self-dependency on the wire (RFC 7540 5.3.1 forbids it). A node v26.3.0 server answered client.request(h, { parent: 1 }), as the first request of a session, with a GOAWAY.
  • respond(headers, { parent: -1 }), { weight: 0 } and { weight: 300 } reset the stream with no frame on the wire, so the client waited forever.

What respond() still gets wrong, outside this PR: { silent: 1 }, { endStream: 0 } and a signal still reach the native type checks and throw. #43478 covers respond() options.

Relation to other PRs. #32995 (closed as stale, not rejected) fixed parent: 0 as one part of a larger rewrite of request(). #41520 (open) moves the HPACK encode after the option checks. It touches the has_priority and priority_overhead lines that this PR deletes, so the second of the two to land needs a rebase. #43536 (open) moves the JS option checks after the options copy, in node's order. It moves the validateNumber(options.parent, ...) line that this PR edits, and the second to land must keep the 0. #43599 (open) corrects the weight octet of the PRIORITY field. This PR stops writing the field, so #43599 has nothing left to fix if this one lands. #43554 (open) fixes the padded frame length and also moves the priority fields behind the Pad Length octet in request(). Its padding fix is independent. The order of the two fields has no effect after this PR, and the second to land needs a rebase.

One difference from node stays, and #43536 removes it. request() validates endStream, parent, exclusive and silent on the caller's object and copies the object later. node copies first and validates the copy. So an inherited or non-enumerable option is checked in bun and ignored by node. On main an inherited parent: "x" throws for this reason. With this PR an inherited parent: -1 throws too.

request(headers, 5) returns a stream, and node throws ERR_INVALID_ARG_TYPE. #43491 covers that.

Suites run with the debug build: test/js/node/http2/node-http2.test.js (395 pass, 6 skip, 0 fail), h2-conformance (70), node-http2-streams-rehash (5), node-http2-invalid-padding (7), node-http2-continuation (9), node-http2-client-close (69), node-http2-settings-ack-ordering (1), and the 261 test-http2-*.js files under test/js/node/test/ (all exit 0).


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

fails on main (without fix)
ASAN without fix: 4 failed, 6 skipped
$ 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.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [836.35ms]
(pass) node none > Client Basics > should be able to send a POST request [547.01ms]
(pass) node none > Client Basics > constants [17.42ms]
(pass) node none > Client Basics > getDefaultSettings [6.95ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [16.89ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.60ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.20ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.56ms]
(pass) node none > Client Basics > should be able to send data using end [570.15ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [560.58ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release without fix: 4 failed, 6 skipped
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [0.91ms]
(pass) node none > Client Basics > getDefaultSettings [0.14ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.29ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.15ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.03ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.07ms]
(pass) node none > Client Basics > is possible to abort request [1.91ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.71ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.66ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.72ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [29.23ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > headers cannot be bigge
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ 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.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [844.45ms]
(pass) node none > Client Basics > should be able to send a POST request [560.43ms]
(pass) node none > Client Basics > constants [17.24ms]
(pass) node none > Client Basics > getDefaultSettings [7.26ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [18.77ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [7.75ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [4.94ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [7.65ms]
(pass) node none > Client Basics > should be able to send data using end [603.68ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [593.04ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     e0b0aa56d4
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 968ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1250] gen bindgenv2
[2/1250] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 22 installs across 61 packages (no changes) [33.00ms]
[3/1250] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[4/1223] gen ErrorCode+*.h
[5/1223] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939d9)

Checked 1 install across 2 packages (no changes) [5.00ms]
[6/1223] gen .bind.ts → GeneratedBindings.cpp
[7/1223] fetch zlib
[zlib] up to date
[8/1223] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (367d939d9)

Checked 111 installs across 104 packages (no changes) [13.00ms]
[9/1223] fetch tinycc
[tinycc] up to date
[10/1222] gen node-fallbacks/react-refresh.js
Bundled 1 module in 21ms

  react-refresh.js  4.81 KB  (entry point)

[11/1222] gen bake.{client,server,error}.js
-> bake.client.js, bake
... (truncated)
diff hotspot
src/js/node/http2.ts                   |   2 +-
 src/runtime/api/bun/h2_frame_parser.rs |   9 +-
 test/js/node/http2/node-http2.test.js  | 216 +++++++++++++++++++++++++++++++++
 3 files changed, 224 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                    reads  edits  tests
src/js/node/http2.ts                        3      1     29
src/runtime/api/bun/h2_frame_parser.rs      5      5     29
test/js/node/http2/node-http2.test.js       6      8     29

@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 2524d680-2a2f-4296-8767-605a2ab5e13b

📥 Commits

Reviewing files that changed from the base of the PR and between 9b7c982 and e0b0aa5.

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

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


Walkthrough

HTTP/2 parent validation now accepts non-negative numeric values, including zero. Frame handling treats zero as the default dependency and removes self-dependencies. Tests cover validation errors, priority encoding, session integrity, and responses.

Changes

HTTP/2 parent handling

Layer / File(s) Summary
Parent value validation
src/js/node/http2.ts
ClientHttp2Session.request() rejects negative options.parent values.
Parent dependency and priority handling
src/runtime/api/bun/h2_frame_parser.rs
Request frame processing accepts non-negative parents, treats zero as the default dependency, and removes self-dependencies.
Validation and frame coverage
test/js/node/http2/node-http2.test.js
Tests cover accepted values, ERR_OUT_OF_RANGE errors, priority encoding, session integrity, and respond({ parent: 0 }).

Suggested reviewers: cirospaciari

Priority: ➖ Normal

🚥 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 the two main changes: validation of options.parent and removal of PRIORITY fields in node:http2.
Description check ✅ Passed The description explains the problem, implementation, expected behavior, compatibility goals, and verification results. It does not use the template headings exactly, but it provides the required info…

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

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:38 PM PT - Sep 21st, 2026

✅ @robobun, your commit 20a7eeba30d0ec8768574d7d4d3bf99af380055f passed in Build #119457! 🎉


🧪   To try this PR locally:

bunx bun-pr 43544

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

bun-43544 --bun

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: closed in favor of #43599, which now contains the same change (no PRIORITY field on any HEADERS frame, and options.parent validated with a minimum of 0). See the closing comment below.

How I reproduced it. On bun 1.4.3-canary.1+367d939d9 and on main, client.request({ ":path": "/" }, { parent: 0 }) against an http2.createServer() peer fails with ERR_HTTP2_STREAM_ERROR: Stream closed with error code NGHTTP2_INTERNAL_ERROR. The next request on the same session fails with ERR_HTTP2_SESSION_ERROR: Session closed with error code 9. node v26.3.0 answers both with 200, and throws RangeError ERR_OUT_OF_RANGE for a negative or NaN parent. The scripts and outputs are in the PR body under Notes.

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

Beyond the inline findings, I also checked whether an out-of-range parent (2**31, Infinity) could reach the u32::try_from(parent).expect(...) in the HEADERS/CONTINUATION writers as a negative after to_int32 — it cannot: JSValue::to_int32 in src/jsc/JSValue.rs saturates to i32::MAX rather than wrapping, so the parent < 0 guard at src/runtime/api/bun/h2_frame_parser.rs:6969 is the only negative path and it returns before the writer.

Extended reasoning...

The source diff is three lines (a 0 minimum on validateNumber, parent <= 0 || > MAX_STREAM_ID narrowed to parent < 0, and has_priority |= parent != 0). The one candidate I traced myself was the reviewer-noted worry that 2**31 might wrap to a negative int32 after JS-side validation passed; JSValue::to_int32 uses Rust as on the double, which saturates, so out-of-range positives become i32::MAX, never negative, and the expect("int cast") at h2_frame_parser.rs:7175/7223 is unreachable from both the client request() and server respond() paths. The inline findings (mostly pre-existing behaviour around the shared native request, padding/priority ordering, and weight) plus an unposted verified finding mean this still warrants a human look rather than approval.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/api/bun/h2_frame_parser.rs — pre-existing: a client session opened with paddingStrategy MAX or ALIGNED dies (GOAWAY PROTOCOL_ERROR or COMPRESSION_ERROR) on any request that still sets a PRIORITY field, same as on the base branch. The single-frame path at h2_frame_parser.rs:7173-7193 writes the 5 priority bytes, then the Pad Length octet, then the block; RFC 7540 6.2 puts Pad Length first, and Bun's own receiver (h2/connection.rs:891-910) reads it first. Fix: when PADDED and PRIORITY are both set, emit Pad Length, then the priority field, then the block (the CONTINUATION branch never pads, so only this site needs it). The PR narrows the trigger to parent != 0 or exclusive: true but lists this as found, not fixed.

    Extended reasoning...

    http2.connect(url, { paddingStrategy: PADDING_STRATEGY_MAX }) reaches native via nativeSettings at http2.ts:5701 and h2_frame_parser.rs:7625, and every new Stream copies it at h2_frame_parser.rs:3395. request(headers, { exclusive: true }) or { parent: 3 } sets has_priority (h2_frame_parser.rs:6955 or 6981). get_padding at 1459 returns a non-zero pad, so flags gain PADDED at 7150. The writer then emits FrameHeader (7170), StreamPriority (7180), and finally the block with buffer[0] = padding (7192). A conforming peer parses byte 0 of the payload as Pad Length. For exclusive: true that byte is 0x80 = 128, larger than the payload, so the peer answers GOAWAY PROTOCOL_ERROR (Bun's own server: connection.rs:894-896). For parent: 3 the byte is 0x00, the peer skips 5 bytes landing at the real block start but keeps the trailing padding zeros as block bytes; 0x00 is a literal with name index 0, invalid in HPACK, so the peer answers COMPRESSION_ERROR and the session is torn down. Base branch behaves the same; this PR only removes parent: 0 from the set of inputs that reach this path.

    Verification: pre-existing (the writer code is byte-identical on base 9b7c982 and HEAD; the PR only changes when has_priority is set, at h2_frame_parser.rs:6979 has_priority |= parent != 0, which narrows rather than widens the inputs reaching this path). Triggering condition: a client session created with paddingStrategy: PADDING_STRATEGY_MAX (or ALIGNED when the length is not already 8-aligned) issues…

Comment thread test/js/node/http2/node-http2.test.js Resolved
Comment thread test/js/node/http2/node-http2.test.js Resolved
Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
Comment thread src/runtime/api/bun/h2_frame_parser.rs 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.

LGTM — the follow-up commit addresses the earlier inline notes (connect now rejects on error/close, respond() with parent: 0 is covered, and a self-dependency is collapsed to 0 instead of going on the wire).

What was reviewed:

  • Traced parent_js.to_int32() (src/jsc/JSValue.rs:765): NaN maps to 0 and out-of-range/Infinity saturate to i32 bounds, so after the parent < 0 check the value is in [0, i32::MAX] and the downstream u32::try_from(parent).expect(...) cannot fire; dropping the > MAX_STREAM_ID clause is a no-op since MAX_STREAM_ID == i32::MAX.
  • validateNumber(options.parent, "options.parent", 0) in request() matches node's minimum and the RangeError/ERR_OUT_OF_RANGE contract; the native parent < 0 rejection remains as the guard for respond(), which passes options unvalidated.
  • has_priority |= parent != 0 preserves the existing exclusive/weight-driven PRIORITY emission; the raw-socket test pins exactly which requests carry the field.
Extended reasoning...

Overview

The PR changes three files: a one-argument change in src/js/node/http2.ts (validateNumber(options.parent, "options.parent", 0)), a ~10-line change in the native HEADERS writer in src/runtime/api/bun/h2_frame_parser.rs (reject only parent < 0, collapse parent == stream_id to 0, set has_priority only for a non-zero dependency), and ~216 lines of new tests in test/js/node/http2/node-http2.test.js covering request() with non-negative/negative/NaN parents, wire-level PRIORITY field presence via a raw net server, and server-side respond(headers, { parent: 0 }).

Security risks

None identified. The change relaxes a rejection of parent: 0 (which previously corrupted HPACK state by encoding a block and never sending it) and tightens JS-side validation to reject negatives/NaN synchronously. The remaining native path is bounded: to_int32 saturates so parent is always in [0, i32::MAX] once the < 0 check passes, which keeps the later u32::try_from(...).expect unreachable on user input. No new allocation, no new untrusted-length arithmetic, and no change to how peer-supplied frames are parsed.

Level of scrutiny

Moderate. It touches the native HTTP/2 frame writer, but the diff is small and each clause is directly exercised by a test: the raw-socket test asserts which HEADERS frames carry a PRIORITY field (including the self-dependency collapse on streams 13/15), the request() tests assert exact error class/code/message and that the HPACK encoder stays in sync afterwards, and the respond() test covers the sibling entry point. I checked that removing the > MAX_STREAM_ID bound is behavior-preserving given MAX_STREAM_ID == i32::MAX and saturating conversion. The changed files are not covered by CODEOWNERS.

Other factors

The prior review's nits (connect promise with no reject path; missing respond() coverage) and the pre-existing self-dependency observation were all addressed in the latest commit, and no new findings surfaced this run (the hunt ran dry). The known pre-existing weight-byte-0 issue is explicitly scoped out in the PR description and is not widened by this change. The PR author's description matches the code as merged.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

On the additional finding in the review summary (a HEADERS frame with both PADDED and PRIORITY writes the priority fields before the Pad Length octet): confirmed, and older than this PR. On main, a client session with paddingStrategy: PADDING_STRATEGY_MAX ends with error code 9 on request(h, { exclusive: true }), and with PADDING_STRATEGY_ALIGNED it ends with error code 1. This PR does not change the frame writer. That fix needs its own change, together with the default weight byte. The PR body lists both under Notes.

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

@cirospaciari

Copy link
Copy Markdown
Member

@robobun Node v26.3.0 never puts a PRIORITY field in a request HEADERS frame: its nghttp2 1.69.0 discards the spec ((void)pri_spec, deps/nghttp2/lib/nghttp2_submit.c:681), so every request() goes out with flags 0x05. The test "request() sends a PRIORITY field only for a dependency the peer does not already assume" expects the field for exclusive: true or a non-zero parent, so it fails on Node. Unless this is for safety, please match Node so the test passes on Node and Bun.
Repro: client.request(h,{exclusive:true,parent:3}) against a raw peer that prints the HEADERS flags: Node prints 0x05 with no PRIORITY bytes.

…N parent like node

request(headers, { parent: 0 }) reset the stream with
NGHTTP2_INTERNAL_ERROR. The native check rejected parent <= 0, and it
ran after the header block was in the session's HPACK encoder. The
HEADERS frame never went out, so the server failed the next header
block with GOAWAY(COMPRESSION_ERROR).

The native check now rejects only a negative parent. Stream 0 is the
dependency every stream starts with (RFC 7540 5.3.5), so parent: 0
alone adds no PRIORITY field to the HEADERS frame.

request() validates options.parent with a minimum of 0, like node's
setAndValidatePriorityOptions. A negative or NaN parent throws
ERR_OUT_OF_RANGE before the request reaches the encoder.
A request whose parent is its own stream id put a self-dependency in
the PRIORITY field. RFC 7540 5.3.1 forbids that, and a node server
answers it with a GOAWAY that ends the session. The native code now
treats such a parent as stream 0, the default dependency.

The tests also cover respond(headers, { parent: 0 }), which uses the
same native function. The wait for 'connect' now rejects when the
session fails.
node v26.3.0 validates the priority options of request() and sends
none of them: nghttp2 1.69 discards the priority spec, so every
request HEADERS frame goes out without the PRIORITY flag. Bun wrote a
PRIORITY field for `exclusive: true`, a non-zero `parent` and a
`weight`.

The HEADERS writer no longer reads `exclusive`, `parent` or `weight`
and no longer writes the field. request() still validates the options
in JS. respond() shares the writer, so it ignores the three options
like node. `StreamPriority`, `UInt31WithReserved::to_uint32`,
`HeadersFrameFlags::PRIORITY` and the per-stream `weight` field had no
other user and are removed. `state.weight` reports 16, as node does.

This also removes three defects of the field: it went out before the
Pad Length octet on a padded session, its weight byte was always 0,
and a parent equal to the stream's own id made a node server end the
session.
@robobun robobun changed the title node:http2: accept parent: 0 in request() and reject a negative or NaN parent like node node:http2: never send a PRIORITY field and validate options.parent, like node Sep 21, 2026
@robobun
robobun force-pushed the robobun/b3b424a2/h2-request-parent-zero branch from e0b0aa5 to 20a7eeb Compare September 21, 2026 23:50
@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari Done in 20a7eeb. It was not for safety. The first version only kept what bun already sent, and matching node is simpler and better.

  • The native HEADERS writer no longer reads exclusive, parent or weight, and it never writes a PRIORITY field. request() still validates the options in JS like node (parent now has the minimum 0). respond() uses the same writer, so it ignores the three options too, like node.
  • I confirmed your repro. With client.request(h, { exclusive: true, parent: 3 }) against a raw peer, node v26.3.0 prints flags 0x05. bun printed 0x25 before, and prints 0x05 now.
  • The test is now request() never sends a PRIORITY field, like node. It expects flags 0x05 for every option shape, your repro included. The same scenario as a standalone node:assert script passes under node v26.3.0 and under this branch (output in the PR body under Notes).
  • The code that only the field used is deleted: StreamPriority, UInt31WithReserved::to_uint32, HeadersFrameFlags::PRIORITY and the per-stream weight field. state.weight reports 16, which is what node reports.

Three defects of the field go away with it, so they need no fix of their own: the field went out before the Pad Length octet on a padded session (the session ended), its weight byte was always 0, and a parent equal to the stream's own id made a node server answer with a GOAWAY. A new test covers the padded session.

I rebased on main and updated the PR title and body.

@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 completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

A review on #43599 asked that bun match node v26.3.0, which never sets the PRIORITY flag on a HEADERS frame (nghttp2 1.69.0 discards the priority spec): #43599 (comment)

#43599 now removes the exclusive, parent and weight handling from the native HEADERS writer. That overlaps with this PR:

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my note above: after a second review comment, #43599 now includes validateNumber(options.parent, "options.parent", 0) as well (commit e4a6018), because its removal of the native parent check left no range check at all. It is the identical line to the one in this PR, with test rows for -1, -0.5 and NaN, and a row for parent: 0.

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #43599. Both PRs got the same request (match node v26.3.0 and send no PRIORITY field), and both now make the same change. The src/ diffs of the two heads are identical apart from one comment and one local variable name.

#43599 covers everything this PR set out to fix:

  • validateNumber(options.parent, "options.parent", 0) in request(), with tests for parent: 0 and for a negative or NaN parent (ERR_OUT_OF_RANGE).
  • No PRIORITY flag and no priority bytes from request() or respond(), with byte-for-byte tests that also cover CONTINUATION frames and padded sessions.
  • It also removes delete options.weight in request(), which has no use after the native change. This PR did not.

Only one of the two needs to land. @cirospaciari there is nothing left to review here.

@robobun robobun closed this Sep 22, 2026
@robobun
robobun removed the request for review from cirospaciari September 22, 2026 04:39
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