Skip to content

node:http: set the parser options of a connection in the server's own listener - #46046

Draft
robobun wants to merge 8 commits into
mainfrom
robobun/00d2abd3/node-http-parser-options-per-connection
Draft

robobun wants to merge 8 commits into
mainfrom
robobun/00d2abd3/node-http-parser-options-per-connection

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A listening node:http server reads maxHeaderSize, maxHeadersCount, insecureHTTPParser and httpValidation from the shared listener on every socket read (packages/bun-uws/src/HttpContext.h:437). Node reads them once per connection.
  • server.maxHeaderSize = 1024 after listen() reaches no connection: Node 431, Bun 200. server.maxHeadersCount = 3 changes open connections: Node 200, Bun 431. No issue reports this.

Fix

  • Each connection stores the four options in its uWS state, HttpResponseData<SSL, true>. The 'connection' listener that the Server constructor registers writes them through setParserOptions. An https.Server gets them behind 'connection'.
  • The listener-wide copies of the four and their setters are deleted.
  • A connection that this listener misses takes them when the event returns.
  • Verified: test/js/node/http/node-http-maxHeadersCount.test.ts (main fails 58 of its tests, Node v26.12.0 passes), then test/js/node/http/. Self-reviewed: 34 concerns raised, 31 addressed. Not done: requireHostHeader and server.timeout, a commit split, a tracking issue.

Background

  • Node sets up the parser in that listener. prependListener() listeners run before it, on() listeners after.
  • Considered a copy at accept: it runs before or after every listener. Considered an own 'secureConnection' listener, as in Node: published wrappers then read socket._parent, null in Bun.

Downsides

  • A connection's state grows from 280 to 288 bytes in the same 384-byte allocation. Its 'connection' path runs 111 more bytecodes.
  • Under bun --hot an open connection keeps its old module's options.
  • On https a prepended 'secureConnection' listener or a later assignment from 'connection' misses its connection (main applied maxHeadersCount). Linux TCP_DEFER_ACCEPT stays.
Notes

The guard of #46045 is on main (#46270). setParserOptions writes to the per-socket block, so this PR depends on that guard in Bun__getOrCreateNodeHTTPServerSocket.

Node. connectionListenerInternal (lib/_http_server.js#L789-L823) reads server.maxHeaderSize || 0, the lenient flags and server.maxHeadersCount once, when it sets up the parser of a connection. http.Server has that function on 'connection'. https.Server has it on 'secureConnection'.

Scenarios. One script, a raw node:net / node:tls client, keep-alive. "tight" rejects the probe request and "loose" accepts it. "rejected" is 431 for maxHeadersCount (3 against 6 fields) and maxHeaderSize (1024 against a 4000-byte field). It is 400 for insecureHTTPParser and httpValidation (a \x08 in a header value). Release builds of main (25ef693) and of this PR.

Scenario Node v26.12.0 main, maxHeadersCount main, the other three this PR
open loose, set tight, probe on the open connection 200 rejected 200 200
same, probe on a new connection rejected rejected 200 rejected
set tight, open, set loose, probe on the open connection rejected 200 rejected rejected
same, probe on a new connection 200 200 rejected 200
listen() loose, set tight before the first connection rejected rejected 200 rejected
http: on('connection') sets tight 200 rejected 200 200
http: prependListener('connection') sets tight rejected rejected 200 rejected
http: set tight, on('connection') sets loose rejected 200 rejected rejected
https: on('connection') sets tight rejected rejected 200 rejected
https: on('secureConnection') sets tight 200 rejected 200 200
https: set tight, on('connection') sets loose 200 200 rejected 200
open strict, then insecureHTTPParser = true and httpAllowHalfOpen = true, lenient request on the open connection 400 200 400
https: prependListener('secureConnection') sets tight rejected rejected 200 200
http on Linux: connect, send nothing, set tight 100 ms later, probe 200 rejected 200 rejected

Of the 84 lines that the script prints, 44 differ from Node on main and 8 with this PR (the last two rows, for the four options).

On main maxHeadersCount follows each assignment on each open connection. The other three stay at their listen() values, except that an assignment to httpAllowHalfOpen sends the lenient flags of that moment to the listener.

Who can see it. A program that assigns one of the options after listen(), for example const server = app.listen(port); server.maxHeaderSize = n. Options passed to createServer() and assignments before listen() already behave like Node on main.

What changes besides the four options.

  • server.listeners('connection').length is 1, as in Node. It was 2: the constructor prepended a listener that set socket.server. The own listener sets it now.
  • server.maxHeadersCount is an own data property again. It was an accessor that called into native on each assignment.
  • maxHeaderSize undefined or 0 selects the limit of the process (--max-http-header-size), as in Node. A string is coerced to a number, as on main. Node aborts for a string.
  • The process limit is read when a connection opens, not at listen(). Bun has a setter for it (http.maxHeaderSize = n, Node has none). Set to 65536 after listen(), a 20,000-byte field: main 431, this PR 200. Set to 1024, a 4,000-byte field: main 200, this PR 431. An open connection keeps its limit.
  • A connection starts with the process limit, a strict parser and no field count. A server that resolves to that makes no setParserOptions call. With --insecure-http-parser each connection makes one.
  • The warning Using insecure HTTP parsing comes at the first connection, as in Node.
  • req.rawTrailers: the parse at 'end' takes the lenient flag of the connection, not server.insecureHTTPParser === true of that moment. So a connection that accepted a trailer value also delivers it. This is what httpValidation: 'relaxed' and --insecure-http-parser lost on main.

A connection that the own listener does not see. The program removed the own listener, or a listener before it threw. Node sets up no parser then, and the connection gets no answer. Bun keeps answering it, as on main, with the options that the server has when the 'connection' event returns (onServerConnection, finally).

  • httpolyglot removes the 'connection' listener of an https.Server and calls it later: one request gets 200 on Node, on main and with this PR. A first draft left such a connection unparsed, as Node does. That broke httpolyglot over HTTPS.
  • If the options cannot be read (a getter of the server throws), the connection is closed. A throwing maxHeadersCount getter: Node gives no answer, main answers 431 from the listener-wide value, this PR closes the connection.
  • A throwing maxConnections or blockList getter, or a socket constructor that throws: the connection still gets its options (431 on main and here).
  • The own listener of another http.Server that sees the socket does not set the options. In Node that server sets up its parser too, and an http request gets 431.

https. Bun emits 'connection' after the handshake, then 'secureConnection'. This PR reads the options between the two.

  • There is no own 'secureConnection' listener, as on main (Node has 1). findhit-proxywrap 0.3.13 and @kldzj/proxy-protocol 1.0.1 put their own listener around each 'secureConnection' listener. It makes the address getters of the socket read socket._parent, which is null in Bun. With an own listener a plain TLS request to such a server threw a TypeError. Node and main answer 200, and so does this PR. A test has the shape of these wrappers.
  • So a listener that a program prepends to 'secureConnection' is too late for its connection (table). 4 options: Node 4 of 4, main 1 of 4 (maxHeadersCount), this PR 0 of 4.
  • An option that a 'connection' listener assigns later (process.nextTick, queueMicrotask, a promise, setImmediate, setTimeout) is too late as well. 4 options by 5 ways: Node 20 of 20, main 3 of 20 (maxHeadersCount by tick or microtask), this PR 0 of 20. node: fix the genuine-hang class — MessagePort loop starvation, pinned sockets, https handshakeTimeout, TLS alert errors (+2 tests) #35538 (draft) moves 'connection' to accept.
  • A 'connection' listener that throws skips 'secureConnection', as on main. Node emits both. The connection still gets its options.

bun --hot. A reload keeps the native listener and its connections. The new module is a new http.Server. An open connection keeps the options of the module that it opened under. On main it took the new module's. A test pins it. A second test starts a server with an option at the address of a Bun.serve server, plain and TLS: it gets none of the connections, so it writes no option to their smaller block.

Still different from Node, as on main.

  • Linux: the listener uses TCP_DEFER_ACCEPT (Enable TCP_DEFER_ACCEPT for HTTP listeners on Linux #28617). A plain TCP connection is accepted when its first bytes arrive, or after 1 s. An assignment before that applies to the connection (last table row): Node 200 for the four options, main 200 for three of them, this PR rejects all four. FreeBSD uses the dataready accept filter, with no timeout, when the kernel has it. This PR keeps the deferred accept: it serves every server. node:http: skip TCP_DEFER_ACCEPT so server.setTimeout fires on time for silent clients #36074 skips it for node:http.
  • server.requireHostHeader is a flag of the listener that Bun.serve shares (packages/bun-uws/src/App.h, setFlags). listen() and the httpAllowHalfOpen setter push it. After listen(), requireHostHeader = false and a request without Host: Node 200, main 400, this PR 400.
  • server.timeout is read in the socket constructor. Set in a prepended 'connection' listener, socket.timeout of that connection: Node 12345, main 0, this PR 0.
  • server.highWaterMark = 1024 after listen(), socket.readableHighWaterMark of a new connection: Node 1024, main 65536, this PR 65536.
  • server.emit('connection', duplex) on an https.Server is parsed as plain HTTP (fix(node:https): wrap injected raw connections with TLS #42594).
  • Wrappers that remove the own listener and call it later on the same socket (PROXY protocol over http) stay as on main.

Measurements. Release builds of main (25ef693) and of this PR at that base.

  • Per-connection block (sizeof, static_assert): Bun.serve 200 to 200 bytes, node:http 280 to 288 bytes. Allocation 360 to 368 bytes, mi_usable_size 384 on both.
  • JS socket object (estimateShallowMemoryUsageOf): 262 to 263. The cell stays 224 bytes, and one more out-of-line slot is in use. The server field of the class became type-only for this: the base constructor defines it.
  • Parser instructions per socket read (ptrace single-step, 3 equal runs) for a 35-byte read, a 322-byte read and 16 pipelined requests: Bun.serve 934 / 2,661 / 38,766 to 885 / 2,612 / 38,567. node:http 1,143 / 2,842 / 40,207 to 1,132 / 2,831 / 40,196.
  • onData before the parser call (llvm-objdump): node:http 57 to 46 instructions, Bun.serve 49 (TCP) and 52 (TLS) to 31 and 35.
  • Calls from JS into the socket handle, 1,000 connections with one request each (gdb breakpoint counts): 13,002 to 13,002 for a server without options, 13,003 to 14,001 with maxHeadersCount = 100. 10,000 requests on one connection: 10,009 to 10,010.
  • JS for one accepted connection of a server without options (bytecode dump, executed path): the 'connection' listeners 22 to 104 bytecodes, onServerConnection 43 to 72. That is 111 more bytecodes, with 7 calls of JS functions that main does not make, no host call and no allocation.
  • Main-thread instructions per accepted connection (perf_event_open, 20,000 connections, 5 interleaved runs): 44,941 on main (spread 1,053) and 44,691 with this PR (spread 2,094) for a server without options. With maxHeadersCount = 100: 44,950 (spread 714) and 45,700 (spread 1,266).
  • Release text (llvm-size): 106,754,213 to 106,752,875 bytes. New: jsFunctionNodeHTTPServerSocketSetParserOptions, 1,193 bytes. Gone: Server__setMaxHTTPHeaderSize (308), Server__setMaxHeadersCount (265), jsHTTPSetMaxHeadersCount (378). jsHTTPSetCustomOptions 1,732 to 969 bytes, jsHTTPSetAppFlags 1,040 to 711.

Tests. All in node-http-maxHeadersCount.test.ts, which runs under Bun and, through its last test, under Node.

  • This PR, debug build with ASAN: 157 pass. The 156 tests before the last bun --hot test also passed with the environment of the ASAN lane (BUN_DESTRUCT_VM_ON_EXIT=1, leak check).
  • main (release, 25ef693), the same 156 tests: 98 pass, 58 fail.
  • Node v26.12.0: 141 pass. Node v26.3.0: 100 pass, 41 skipped (it cuts the fields off and does not reject).
  • For http.Server and https.Server on listen(), and for emit('connection'): each option on an open and on a new connection, on() and prependListener() of the own event, trailer sections, 'connection' listeners of https, the httpolyglot sequence, the wrapper shape, httpAllowHalfOpen, the property descriptors, the listener counts, --insecure-http-parser.
  • In Bun only: the own listener removed, another server's own listener, the throw cases above in a process of their own, a string maxHeaderSize, the process limit, and the two bun --hot cases.
  • The test that runs the file under Node.js has a 60 s timeout: the first start of Node after a build took up to 14 s here. Every other test takes the default.
  • After the merge of main (e81a34c), debug build: test/js/node/http/ 1,372 pass, 0 fail.
  • Release builds at the base 25ef693, this PR against main: test/js/node/http/ 1,907 pass, 0 fail. 1,031 vendored Node tests of http, https, http2, tls and net: 1,030 pass, and the 1 that fails also fails on main. test/js/bun/http/, express, socket.io, fastify, hono, msw, st, http2-wrapper, grpc-js, node/http2, node/tls, node/net, node/cluster and web/websocket: no test fails only with this PR.
  • body-parser: memory leak check - response body sends 100,000 requests under a 20 s limit. Main took 19.5 s and this PR timed out at 20 s. Interleaved runs of the same load (empty response, 4 each): 15.25 s on main, 15.38 s here, spread 2 s.

Self-review. Two rounds, 34 concerns. The largest:

Not done:

  • requireHostHeader, server.timeout and server.highWaterMark keep their timing. The first is a flag that Bun.serve shares. Each is a behaviour change of its own.
  • The commits are not split further. The PR merges as one commit.
  • No tracking issue for the three values above. The rows are in this body.

Other open PRs. git merge-tree against each head:


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/http/node-http-server-close-drain.test.ts

…ener

Under bun --hot a node:http server can take the native listener of a
Bun.serve server at the same address. The sockets of that listener have
the smaller per-socket block, and the node:http socket wrapper reads and
writes the larger one. Bun__getOrCreateNodeHTTPServerSocket now returns
null for them, so the server gets no 'connection' and no 'clientError'.
… listener

A node:http server on listen() kept maxHeaderSize, maxHeadersCount and the
lenient flags (insecureHTTPParser, httpValidation) in the uWS listener, and
every socket read took them from there. Node reads them once per connection,
when its own 'connection' listener sets up the parser ('secureConnection'
for https).

The options are now fields of the per-connection block. The server's own
'connection' listener writes them through
JSNodeHTTPServerSocket::setParserOptions. An https.Server reads them behind
the 'connection' event. A connection that the own listener does not see
takes them when the event returns, and a connection that cannot get them is
closed. The listener-wide copies and their setters are deleted.
@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Oct 22nd, 2026

❌ @robobun, your commit 050ceee has some failures in Build #130807 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 46046

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

bun-46046 --bun

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. The change and its tests are pushed in #46046.

How I reproduced it: test/js/node/http/node-http-maxHeadersCount.test.ts fails 58 of its tests on a release build of main (25ef693). Node v26.12.0 passes its 141 and this branch passes all 157. The scenario table in the PR body has the rows, for example server.maxHeaderSize = 1024 after listen() and a 4000-byte header field: Node 431, main 200, this branch 431.

Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/runtime/server/NodeHTTPResponse.rs Resolved
…-http-parser-options-per-connection

# Conflicts:
#	src/uws_sys/libuwsockets.cpp
… new tests

The test that runs the file under Node.js keeps a timeout: the first start
of Node after a build reads its binary from the disk.
…-http-parser-options-per-connection

# Conflicts:
#	src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
#	src/runtime/server/NodeHTTPResponse.rs
#	test/js/node/http/node-http-server-close-drain.test.ts

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