node:http2: submit requests made before the connect from a 'connect' listener

RO
16d ago
+832-56

Problem

  • A node:http2 client submits a request made before the connect in the socket's connect callback. A listener that refuses the peer (session.destroy(), a throw) cannot stop it: the server reads the authorization header. node v26.3.0 sends nothing.
  • Cause: #onConnect (src/js/node/http2.ts:5477) submits the queue before any connect listener runs.

Fix

  • The first request() made while the socket connects adds one 'connect' listener that submits those requests, as in node (lib/internal/http2/core.js:1914).
  • An earlier listener finds the request pending: destroy() cancels it (ERR_HTTP2_STREAM_CANCEL), close() rejects it (ERR_HTTP2_GOAWAY_SESSION).
  • Verified: test/js/node/http2/node-http2-request-before-connect.test.ts (33 tests, main fails 25, node passes it). Also test/js/node/http2/, 261 vendored test-http2-* files, grpc-js.
  • Self-reviewed: 43 concerns raised, 41 addressed. 2 left: tests beyond this change (Notes).

Background

  • A pending request has no stream id. The session emits 'connect' one tick after its socket connects.
  • A session on a connected socket (createConnection) sends its requests at once, as in node.
  • Considered submitting after the whole emit: a listener added after the request finds it pending, where node shows it open.

Downsides

  • Still sent, unlike node: a throw from a TLS socket listener (#34661), a refusal after an await or from a listener added later (Notes).
  • As in node, user code sees the listener: removeAllListeners('connect') strands those requests, ten own 'connect' listeners give a MaxListenersExceededWarning.
  • Per session that queues a request: 1 array, 1 bound function, 1 once wrapper. No added socket write. http2.js: +1,724 bytes.
Notes

Origin. A differential run against node v26.3.0 found this. No issue reports it.

Repro. Run with bun and with node:

const http2 = require("node:http2");
const net = require("node:net");

// A raw HTTP/2 peer. It prints the type of each frame that the client sent. Type 1 is HEADERS.
const server = net.createServer(socket => {
  const chunks = [];
  socket.write(Buffer.from([0, 0, 0, 4, 0, 0, 0, 0, 0]));
  socket.on("data", chunk => chunks.push(chunk));
  socket.on("error", () => {});
  socket.on("close", () => {
    const bytes = Buffer.concat(chunks);
    const types = [];
    for (let at = 24; at + 9 <= bytes.length; at += 9 + bytes.readUIntBE(at, 3)) types.push(bytes[at + 3]);
    console.log("frame types from the client:", types.join(" "));
    server.close();
  });
});
server.listen(0, () => {
  const session = http2.connect(`http://127.0.0.1:${server.address().port}`, () => {
    // The check of the peer fails here.
    session.destroy();
  });
  const req = session.request({ ":path": "/", authorization: "Bearer SECRET" });
  req.on("error", err => console.log("request:", err.code));
});

All three print request: ERR_HTTP2_STREAM_CANCEL. node v26.3.0 and this PR then print frame types from the client: 4 7 (SETTINGS, GOAWAY). Bun 1.4.3 prints 4 1 7: the HEADERS frame of the request went out.

Where the listener refuses, and whether the peer gets the HEADERS frame. node v26.3.0 sends nothing in each row.

The listenerIt refuses withBun 1.4.3This PR
http2.connect(url, options, listener) over TLSsession.destroy()sentnot sent
the samea throwsentnot sent
session.on('connect', listener)session.destroy()sentnot sent
the samea throwsentnot sent
session.socket.on('secureConnect', listener)session.destroy()sentnot sent
the samea throwsentsent
http2.connect(url, listener), cleartextsession.destroy()sentnot sent
the samea throwsentnot sent
createConnection with a net socket that still connects, socket.on('connect', listener)session.destroy()sentnot sent
the samea thrownot sentnot sent

The row that stays open needs the process to end at the throw. Bun reports the error and then runs the queued ticks, and the 'connect' emit of the session is one of them. #34661 makes the report end the process. The test file has this row as an expected failure.

Other behaviour, node v26.3.0 against Bun 1.4.3. The peer answers each HEADERS frame with a 200. This PR gives node's value in each row.

Casenode v26.3.0Bun 1.4.3
req.pending in a 'connect' listener added before the requesttruefalse
req.pending and req.id in a 'connect' listener added after the requestfalse, 1the same
session.listenerCount('connect') after the first early request()one moreno change
the connect() listener makes a requeststream ids: early 3, new 1early 1, new 3
a 'newListener' listener makes a request inside the first request()stream ids 1 and 3request() adds no listener
session.close() in the connect() listenerERR_HTTP2_GOAWAY_SESSION, not sentresponse 200
the connect() listener makes a request, then session.close()early: ERR_HTTP2_GOAWAY_SESSION. new: response 200both: response 200
req.destroy() in the connect() listenernot sentsent
session.removeAllListeners('connect') before the connect, then two requests after itearly stays pending, later ids 1 and 3early is sent, later ids 3 and 5
a 'connect' listener throws and an 'uncaughtException' listener keeps the process aliveearly stays pending, a later request gets id 1early is sent
the TLS handshake runs on a connected socket (tls.connect({ socket })), the listener destroys the sessionnot sentsent
a tunnel gives the end of the TLS handshake and the peer's SETTINGS in one chunk, a 'connect' listener destroys the sessionnot sentsent
removeAllListeners('connect'), then session.close() while a later request is openthe session stays open after that requestthe early request is sent
a session on a connected socket (createConnection)pending is false in the listener, ids 1 and 3the same

The rest of the diff.

  • request() ends the stream and adds the abort listener before it queues the request, as node does. A call that throws there leaves nothing queued (signal: { aborted: false } passes the check of the signal and has no addEventListener). On main the stream of the failed call stays in the queue. At the connect it takes stream id 1 and ends with an 'error' that nothing can listen to.
  • #closedWithNoStream() is the one test of whether a closed session can end: no open stream, and no request that waits for the connect. Each place that ends a closed session asks it. Main has that test in seven places.
  • A request that is destroyed before it has a stream id leaves that list: Http2Stream._destroy tells the client session, as a stream leaves pendingStreams in node. When it was the last thing that a closed session waited for, the session ends. This matters without the 'connect' listener of request() (session.removeAllListeners('connect')). The stranded request is destroyed, before or after close(), and the session then ends as in node. While the stranded request lives, a closed session stays open, also as in node.
  • request() refuses while destroy() runs: the stream ends with ERR_HTTP2_INVALID_SESSION, as on a destroyed session. destroy() cancels the queued requests, and a request that is cancelled with its writable side open emits 'aborted' on the stack of destroy(). In Bun session.destroyed is still false there (node: true), so a request from that listener was queued again and nothing ended it. That is the state of main when a session is destroyed before its connect. When the connect() listener destroys the session, main ends that request with ERR_HTTP2_INVALID_SESSION too, because its early request is already open. node ends it with ERR_HTTP2_STREAM_CANCEL.
  • One row keeps Bun's rule and not node's. A 'connect' listener calls session.ping() and session.close(). Bun's close() waits for an ACK that is due before it ends the session, and the listener that rejects the early requests does the same: the ping is acknowledged. node ends the session at once and the ping gets ERR_HTTP2_PING_CANCEL.
  • The listener counts toward maxListeners. With ten own 'connect' listeners, or setMaxListeners(1) and one, an early request() gives a MaxListenersExceededWarning on node and on this PR. Bun 1.4.3 gives none.

Still sent after this PR, where node sends nothing. The cause of these rows is below http2.ts, so this PR does not change them.

Clientnode v26.3.0Bun 1.4.3 and this PR
an async 'connect' listener throwsnot sentsent
await once(session, 'connect'), then a thrownot sentsent
a 'connect' listener added after the request throwsnot sentsent
a 'connect' listener added after the request calls process.exit()not sentsent
a session on a connected socket, the connect() listener throwsnot sentsent
a session on a connected socket, a 'connect' listener calls process.exit()not sentsent
  • Write time: Bun writes the HEADERS frame inside the tick that submits the request. node schedules the write, so a fatal error or process.exit() in the same turn ends the process first. #42353 changes when Bun writes.
  • Fatal errors: after Bun reports an uncaught error, it runs the queued ticks before it exits. #34661 changes that.
  • A session on a connected socket whose listener calls session.destroy() sends the request in node too.

Cost, measured.

  • Socket writes of one client session (one request made before the connect, then session.close()), counted with an LD_PRELOAD wrapper of send, write and writev (strace is not available here). Same debug binary, http2.ts of main against this PR at 59e77bd58f (the later commits do not touch the path of a request): the counts and the sizes are equal. Dialed cleartext: 4 writes (33, 39, 9, 17 bytes). Dialed TLS: 6 writes (1480, 135, 59, 31, 39, 24). A session on a connected socket that holds the peer's SETTINGS: 4 writes (33, 39, 9, 17). Three runs each.
  • node/http2.js as bundle-modules.ts --debug=OFF emits it, same output path: 169,582 to 171,306 bytes (+1,724).
  • Per session that makes a request while its socket connects: 1 array, 1 bound function, 1 once wrapper. A session that makes no request before the connect allocates nothing new. request() on a connected session reads one more private field in its queue branch only.

Suites. All on a debug build of this branch, with main 54152a47f9 merged.

  • test/js/node/http2/node-http2-request-before-connect.test.ts: 33 pass. With the src/ of main: 8 pass, 25 fail. Bun 1.4.3: 8 pass, 25 fail. node v26.3.0 on the file: 31 pass, 1 skipped.
  • On main 24 tests fail by their own assertion. The 25th is a follow-on: the stream of the failed request() (see "The rest of the diff") throws an uncaught error, and the assertion of the test that it ended lands in a later test.
  • test/js/node/http2/ (12 files, --timeout 120000): 668 pass, 0 fail.
  • The 261 vendored test-http2-* files: 261 pass.
  • test/js/third_party/grpc-js/, the serve-http2* tests, node-http2-ping-flood-staged.test.ts, 25589-frame-size-grpc.test.ts (34 files): 648 pass, 5 fail. The 5 are the Name Resolver > DNS Names tests. They need a network.
  • tsc --noEmit -p src/js/tsconfig.json: no error.

The tests.

  • The file runs under bun test and under node (node:test). Its last test runs the whole file in node.
  • Each cell uses a raw HTTP/2 peer that records the frames of the client. The assertion is on the wire: no HEADERS frame.
  • The cells where the listener throws run the fixture in a process of its own, because node ends the process at the throw. Each cell compares the whole outcome: stdout, the exit code and the HEADERS frames. node ends the process with exit code 1. Bun gives a throw from the connect() listener, or from a listener on a net socket, to the session as its 'error', and the process goes on (exit code 0).
  • One test runs in Bun only: the request() call that throws. node keeps the stream of the failed call as a pending stream. It holds a closed session open, and session.destroy() gives it an 'error' that nothing listens to.
  • The warm-up and the tests that start a process have a 60 s limit in a debug build only. The first of those tests took 6.8 s in a debug build, and the default is 5 s. The test that runs the file in node has that limit in each build: that run took 1.7 s to 11.7 s here, with the load of the machine.

Related open PRs.

  • #43591 has a first version of this mechanism next to its own subject (session.destroy() and open requests). That version waits on !#connected and keeps the requests in the stream-slot queue. This PR is the mechanism alone, with node's condition and node's list. #43591 should rebase on this PR without its copy.
  • #43492 registers the connect() listener with once('connect'). It does not change when the requests are submitted. Its hunks in #onConnect are next to the line that this PR adds there.
  • #45448 makes the stream-slot queue a linked list, so that a destroyed request leaves it. It changes the same places: the field, the GOAWAY handler, #onConnect, close(), destroy() and request(). The second of the two to land has to re-apply its hunks there.
  • #44816 makes session.connecting node's READY flag. #socketConnecting in this PR is the same condition. The second of the two to land reads the flag of the first.
  • #34661 and #42353: see "Still sent after this PR".

Self-review. A review of the first draft said: wanted in a different shape. That draft waited on !#connected and kept the early requests in the stream-slot queue behind a boolean. It had three regressions. A session on a connected socket got an order that node and main do not have. A lost 'connect' listener stalled the requests that wait for a stream slot. A request() made during the teardown of a session never closed. This PR is the rework: node's condition (#socketConnecting) and node's list (#requestsBeforeConnect). 43 concerns were raised and 41 are addressed. The 2 that are left:

  • No expected-failure tests for the rows in "Still sent after this PR". Those rows pin the write and exit behaviour that this PR does not change.
  • No tape test that compares each event of the connect phase between node and Bun.

Not changed here.

  • The 'ready' event of a stream is emitted one tick after the submit in Bun and synchronously in node.
  • req.close(code) in a listener that runs before the submit: the request emits 'end' before 'ready'. node and main emit 'ready' first. Http2Stream.close() ends the readable side of a pending stream at once, and node does that at 'ready'. The request is sent and then reset in all three.
  • After session.close() or req.destroy() in such a listener the session emits 'remoteSettings' and 'localSettings' before 'close'. node emits 'close' alone. A closed Bun session with no stream waits for the ACK of its SETTINGS, which is the rule of close() on main.
  • A session on a connected socket (createConnection), then request() and close() in the same tick: the request ends with ERR_HTTP2_GOAWAY_SESSION. node sends it. It is the same on main.
  • A 'connect' listener added after the request destroys the session: the request is submitted before that listener runs, as in node. The events that the request then emits are the subject of #43591.
  • session.close() before the connect, and then the socket closes with no 'error': the queued request and the session never close. node cancels the request and closes the session. It is the same on main (destroy() returns early when the parser is gone). #45448 has the analysis.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/http2/node-http2-request-before-connect.test.ts