node:http2: submit requests made before the connect from a 'connect' listener
Problem
- A
node:http2client 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 theauthorizationheader. 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). Alsotest/js/node/http2/, 261 vendoredtest-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
awaitor from a listener added later (Notes). - As in node, user code sees the listener:
removeAllListeners('connect')strands those requests, ten own'connect'listeners give aMaxListenersExceededWarning. - Per session that queues a request: 1 array, 1 bound function, 1
oncewrapper. 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 listener | It refuses with | Bun 1.4.3 | This PR |
|---|---|---|---|
http2.connect(url, options, listener) over TLS | session.destroy() | sent | not sent |
| the same | a throw | sent | not sent |
session.on('connect', listener) | session.destroy() | sent | not sent |
| the same | a throw | sent | not sent |
session.socket.on('secureConnect', listener) | session.destroy() | sent | not sent |
| the same | a throw | sent | sent |
http2.connect(url, listener), cleartext | session.destroy() | sent | not sent |
| the same | a throw | sent | not sent |
createConnection with a net socket that still connects, socket.on('connect', listener) | session.destroy() | sent | not sent |
| the same | a throw | not sent | not 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.
| Case | node v26.3.0 | Bun 1.4.3 |
|---|---|---|
req.pending in a 'connect' listener added before the request | true | false |
req.pending and req.id in a 'connect' listener added after the request | false, 1 | the same |
session.listenerCount('connect') after the first early request() | one more | no change |
the connect() listener makes a request | stream ids: early 3, new 1 | early 1, new 3 |
a 'newListener' listener makes a request inside the first request() | stream ids 1 and 3 | request() adds no listener |
session.close() in the connect() listener | ERR_HTTP2_GOAWAY_SESSION, not sent | response 200 |
the connect() listener makes a request, then session.close() | early: ERR_HTTP2_GOAWAY_SESSION. new: response 200 | both: response 200 |
req.destroy() in the connect() listener | not sent | sent |
session.removeAllListeners('connect') before the connect, then two requests after it | early stays pending, later ids 1 and 3 | early is sent, later ids 3 and 5 |
a 'connect' listener throws and an 'uncaughtException' listener keeps the process alive | early stays pending, a later request gets id 1 | early is sent |
the TLS handshake runs on a connected socket (tls.connect({ socket })), the listener destroys the session | not sent | sent |
a tunnel gives the end of the TLS handshake and the peer's SETTINGS in one chunk, a 'connect' listener destroys the session | not sent | sent |
removeAllListeners('connect'), then session.close() while a later request is open | the session stays open after that request | the early request is sent |
a session on a connected socket (createConnection) | pending is false in the listener, ids 1 and 3 | the 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 noaddEventListener). 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._destroytells the client session, as a stream leavespendingStreamsin node. When it was the last thing that a closed session waited for, the session ends. This matters without the'connect'listener ofrequest()(session.removeAllListeners('connect')). The stranded request is destroyed, before or afterclose(), and the session then ends as in node. While the stranded request lives, a closed session stays open, also as in node. request()refuses whiledestroy()runs: the stream ends withERR_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 ofdestroy(). In Bunsession.destroyedis 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 theconnect()listener destroys the session, main ends that request withERR_HTTP2_INVALID_SESSIONtoo, because its early request is already open. node ends it withERR_HTTP2_STREAM_CANCEL.- One row keeps Bun's rule and not node's. A
'connect'listener callssession.ping()andsession.close(). Bun'sclose()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 getsERR_HTTP2_PING_CANCEL. - The listener counts toward
maxListeners. With ten own'connect'listeners, orsetMaxListeners(1)and one, an earlyrequest()gives aMaxListenersExceededWarningon 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.
| Client | node v26.3.0 | Bun 1.4.3 and this PR |
|---|---|---|
an async 'connect' listener throws | not sent | sent |
await once(session, 'connect'), then a throw | not sent | sent |
a 'connect' listener added after the request throws | not sent | sent |
a 'connect' listener added after the request calls process.exit() | not sent | sent |
a session on a connected socket, the connect() listener throws | not sent | sent |
a session on a connected socket, a 'connect' listener calls process.exit() | not sent | sent |
- 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 anLD_PRELOADwrapper ofsend,writeandwritev(strace is not available here). Same debug binary,http2.tsof main against this PR at59e77bd58f(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.jsasbundle-modules.ts --debug=OFFemits 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
oncewrapper. 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 thesrc/of main: 8 pass, 25 fail. Bun 1.4.3: 8 pass, 25 fail.nodev26.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/, theserve-http2*tests,node-http2-ping-flood-staged.test.ts,25589-frame-size-grpc.test.ts(34 files): 648 pass, 5 fail. The 5 are theName Resolver > DNS Namestests. They need a network.tsc --noEmit -p src/js/tsconfig.json: no error.
The tests.
- The file runs under
bun testand undernode(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, andsession.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!#connectedand 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 withonce('connect'). It does not change when the requests are submitted. Its hunks in#onConnectare 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()andrequest(). The second of the two to land has to re-apply its hunks there. - #44816 makes
session.connectingnode's READY flag.#socketConnectingin 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()orreq.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 ofclose()on main. - A session on a connected socket (
createConnection), thenrequest()andclose()in the same tick: the request ends withERR_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