Repository navigation
Conversation
…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.
Collaborator
Author
|
Updated 4:05 AM PT - Oct 22nd, 2026
❌ @robobun, your commit 050ceee has some failures in 🧪 To try this PR locally: bunx bun-pr 46046That installs a local version of the PR into your bun-46046 --bun |
Collaborator
Author
|
Status: draft. The change and its tests are pushed in #46046. How I reproduced it: |
…-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
…rver, plain and TLS
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
node:httpserver readsmaxHeaderSize,maxHeadersCount,insecureHTTPParserandhttpValidationfrom the shared listener on every socket read (packages/bun-uws/src/HttpContext.h:437). Node reads them once per connection.server.maxHeaderSize = 1024afterlisten()reaches no connection: Node 431, Bun 200.server.maxHeadersCount = 3changes open connections: Node 200, Bun 431. No issue reports this.Fix
HttpResponseData<SSL, true>. The'connection'listener that theServerconstructor registers writes them throughsetParserOptions. Anhttps.Servergets them behind'connection'.test/js/node/http/node-http-maxHeadersCount.test.ts(main fails 58 of its tests, Node v26.12.0 passes), thentest/js/node/http/. Self-reviewed: 34 concerns raised, 31 addressed. Not done:requireHostHeaderandserver.timeout, a commit split, a tracking issue.Background
prependListener()listeners run before it,on()listeners after.'secureConnection'listener, as in Node: published wrappers then readsocket._parent, null in Bun.Downsides
'connection'path runs 111 more bytecodes.bun --hotan open connection keeps its old module's options.'secureConnection'listener or a later assignment from'connection'misses its connection (main appliedmaxHeadersCount). LinuxTCP_DEFER_ACCEPTstays.Notes
The guard of #46045 is on main (#46270).
setParserOptionswrites to the per-socket block, so this PR depends on that guard inBun__getOrCreateNodeHTTPServerSocket.Node.
connectionListenerInternal(lib/_http_server.js#L789-L823) readsserver.maxHeaderSize || 0, the lenient flags andserver.maxHeadersCountonce, when it sets up the parser of a connection.http.Serverhas that function on'connection'.https.Serverhas it on'secureConnection'.Scenarios. One script, a raw
node:net/node:tlsclient, keep-alive. "tight" rejects the probe request and "loose" accepts it. "rejected" is 431 formaxHeadersCount(3 against 6 fields) andmaxHeaderSize(1024 against a 4000-byte field). It is 400 forinsecureHTTPParserandhttpValidation(a\x08in a header value). Release builds of main (25ef693) and of this PR.maxHeadersCountlisten()loose, set tight before the first connectionon('connection')sets tightprependListener('connection')sets tighton('connection')sets looseon('connection')sets tighton('secureConnection')sets tighton('connection')sets looseinsecureHTTPParser = trueandhttpAllowHalfOpen = true, lenient request on the open connectionprependListener('secureConnection')sets tightOf 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
maxHeadersCountfollows each assignment on each open connection. The other three stay at theirlisten()values, except that an assignment tohttpAllowHalfOpensends the lenient flags of that moment to the listener.Who can see it. A program that assigns one of the options after
listen(), for exampleconst server = app.listen(port); server.maxHeaderSize = n. Options passed tocreateServer()and assignments beforelisten()already behave like Node on main.What changes besides the four options.
server.listeners('connection').lengthis 1, as in Node. It was 2: the constructor prepended a listener that setsocket.server. The own listener sets it now.server.maxHeadersCountis an own data property again. It was an accessor that called into native on each assignment.maxHeaderSizeundefinedor0selects 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.listen(). Bun has a setter for it (http.maxHeaderSize = n, Node has none). Set to 65536 afterlisten(), 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.setParserOptionscall. With--insecure-http-parsereach connection makes one.Using insecure HTTP parsingcomes at the first connection, as in Node.req.rawTrailers: the parse at'end'takes the lenient flag of the connection, notserver.insecureHTTPParser === trueof that moment. So a connection that accepted a trailer value also delivers it. This is whathttpValidation: 'relaxed'and--insecure-http-parserlost 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).'connection'listener of anhttps.Serverand 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.maxHeadersCountgetter: Node gives no answer, main answers 431 from the listener-wide value, this PR closes the connection.maxConnectionsorblockListgetter, or a socket constructor that throws: the connection still gets its options (431 on main and here).http.Serverthat 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.'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 readsocket._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.'secureConnection'is too late for its connection (table). 4 options: Node 4 of 4, main 1 of 4 (maxHeadersCount), this PR 0 of 4.'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 (maxHeadersCountby 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.'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 newhttp.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 aBun.serveserver, 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.
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 thedatareadyaccept 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 fornode:http.server.requireHostHeaderis a flag of the listener thatBun.serveshares (packages/bun-uws/src/App.h,setFlags).listen()and thehttpAllowHalfOpensetter push it. Afterlisten(),requireHostHeader = falseand a request withoutHost: Node 200, main 400, this PR 400.server.timeoutis read in the socket constructor. Set in a prepended'connection'listener,socket.timeoutof that connection: Node 12345, main 0, this PR 0.server.highWaterMark = 1024afterlisten(),socket.readableHighWaterMarkof a new connection: Node 1024, main 65536, this PR 65536.server.emit('connection', duplex)on anhttps.Serveris parsed as plain HTTP (fix(node:https): wrap injected raw connections with TLS #42594).Measurements. Release builds of main (25ef693) and of this PR at that base.
sizeof, static_assert):Bun.serve200 to 200 bytes,node:http280 to 288 bytes. Allocation 360 to 368 bytes,mi_usable_size384 on both.estimateShallowMemoryUsageOf): 262 to 263. The cell stays 224 bytes, and one more out-of-line slot is in use. Theserverfield of the class became type-only for this: the base constructor defines it.Bun.serve934 / 2,661 / 38,766 to 885 / 2,612 / 38,567.node:http1,143 / 2,842 / 40,207 to 1,132 / 2,831 / 40,196.onDatabefore the parser call (llvm-objdump):node:http57 to 46 instructions,Bun.serve49 (TCP) and 52 (TLS) to 31 and 35.maxHeadersCount = 100. 10,000 requests on one connection: 10,009 to 10,010.'connection'listeners 22 to 104 bytecodes,onServerConnection43 to 72. That is 111 more bytecodes, with 7 calls of JS functions that main does not make, no host call and no allocation.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. WithmaxHeadersCount = 100: 44,950 (spread 714) and 45,700 (spread 1,266).llvm-size): 106,754,213 to 106,752,875 bytes. New:jsFunctionNodeHTTPServerSocketSetParserOptions, 1,193 bytes. Gone:Server__setMaxHTTPHeaderSize(308),Server__setMaxHeadersCount(265),jsHTTPSetMaxHeadersCount(378).jsHTTPSetCustomOptions1,732 to 969 bytes,jsHTTPSetAppFlags1,040 to 711.Tests. All in
node-http-maxHeadersCount.test.ts, which runs under Bun and, through its last test, under Node.bun --hottest also passed with the environment of the ASAN lane (BUN_DESTRUCT_VM_ON_EXIT=1, leak check).http.Serverandhttps.Serveronlisten(), and foremit('connection'): each option on an open and on a new connection,on()andprependListener()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.maxHeaderSize, the process limit, and the twobun --hotcases.test/js/node/http/1,372 pass, 0 fail.test/js/node/http/1,907 pass, 0 fail. 1,031 vendored Node tests ofhttp,https,http2,tlsandnet: 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/clusterandweb/websocket: no test fails only with this PR.memory leak check - response bodysends 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:
'secureConnection'listener. It broke the two https wrappers above. Now the options are read between the two events.Not done:
requireHostHeader,server.timeoutandserver.highWaterMarkkeep their timing. The first is a flag thatBun.serveshares. Each is a behaviour change of its own.Other open PRs.
git merge-treeagainst each head:maxHeadersCountinonOpenandonHandshakeand its field, and keeps itsHttpParser.hchange.'end'. 2 conflicting files. The one that merges second drops the lenient flag that this PR passes to that parse.setServerCustomOptions,setFlags): 9 files conflict that do not conflict with main._http_server.ts. fix(node:https): wrap injected raw connections with TLS #42594 conflicts with main in that file already.'connection'listener throws unhandled. No conflict. After it, thefinallyhere sets the options of a connection that is then closed.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