node:http2: refresh the session idle timer where node does
Problem
session.setTimeout(1000, cb)callscbafter 2 s when anhttps://server never answers. Node v26.3.0 and bun 1.3.14 need 1 s.sessionTimerExpired(src/js/node/http2.ts:4901, from #32488) refreshes the timer when the socket wrote any byte. That hid a gap: frames that arrive, and calls without a body, never refreshed it.
Fix
- The session refreshes its timer where node calls
[kUpdateTimer]. Each site links node's line. - An expiry emits
'timeout'unless a stream write is in flight and frames left since the last expiry (node'scallTimeout). - The parser passes
nullto the callback of a dropped write, which is not activity. - Verified:
test/js/node/http2/node-http2-session-timeout.test.ts(16 tests, 11+ fail on main). Node v18 to v26 and bun give equalrefresh()counts for 31 steps. Self-reviewed: 14 concerns raised, 8 addressed, the rest are Downside 2 and Notes.
Background
session.setTimeout(ms)arms an idle timer that activity refreshes. An expiry emits'timeout'.- Considered an expiry gate only: live sessions get
'timeout'. Considered an activity stamp read at expiry: fewerrefresh()calls, a second mechanism.
Downsides
- Per frame or call, a session without a timer reads
session[kTimeout]once. With a timer it callsrefresh()like node: 4 per side for a GET (main 0 and 1). Instructions are not measured (perf,valgrindmissing). - A session
'timeout'still reaches every open stream (#30307) and now repeats: 4 on an idle stream in 6 T (main 1, node 1). #30308 gives streams their own timer. - A session gets no
'timeout'from its socket's timer. Node never emits it.
Notes
Root cause. The expiry handler had two checks before it emitted 'timeout'. The first compared the bytesWritten of the socket with a snapshot, with no condition that a write was in flight (http2.ts:4915 on main). Any frame since the snapshot, for example the SETTINGS ACK, made the first expiry refresh. The second compared parser.bufferSize() with a snapshot that nothing seeded (http2.ts:4927). In the TLS repro the handshake bytes are not in bytesWritten, which counts plaintext. There the frame parser holds the 33 bytes of preface and SETTINGS until the handshake completes, and the second check refreshes.
Why frames that arrive did not refresh. On a TCP or TLS socket the frame parser reads the socket in native code. The JS read handler of the session (#onRead), the one place that refreshed for inbound bytes, runs only for a JS stream transport.
Expiry that emits 'timeout' on an idle session (server never answers over https and over http, idle client after connect, idle server session): expiry 1 in node v26.3.0 and in this PR, expiry 2 on main and in bun 1.4.3.
refresh() calls per step, counted by test/js/node/http2/http2-session-timer-refresh.fixture.js. The table is the output of node v18.20.8, v20.20.2, v22.23.3, v24.21.0, v26.3.0 and v26.10.0, and of this PR. The test runs the fixture in bun on a TCP socket, a TLS socket and a duplexPair, and once in the installed node. With the JS of main the test fails on the three transports.
| step | client | server |
|---|---|---|
| connect | 1 | 0 |
| request() | 1 | 1 |
| write() on the client | 2 | 1 |
| additionalHeaders() | 1 | 1 |
| origin() and additionalHeaders() | 2 | 1 |
| altsvc() and additionalHeaders(), no 'altsvc' listener | 1 | 1 |
| altsvc(), 'altsvc' listener | 1 | 0 |
| pushStream() and respond() on the pushed stream | 2 | 2 |
| respond() | 1 | 1 |
| write() on the server | 1 | 2 |
| end() and sendTrailers() | 1 | 1 |
| end() on the client | 0 | 1 |
| end() with data, request not read | 4 | 4 |
| end() with data, request read to its end | 6 | 5 |
| resume(), respond() and end() with data in the 'stream' event | 4 | 3 |
| end() with data, request ended by an empty DATA frame and not read | 4 | 4 |
| end() with data on the client, response ended by an empty DATA frame and not read | 4 | 4 |
| respondWithFD() | 4 | 2 |
| respondWithFile() | 4 | 2 |
| respondWithFD(), statCheck responds and returns false | 2 | 3 |
| respondWithFile() of a missing file, onError responds | 2 | 3 |
| close() with a code, on a stream with a write that waits for window | 4 | 3 |
| settings() on the server, no 'remoteSettings' listener | 0 | 1 |
| settings() on the server, 'remoteSettings' listener | 1 | 1 |
| ping() on the server, no 'ping' listener | 0 | 0 |
| ping() on the server, 'ping' listener | 1 | 0 |
| settings() on the client, no 'remoteSettings' listener | 1 | 0 |
| settings() on the client, 'remoteSettings' listener | 1 | 1 |
| ping() on the client, no 'ping' listener | 0 | 0 |
| ping() on the client, 'ping' listener | 0 | 1 |
| close() | 1 | 1 |
On a duplexPair the connect row is 0 and 0 in node and in this PR. node sets the session up inside connect() when the stream has connected already, before a timer can exist.
The write rule. node's onWriteComplete refreshes when the write completed and the stream is not destroyed (stream_base_commons.js#L81-L101).
- A write whose frames the parser drops (the stream is reset) or refuses is not complete. The parser now calls the callback of such a write with
null(dispatch_dropped_write_callbackinh2_frame_parser.rs). A Writable callback readsnullas no error, so nothing else changes. Without it, a'timeout'listener that resets a stalled stream got a second'timeout'one period later. - node has destroyed the stream at completion when END_STREAM follows the write and the readable side has emitted
'end'(core.js#L607-L609). Bun destroys a stream later, so it tests that state itself.StreamState.EndedUnreadmarks a readable side that bun ended with no consumer where node waits for the close. - 13 of 14 write patterns give the same counts in node and in this PR (GET or POST, request read or not, consumer attached or not, response at once or later, early response). With a node client and this PR as the server, 12 of 17 do. The rest is the first difference below.
Differences from node that stay (the self-review found them, each is one refresh() call).
- A POST handler attaches a consumer and ends the response inside the
'stream'event, and the END_STREAM of the request is in the same read. Bun refreshes at the completion and node does not. Bun runs the tick queue between the frames of one read, so the write completes before bun has read that END_STREAM. write(a); end(b)after the request was read to its end: node refreshes at the completion ofb, because it sendsbbefore its END_STREAM. Bun puts END_STREAM onb.- A write that is in flight when
setTimeout()first arms the timer does not refresh at its completion. To track it, every write of a session without a timer would pay for a wrapper. When a JS transport holds such a write and stalls,'timeout'comes at the first expiry, as on main. node emits at the second. settings()before the socket connects: node refreshes for it at connect, a second time at the same instant.- On a transport that delivers in the same tick (a
duplexPair), the answer of the peer can arrive before the write callback runs. The fixture orders its steps so that this does not happen. - The refresh on a frame error (
core.js#L670) has no test: node closes the session after a frame error and bun does not. destroy()keeps the'timeout'listeners of the session. node removes them (core.js#L1229). Here an expiry emits on the open streams before it emits on the session, so a stream listener that destroys the session would take away the callback ofsession.setTimeout().
Other changes, all toward node.
- A request without a body arms a timer that has fired. With
session.setTimeout(T)and a GET every 1.5 T, node and this PR emit 4 session'timeout'events in 6.4 T, main 1. - Inbound DATA on a TCP socket refreshes. During a 3.2 T upload with a chunk every 0.4 T, the server session emits
'timeout'0 times in node and in this PR, and once on main. - One large write that waits in the socket holds back one expiry only. A server writes 16 MiB to a client with a 2 GiB window. If the client reads nothing,
'timeout'comes at expiry 2 in node and in this PR, and at expiry 3 on main. If the client reads 1 MiB between expiries, it comes at expiry 2 in node and in this PR, and at expiry 6 or 7 on main. - Frames for a stream that is reset or destroyed do not refresh.
The fan-out in the second Downside. An idle open stream has setTimeout(T), and a GET without a body runs every 1.5 T for 6.4 T. 'timeout' events on the stream and on the session: node 1 and 0, main 1 and 1, this PR 4 and 4. In bun stream.setTimeout() arms the session timer, and an expiry of the session emits on each open stream. Both are from before this PR.
Costs, measured on Linux x64 (debug build for the counts, release-mode bundle for the sizes).
- Reads of
session[kTimeout]per request on a session without a timer, client and server: GET with 1 KiB body 4 and 4 (main 0 and 1), POST 1 KiB 5 and 5 (1 and 1), POST 64 KiB 5 and 10 (1 and 1), GET with 1 MiB body 84 and 4 (0 and 1). That path has no allocation and no host call. refresh()calls per request on a session with a timer, client and server: GET with a body 4 and 4 in node and in this PR (main 0 and 1), POST with a body 6 and 5 (main 1 and 1).- 20,000 writes on one stream of a session with a timer allocate no function object. A stream that writes keeps one bound function.
- Shallow size of a session (
estimateShallowMemoryUsageOf): client 260 to 261 bytes, server 229 to 230 bytes. - Bundled
node:http2: 168,936 to 172,248 bytes. The embedded module blob: 2,631,105 to 2,634,023 bytes (+2,918). - The parser gains one small function and no host function. The size of a release binary is not measured: the container has no release build.
- Not measured: instructions per request and wall-clock time.
perfandvalgrindare not installed and the package index is not reachable. The host load average was above 500, so a wall-clock comparison has no value.
Tests. 11 to 13 of the 16 tests fail on main. The node run and the two idle tests on a duplexPair pass there. The idle client on a TCP socket and on a TLS socket fails on main in some runs only: 13 tests fail with bun 1.4.2, and 11 with the JS of main in a debug build. The three fixture tests run one at a time. The tests have their own file: a debug build needs more than 200 s for node-http2.test.js, and other tests of that file reach the default 5 s limit there.
Found on the way, not changed here.
- After
session.goaway()with no open stream, node stops reading. Bun reads the GOAWAY that the peer sends back and answers it withclose(). - Bun has no handler for an inbound PRIORITY frame, so that frame does not refresh (
core.js#L654). session.settings(settings, callback)does not callcallbackwhen the transport delivers in the same tick: the ACK arrives before the callback is queued. On such a transport'wantTrailers', and the'trailers'event of the peer, fire insidestream.end().- A node client gets
ERR_HTTP2_ERROR: Protocol errorwhen a bun server (1.4.3 too) callsstream.close(NGHTTP2_CANCEL)on a request whose 200 KB body waits for window.
Suites run with a debug build of the head, after the merge of main at 47ba1c8d85.
node-http2-session-timeout.test.ts: 16 pass in ten runs. With the JS of main: 11 fail in eight runs.- The 12 test files in
test/js/node/http2/with the default 5 s limit: 623 pass, and 9 tests reach the limit. Each of the 9 spawns a debug bun or is slow in a debug build. With--timeout 90000,node-http2.test.js(405 pass),node-http2-streams-rehash.test.tsandnode-http2-syscall-fault.test.tspass in full. The last two files give the same results with the JS of main. - The 261 vendored
test-http2-*tests: all pass.