Skip to content

node:http: reject requests with more header fields than server.maxHeadersCount - #42532

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/b86fc13b/reject-over-max-headers-count
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/b86fc13b/reject-over-max-headers-count

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A lowered server.maxHeadersCount hid header fields from the handler, not from the parser. A Content-Length past the limit left req.headers and req.rawHeaders, and still framed a body. Node fixed this as CVE-2026-58044 (nodejs/node@821688aaa0): the parser rejects the request with 431 and clientError HPE_HEADER_OVERFLOW.
  • The rawHeaders getter (src/js/node/_http_incoming.ts:221) sliced the list after the native parser framed the message. That copied Node before its fix, and its author invited a change.

Fix

  • Native server: getHeaders takes the limit as its field loop bound, clamped to the 198 fields the array holds. One more field takes the existing parse error path. Trailers count against the same limit. The value reaches uWS at listen() and on each assignment.
  • llhttp binding: a port of Node's TrackHeaderPair, request parsers only. The fallback listener starts from Node's 2000-pair default and assembles the blocks the parser flushes through kOnHeaders. Before, 32 or more fields reached the handler as the last block only.
  • The JS truncation is removed. The default is unchanged: 198 fields pass, 199 get 431.
  • Verified: test/js/node/http/node-http-maxHeadersCount.test.ts (67 of 67 on Node v26.5.1, 42 fail on bun 1.4.3). Self-reviewed: 14 concerns, 13 addressed, 1 declined.

Background

  • uWS parses http.Server connections. HttpRequest::headers has 200 slots: the request line, 198 fields, an end sentinel.
  • HTTPParser (llhttp) parses server.emit("connection", socket) sockets and allowHTTP1 HTTP/2 servers. It hands fields past the 31st to kOnHeaders.
  • A uWS parse error stops the connection's parser and reaches socketOnError as Node's error.
Notes

Reproduction.

import http from "node:http";
import net from "node:net";
const s = http.createServer((req, res) => {
  let n = 0;
  req.on("data", d => (n += d.length));
  req.on("end", () => {
    console.log(req.method, req.url, JSON.stringify(req.headers), "rawHeaders pairs =", req.rawHeaders.length / 2, "body bytes =", n);
    res.end("ok");
  });
});
s.on("clientError", (err, sock) => {
  console.log("clientError", err.code);
  sock.end("HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n");
});
s.maxHeadersCount = 3;
s.listen(0, "127.0.0.1", () => {
  const c = net.connect(s.address().port, "127.0.0.1", () =>
    c.write("POST /a HTTP/1.1\r\nHost: x\r\nA: 1\r\nB: 2\r\nC: 3\r\nContent-Length: 28\r\n\r\nGET /inner HTTP/1.1\r\nX: y\r\n\r\n"),
  );
  let b = "";
  c.on("data", d => (b += d));
  c.on("close", () => (console.log(b.split("\r\n")[0]), process.exit(0)));
});
runtime output
bun 1.4.3-canary, main POST /a {"host":"x","a":"1","b":"2"} rawHeaders pairs = 3 body bytes = 28, then HTTP/1.1 200 OK
Node v26.3.0 (before the fix) the same, with 5 raw pairs
Node v26.5.1 clientError HPE_HEADER_OVERFLOW, then HTTP/1.1 431 Request Header Fields Too Large
this branch clientError HPE_HEADER_OVERFLOW, then HTTP/1.1 431 Request Header Fields Too Large

With no clientError listener both Node v26.5.1 and this branch write exactly HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n.

Matrix against Node v26.5.1. A probe of 13 scenarios on the native server and 10 on emit("connection") prints the same result on Node v26.5.1 and on this branch: at the limit, one over, clientError, maxHeadersCount = 0, more than 32 fields, trailers at and over the limit, assignment after listen(), a client response over req.maxHeadersCount, odd values (2.5 allows 2 fields, and 0.5, -1, NaN, Infinity, "3" set no limit), 1000 and 1001 fields with the option unset. Two results differ, and both predate this change: err.reason is unset and err.bytesParsed is the packet length on every parse error of the native server, and bun answers a pipelined request before it parses the next one, so a first response can precede the 431.

Node versions. The reject shipped in v22.23.2, v24.18.1 and v26.5.1. The per-section cache of maxHeaderPairs (nodejs/node#64988) shipped in v24.21.0 and v26.8.0, and trackHeaderPair ports that revision. In the test file the reject cases skip on an older Node, with the reason. Node v26.3.0 runs 40 and skips 27. Node v26.5.1 runs 67. This branch runs those 67 plus one case that runs the file under Node.

Scope.

  • CVE-2026-58044 only. The Node release that carried it had other advisories. This PR claims nothing about them.
  • llhttp is not changed. Node's fix is in its binding, not in llhttp.
  • Responses keep truncating at ClientRequest.maxHeadersCount, as in every Node release. http: reject responses exceeding header limit nodejs/node#65010 (open, semver-major) proposes to reject them too.
  • The vendored tests are ahead of the v26.3.0 pin on purpose: test-http-max-headers-count-overflow.js (new), test-http(s)-max-headers-count.js (updated) and the removal of test-http-rawheaders-limit.js are upstream commit 821688aaa0, byte for byte. test-http-parser-max-header-pairs-cache.js is from v26.8.0. The removed test asserts the truncation and cannot complete after the fix, on Node or here. test/expected-durations.json is generated and is not edited.
  • server.maxHeadersCount is now an accessor on the server, like httpAllowHalfOpen, so that an assignment after listen() reaches uWS. Express users set it on the server that app.listen() returns, so this path matters. The limit lives on the server's uWS context, not on the connection: a lowered value applies to open keep-alive connections at once, where Node applies it to connections accepted later. A general way to sync server options after listen() would replace the accessor. For example server.maxHeaderSize assigned after listen() is still ignored, before and after this PR.
  • A trailer section that is both over the limit and malformed in another way can report a different err.code than Node. The connection fails in both cases.

Sibling PRs (all robobun, none reviewed).

Self-review. Addressed: port the upstream revision of TrackHeaderPair that is current, and vendor its test. Seed the 2000-pair default. Assemble flushed header blocks and trailers in the fallback, and assert field counts there. List the sibling PRs explicitly. State the scope. Declined: a split by parser backend. REVIEW.md counts fast and slow parser paths as one concern, and the llhttp half is what keeps the fallback from a silent truncation.

Fixed in passing. maxHeadersCount = 0.5 truncated req.rawHeaders to half a pair. Node reads it as no limit (0.5 << 1 is 0), and so does this branch.

Cost. Default path of the native server: one uint32_t read and one comparison per parse call, no branch per field. HttpContextData grows by one uint32_t. Request parsers of the llhttp binding read one property per header section.

Suites run on the debug and ASAN build. node-http-maxHeadersCount.test.ts (68 pass), node-http.test.ts (159 pass, 1 skip), node-http2.test.js (387 pass, 6 skip), node-http-parser, node-http-transfer-encoding, node-http-maxHeaderSize, node-http-primoridals, node-http-server-timeouts, and 190 vendored test-http*, test-https* and test-http2-*fallback* files that touch headers, trailers, the parser, pipelining or clientError. 188 pass. test-http-agent-keepalive.js and test-https-proxy-request-incomplete-headers.mjs fail in this container with and without this diff. BUN_JSC_validateExceptionChecks=1 is clean on the parser cases.


[human-review] gate passed · iteration 0 · 23 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ 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-maxHeadersCount.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/165] cc obj/src/jsc/bindings/sqlite/sqlite3.c.o
[2/165] gen cpp.rs (cppbind)
[3/165] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[4/165] gen JS modules (bundle-modules)
Preprocess modules (10815ms)
Bundle modules (52ms)
Postprocesss modules (100ms)
Bundle Functions (545ms)
Generate Code (46ms)

[11.57s] Bundled "src/js" for development
  2792 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[4/165] cargo bun_runtime → libbun_runtime.a
[162/165] cxx obj/codegen/ZigGeneratedClasses.cpp.o
[163/165] link bun-debug
FAILED: bun-debug 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts link --console /usr/lib/llvm-21/bin/clang++ @bun-debug.rsp -fsanitize=address -fsanitize=null -Wl,--wrap=exp -Wl,--wrap=exp2 -Wl,--wrap=expf -Wl,--wrap=fcntl64 -Wl,--wrap=getrandom -Wl,--wrap=gettid -Wl,
... (truncated)

release without fix: 42 FAILED
bun test v1.4.3-canary.1 (6a92015fc)

test/js/node/http/node-http-maxHeadersCount.test.ts:
132 |         const response = await roundtrip(
133 |           server,
134 |           "POST /a HTTP/1.1\r\nHost: x\r\nA: 1\r\nB: 2\r\nC: 3\r\nContent-Length: 28\r\nConnection: close\r\n\r\n" +
135 |             "GET /inner HTTP/1.1\r\nX: y\r\n\r\n",
136 |         );
137 |         assert.strictEqual(response, tooLarge);
                     ^
AssertionError: Expected values to be strictly equal:
+ actual - expected

+ 'HTTP/1.1 200 OK\r\n' +
+   'Date: Sun, 13 Sep 2026 00:13:49 GMT\r\n' +
+   'Connection: close\r\n' +
+   'Content-Length: 2\r\n' +
+   '\r\n' +
+   'ok'
- 'HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n'

 generatedMessage: true,
     actual: "HTTP/1.1 200 OK\r\nDate: Sun, 13 Sep 2026 00:13:49 GMT\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok",
   expected: "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
   operator: "strictEqual",
       diff: "simple",
       code: "ERR_ASSERTION"

      at /workspace/bun/test/js/node/http/node-http-maxHeadersCount.test.ts:137:16
      at node:test:1445:26
      at
... (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-maxHeadersCount.test.ts
bun test v1.4.3 (6a92015fc)

test/js/node/http/node-http-maxHeadersCount.test.ts:
(pass) server.maxHeadersCount, listen() > a request over the limit gets 431 and never reaches the handler [615.02ms]
(pass) server.maxHeadersCount, listen() > 'clientError' receives HPE_HEADER_OVERFLOW and owns the response [99.08ms]
(pass) server.maxHeadersCount, listen() > limit 2: a request at the limit passes with every field [246.12ms]
(pass) server.maxHeadersCount, listen() > limit 2: one more field fails [74.87ms]
(pass) server.maxHeadersCount, listen() > limit 31: a request at the limit passes with every field [85.24ms]
(pass) server.maxHeadersCount, listen() > limit 31: one more field fails [77.33ms]
(pass) server.maxHeadersCount, listen() > limit 32: a request at the limit passes with every field [81.30ms]
(pass) server.maxHeadersCount, listen() > limit 32: one more field fails [57.25ms]
(pass) server.maxHeadersCount, listen() > limit 33: a request at the limit passes with every field [68.86ms]

... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     99eadd33e6
  features     baseline

23 deps, 131 codegen, 1172 objects in 814ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (6a92015fc)

Checked 22 installs across 61 packages (no changes) [12.00ms]
[2/1244] gen ErrorCode+*.h
[3/1244] gen bindgenv2
[4/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (6a92015fc)

Checked 1 install across 2 packages (no changes) [1.00ms]
[5/1244] fetch tinycc
[tinycc] up to date
[6/1243] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (6a92015fc)

Checked 111 installs across 104 packages (no changes) [8.00ms]
[7/1243] gen node-fallbacks/react-refresh.js
Bundled 1 module in 24ms

  react-refresh.js  4.81 KB  (entry point)

[8/1243] gen .bind.ts → GeneratedBindings.cpp
[9/1243] fetch zlib
[zlib] up to date
[10/1243] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[11/1243] fetch libjpeg-turbo
[libjpe
... (truncated)
diff hotspot
packages/bun-uws/src/App.h                         |   5 +
 packages/bun-uws/src/HttpContext.h                 |   2 +-
 packages/bun-uws/src/HttpContextData.h             |   1 +
 packages/bun-uws/src/HttpParser.h                  |  42 +-
 src/js/builtins/BunBuiltinNames.h                  |   1 +
 src/js/internal/http.ts                            |  13 +-
 src/js/internal/http1_server_fallback.ts           |  38 +-
 src/js/node/_http_common.ts                        |   1 +
 src/js/node/_http_incoming.ts                      |   8 -
 src/js/node/_http_server.ts                        |  37 +-
 src/jsc/bindings/NodeHTTP.cpp                      |  25 +-
 src/jsc/bindings/node/http/NodeHTTPParser.cpp      |  43 ++
 src/jsc/bindings/node/http/NodeHTTPParser.h        |   4 +
 src/runtime/server/mod.rs                          |   7 +
 src/runtime/server/server_body.rs                  |  37 ++
 src/uws_sys/App.rs                                 |   9 +
 src/uws_sys/libuwsockets.cpp                       |   9 +
 .../js/node/http/node-http-maxHeadersCount.test.ts | 555 +++++++++++++++++++++
 .../test-http-max-headers-count-overflow.js        |  34 ++
 .../test/parallel/test-http-max-headers-count.js   |  44 +-
 .../test-http-parser-max-header-pairs-cache.js     |  77 +++
 .../test/parallel/test-http-rawheaders-limit.js    |  26 -
 .../test/parallel/test-https-max-headers-count.js  |  40 +-
 23 files changed, 962 insertions(+), 96 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
packages/bun-uws/src/App.h                         1      1     23
packages/bun-uws/src/HttpContext.h                 1      0     23
packages/bun-uws/src/HttpContextData.h             1      2     23
packages/bun-uws/src/HttpParser.h                  6      8     23
src/js/builtins/BunBuiltinNames.h                  0      0     23
src/js/internal/http.ts                            2      3     23
src/js/internal/http1_server_fallback.ts           1      0     23
src/js/node/_http_common.ts                        0      0     23
src/js/node/_http_incoming.ts                      2      2     23
src/js/node/_http_server.ts                        4      0     23
src/jsc/bindings/NodeHTTP.cpp                      2      3     23
src/jsc/bindings/node/http/NodeHTTPParser.cpp      1      0     23
src/jsc/bindings/node/http/NodeHTTPParser.h        1      0     23
src/runtime/server/mod.rs                          1      1     23
src/runtime/server/server_body.rs                  1      2     23
src/uws_sys/App.rs                                 2      2     23
(+ 7 more files)

…dersCount

A lowered server.maxHeadersCount cut the header list the handler saw, after
the parser had framed the message with every field. A Content-Length past
the limit was hidden from req.headers and req.rawHeaders and still
delimited a body. Node rejects such a request since nodejs/node 821688aaa0
(CVE-2026-58044).

The native server now takes the limit as the bound of the field loop in
uWS getHeaders, so the existing parse error path answers: 'clientError'
with HPE_HEADER_OVERFLOW, 431 by default, no more parsing on the
connection. Trailer fields count from zero against the same limit. The
value reaches the parser at listen() and on every later assignment.

The llhttp binding gets Node's TrackHeaderPair for request parsers, in the
revision that reads maxHeaderPairs once per header section
(nodejs/node#64988). Responses keep truncating. This covers sockets passed
to server.emit('connection') and the HTTP/1.1 connections of an allowHTTP1
HTTP/2 server. That listener now also starts from Node's 2000-pair default
and assembles the header blocks and trailers the parser flushes through
kOnHeaders. Before, a request with 32 or more fields reached the handler
with only its last block, so Host and Content-Length were hidden there too.

The JS truncation of rawHeaders and rawTrailers is gone. The vendored Node
tests follow upstream: two added, two updated, and
test-http-rawheaders-limit.js removed.
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 8 days. After that, they cost $0.25 per reviewed file.

Or wait 5 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 9aa47050-5490-46fa-9135-7c0a6a380b17

📥 Commits

Reviewing files that changed from the base of the PR and between 5822b76 and 99eadd3.

📒 Files selected for processing (23)
  • packages/bun-uws/src/App.h
  • packages/bun-uws/src/HttpContext.h
  • packages/bun-uws/src/HttpContextData.h
  • packages/bun-uws/src/HttpParser.h
  • src/js/builtins/BunBuiltinNames.h
  • src/js/internal/http.ts
  • src/js/internal/http1_server_fallback.ts
  • src/js/node/_http_common.ts
  • src/js/node/_http_incoming.ts
  • src/js/node/_http_server.ts
  • src/jsc/bindings/NodeHTTP.cpp
  • src/jsc/bindings/node/http/NodeHTTPParser.cpp
  • src/jsc/bindings/node/http/NodeHTTPParser.h
  • src/runtime/server/mod.rs
  • src/runtime/server/server_body.rs
  • src/uws_sys/App.rs
  • src/uws_sys/libuwsockets.cpp
  • test/js/node/http/node-http-maxHeadersCount.test.ts
  • test/js/node/test/parallel/test-http-max-headers-count-overflow.js
  • test/js/node/test/parallel/test-http-max-headers-count.js
  • test/js/node/test/parallel/test-http-parser-max-header-pairs-cache.js
  • test/js/node/test/parallel/test-http-rawheaders-limit.js
  • test/js/node/test/parallel/test-https-max-headers-count.js

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:39 PM PT - Sep 12th, 2026

✅ @robobun, your commit 99eadd33e6d206130e1aae2de842d061c9dbf351 passed in Build #114927! 🎉


🧪   To try this PR locally:

bunx bun-pr 42532

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

bun-42532 --bun

@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Closed. #43557 supersedes this PR and is merged (5d5f03f). It carries the same fix and the tests of this PR.

Checked on main at 5d5f03f: with server.maxHeadersCount = 3, a POST with Host, A, B, C and Content-Length: 28 gets 431, clientError fires with HPE_HEADER_OVERFLOW, and the handler does not run. Bun 1.4.3 calls the handler with req.headers = {host, a, b} and 28 body bytes.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes the loop bound in the hot-path uWS getHeaders parser and threads a CVE-relevant limit across five layers (uWS C++, llhttp binding, Rust FFI, JS builtins), a human look is still worthwhile.

What was reviewed:

  • getHeaders loop-bound change: maxHeaderFields is clamped to MAX_HEADER_FIELDS (198) in fenceAndConsumePostPadded before every call, and the static_assert bounds it against HttpRequest::headers extent — no OOB write path.
  • All call sites updated: both fenceAndConsumePostPadded invocations, all three validateNodeTrailerSection sites, and the sole consumePostPadded caller in HttpContext.h.
  • trackHeaderPair(): RETURN_IF_EXCEPTION after the maxHeaderPairs property read; new counters reset at init/onMessageBegin/onHeadersComplete/onMessageComplete.
  • Rust shim and JS accessor mirror the existing maxHeaderSize / httpAllowHalfOpen patterns; dead JS truncation removed from _http_incoming.ts and internal/http.ts.
Extended reasoning...

Overview

This PR ports Node's CVE-2026-58044 fix: server.maxHeadersCount now causes the parser to reject requests (431 / HPE_HEADER_OVERFLOW) instead of silently truncating rawHeaders after the message has already been framed. It touches the uWS HTTP parser (HttpParser.h — loop bound in getHeaders, trailer validation, HttpContextData field, App::setMaxHeadersCount), the llhttp binding (NodeHTTPParser.{cpp,h} — new trackHeaderPair() with per-section maxHeaderPairs caching), Rust FFI plumbing (server_body.rs, mod.rs, uws_sys/App.rs, libuwsockets.cpp), JS builtins (_http_server.ts accessor, http1_server_fallback.ts kOnHeaders assembly, removal of dead truncation in _http_incoming.ts and internal/http.ts), and ~700 lines of tests including vendored upstream Node tests.

Security risks

The change is itself a security hardening. The primary risk to check was whether making the getHeaders field-loop bound runtime-configurable could allow an out-of-bounds write into HttpRequest::headers[200]. It cannot: the only caller (fenceAndConsumePostPadded) computes maxHeaderFields = maxHeadersCount && maxHeadersCount < MAX_HEADER_FIELDS ? maxHeadersCount : MAX_HEADER_FIELDS, so the bound is always ≤ 198, and a static_assert(MAX_HEADER_FIELDS + 2 <= extent_v<...>) pins that against the array size. The old loop ran 198 iterations (i = 1..UWS_HTTP_MAX_HEADERS_COUNT-1); the new default runs the same 198 (i = 0..MAX_HEADER_FIELDS) after the same pre-loop headers++, so the default path is byte-identical. The uint32_t type on maxHeadersCount and the JS-side << 1 int32 arithmetic in nativeMaxHeadersCount mean odd/negative/non-finite user values collapse to 0 (no limit), matching Node.

Level of scrutiny

High. This is a ~960-line change to the request-parsing hot path across C++ (uWS and JSC bindings), Rust FFI, and JS builtins, motivated by a CVE. Per the approval guidelines, changes that are large and touch security-sensitive parsing code should get a human review even when the automated pass finds nothing. The uWS parser in particular is memory-safety-critical and hand-optimized; a maintainer familiar with its invariants should confirm the loop-bound refactor and the three validateNodeTrailerSection call-site updates.

Other factors

Test coverage is thorough: a 555-line test file exercising both the native server and the emit("connection") llhttp path, boundary values around the 31-field flush, trailers, post-listen() assignment, odd maxHeadersCount values, plus four vendored Node parallel tests (two new, two updated to upstream, one deleted with stated reason — it asserted the removed truncation behavior). The new mutable state on HTTPParser (m_headerPairs, m_maxHeaderPairs) is reset at every documented lifecycle point. The Rust Server__setMaxHeadersCount shim is a line-for-line sibling of the existing Server__setMaxHTTPHeaderSize, routed through host_fn::to_js_host_fn_result. The maxHeadersCount accessor in _http_server.ts copies the adjacent defineHttpAllowHalfOpen pattern. No CODEOWNERS entries cover the changed paths. The bug-hunt exit reason was dry_streak.

Jarred-Sumner added a commit that referenced this pull request Sep 26, 2026
…inish, lifecycle) (#43557)

One pull request for the open `node:http` server pull requests. Each
root cause is fixed once, and each pull request's tests are carried
over. Node is the reference: every scenario was run under Node and under
Bun from one script, and the outputs were compared.

Fixes #4733
Fixes #18613
Fixes #40350
Fixes #43155
Fixes #43297
Fixes #43513
Fixes #43527
Fixes #25632
Fixes #31301
Fixes #43027
Fixes #43163
Fixes #43342
Fixes #43344
Fixes #43370
Fixes #43490
Fixes #43512
Fixes #43519

Each of these has a repro that is wrong on Bun 1.4.3, right on this
branch, and the same as Node.

| Issue | Not closed by this PR, because |
| --- | --- |
| #30501 (msal-node keeps Bun alive at exit) | Probably fixed. The repro
copies the teardown of msal-node. The package itself was not run. |
| #14430 (yarn: "does not support SSL") | Probably fixed.
`response.hasOwnProperty("socket")` is now `true`. yarn itself was not
run. |
| #39681 (`server.setTimeout` callback after destroy) | Probably fixed.
The repro is the deterministic case of #39686. The script in the issue
depends on timing and on Windows. |
| #43455 (`req.complete`, nine flows) | Partially addressed. Flows 1, 2,
3, 5 and 7 are fixed, and flow 6 was already right. Flow 8 (a socket
timeout while the body of an Upgrade request arrives) and flow 9 (a
request that `stream.pipeline()` destroyed never reports `complete`) are
not. Flow 4 differs only in `_readableState.ended`. |

### What changes for users

| Area | Before | After (same as Node) |
| --- | --- | --- |
| `req.pause()` | The socket stops at once. `req.complete` stays `false`
for a small body. | The body is received until the buffer is full. Then
the socket stops. |
| `res.end()` before the body arrives | `req` gets `'end'` and `'close'`
at once, and the body is lost | The request completes when its body
really ends |
| `res.destroy()` in the middle of a body | `'end'` with bytes missing |
`aborted`, then `ECONNRESET` |
| `socket.destroy()` inside the `'request'` listener | The body that
came with the head is dropped | That body is still delivered |
| `'finish'` and the `end()` callback | Fire when `end()` buffers the
bytes | Fire when the last bytes have left the socket |
| A response that closes the connection | The server half-closes and
waits for the peer | The socket closes right behind the FIN |
| `'drain'` after a later write flushed the backlog | Lost. `pipe(res)`
could hang. | Emitted |
| A pipelined request whose body continues after the previous response
ends | Body dropped, no response, `server.close()` hangs | Delivered |
| CONNECT and Upgrade tunnel sockets | Keep reading when paused or full
| Stop reading. `_read()` starts them again. |
| A tunnel write that waits for a drain when the client goes away | Its
callback, the callbacks of the writes behind it and the `end()` callback
never run | They run with an error before `'close'` |
| Upgrade request with a body, paused in its listener | The body flows
away | The request keeps its body |
| `ws` on a reused keep-alive socket | Writes after the Upgrade could
stall | Sent |
| A raw `socket.write()` behind a response that still drains (the 400
for a bad pipelined request, the reply of a `'clientError'` listener) |
Lands in the middle of that response | Sent after it |
| `server.close()` | Could report closed while connections were open |
Waits for every connection. An idle tunnel does not keep the process
alive. |
| `closeAllConnections()` on a listening server | Also stops the
listener and destroys tunnels and WebSockets | Destroys only the HTTP
connections |
| `Proxy-Connection: close` (node:http only) | Ignored. The connection
stays open. | Ends the connection, like `Connection: close` |
| A response larger than 16 KB, also in `Bun.serve` and over TLS | Up to
4 `send()` calls for each chunk. Slower than Node in most cases. | One
write for the writes of one tick. 1.1x to 2.7x the requests per second
of main, and faster than Node. |
| `socket.destroy()` and then `res.end()` in a listener | (this PR,
earlier) `req` ended as if it were complete | `'aborted'`, then
`ECONNRESET` |
| `emit('connection')` or http2 `allowHTTP1`: the response ends while
the listener still reads the body | The rest of the body is dropped |
The body is complete |
| `httpValidation: "relaxed"`, `Content-Length` or `Transfer-Encoding`
in trailers | Accepted | `HPE_INVALID_CONTENT_LENGTH`,
`HPE_INVALID_TRANSFER_ENCODING` |
| `req.complete` inside `'connect'`, and inside `'upgrade'` without a
body | `false` | `true` |
| `optimizeEmptyRequests`: `socket.parser.incoming` after the response |
Keeps the request alive on an idle connection | `null` |
| A HEAD or OPTIONS request with `Content-Length` | The body is dropped,
and `req.complete` is `true` before it comes | The request has its body
|
| `res.end(chunk)` after the client went away | `finished` and
`writableEnded` stay `false`, no `'prefinish'` | The response ends |
| An HTTP/1.0 request with an `Expect` header | `100 Continue`,
`'checkContinue'`, `'checkExpectation'` or a 417 | A plain `'request'` |
| The idle sweep of `close()` and `closeIdleConnections()` | Could
destroy a connection that was still receiving a request, or whose
response was still draining | Closes only idle connections |

### Design

| Piece | What it is |
| --- | --- |
| Request body state | `None / Pending / Complete / Aborted / Upgraded /
Detached`. Only the last chunk sets `Complete`. One function,
`leave_pending`, is the only other way out of `Pending`. |
| Read flow control | One path: `push()` returning false stops the
socket, `_read()` starts it. Both native pause buffers are removed: no
read is copied and replayed. |
| "This read is parsed" signal | `notifyWhenReadParsed()` sets a uws
state bit. uws delivers a `readParsed` event after the read. It replaces
a `setImmediate`. |
| Close during a parse | One uws bit defers a close to the end of the
current message. |
| Response finish | A response is finished when it has ended and the
socket has fully drained. |
| Idle connection | One rule, `HttpResponse::closeIfIdle()`. A
connection is idle when it receives no request (head or body) and no
response is in flight, queued or undrained. The sweep of `close()` and
`closeIdleConnections()` both use it. |
| Idle tunnel | A tunnel at read EOF with nothing left to send. uws
reports it to the server through the connection filter (`-3`, `+3`,
`-4`). It still counts for `'close'`, but it does not hold the event
loop, like a libuv handle in that state. |
| Server `'close'` | One native close promise per `listen()`. `close()`
records whether its sweep left nothing open. Then a `listen()` in the
same tick cannot hold `'close'` back, as in
`net.Server._emitCloseIfDrained`. |
| Raw socket writes | While uws holds response bytes (its buffer, the
zero-copy tail of a `res.write()`, the cork buffer), a raw write goes
through `AsyncSocket::write`, the path a 1xx line takes. So the order on
the wire is the order of the calls. |
| Upgrade verdict | One scanner and one verdict, shared by the parser
and the dispatcher. |
| llhttp | Updated from 9.3.0 to 9.4.2, as Node v26.5.0 vendors it, plus
one local patch (see below). Node v26.5.1 and later vendor 9.4.3. That
update is not in this PR. |

The parser changes also tighten request framing so that it agrees with
llhttp in more cases. There is no new API surface.

### A pause holds from the next read

The copy of the rest of a read (`nodeHttpPausedSpill`), its replay from
a posted task and the nested parse are removed. Like in Node, the rest
of the read that caused a pause is still parsed, and the socket stops at
the next read. usockets reads up to 512 KB in one call. libuv reads 64
KB.

| One paused, unread request (client sends 64 MB) | Bytes held |
| --- | --- |
| Node 25.6 | 131,018 |
| This pull request | 524,234 |

The price is in one case. A client sends 512 KB of small pipelined
requests (19,418 of them) and never reads. Each handler answers with its
own 64 KB body:

| Handler | Runtime | Requests dispatched | RSS |
| --- | --- | --- | --- |
| Answers at once | Node 26.3 | 2,425 | +177 MB |
| Answers at once | main | 41 | +9 MB |
| Answers at once | This PR | 2,425 | +171 MB |
| Answers one tick later | Node 26.3 | 4,850 | +336 MB |
| Answers one tick later | main | 2,426 | +181 MB |
| Answers one tick later | This PR | 4,850 | +330 MB |

Release builds on Linux x64. This PR now does what Node does. main held
fewer responses, mostly for a handler that answers at once. On macOS one
read can return all 512 KB. There, Bun 1.4.3 already reached +951 MB for
the handler that answers one tick later, and Node reached +1,294 MB.
`server.maxRequestsPerSocket` bounds it.

### Performance

#### Responses larger than 16 KB are faster, and now faster than Node

On main, a response that did not fit the 16 KB uWS cork buffer released
the cork. After that, each piece was its own `send()`: the buffered
head, the chunk-size line, the data, the `\r\n` and the last chunk. Over
TLS, each 2-byte piece was also its own record. Two changes fix that,
for `Bun.serve` and for node:http:

| Change | Effect |
| --- | --- |
| A write that does not fit goes out with the cork buffer and its
framing in one vectored write | No copy is added. Over TLS, the records
of all the pieces share the write batch that one `SSL_write` loop
already had. |
| The cork buffer holds 128 KB, up from 16 KB. Only a write of 16 KB or
less is copied into it, as before. | Several writes in one tick go out
in one write, like in Node. A longer write still goes out without a
copy. |

The bytes on the wire are the same. The vectored write uses `sendmsg()`
with the flags that `send()` uses.

Write syscalls for one response:

| Response | Node 26.3 | main | This PR |
| --- | --- | --- | --- |
| 4 x `res.write(16 KB)` | 1 | 16 | 1 |
| 40 x `res.write(2 KB)` | 1 | 16 | 1 |
| `res.end(64 KB)` | 1 | 2 | 1 |
| 256 KB file, `.pipe(res)` | 4 | 16 | 5 |

Throughput (req/s, the mean of 2 rounds). Node v26.3.0, main
`97246d044e`, this PR `fe0ed1fbea`, with the method below:

| Case | Node | main | This PR | main / Node | PR / Node | PR / main |
| --- | --- | --- | --- | --- | --- | --- |
| http, 4 x `res.write(16 KB)` | 17,735 | 6,983 | 19,160 | 0.39x | 1.08x
| 2.74x |
| https, 4 x `res.write(16 KB)` | 11,720 | 6,110 | 15,510 | 0.52x |
1.32x | 2.54x |
| http, 40 x `res.write(2 KB)` | 9,879 | 6,140 | 14,980 | 0.62x | 1.52x
| 2.44x |
| https, 40 x `res.write(2 KB)` | 6,981 | 5,541 | 11,780 | 0.79x | 1.69x
| 2.13x |
| http, `res.end(64 KB)` | 18,535 | 16,528 | 19,889 | 0.89x | 1.07x |
1.20x |
| http, 256 KB file `.pipe(res)` | 2,684 | 2,375 | 2,719 | 0.88x | 1.01x
| 1.15x |
| https, `res.end(64 KB)` | 11,894 | 14,586 | 16,426 | 1.23x | 1.38x |
1.13x |
| http, GET hello (control) | 55,994 | 70,989 | 71,958 | 1.27x | 1.29x |
1.01x |

main was slower than Node in six of these eight cases. This PR is faster
than Node in all eight.

`Bun.serve`, measured on `a771572a8d`, before the larger cork buffer
(req/s, the mean of 2 rounds):

| Case | main | PR | Change |
| --- | --- | --- | --- |
| Direct stream, 4 x 16 KB | 6,826 | 12,479 | +83% |
| 64 KB string | 16,944 | 20,145 | +19% |
| TLS, 64 KB string | 15,045 | 16,892 | +12% |
| hello (control) | 83,957 | 83,137 | -1.0% |

These runs are on loopback, where the kernel send buffer is 2.6 MB and
the work of the receiver runs inside `send()`. That is the best case for
fewer writes. A new connection over a real network takes about 46 KB in
its first write on Linux. The rest waits in the socket buffer, as it
would after separate writes.

#### Small responses are unchanged

A small response is already one `recvfrom` and one `sendto` on both
builds. `perf` puts 66% of the time of a hello-world server in the
kernel, on both builds.

CI release builds on Linux x64: main `97246d044e` (the merge base)
against this PR `8834cd0787`. Both use the same WebKit. The server runs
on one pinned core. `oha` sends 64 connections for 5 s after a 2 s
warm-up. There are 2 rounds, and the order of the builds alternates.
"Change" compares the means of the two rounds.

Framework servers from `bun-perf-tester` (req/s):

| Server | main, round 1 | main, round 2 | PR, round 1 | PR, round 2 |
Change |
| --- | --- | --- | --- | --- | --- |
| express | 49,803 | 51,103 | 49,968 | 50,263 | -0.7% |
| fastify | 61,214 | 61,105 | 60,705 | 60,668 | -0.8% |
| node:http | 70,934 | 71,405 | 73,178 | 71,053 | +1.3% |
| elysia | 84,696 | 85,096 | 85,027 | 84,882 | +0.1% |
| `Bun.serve` | 89,020 | 89,099 | 88,168 | 88,373 | -0.9% |

node:http paths that this PR changes (req/s):

| Case | main, round 1 | main, round 2 | PR, round 1 | PR, round 2 |
Change |
| --- | --- | --- | --- | --- | --- |
| GET hello | 70,226 | 70,341 | 70,151 | 72,359 | +1.4% |
| POST, 16 KB body | 47,446 | 47,789 | 48,191 | 48,785 | +1.8% |
| 64 KB response in four writes | 6,885 | 6,894 | 6,834 | 6,868 | -0.6%
|
| Pipelined keep-alive, depth 8 | 94,063 | 93,294 | 92,909 | 93,809 |
-0.3% |

p99 latency (ms), the higher of the two rounds:

| Server | main | PR |
| --- | --- | --- |
| express | 1.94 | 1.91 |
| fastify | 1.52 | 1.55 |
| node:http | 1.11 | 1.12 |
| elysia | 0.98 | 0.96 |
| `Bun.serve` | 0.82 | 0.83 |

RSS (MB), one pass of 8 s of load:

| Server | Build | Start | Under load | 5 s idle | 15 s idle |
| --- | --- | --- | --- | --- | --- |
| express | main | 39 | 94 | 61 | 57 |
| express | PR | 40 | 92 | 60 | 57 |
| fastify | main | 41 | 91 | 58 | 55 |
| fastify | PR | 41 | 91 | 58 | 55 |
| node:http | main | 20 | 64 | 43 | 40 |
| node:http | PR | 20 | 65 | 45 | 41 |
| elysia | main | 28 | 46 | 36 | 35 |
| elysia | PR | 29 | 46 | 37 | 36 |
| `Bun.serve` | main | 14 | 30 | 22 | 22 |
| `Bun.serve` | PR | 14 | 30 | 22 | 22 |

Every change is within 2%. fastify and `Bun.serve` hello are lower in
both rounds, by about 1%. `Bun.serve` hello shows the same -1.0% in the
control row above, so a small real cost there is possible. The RSS pass
ran at the same time as the throughput runs, on other cores. The commits
after `8834cd0787` change tests and add one version check to the
node:http dispatcher. They were not measured.

### Supersedes

| Theme | Pull requests |
| --- | --- |
| Request body | #43592 #43579 #38196 #43518 #43602 #43408 #43427 #43597
#43555 #43456 #43466 |
| Tunnels | #43570 #43485. #43596 is a duplicate of #43570. |
| Parser | #43182 #43161 #43326 #43327 #40505 #43363 #42532 #42194 |
| Response write | #39386 #43371 #43548 #43499 #43496 #43464 #42008
#43549 |
| Response finish | #40351 #43021 #41822 #43473 #42068 #35207 #43425
#43503 |
| Lifecycle | #43413 #39686 #43028 #42727 #42622 #42610 #35837 #35839
#37825 #37749 #43376 #35268 |
| JS API | #41691 #41738 #38036 #42462 #36527 #39718 #37964. #42947
merged on its own. |

The close drain, `resetAndDestroy()`, the pending write callback
handling and the response `'close'` ordering come from #42622 and #42727
by @steipete. The diagnosis and the tests for the stalled `ws` writes
come from his #42610.

Not included:

| Pull request | Reason |
| --- | --- |
| #33061 | main already enforces `headersTimeout` and `requestTimeout` |
| #41672 | It makes `http.createServer({ key, cert })` stop serving TLS.
That needs a product decision. |
| #37543 | A type refactor with no tests and no user-visible change |
| #35465 | It makes `http.Server` extend `net.Server`. Only the
prototype chains were joined. The `net.Server` constructor never ran, so
`_handle` and `_connections` were `undefined`, and
`_emitCloseIfDrained()` emitted `'close'` on a listening server. The
server is backed by uWS, not `node:net`. |
| The `AutoFlusher` removal in #42622 | It makes `flushHeaders()` flush
at once. That is a performance change with no relation to the rest. |

### Tests

| Check | Result on a debug build (macOS arm64) | Head |
| --- | --- | --- |
| Every test file that this PR touches (28 files) | 1,866 pass, 2 fail.
The 2 failures are `serve.test.ts` "bounds memory when proxying ... to a
stalled client". They fail the same way on a debug build of main. |
`83af4da4a3`, run before the last commit of main came in |
| `test/js/third_party/express` (9 files) and the `body-parser` test |
299 pass, 0 fail | `83af4da4a3`, run before the last commit of main came
in |
| Node 25.6 against Bun, 32 scenarios from two scripts (event order,
framing, lifecycle) | No regression against Bun 1.4.3 | `0ff1a23f61` |
| Every vendored Node `test-http-*` and `test-https-*` file, plus the
`test-net-*` and `test-tls-*` files for pause, write, end and close |
535 of 537 exit 0. `test-http-agent-keepalive.js` and
`test-https-timeout.js` fail on that debug build. Both pass on every CI
lane. | `daee05fcfd` (before the rebase) |
| The tests that depend on what the kernel takes in one send, on Windows
Server 2019 x64 and Windows 11 arm64 | pass | `3eef223328` (x64),
`a29289bcc1` (arm64) |
| CI build 120191 (Linux, macOS and Windows, release and ASAN) | every
lane passed | `daee05fcfd` (before the rebase) |

Each new test fails on Bun 1.4.3, or on the commit before its fix for a
fault that this branch introduced.

The two tests over the limit are `node-http-connect.test.ts` ("tests
should run on bun") and `node-http-syscall-fault.test.ts` ("racing a
queued drain"). Each starts a debug subprocess that needs more than 5 s
on this machine. Both pass on CI.

### Changes in the last push

The branch is rebased on main (`daee05fcfd` was the head before). It is
now linear.

Four regressions against main, each with a test that fails without its
fix:

| Case | main | Before this push | Now (same as Node) |
| --- | --- | --- | --- |
| `emit('connection')` or http2 `allowHTTP1`: `res.end()` on a request
that nobody reads | `'end'`, `'close'` | No events | `'end'`, `'close'`
|
| The same server, an unread 32 MB body | 0 bytes held | 32 MB held | 0
bytes held |
| A NUL in a header value with `httpValidation: "relaxed"` (client,
`HTTPParser`, `emit('connection')` server) | Accepted | The process
spins forever | `HPE_INVALID_HEADER_TOKEN` |
| `Connection: close`, body in the same read as the head, a 20 KB
response before the body is read | `'end'` with an empty body | No
events on `req` | `'end'` with the body, `'close'` |
| An empty line on an idle keep-alive connection, then `server.close()`
| 0 s | About 6 s | 0 s |

| Fix | Where |
| --- | --- |
| The finish listener of a fallback connection dumps an unread request,
like Node's `resOnFinish` | `http1_server_fallback.ts` |
| llhttp patch: `llhttp__internal__c_test_lenient_flags_20` is false for
a NUL. The relaxed state does not consume a NUL, and the next state sent
it back there. 9.4.3 has the same loop. | `llhttp.c`, noted in its
`README.md` |
| A node:http socket that `onData` is parsing gets the close gate of
`onData`, also when a large write released the cork | `HttpResponse.h`
`uncorkCompletedResponse()` |
| A read that starts no message leaves an idle connection idle |
`HttpContext.h` `onData` |

The open review threads are fixed in `8834cd0787`:
`closeAllConnections()`, `Proxy-Connection: close`, five comments cut to
one line, and the test of two overlapping listeners, which now waits on
events. With the generation gate in `emitCloseServer` removed, that test
fails in both cases. `AsyncSocketData` keeps its bools together, which
takes it from 56 to 48 bytes per socket.

`http.Server` no longer extends `net.Server` (see "Not included"). The
special case for it in `Ipc.ts` is gone too. `child.send(msg,
httpServer)` still throws `ERR_INVALID_HANDLE_TYPE`, and its test stays.

<details><summary>Changes since the first revision (2988a61)</summary>

Merged with main at `c8e1f6fa5b`. The one conflict was #43708
(`req.socket` emits `'end'` and `'error'`). Its state bit
`HTTP_NODE_PEER_ENDED` moved to bit 22, because bit 19 is
`HTTP_NODE_NOTIFY_READ_PARSED` here. Its 15 tests run in
`node-http-server-abort-events.test.ts` next to the tests of this branch
(103 pass).

CI on `2988a610c` had ten red tests from four causes. They are fixed:
- `ed882e5e99`: `write()` to a response without a body (HEAD, 204) does
not wait for unsent bytes.
- `811f817704`: an idle tunnel does not hold the event loop after
`server.close()`. Four vendored Node tests timed out on every platform.
- `0697deec11`, `d428824c08`, `7aec062d14`: the write callback tests use
a body that backs up a loopback socket, and accept what Winsock does.
- `c7af1c1615`: two tests from main asserted the old `close()` contract.

Review findings, each reproduced against Node v26.3.0 and fixed with a
test that fails without the fix:
- `d4d2b783ea`, `2cc79939bb`, `45141abca9`, `9a63dbc470`, `80a23a1822`:
the idle rule. A keep-alive connection is idle again when its body ends
after its response. A connection that owes a queued pipelined response,
that still receives a request head or body, or whose response still
drains is not idle.
- `87d8904aa3`, `7eca4f7806`: `close(cb)` followed by `listen()` in the
same tick reports `'close'`, also for an https server whose only
connection was idle.
- `3fd7b25382`: a paused pipelined request behind a response that still
drains stops the connection. The first revision read 512 MiB of 512 MiB
into memory.
- `2d1a5e2d8d`, `33e4683a8a`, `539cb41eb9`, `197c7dfc29`, `0490e3540b`:
raw socket writes stay behind every unsent response byte. The cases were
a CONNECT pipelined behind a response that still drains (its `200`
landed at offset 2.6 MB of a 64 MiB body), a zero-length tunnel write
(it hung the tunnel), the 400 replies above, the zero-copy tail of a
large `res.write()`, the cork buffer, and Windows 11, where the kernel
takes the whole response and refuses the next send.
- `0abd39c133`: an upgrade from the request's `'end'` listener keeps the
body bytes out of the WebSocket. The connection closed with 1006 right
after the 101.
- `5aafc61aa6`: the callback of a small `res.write()` that the kernel
refuses at the uncork runs on the drain. Reproduced on Windows 11 only.
- `a29289bcc1`: a tunnel write that waits for a drain settles its
callbacks when the connection closes.

Known differences from Node that this PR leaves:
- A handler that calls `res.end()` and then `server.close()` closes its
keep-alive connection at once. Node waits for the `keepAliveTimeout`.
- A pipelined Upgrade behind a response that still drains is served as a
plain request.
- An `end()` on a tunnel with no write pending, while the response
before the CONNECT still drains, closes both directions after the flush.
Node half-closes.
- A raw `req.socket.write(big)` and `req.socket.end()` with no
`res.end()` sends every byte but no FIN. main loses bytes here.
- A CONNECT socket that is given back with `server.emit('connection',
socket)` answers only the first of several pipelined requests. main
answers none.
- A large write from an `'upgrade'` listener stalls while the body of
that Upgrade request is still pending. A second `listen()` on a
listening server does not throw. Both are the same on main.
- A tunnel write that fails because the client went away fails its
callbacks but emits no `'error'`. Node emits `ECONNRESET`. A new
`'error'` could end a process that has no listener for it.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 8 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/web/fetch/fetch.stream.test.ts, test/js/node/url/url.test.ts,
test/js/node/tls/tls-syscall-fault.test.ts,
test/js/node/net/node-net-server.test.ts,
test/js/node/http/node-http.test.ts,
test/js/node/http/node-http-syscall-fault.test.ts,
test/js/node/http/node-http-server-close-drain.test.ts,
test/js/node/http/node-http-connect.test.ts,
test/js/node/http/node-http-backpressure.test.ts,
test/js/node/child_process/child_process_ipc_handle.test.ts,
test/js/bun/http/serve.test.ts,
test/js/bun/http/serve-syscall-fault.test.ts,
test/js/bun/http/bun-server.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Superseded by #43557, which is merged (5d5f03f). It fixes this once for the whole node:http server and carries the tests over.

@robobun

robobun commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed on main at 5d5f03f. With server.maxHeadersCount = 3 and Content-Length as the fifth field, the native server and the server.emit("connection") path both answer 431 and emit clientError with HPE_HEADER_OVERFLOW. The handler does not run.

test/js/node/http/node-http-maxHeadersCount.test.ts passes there (68 of 68), and so do the four vendored Node tests. Nothing is left for this PR.

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.

2 participants