Skip to content

node:http2: read the session.goaway() error code with ToUint32, like node - #37558

Open
robobun wants to merge 5 commits into
mainfrom
farm/6738b33f/http2-goaway-u32-code
Open

robobun wants to merge 5 commits into
mainfrom
farm/6738b33f/http2-goaway-u32-code

Conversation

@robobun

@robobun robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • session.goaway(0xffffffff) reaches the peer as 0x7fffffff, like every code from 2^31 to 2^32 - 1. Node v26.3.0 sends 0xffffffff.
  • H2FrameParser::goaway reads the code with JSValue::to_int32() (h2_frame_parser.rs:4710), which saturates a double at i32::MAX. session.destroy(error, code) uses the same read.

Fix

  • The read is now coerce_to_i32(global)? as u32: JavaScript's ToUint32, the conversion behind Node's Uint32Value.
  • A code from 0 to 2^32 - 1 reaches the wire as passed. Any other number wraps, as in Node: -1 is 0xffffffff and 2^32 is 0.
  • Verified: test/js/node/http2/node-http2.test.js, 8 cases. 7 fail on 1.4.3-canary, and all 8 pass under Node v26.3.0. Also ran the whole file and the ported goaway tests.

Background

  • GOAWAY is the HTTP/2 frame that ends a connection. It carries a 32-bit unsigned error code, and 0 means no error.
  • ToUint32 truncates a number and keeps its low 32 bits. NaN and the infinities give 0.
  • to_int32() saturates and to_u32() clamps. coerce_to_i32() calls JSC's toInt32, which wraps.
  • An earlier revision used to_u32() and sent client.goaway(-1) as 0. Main and Node send 0xffffffff.

Downsides

Notes

Repro

const http2 = require("node:http2");
const server = http2.createServer();
server.on("session", s => s.goaway(0xffffffff, 0, Buffer.from("big")));
server.listen(0, "127.0.0.1", () => {
  const client = http2.connect(`http://127.0.0.1:${server.address().port}`);
  client.on("error", () => {});
  client.on("goaway", code => {
    console.log(code.toString(16));
    client.destroy();
    server.close();
  });
});

Bun 1.4.3-canary and main print 7fffffff. Node v26.3.0 and this branch print ffffffff.

The error code on the wire

A raw TCP peer reads the 4 code bytes of the GOAWAY frame that client.goaway(code) writes. One connection per cell. client.destroy(new Error("x"), code) gives the same value in every row, on all three.

code Node v26.3.0 1.4.3-canary (same read as main) this branch
2**31 - 1 7fffffff 7fffffff 7fffffff
2**31 80000000 7fffffff 80000000
2**32 - 1 ffffffff 7fffffff ffffffff
-1 ffffffff ffffffff ffffffff
-(2**31) 80000000 80000000 80000000
2**32 00000000 7fffffff 00000000
2**32 + 1 00000001 7fffffff 00000001
Infinity 00000000 7fffffff 00000000
-Infinity 00000000 80000000 00000000
NaN 00000000 00000000 00000000
1.5 00000001 00000001 00000001
-1.5 ffffffff ffffffff ffffffff
2**53 00000000 7fffffff 00000000

A server session gives the Node value for every integer code from 0 to 2^32 - 1 in the table. For any other number its JS wrapper throws ERR_OUT_OF_RANGE before the native read, on main and on this branch.

Node's read: node_http2.cc#L2992-L2995. Http2Session::Destroy reads its code the same way (#L2924-L2927). Node's goaway() in JS only runs validateNumber(code).

Why not to_u32()

to_u32() is to_int64() clamped to 0..2^32 - 1. It is the identity inside that range. Outside it -1 becomes 0 and 2**32 becomes 0xffffffff. The first is worse than main, which sends 0xffffffff for -1 because the i32 value -1 is cast to u32.

The test

  • The receiving session's 'goaway' event reports the 4 code bytes of the frame, so the test reads the code there.
  • Rows 1 to 4 send 0xffffffff and 0x80000000 from both session kinds, with and without opaque data (the two send paths of the native method). Rows 5 to 8 send -1, 2**32, 2**32 + 1 and Infinity from a client session, which passes any number to the native method.
  • The test compares the opaque data by bytes. For a GOAWAY with no opaque data Node's event passes undefined and bun passes an empty Buffer (src/js/node/http2.ts:4311, :5322). That difference is older than this PR and is not changed here.
  • BUN_JSC_validateExceptionChecks=1 reports nothing for the new read.

Not changed

  • The lastStreamID read a few lines below. It also differs from Node for 2^31 and above, and nghttp2 refuses an id of the sender's own parity. That is a separate change.
  • The other reads of an error code in this file (rst_stream, emit_error_to_all_streams).

Related

Suites run with the debug build

test/js/node/http2/node-http2.test.js (401 pass, 6 skip), and from test/js/node/test/parallel: test-http2-goaway-opaquedata, test-http2-goaway-delayed-request, test-http2-server-shutdown-before-respond, test-http2-server-shutdown-options-errors, test-http2-server-shutdown-redundant, test-http2-session-graceful-close, test-http2-client-destroy, test-http2-session-cleanup-on-nghttp2-goaway.


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

fails on main (without fix)
ASAN without fix: 7 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 [845.06ms]
(pass) node none > Client Basics > should be able to send a POST request [573.23ms]
(pass) node none > Client Basics > constants [16.98ms]
(pass) node none > Client Basics > getDefaultSettings [8.11ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [18.50ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [6.82ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [4.27ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.76ms]
(pass) node none > Client Basics > should be able to send data using end [616.51ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [605.99ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release without fix: 12 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.79ms]
(pass) node none > Client Basics > getDefaultSettings [0.13ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.25ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.13ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.02ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.04ms]
(pass) node none > Client Basics > is possible to abort request [1.63ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.73ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.67ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.84ms]
(pass) node none > Client Basics > headers cannot be bigger than 65536 bytes [36.36ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > close callback [34
... (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 [737.59ms]
(pass) node none > Client Basics > should be able to send a POST request [521.21ms]
(pass) node none > Client Basics > constants [11.70ms]
(pass) node none > Client Basics > getDefaultSettings [5.63ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [11.71ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [4.87ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [2.86ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.45ms]
(pass) node none > Client Basics > should be able to send data using end [532.20ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [522.83ms]
(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) in 1360ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/1443] gen bindgenv2
[2/1443] gen .bind.ts → GeneratedBindings.cpp
[3/1443] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 7 sinks, 91 exported symbols
Generating /workspace/bun/build/release/codegen/JSSink.lut.h from /workspace/bun/build/release/codegen/JSSink.lut.txt
[4/1443] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 248 extern-C blocks audited
[5/1443] rustc build_script_build 
[6/1443] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[7/1443] fetch tinycc
[tinycc] up to date
[8/1443] gen BunObject.lut.h
Generating /workspace/bun/build/release/codegen/BunObject.lut.h from /workspace/bun/src/jsc/bindings/BunObject.cpp
[9/1443] build.rs build_script_build
[10/1443] rustc build_script_build 
[11/1443] build.rs build_script_build
[12/1443] gen ZigGlobalObject.lut.h
Generating /workspace/bun/build/release/codegen/ZigGlobalObject.lut.h from /workspace/bun
... (truncated)
diff hotspot
src/runtime/api/bun/h2_frame_parser.rs | 13 +++-------
 test/js/node/http2/node-http2.test.js  | 47 ++++++++++++++++++++++++++++++++++
 2 files changed, 51 insertions(+), 9 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                    reads  edits  tests
src/runtime/api/bun/h2_frame_parser.rs      0      0      5
test/js/node/http2/node-http2.test.js       0      0      5

root cause · written by the author bot

The native goaway() read the errorCode argument with a saturating to_int32 conversion, so codes at or above 2^31 (such as 0xffffffff) were clamped rather than wrapped and the wrong value went on the wire, unlike Node's ToUint32 read. The fix coerces the argument with the modular ToInt32 and reinterprets the result as u32, which is bit-identical to Node's Uint32Value for every double, and threads that single ErrorCode through both send_go_away call sites. A parameterized test covers client and server sessions, both opaqueData paths, and out-of-range inputs such as -1, 2^32+1 and Infinity.

The goaway() binding read the error code with to_int32(), which saturates
doubles outside the i32 range, so any code in (0x7fffffff, 0xffffffff]
went out on the wire as 0x7fffffff. The GOAWAY error code is an unsigned
32-bit field and node transmits the argument via Uint32Value, so read it
with to_u32() like rst_stream() already does.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 2 billable files and costs up to $0.50.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 30 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 87cc8665-d3c2-4442-b4d3-81787b8f5491

📥 Commits

Reviewing files that changed from the base of the PR and between faac63e and 021468d.

📒 Files selected for processing (2)
  • src/runtime/api/bun/h2_frame_parser.rs
  • test/js/node/http2/node-http2.test.js
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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

@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on Bun 1.4.3-canary (367d939, the same read as main faac63e) with the script in the PR description: session.goaway(0xffffffff) and session.goaway(0x80000000) arrive at the peer as 0x7fffffff, from a server session and from a client session. Node v26.3.0 delivers both unchanged.

The description has the table of codes against 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.

LGTM — one-line coercion fix (to_int32 → to_u32) so GOAWAY error codes >2^31-1 reach the wire unchanged.

What was reviewed:

  • Confirmed to_u32() clamps into [0, u32::MAX] and cannot throw after the is_number() guard; ErrorCode is Copy so building it once is fine.
  • Matches the existing rst_stream() pattern at h2_frame_parser.rs:7350 which already reads its error code with to_u32().
  • Test covers both session types × both send paths (with/without opaqueData), awaits the actual goaway event, uses port 0, and cleans up in finally.
Extended reasoning...

Overview

Single-line semantic change in H2FrameParser::goaway() (src/runtime/api/bun/h2_frame_parser.rs): the JS errorCode argument is now read with JSValue::to_u32() instead of to_int32(), and the ErrorCode newtype is constructed once instead of at each send_go_away call site. The rest of the diff is a mechanical consequence of hoisting the ErrorCode(...) construction. A four-case parameterized test is added to test/js/node/http2/node-http2.test.js alongside the existing goaway test.

Security risks

None. This changes how a numeric argument already validated by the JS layer is coerced before being written into a fixed-width u32 protocol field. No new inputs are accepted, no bounds change on the wire (the field was always 4 bytes), and the receive path is untouched.

Level of scrutiny

Low. The root cause is well-explained and verifiable: to_int32() saturates doubles above i32::MAX (JSValue.rs:759), so 0xffffffff became 0x7fffffff before the as u32 cast. to_u32() clamps into the full [0, u32::MAX] range (JSValue.rs:784-785) via to_int64(), which is exactly what RFC 9113 §6.8's unsigned 32-bit error-code field needs. The is_number() check immediately above means neither helper can throw. ErrorCode is #[derive(Copy)] (line 282), so passing the hoisted value to two call sites is sound. The sibling rst_stream() method already reads its error code with to_u32() (line 7350), so this brings goaway() in line with the file's own convention.

Other factors

The test is well-constructed per repo guidelines: it.each over the 2×2 matrix (server/client sender × with/without opaqueData, hitting both send_go_away branches), Promise.withResolvers to await the actual goaway event, port 0, try/finally cleanup registered before the assertion, and a single toEqual on {code, lastStreamID, opaqueData}. The client's expected ERR_HTTP2_SESSION_ERROR on receiving a non-zero GOAWAY is swallowed. The PR body documents that all four cases fail on the released binary (receiving 2147483647) and pass on the debug build, and that the existing 351 tests in the file plus the ported Node goaway suites still pass. The one-line comment on the changed line explains why (i32 saturation) rather than what, which is the repo's bar. No prior reviews or outstanding comments to address.

@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:27 PM PT - Aug 11th, 2026

✅ @robobun, your commit 91c2fbdc9a9533e8e644b3c2f024f1b8a631b105 passed in Build #92555! 🎉


🧪   To try this PR locally:

bunx bun-pr 37558

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

bun-37558 --bun

@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 — one-line fix swapping a saturating signed read for the unsigned one the GOAWAY error-code field needs.

What was reviewed

  • Confirmed to_u32() is non-throwing after the is_number() guard and matches the sibling error-code reads in rst_stream() and emit_error_to_all_streams().
  • Checked send_go_away already takes ErrorCode(u32), so the two call-site simplifications are pure refactor.
  • Test: wires session error/close on both ends to reject (no timeout-only failure), covers both session classes × both send paths, uses port: 0 and cleans up in finally.
Extended reasoning...

Overview

This PR changes one argument read in H2FrameParser::goaway() (src/runtime/api/bun/h2_frame_parser.rs) from to_int32() to to_u32(), wraps it directly in ErrorCode(...), and simplifies the two send_go_away call sites that previously did ErrorCode(error_code as u32) inline. It adds a 4-case it.each test to test/js/node/http2/node-http2.test.js that sends 0xffffffff and 0x80000000 from both a server session and a client session and asserts the peer's 'goaway' event delivers the code, lastStreamID, and opaque data unchanged.

Security risks

None. This widens the range of an outbound protocol field from a saturated i32 to the full u32 the RFC 9113 GOAWAY frame defines. No untrusted-input parsing changes; the receive path was already u32. The value is caller-supplied and JS-layer-validated (validateInteger(code, "code", 0, kMaxUint32) on the server side).

Level of scrutiny

Low. The effective change is a single-token substitution (to_int32 → to_u32) on a value already guarded by is_number(). to_u32() is self.to_int64().clamp(0, u32::MAX as i64) as u32 — non-throwing on numbers, and the identity on [0, 2^32-1], which is exactly the range the JS wrapper validates. The two other error-code reads in this file (rst_stream at line 7349, emit_error_to_all_streams at line 8698) already use to_u32(), so this brings goaway() in line with its siblings. The send_go_away signature already takes ErrorCode, so hoisting the wrapper to the read site is a no-op refactor.

Other factors

The test follows repo conventions well: it.each for the matrix, port: 0, error/close events on both sessions wired to reject so a lost frame fails with a cause instead of timing out (the second commit on the PR is exactly this hardening), exact toEqual assertion on {code, lastStreamID, opaqueData}, and cleanup in finally. Post-resolve reject() calls from the close events fired during finally are no-ops on an already-settled promise. The PR description documents that all four cases fail on the released binary (receiving 2147483647) and pass on the debug build, and that the rest of the file plus the ported Node goaway/shutdown tests still pass. No prior reviewer comments to address.

@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 — one-line signed→unsigned read fix that brings goaway() in line with its sibling error-code readers.

What was reviewed:

  • Confirmed to_u32() clamps via to_int64().clamp(0, u32::MAX) (src/jsc/JSValue.rs:784), which is the identity over the [0, 2^32-1] range the JS layer validates.
  • Verified rst_stream() and emit_error_to_all_streams() in the same file already read error codes with to_u32() after an is_number() guard — this change matches them exactly.
  • Test wires session error/close on both ends to reject the awaited promise, covers server+client sessions and both send_go_away call sites (with/without opaqueData), and asserts exact code/lastStreamID/opaqueData.
Extended reasoning...

Overview

Single-line native fix in H2FrameParser::goaway() (src/runtime/api/bun/h2_frame_parser.rs): the GOAWAY error code argument is now read with JSValue::to_u32() instead of to_int32(), and the ErrorCode wrapper is constructed once at the read site rather than at each of the two send_go_away call sites. The old to_int32() saturates doubles above i32::MAX, so any code in (0x7fffffff, 0xffffffff] went out on the wire as 0x7fffffff. RFC 9113 §6.8 defines the field as unsigned 32-bit, and Node reads it with Uint32Value. A 40-line it.each test in node-http2.test.js sends 0xffffffff and 0x80000000 from both server and client sessions and asserts the peer's 'goaway' event reports the exact code, lastStreamID, and opaqueData.

Security risks

None. This widens the range of an integer that is written verbatim into a fixed 4-byte GOAWAY frame field. No allocation sizing, no indexing, no length arithmetic depends on it. The is_number() guard is preserved before the coercion, and to_u32() clamps rather than wraps, so out-of-range inputs cannot produce anything outside [0, u32::MAX].

Level of scrutiny

Low-to-medium. The behavioral change is one token (to_int32 → to_u32); the rest is moving the ErrorCode(...) construction up so both call sites take the value directly. I verified in src/jsc/JSValue.rs:784 that to_u32() is to_int64().clamp(0, u32::MAX as i64) as u32, which is the identity on the [0, 2^32-1] range that ServerHttp2Session#goaway already validates via validateInteger(code, "code", 0, kMaxUint32). I also confirmed the two sibling error-code reads in the same file — rst_stream() at line 7341+ and emit_error_to_all_streams() at line 8698 — use the identical is_number() guard + to_u32() pattern, so this change brings goaway() into consistency rather than introducing a new idiom.

Other factors

The test follows the repo's review rules well: it awaits a promise resolved from the 'goaway' event handler, wires sessionError/error/close on both sides to reject (so a lost frame fails with a cause instead of timing out), covers the variant matrix (server/client × with/without opaqueData × two boundary values), asserts an exact object with toEqual, and cleans up in finally. The PR body documents that all four cases fail on the released binary (receiving 2147483647) and pass with the fix, and that the rest of the 351-test file plus the ported Node goaway tests still pass. The description also explicitly addresses the out-of-validated-range behavior difference (to_u32 clamps, V8 wraps) and why it doesn't matter here — those inputs are rejected at the JS layer. No outstanding reviewer comments; no prior claude[bot] review on this PR.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up from #37554: that PR no longer makes the client range-check the code. Review there pointed out that Node's goaway() only does validateNumber(code) (and destroy(error, code) goes through the same path), so #37554 now uses validateNumber on both sessions and removes the server's validateInteger(0, kMaxUint32). The premise in this description that out-of-range codes are rejected by the JS layer therefore goes away: negative, fractional, NaN and overflowing codes all reach the native read.

What Node v26.3.0 puts on the wire for those (Uint32Value, i.e. JS ToUint32 wrap), checked against a real server:

goaway(-1)          -> 0xffffffff
goaway(1.5)         -> 0x1
goaway(NaN)         -> 0x0
goaway(Infinity)    -> 0x0
goaway(-Infinity)   -> 0x0
goaway(2 ** 32)     -> 0x0
goaway(2 ** 32 + 1) -> 0x1
goaway(0xffffffff)  -> 0xffffffff

to_u32() clamps, so with it -1 would go out as 0 and 2 ** 32 as 0xffffffff. #37554 adds a test that pins the -1 -> 0xffffffff, 1.5 -> 1 and NaN -> 0 rows from both sessions (the current to_int32 read already produces those), so this read needs ToUint32 wrap semantics rather than a clamp to keep that test green once both land; the overflow rows above would then be the natural additions to the test here.

node reads the code with Uint32Value, so a number outside 0..2^32-1 wraps:
-1 goes out as 0xffffffff and 2^32 as 0. to_u32() clamps, so -1 went out
as 0 (NO_ERROR) and 2^32 as 0xffffffff. session.destroy(error, code) goes
through the same read.

The test gets rows for -1, 2^32, 2^32 + 1 and Infinity from a client
session, and compares the opaque data by bytes so that it also passes
under node.
@robobun robobun changed the title node:http2: send session.goaway() error codes above 2^31-1 unchanged node:http2: read the session.goaway() error code with ToUint32, like node Oct 2, 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.

Beyond the inline findings, I also checked the new coerce_to_i32(...)? as u32 read at h2_frame_parser.rs:4711: JSC toInt32 is the modular ToInt32, so reinterpreting it as u32 is bit-identical to Node's Uint32Value for every double (NaN/Infinity -> 0, -1 -> 0xffffffff, 2^32+1 -> 1), and behind the preceding is_number() guard it cannot re-enter JS or throw. The sibling to_u32() (clamping) reads in rst_stream are unaffected because Http2Stream.close validates code as an integer before it reaches native. Note the PR description still describes the earlier to_u32() version (clamp: goaway(-1) -> 0), which no longer matches the code or the new test rows.

Extended reasoning...

The change replaces a saturating to_int32 read of the goaway() errorCode argument in src/runtime/api/bun/h2_frame_parser.rs with a wrapping ToInt32-then-u32 read and threads the single ErrorCode through both send_go_away call sites, plus an 8-row it.each matrix in test/js/node/http2/node-http2.test.js. It touches no auth, crypto, or injection surface; the only numeric boundary is handled by the modular conversion. Two confirmed findings are posted inline, so a human look is already signalled; this note only records what else was ruled out.

Comment thread test/js/node/http2/node-http2.test.js
Comment thread src/runtime/api/bun/h2_frame_parser.rs
@robobun

robobun commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

The read is ToUint32 now (021468d), which the comment above asked for. With to_u32() a code outside 0..2^32 - 1 was clamped: client.goaway(-1) went out as 0 and client.goaway(2 ** 32) as 0xffffffff. Node sends 0xffffffff and 0.

What changed in this push:

  • goaway() reads the code with coerce_to_i32(global)? as u32.
  • The test has four more rows from a client session: -1, 2 ** 32, 2 ** 32 + 1 and Infinity. It compares the opaque data by bytes, so the same 8 cases pass under Node v26.3.0.
  • main (faac63e) is merged in. The description and the title describe the new read.

Checked with a debug build and a raw TCP peer that reads the code bytes of the GOAWAY frame: 14 codes, goaway(code) and destroy(error, code), client and server session.

The description has the table.

robobun added a commit that referenced this pull request Oct 2, 2026
The check no longer rewrites the copy below it, and the new test sits after the detach test. This branch then merges with #37558 and #37554 with no textual conflict. No behavior change.

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.

1 participant