node:http: create the 'head' Buffer only for a CONNECT or an Upgrade request without a body
Fixes #44208
Problem
- A
node:httpserver copies the bytes behind each request head into a Buffer that JavaScript drops. A 100,000 B POST on loopback copies 100,000 B. 2,000 pipelined GETs copy 54,010,981 B. NodeHTTPServer__onRequest(src/jsc/bindings/NodeHTTP.cpp:348) callscreateBufferwheneverrequest->headis not empty. #25938 added that for CONNECT. No user reported it.
Fix
- Only a CONNECT gets the Buffer, and an Upgrade request with no body that is not pipelined. JavaScript reads
headonly for these, so listeners get the same value. - The parser still sets
request->headfor every request, as #25938 asked. - Verified:
test/js/node/http/node-http-head-buffer.test.ts, 2 of 20 tests fail on main. - Self-reviewed: 16 concerns raised, 14 addressed. The 2 open ones are in Notes.
Background
request->headis a span into the receive buffer of the event loop. It holds the bytes of the current read behind the request head.- An Upgrade request has an
Upgradefield andupgradeinConnection. Native code passes both to the JS dispatcher as the two upgrade bits. - Considered a copy at the 'connect' and 'upgrade' emit, like Node.js. User code runs before the emit, and a run of the event loop there (#33261) overwrites the span.
Downsides
- A CONNECT or an Upgrade request that no listener takes keeps the copy. 1,000 such GETs in one write copy up to 30,975,993 B, as on main.
- A GET runs 5 more instructions. Release text grows by 500 B.
- The condition exists in C++ and in JavaScript. A new reader of
connectHeadmust extendcanHandOffHead.
Notes
Copies
Base is main bf42a525d5, Linux x64, release builds. Each cell is head Buffers / bytes that the native dispatcher passed to JavaScript. The client sends each row in one write unless the row says otherwise.
| Requests | main | this PR |
|---|---|---|
| POST, 100,000 B body | 1 / 100,000 | 0 / 0 |
| POST, 60,000 B body, 14,480 B in the first write | 1 / 14,406 | 0 / 0 |
| POST, 100,000 B body, request head in two reads | 1 / 100,000 | 0 / 0 |
POST, 100,000 B body, upgrade fields of curl --http2 | 1 / 100,000 | 0 / 0 |
| 100 pipelined GETs | 99 / 135,531 | 0 / 0 |
| 200 pipelined GETs | 199 / 541,081 | 0 / 0 |
| 1,000 pipelined GETs | 999 / 13,505,481 | 0 / 0 |
| 2,000 pipelined GETs | 1,999 / 54,010,981 | 0 / 0 |
| CONNECT + 4 bytes, 'connect' listener | 1 / 4 | 1 / 4 |
| Upgrade + 4 bytes, 'upgrade' listener | 1 / 4 | 1 / 4 |
| Upgrade + 4 bytes, no 'upgrade' listener | 1 / 4 | 1 / 4 |
| CONNECT + 4 bytes, no 'connect' listener | 1 / 4 | 1 / 4 |
1,000 pipelined GETs with Connection: Upgrade and Upgrade: h2c, the 'request' listener answers at once | 999 / 30,975,993 | 999 / 30,975,993 |
| The same requests, the 'request' listener answers in a later tick | 999 / 30,975,993 | 1 / 61,945 |
- Only the first read of a request reaches the dispatcher. On loopback one write of the client is one read of the server, so the whole body is behind the head. A new connection over a link with latency delivers the initial window first (10 segments, 14,480 B). The second row is that case.
- The bytes of the pipelined GETs grow by a factor of 4.0 when the count doubles: each dispatch copied the rest of the read.
- The last two rows differ in one thing. When the listener answers at once, each response is complete before the parser reaches the next request, so no dispatch is pipelined. When it answers later, each request behind the first is a pipelined dispatch. JavaScript gives a pipelined Upgrade request to 'request'.
- https gives the same numbers. One exception: the first TLS read of the 100,000 B POST holds 65,461 B.
- Bun 1.4.2 gives the main column. One exception: the head in two reads copied 17,173 B before #44210.
- With a breakpoint on the
createBufferline (lldb, debug builds): a POST with a 262,144 B body hits it 1 time on main (127,902 B) and 0 times here. 2,000 pipelined GETs hit it 1,999 times on main and 0 times here. A CONNECT and an Upgrade request hit it 1 time on both. - Node.js v26.3.0 slices the read only where it emits 'connect' or 'upgrade' (
onParserExecuteCommoninlib/_http_server.js), and the slice is a view.
Cost for each request
Instructions that one dispatch runs in NodeHTTPServer__onRequest_http plus assignHeadersFromUWebSocketsForCall, counted with gdb nexti on the release builds. Other callees are not in the count.
| Request | main | this PR |
|---|---|---|
| GET, nothing behind the head | 357 + 310 | 360 + 312 |
| POST, 5 B behind the head | 385 + 454 | 370 + 456 |
| CONNECT + 4 bytes | 385 + 225 | 390 + 227 |
| Upgrade + 4 bytes | 385 + 425 | 398 + 427 |
- The 5 added instructions for a request with nothing behind its head are 4 moves and 1 jump. They keep the method and the dispatch bits for the condition. There is no new call, conditional branch or allocation.
- The POST makes 2 calls fewer in the dispatcher (10 to 8): the allocation of the Buffer and
memcpy. NodeHTTPServer__onRequest_http: 6,720 B to 6,917 B._https: 6,720 B to 6,933 B (nm -S).assignHeadersFromUWebSocketsForCall: 3,327 B to 3,295 B. Text ofbun: 80,676,555 B to 80,677,055 B (size).- No host function, argument or field is added.
Throughput
Measured at 5e11c2da97, the first revision of this PR, against its base 2722608f47. The later change adds one test of a boolean to the condition.
The shape of the table in #43557: the server on one pinned core, oha with 64 connections, 5 s after a 2 s warm-up, 4 rounds, and the order of the runtimes alternates. The builds are local release builds, not CI builds. The machine is shared (load average 230 to 660), and the server does not get its whole core. So each cell is requests for each CPU-second of the server process: median (lowest to highest round). Raw requests per second for one build ranged from 1,417 to 34,265.
| Case | Node v26.3.0 | Bun 1.4.2 | main | this PR | main to PR |
|---|---|---|---|---|---|
| GET hello | 26,890 (24,492 to 27,616) | 39,317 (38,114 to 41,190) | 35,818 (35,560 to 37,113) | 35,961 (34,885 to 36,937) | +0.4% |
| POST, 1 KB body | 23,402 (23,001 to 23,817) | 33,201 (29,890 to 34,510) | 29,913 (28,436 to 30,146) | 29,949 (28,956 to 31,464) | +0.1% |
| POST, 16 KB body | 20,440 (17,053 to 21,213) | 23,296 (22,796 to 24,494) | 20,776 (19,553 to 22,949) | 21,720 (19,980 to 24,589) | +4.5% |
| POST, 64 KB body | 11,482 (10,670 to 12,017) | 11,686 (10,585 to 12,485) | 10,816 (10,668 to 11,304) | 14,025 (13,816 to 14,401) | +29.7% |
| Pipelined keep-alive, depth 8 | 54,947 (49,759 to 57,470) | 68,422 (66,431 to 69,468) | 70,410 (67,388 to 79,356) | 75,191 (68,906 to 78,118) | +6.8% |
- A second run with main and this PR only, 6 rounds: GET +2.4%, POST 1 KB +5.9%, POST 16 KB +13.6%, POST 64 KB +24.0%, pipelined +1.1%. The rounds of the two builds overlap for GET, POST 1 KB and pipelined in both runs. They do not overlap for POST 64 KB in both runs, and for POST 16 KB in the second run.
- Peak RSS, median of 4 rounds: POST 16 KB 89.3 MB to 77.8 MB, POST 64 KB 107.9 MB to 95.1 MB. The other rows do not change.
- These are loopback numbers. The gain needs the body in the first read (see Copies).
Tests
- The first test of the file calls
Bun.serve({ onNodeHTTPRequest }), the form thatnode:httpuses, and records the typed array among the arguments of each dispatch. It has 10 request shapes for http and https. On main it fails for the 7 shapes that have no reader. - The other 18 tests pass on main. They hold what a listener gets. 16 hold the
headof 'connect' and 'upgrade': after the next read overwrote the receive buffer, and after user code destroyed the socket. They cover authority-form and origin-form CONNECT, and an Upgrade request with and withoutSec-WebSocket-Key. 2 hold that an Upgrade request with a body gives 'upgrade' an emptyheadand gives the request its body. Node.js v26.3.0 gives the same values. - Each part of the condition has a test. Failing tests of 20 when a part is removed: CONNECT 10, both upgrade bits 10, no body 4, not pipelined 2. With "either bit", "Connection token only" or "Upgrade field only" in place of both bits: 2 each. With the pipelined rule also on CONNECT: 2. Without the body rule,
test-http-upgrade-server-with-body.mjsalso fails. - The tests are in a file of their own. The first test reads the arguments of the native dispatch, and
node-http.test.tstakes only tests that also run in Node.js. 12 of the 20 tests are not about CONNECT. - Also run with the debug build:
test/regression/issue/25862.test.ts,node-http-connect.test.ts,node-http-with-ws.test.ts,node-http-upgrade-body.test.ts,node-http-displaced-response.test.ts,node-http-transfer-encoding.test.ts,node-http.test.ts,node-http-req-complete.test.ts,node-http-server-abort-events.test.ts,node-http-backpressure.test.ts,test/js/bun/http/bun-serve-routes.test.ts,test/js/first_party/ws/ws.test.ts, andtest-http-upgrade-*,test-http-connect*.jsintest/js/node/test/parallel. All pass. The machine is shared, so the files with tests that spawn a process ran with--timeout 180000. test/js/bun/http/serve.test.ts: 327 pass and 2 fail. The same 2 fail with a debug build of main on this machine (a privileged port, and/bun:infofrom a loopback client).BUN_JSC_validateExceptionChecks=1reports nothing for the new file and for25862.test.ts.- The local runs are on Linux x64.
The condition
- The upgrade arm has the two rules of the JS dispatcher: the request has no body, and the dispatch is not pipelined. A pipelined Upgrade request goes to 'request'. A CONNECT gets its
headalso when it is pipelined. - The 'upgrade' emit in
_http_server.tsno longer testshasBody. That rule is incanHandOffHeadonly. - #43441 gives a pipelined Upgrade request to 'upgrade'. It must remove the pipelined rule from
canHandOffHead. Its own tests fail if it does not. - #35983 reads
headfor an Upgrade request whose body is complete in the read. It must add that case tocanHandOffHead. Its own test fails if it does not. - #44199 changes the same two lines of
assignHeadersFromUWebSocketsForCall(its signature and its call).
Other designs
- A copy at the emit sites is the order of Node.js, and it needs no condition in native code. After #44210 the span always points into the receive buffer of the loop, so a closed socket does not invalidate it. A run of the event loop inside the dispatch does: the next read writes into the same buffer. #33261 tracks that, and #44210 states that it is not supported. This PR changes only which requests get the copy.
- A condition in the parser was not taken.
request->headstays set for every request.
Found outside this task
- Bun parses the rest of a read behind an Upgrade request that goes to 'request'. Node.js v26.3.0 drops the rest of that read: 3 GETs with upgrade fields in one write give 1 'request' event in Node.js and 3 in Bun. The row "the 'request' listener answers at once" of Copies exists for that reason.
NodeHTTPServer__onRequestcopies the header section two times: intoflatHeaders, and from there inNodeHTTPResponse__adoptRawRequestHeaders. Its 12 arguments do not fit the 8 inline slots ofMarkedArgumentBuffer, so each dispatch callsmallocfor the arguments.- In the throughput table, main handles about 10% fewer requests for each CPU-second than Bun 1.4.2 for GET hello and the POSTs of 1 KB and 16 KB. This PR does not change that.
Self-review, open items
- No issues were opened for the first two items above.
- The throughput runs use local release builds on a shared machine, not CI builds.
[human-review] gate passed · iteration 0 · 3 files touched
fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-head-buffer.test.ts
bun test v1.4.3 (367d939d9)
test/js/node/http/node-http-head-buffer.test.ts:
117 | await dispatch(`${url}/barrier`, barrier => void barrier.write(get(`${url}/barrier`)));
118 | client.write(bytes.slice(splitAt));
119 | });
120 | }
121 |
122 | expect(Object.fromEntries(names.map(name => [name, passed.get(`/${names.indexOf(name)}`)]))).toEqual(
^
error: expect(received).toEqual(expected)
{
"CONNECT": "tunneled",
"CONNECT behind a pending response": "tunneled",
- "GET and a pipelined request": null,
- "GET and a pipelined request, Upgrade field and no `upgrade` in Connection": null,
- "GET and a pipelined request, `upgrade` in Connection and no Upgrade field": null,
- "POST and its body": null,
- "POST and its body, Upgrade request": null,
- "POST and its body, head in two reads": null,
+ "GET and a pipelined request":
+ "GET /next HT
... (truncated)
release without fix: 2 FAILED
bun test v1.4.3-canary.1 (1a0ed858f)
test/js/node/http/node-http-head-buffer.test.ts:
117 | await dispatch(`${url}/barrier`, barrier => void barrier.write(get(`${url}/barrier`)));
118 | client.write(bytes.slice(splitAt));
119 | });
120 | }
121 |
122 | expect(Object.fromEntries(names.map(name => [name, passed.get(`/${names.indexOf(name)}`)]))).toEqual(
^
error: expect(received).toEqual(expected)
{
"CONNECT": "tunneled",
"CONNECT behind a pending response": "tunneled",
- "GET and a pipelined request": null,
- "GET and a pipelined request, Upgrade field and no `upgrade` in Connection": null,
- "GET and a pipelined request, `upgrade` in Connection and no Upgrade field": null,
- "POST and its body": null,
- "POST and its body, Upgrade request": null,
- "POST and its body, head in two reads": null,
+ "GET and a pipelined request":
+ "GET /next HTTP/1.1
+ Host: example.com
+
+ "
+ ,
+ "GET and a pipelined request, Upgrade field and no `upgrade` in Connection":
+ "GET /next HTTP/1.1
+ Host: example.com
+
+ "
+
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-head-buffer.test.ts
bun test v1.4.3 (367d939d9)
test/js/node/http/node-http-head-buffer.test.ts:
(pass) the bytes behind a request head > http: reach JavaScript only for a request that can hand them to a listener [731.59ms]
(pass) the bytes behind a request head > https: reach JavaScript only for a request that can hand them to a listener [732.38ms]
(pass) the bytes behind a request head > http: are the head of 'connect' for example.com:80 [389.55ms]
(pass) the bytes behind a request head > http: are the head of 'connect' for /path [92.39ms]
(pass) the bytes behind a request head > http: are the head of 'upgrade' for /plain [111.35ms]
(pass) the bytes behind a request head > http: are the head of 'upgrade' for /websocket [82.89ms]
(pass) the bytes behind a request head > https: are the head of 'connect' for example.com:80 [177.16ms]
(pass) the bytes behind a request head > https: are the head of 'connect' for /path [147.72ms]
(pass) the bytes behind a request head > https: are the head of 'upgrade' for /plai
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 7803ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/140] gen cpp.rs (cppbind)
[2/140] gen JS modules (bundle-modules)
Preprocess modules (11782ms)
Bundle modules (797ms)
Postprocesss modules (162ms)
Bundle Functions (933ms)
Generate Code (56ms)
[13.74s] Bundled "src/js" for production
2613 kb
198 internal modules
13 native modules
50 internal functions across 16 files
[3/8] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[4/8] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-f20ce7744553c910-lto/lib/libJavaScriptCore.a(UnifiedSource-inspector-2.cpp.o at 102909272)' is 'x86_64-unknown-linux-gnu'
ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-f20ce7744553c910-lto/lib/libJavaScriptCore.a(UnifiedSource-inspector-2.cpp.o at 102909272)' i
... (truncated)
diff hotspot
src/js/node/_http_server.ts | 3 +-
src/jsc/bindings/NodeHTTP.cpp | 19 +-
test/js/node/http/node-http-head-buffer.test.ts | 234 ++++++++++++++++++++++++
3 files changed, 251 insertions(+), 5 deletions(-)
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
file reads edits tests
src/js/node/_http_server.ts 4 5 41
src/jsc/bindings/NodeHTTP.cpp 4 4 42
test/js/node/http/node-http-head-buffer.test.ts 4 8 41