Skip to content

node:http2: report a rejected options.settings from connect() where node does - #43583

Open
robobun wants to merge 7 commits into
mainfrom
robobun/a00c8729/http2-connect-settings-error-event
Open

robobun wants to merge 7 commits into
mainfrom
robobun/a00c8729/http2-connect-settings-error-event

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • http2.connect(url, { settings }) throws for each settings value that is not a valid settings object: ERR_INVALID_ARG_TYPE: The "settings" argument must be of type object. Received type number (1).
  • node v26.3.0 ignores a value that is not an object. For null, an array, or an invalid value, its session emits 'error', then 'close'.
  • Cause: the ClientHttp2Session constructor calls validateSettings(options.settings) right after it creates the socket (src/js/node/http2.ts:5698 on main).

Fix

  • The constructor ignores a value that is not an object. If validateSettings throws while the socket connects, the connect handler destroys the session with that error.
  • connect() still throws in node's two cases. One is a createConnection socket that is already connected. The other is an https: URL without createConnection, where assertIsObject now checks options.settings first.
  • Verified: test/js/node/http2/node-http2-connect-settings.test.ts runs one script under Bun and under node v26.3.0 against one expected table. Also test/js/node/http2/ and 261 vendored test-http2-*.js tests.
  • Self-reviewed: 13 concerns raised, 9 addressed. Notes name the 4 that stay.

Background

  • setupHandle is node's function that attaches the native session to the socket and sends the first SETTINGS frame. For a connecting socket it runs in the connect event inside a try. The catch destroys the socket with the error.
  • The session reports a socket error as its own 'error'. With no listener it is an uncaught exception.
  • H2FrameParser is Bun's native session. The constructor builds it before the connect, because close() and settings() use it. After a rejected value, the session is destroyed before the parser gets the socket.
Notes

What node v26.3.0 does (lib/internal/http2/core.js)

  • connect() checks options itself and nothing under settings. For https: without createConnection it builds the socket options with initializeTLSOptions, which runs assertIsObject(options.settings, 'options.settings') before tls.connect().
  • setupHandle reads typeof options.settings === 'object' ? options.settings : {} and calls this.settings(settings). That runs assertIsObject(settings, 'settings') and validateSettings.
  • For a connecting socket the session constructor wraps setupHandle in try { ... } catch (error) { socket.destroy(error) }. For a connected socket it calls setupHandle inline, so the throw leaves connect().
  • setupHandle returns early for a destroyed session. close() destroys a session that has no pending or open stream at once.

Outcomes (one script, each runtime)

connect() call node v26.3.0 bun 1.4.3 this branch
http, settings: null or [] 'error' ERR_INVALID_ARG_TYPE, 'close' throws as node
http, settings: { initialWindowSize: -1 } 'error' ERR_HTTP2_INVALID_SETTING_VALUE, 'close' throws as node
http, settings: 1, "x", true, a function connects, request gets 200 throws as node
https, settings: null, [], 1 throws, "options.settings" property message throws, "settings" argument message as node
https, invalid value 'error', 'close' throws as node
https with createConnection, settings: null 'error', 'close' throws as node
createConnection, socket still connecting 'error', 'close' throws as node
createConnection, stream that is not connecting throws throws throws
invalid value, request(), then close() before the connect 'error', request cancelled with the error as cause throws as node
invalid value, close() with no live request no error throws no error
invalid value, no 'error' listener uncaughtException throws uncaughtException

Why the connect handler catches a throw from destroy(). destroy(error) emits 'error'. With no listener that emit throws. Inside the socket's connect callback, Bun's socket layer reports a throw on the socket (SocketHandlers.error in net.ts), and the session's socket error handler ignores it because the session is already destroyed. The error would be lost. The handler catches it and throws it again from process.nextTick, where it is an ordinary uncaught exception. _http_server.ts does the same for an 'upgrade' listener that throws. An earlier revision deferred the whole destroy by one tick. Review showed that a stream from createConnection could deliver data, or close, inside that tick, so the destroy now runs at once.

Why the session, not the socket. A first revision destroyed the socket with the error, which is what node's catch does. Bun's session handler for socket errors drops an error on a session that close() was called on. So request() then close() before the connect lost the error. node reports it there.

close() before the connect. Bun's close() only schedules the destroy of an idle session (a 250 ms wait for the SETTINGS ACK), so that session can still be alive at the connect event. The connect handler treats it as node does: it does not report the rejected value. A pending request that was destroyed does not count as live.

What still throws from connect(). A value that validateSettings accepts and the native parser rejects, and a getter that throws on a read after validation.

What the two open PRs on the same lines need when this lands first

Differences that this PR leaves alone

  • https with a function value: Bun's assertIsObject accepts a function, so connect() does not throw and the value is ignored. Another change owns that helper.
  • The order of a pending request's 'error' and the session's 'error', and socket.destroyed at the session's 'close': node:http2: destroy the socket on session.destroy() without close(), emit the session 'close' once the socket has closed #38195.
  • settings: { customSettings: 5 } reports ERR_HTTP2_INVALID_SETTING_VALUE where node reports ERR_INVALID_ARG_TYPE. validateSettings also rejects NaN, which node's range check lets through.
  • Review concerns that stay as they are:
    • A plain Duplex from createConnection that sets connecting = true: node wraps it and sets the session up inline, so node throws. Bun honours the flag (older code) and now reports 'error' when the stream emits 'connect'.
    • Bun validates the settings object during connect(). node reads it at the connect event, so a caller that mutates the object in between sees a different result.
    • https with a settings getter that throws: node throws from connect() (it copies the object first), Bun reports 'error'.
    • util.promisify(http2.connect) throws synchronously when connect() throws. node rejects the promise. This is older behaviour and a separate bug. The new https check is one more source of it.

Found while working, not part of this PR

  • A connect() listener that throws destroys the session, or is lost when the session has no 'error' listener. node raises uncaughtException and keeps the session.
  • connect() and createServer() skip node's strictSingleValueFields check. https connect() skips the three validateUint32 checks of initializeOptions.
  • connect("https://...", { ALPNProtocols }) lets the caller's list replace h2. node always offers h2.

Test design. The tests have their own file. node-http2.test.js has about 400 tests, and several of them start a bun-debug child. On a loaded machine a local run of that file hits the default 5 s timeout in a dozen tests that this change does not touch. The fixture starts its servers once and exposes the cases in three groups. Under Bun each group is one in-process test, so each test makes a few connections. node runs the whole fixture as one script. The first connect() uses a socket that the fixture owns. If it throws, the fixture destroys the socket and no group makes another connect(). On a build without this change every later case would throw after it made its own socket, and on main that socket's connect callback re-queues itself forever (#42184), which would hang the test runner. The no-listener case needs a child process, because an uncaught exception fails an in-process test. A debug build needs several seconds to load node:http2 in a child, so that case has a 60 s timeout on debug builds only, like the child process tests in node-http2-streams-rehash.test.ts. The test does not assert the order of the request error and the session error (#38195).

Suites run on the debug (ASAN) build

  • test/js/node/http2/node-http2-connect-settings.test.ts: 6 pass on the debug build and on a release build with this change. On bun 1.4.2: the four Bun cases fail in under a second with a diff, and the two node cases pass.
  • test/js/node/http2/node-http2.test.js, which this PR no longer touches: 386 pass, 6 skip, 5 to 11 fail. All of the failures are 5 s timeouts in tests that start a bun-debug child. The machine had a load average near 100.
  • The other ten files in test/js/node/http2/: all pass except the h2-conformance.test.ts GC case that test: deflake the h2 stream-release cases on debug builds #42357 tracks.
  • test/js/node/test/parallel/test-http2-*.js and sequential/test-http2-*.js, measured at 8bf94e1: 261 of 261 exit 0. test-http2-forget-closed-streams.js needs about 165 s on this build, so in the full run it hit the 180 s limit of my loop and passed when run alone.
  • After the last change to the connect handler (4bc5d40): the four new tests, and the 17 vendored files that cover connect() and session settings, pass again.

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

fails on main (without fix)
ASAN without fix: 4 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-connect-settings.test.ts"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2-connect-settings.test.ts:
56 |     });
57 |     afterAll(() => fixture?.close());
58 | 
59 |     for (const group of ["createConnection", "http", "https"] as const) {
60 |       it(group, async () => {
61 |         expect(await fixture[group]()).toEqual(expected[group]);
                                            ^
error: expect(received).toEqual(expected)

  {
-   "connected socket": {
-     "thrown": "ERR_HTTP2_INVALID_SETTING_VALUE: Invalid value for setting "initialWindowSize": -1",
-   },
    "connecting socket": {
-     "events": [
-       "error ERR_HTTP2_INVALID_SETTING_VALUE: Invalid value for setting "initialWindowSize": -1",
-       "close",
-     ],
-     "request": "ERR_HTTP2_STREAM_CANCEL caused by ERR_HTTP2_INVALID_SETTING_VALUE",
-   },
-   "https null": {
-     "events": [
-       "error ERR_INVALID_ARG_TYPE: The "settings" argument must be of type object. Received null",
-       "close",
-     ],
-     "
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (683ae8d80)

test/js/node/http2/node-http2-connect-settings.test.ts:
(pass) http2.connect reports a rejected options.settings where Node.js does > Bun > createConnection [4.72ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > Bun > http [16.56ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > Bun > https [5.67ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > Node.js [206.37ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > with no 'error' listener the error is an uncaught exception (Bun) [64.33ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > with no 'error' listener the error is an uncaught exception (Node.js) [108.17ms]

 6 pass
 0 fail
 9 expect() calls
Ran 6 tests across 1 file. [578.00ms]
__F:0:S:0
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-connect-settings.test.ts"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2-connect-settings.test.ts:
(pass) http2.connect reports a rejected options.settings where Node.js does > Bun > createConnection [215.10ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > Bun > http [727.49ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > Bun > https [215.12ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > Node.js [124.39ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > with no 'error' listener the error is an uncaught exception (Bun) [1989.57ms]
(pass) http2.connect reports a rejected options.settings where Node.js does > with no 'error' listener the error is an uncaught exception (Node.js) [58.91ms]

 6 pass
 0 fail
 9 expect() calls
Ran 6 tests across 1 file. [6.38s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 642ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/126] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 245 extern-C blocks audited
[2/126] gen JS modules (bundle-modules)
Preprocess modules (7719ms)
Bundle modules (71ms)
Postprocesss modules (28ms)
Bundle Functions (431ms)
Generate Code (32ms)

[8.29s] Bundled "src/js" for production
  2607 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/8] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
 
... (truncated)
diff hotspot
src/js/node/http2.ts                               |  46 ++++++-
 .../node/http2/http2-connect-settings.fixture.js   | 144 +++++++++++++++++++++
 .../node/http2/node-http2-connect-settings.test.ts | 115 ++++++++++++++++
 3 files changed, 302 insertions(+), 3 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                                    reads  edits  tests
src/js/node/http2.ts                                       15     21     22
test/js/node/http2/http2-connect-settings.fixture.js        4      8     24
test/js/node/http2/node-http2-connect-settings.test.ts      1      1     10

…ode does

connect() validated options.settings in the ClientHttp2Session constructor and
threw for every value that was not a valid settings object.

node reads options.settings in setupHandle, which runs when the socket
connects. It ignores a value that is not an object. A throw from validation is
caught at the connect event and destroys the socket with that error, so the
session emits 'error' and then 'close'. connect() throws only when the socket
is already connected, because setupHandle then runs inline.

The https path is different in node: it builds the tls.connect() options with
initializeTLSOptions, which throws ERR_INVALID_ARG_TYPE for an
options.settings that is not an object, before a socket exists.

The constructor now does the same. It holds a validation error until the
connect event and destroys the socket with it, ignores a value that is not an
object, and keeps the throw for a socket that is already connected. For an
https URL without createConnection it checks options.settings with
assertIsObject before it connects.
…ns.settings

The connect handler destroyed the socket with the validation error and relied
on the session's socket error handler to report it. That handler drops an
error on a session that close() was called on, so a request made before a
close() lost the error. node reports it.

The handler now destroys the session with the error, one tick after the
connect callback. The tick is needed because the socket layer reports a throw
from its connect callback on the socket, so an 'error' with no listener would
not reach the process.

A session that close() was called on with no request pending stays quiet, as
in node, where close() has already destroyed it.
…le session

A pending request that was destroyed before close() no longer counts as
pending when the connect handler decides whether node would still validate
options.settings. node's close() destroys such a session at once.

The test gains cases for a session closed with nothing pending, for https
with createConnection, and for a session with no 'error' listener, where the
error must reach the process as an uncaught exception. The test comments now
say that the https throw needs a URL without createConnection and a value
that is not an object.
@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: 3767aeaa-26ec-4bab-9eb2-8d7ede48ee0d

📥 Commits

Reviewing files that changed from the base of the PR and between 4bc5d40 and 683ae8d.

📒 Files selected for processing (1)
  • src/js/node/http2.ts

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


Walkthrough

Changes

HTTP/2 client setup now validates options.settings according to socket state. Connecting sessions defer validation errors until connection handling completes. New fixtures and tests cover plain, TLS, custom, invalid, primitive, and early-closed connection cases.

HTTP/2 settings handling

Layer / File(s) Summary
Settings validation and native conversion
src/js/node/http2.ts
HTTPS connections validate object settings before socket creation. Connected sessions validate settings synchronously. Native settings use validated input.
Deferred validation error handling
src/js/node/http2.ts
Connecting sessions defer validation errors, check for live pending requests, and destroy or rethrow errors according to session state.
Connection settings behavior coverage
test/js/node/http2/http2-connect-settings.fixture.js, test/js/node/http2/node-http2.test.js
The fixture and tests compare connection behavior across socket states, protocols, settings values, custom connections, and uncaught errors.

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 main change: reporting rejected options.settings values from http2.connect() to match Node.js behavior. The wording is awkward but remains specific and relevant.
Description check ✅ Passed The description provides the problem, fix, behavioral details, background, test design, verification results, and known differences. It does not use the template headings exactly, but it covers both r…

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

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How to reproduce. Run this script with bun and with node:

const http2 = require("node:http2");
const vals = { null: null, number: 1, string: "x", array: [], fn: function fn() {}, bool: true, badvalue: { initialWindowSize: -1 } };
const server = http2.createServer();
server.listen(0, "127.0.0.1", async () => {
  const port = server.address().port;
  for (const [name, v] of Object.entries(vals)) {
    let client;
    try { client = http2.connect(`http://127.0.0.1:${port}`, { settings: v }); }
    catch (e) { console.log(name, "SYNC THROW", e.code, "|", e.message); continue; }
    const outcome = await new Promise(resolve => {
      client.once("error", e => resolve(`'error' event ${e.code} | ${e.message}`));
      client.once("remoteSettings", () => resolve("connected, no error"));
    });
    console.log(name, outcome);
    client.destroy();
  }
  server.close();
  process.exit(0);
});
  • bun 1.4.3 prints SYNC THROW for all seven values.
  • node v26.3.0 prints connected, no error for number, string, fn and bool, and an 'error' event line for null, array and badvalue.
  • This branch prints the same lines as node.

The new tests are in test/js/node/http2/node-http2-connect-settings.test.ts. On a bun without this change the four Bun cases fail and the two node cases pass.

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:19 PM PT - Sep 19th, 2026

✅ @robobun, your commit 81ea8d51187f45538907340384fbd2ecbd64da8b passed in Build #118741! 🎉


🧪   To try this PR locally:

bunx bun-pr 43583

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

bun-43583 --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.

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 connected-socket rethrow at src/js/node/http2.ts:5734 — it throws before the session attaches any socket listeners, exactly where the base branch's validateSettings threw, so it does not introduce a new orphaned-listener state. The typeof === "object" gate at line 5729 deliberately lets null and arrays through to validateSettings so they are reported as errors rather than ignored, matching Node.

Extended reasoning...

Four verified findings are being posted inline, so approval is off the table and a human look is already signaled. This note only records two things examined and ruled out: the synchronous rethrow for an already-connected createConnection socket happens at the same point in the constructor as the base branch's validateSettings call (before socket.on(...) registration and before #parser assignment), so the caller-visible state on that path is unchanged; and the typeof options.settings === "object" filter intentionally passes null/arrays to validation, which is the intended Node-matching outcome ('error' then 'close'), not an oversight.

Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts
…e connect event

The connect handler deferred the destroy by one tick so that an 'error' with
no listener could reach the process. In that tick a stream from
createConnection could still deliver data to the parser, and a 'close' or
'error' from the stream could destroy the session first and lose the settings
error.

The handler now destroys the session at once. If destroy() throws because
nobody listens for 'error', it throws the error again from the next tick. A
throw from inside the socket's connect callback is reported on the socket,
where the destroyed session no longer listens.
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated

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

The cases move out of node-http2.test.js into
node-http2-connect-settings.test.ts. The fixture now starts its servers once
and exposes the cases in three groups, so each in-process test makes a few
connections and stays far below the default timeout on a slow debug build.
node still runs the whole fixture as one script.

The case with no 'error' listener needs a child process. A debug build needs
several seconds to load node:http2 in a child, so that case does not run on
debug builds. Release and ASAN builds run it.

@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 note, I re-checked the two connect-tick concerns from the earlier review against the latest push: onConnect now calls this.destroy(settingsError) synchronously (src/js/node/http2.ts:5667-5677) instead of deferring to process.nextTick, so a createConnection stream that emits 'close'/'error' or delivers buffered data right after 'connect' can no longer beat the destroy or feed the parser first. Also confirmed null still reaches validateSettings (typeof null is "object"), matching node's rejection of settings: null.

Extended reasoning...

The latest commit replaced the nextTick-deferred destroy with a synchronous this.destroy(settingsError) inside the connect handler and added the rethrowUncaught nextTick rethrow for the no-'error'-listener case. Reading Http2Session#destroy (http2.ts:4692) confirms it latches #destroying, marks the session closed, and ends/destroys the socket in the same call, so the ordering hazards raised against the previous deferred version no longer apply. The typeof options.settings === "object" filter deliberately lets null through to validateSettings, which is the behavior node exhibits (assertIsObject rejects null). The remaining inline finding concerns test coverage on debug builds, not runtime behavior.

Comment thread test/js/node/http2/node-http2-connect-settings.test.ts Outdated
The case started a bun child and was skipped on debug builds, because the
child needs several seconds to load node:http2 there. It now runs on every
build and gets a longer timeout on debug builds only, as the other child
process tests in this directory do.

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

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