From 9383d833919edb84c49bb0cf3389647ddb85e302 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 18:06:23 -0700 Subject: [PATCH 01/43] http: honor --insecure-http-parser, freeze writeHead framing, drop over maxConnections Three node:http v26.3.0 compat gaps, each with the upstream test it unblocks. --insecure-http-parser was parsed by nothing: _http_common hardcoded `const insecureHTTPParser = false` behind a TODO. Add the flag, back it with a process-wide atomic set during CLI parsing, and read it through a getInsecureHTTPParser binding. The server also has to fall back to it: it passed `!!server.insecureHTTPParser`, which coerces an unset option to false and drops the flag, where Node resolves the same choice through calculateLenientFlags -> isLenient(). writeHead() has to freeze the body framing, not just the status line. Node's _storeHeader runs eagerly and picks chunked while _contentLength is still null, and a later end(chunk) cannot add a Content-Length once _header exists. Headers are rendered lazily here, so end(chunk) still has the body and derives a length Node never sends. Record the frozen choice in writeHead and honor it at render time through the existing forceChunked sentinel. Only one shape changes: `writeHead(200, {...}); end('bye')` now frames chunked, matching Node. maxConnections / 'drop' were implemented only in net.Server's JS accept path; http.Server is served by the native listener and never consulted them, so the limit did nothing and 'drop' never fired. Apply the same gate when the connection is accepted. cluster's child was also missing Node's `&& !self.dropMaxConnection` in its round-robin accept decision. Adds the three upstream tests: test-http-insecure-parser, test-http-chunk-extensions-limit, test-http-server-drop-connections-in-cluster. --- src/http/lib.rs | 17 +++ src/js/internal/cluster/child.ts | 2 +- src/js/node/_http_common.ts | 8 +- src/js/node/_http_server.ts | 47 +++++- src/runtime/cli/Arguments.rs | 7 + src/runtime/node/node_http_binding.rs | 9 ++ .../test-http-chunk-extensions-limit.js | 136 ++++++++++++++++++ .../parallel/test-http-insecure-parser.js | 36 +++++ ...http-server-drop-connections-in-cluster.js | 21 +++ 9 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 test/js/node/test/parallel/test-http-chunk-extensions-limit.js create mode 100644 test/js/node/test/parallel/test-http-insecure-parser.js create mode 100644 test/js/node/test/parallel/test-http-server-drop-connections-in-cluster.js diff --git a/src/http/lib.rs b/src/http/lib.rs index 155e4ed70ab6..c327bf683c7f 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -267,6 +267,23 @@ pub fn set_max_http_header_size(v: usize) { MAX_HTTP_HEADER_SIZE.store(v, Ordering::Relaxed); } +/// `--insecure-http-parser`: the process-wide default for node:http's +/// `insecureHTTPParser` option. Set once during single-threaded CLI parsing; +/// read from JS when node:http builds its parser leniency flags. +pub static INSECURE_HTTP_PARSER: AtomicBool = AtomicBool::new(false); + +/// Safe accessor for `INSECURE_HTTP_PARSER`. +#[inline] +pub fn insecure_http_parser() -> bool { + INSECURE_HTTP_PARSER.load(Ordering::Relaxed) +} + +/// Safe setter for `INSECURE_HTTP_PARSER` (see [`insecure_http_parser`]). +#[inline] +pub fn set_insecure_http_parser(v: bool) { + INSECURE_HTTP_PARSER.store(v, Ordering::Relaxed); +} + /// Set once during single-threaded CLI parsing; read from the HTTP thread. pub static OVERRIDDEN_DEFAULT_USER_AGENT: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new(); diff --git a/src/js/internal/cluster/child.ts b/src/js/internal/cluster/child.ts index 28aa980e08cb..f2fd8a177021 100644 --- a/src/js/internal/cluster/child.ts +++ b/src/js/internal/cluster/child.ts @@ -216,7 +216,7 @@ function onconnection(message, handle) { if (accepted && server[owner_symbol]) { const self = server[owner_symbol]; - if (self.maxConnections != null && self._connections >= self.maxConnections) { + if (self.maxConnections != null && self._connections >= self.maxConnections && !self.dropMaxConnection) { accepted = false; } } diff --git a/src/js/node/_http_common.ts b/src/js/node/_http_common.ts index d31a85a3581f..333e678911cb 100644 --- a/src/js/node/_http_common.ts +++ b/src/js/node/_http_common.ts @@ -47,9 +47,11 @@ const validateHeaderValue = (name, value) => { } }; -// TODO: TODO! -// const insecureHTTPParser = getOptionValue('--insecure-http-parser'); -const insecureHTTPParser = false; +// Node reads this as `getOptionValue('--insecure-http-parser')`. Bun parses the +// flag during CLI startup into a process-wide atomic, so this is a plain read of +// the same value. Bound directly rather than via `internal/http` to keep +// `_http_common` free of a require cycle (internal/http pulls in the http stack). +const insecureHTTPParser = $newRustFunction("node_http_binding.rs", "getInsecureHTTPParser", 0)(); const kIncomingMessage = Symbol("IncomingMessage"); const kSkipPendingData = Symbol("SkipPendingData"); diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 0b384cc006e1..de705df10669 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -8,6 +8,7 @@ const { validateHeaderName, validateHeaderValue, HTTPParser, + isLenient, } = require("node:_http_common"); const { validateObject, @@ -1171,7 +1172,11 @@ function applyServerCustomOptions(server: Server) { handle, server.requireHostHeader, true, - !!server.insecureHTTPParser, + // Node resolves this through calculateLenientFlags: an explicit + // `insecureHTTPParser` wins, `httpValidation: "insecure"` is equivalent to it, + // and otherwise the process-wide `--insecure-http-parser` decides. Coercing + // `undefined` straight to false here would drop the flag. + server.httpValidation === "insecure" || (server.insecureHTTPParser ?? isLenient()), typeof server.maxHeaderSize !== "undefined" ? server.maxHeaderSize : getMaxHTTPHeaderSize(), onServerClientError.bind(server), onServerConnection.bind(server), @@ -1217,6 +1222,29 @@ function onServerConnection(this: Server, socketHandle) { } const isTLS = !!this[tlsSymbol]; const socket = new NodeHTTPServerSocket(this, socketHandle, isTLS); + + // Node's http.Server inherits net.Server's accept path, which refuses a + // connection once maxConnections is reached and reports it as 'drop' instead + // of 'connection'. Bun's http.Server is served by the native listener and + // never goes through that path, so apply the same gate here. The socket is + // already tracked by the constructor above, hence `>` rather than Node's `>=` + // against a count that excludes the pending connection. + const maxConnections = this.maxConnections; + if (maxConnections != null && (this[kTrackedConnections]?.size ?? 0) > maxConnections) { + this[kTrackedConnections]?.delete(socket); + const data = { + localAddress: socket.localAddress, + localPort: socket.localPort, + localFamily: socket.localFamily, + remoteAddress: socket.remoteAddress, + remotePort: socket.remotePort, + remoteFamily: socket.remoteFamily, + }; + socket.destroy(); + this.emit("drop", data); + return; + } + // Node's connectionListener attaches the HTTPParser (socket.parser) before // emitting 'connection'; expose the shim here so listeners see it populated. socket.parser = createServerParserShim(socket); @@ -2324,6 +2352,8 @@ function renderNativeHeaders(res) { res[kMustCloseConnection] = true; } else if (res._removedContLen) { forceChunked = true; + } else if (res[kFramingFrozenChunked]) { + forceChunked = true; } } @@ -3531,6 +3561,9 @@ ServerResponse.prototype._send = function (data, encoding, callback, _byteLength const kSnapshotStatusCode = Symbol("kSnapshotStatusCode"); const kSnapshotStatusMessage = Symbol("kSnapshotStatusMessage"); +// Set by writeHead() when it froze the framing with no length/encoding of its +// own — the state Node's _storeHeader resolves to chunked. +const kFramingFrozenChunked = Symbol("kFramingFrozenChunked"); ServerResponse.prototype.writeHead = function (statusCode, statusMessage, headers) { if (this.headersSent) { throw $ERR_HTTP_HEADERS_SENT("writeHead"); @@ -3543,6 +3576,18 @@ ServerResponse.prototype.writeHead = function (statusCode, statusMessage, header this[kSnapshotStatusCode] = this.statusCode; this[kSnapshotStatusMessage] = this.statusMessage; + // Node's writeHead() also freezes the body framing, not just the status line: + // _storeHeader picks chunked while _contentLength is still null, and the later + // end(chunk) cannot add a Content-Length once _header exists. Rendering lazily + // means end(chunk) still has the body in hand and would derive a length Node + // never sends, so record the frozen choice here and honor it at render time. + if ( + this[kOutHeaders] === null || + (this[kOutHeaders]["content-length"] === undefined && this[kOutHeaders]["transfer-encoding"] === undefined) + ) { + this[kFramingFrozenChunked] = true; + } + this[headerStateSymbol] = NodeHTTPHeaderState.assigned; // Standalone responses (no native handle, e.g. new ServerResponse(req) + diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 14e20c0e7b5a..357bd5734e7b 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -262,6 +262,9 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!( "--max-http-header-size Set the maximum size of HTTP headers in bytes. Default is 16KiB" ), + parse_param!( + "--insecure-http-parser Use an insecure HTTP parser that accepts invalid HTTP headers" + ), parse_param!( "--dns-result-order Set the default order of DNS lookup results. Valid orders: verbatim (default), ipv4first, ipv6first" ), @@ -1059,6 +1062,10 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result JsResult { + Ok(JSValue::js_boolean(bun_http::insecure_http_parser())) +} + pub(crate) fn set_max_http_header_size( global: &JSGlobalObject, frame: &CallFrame, diff --git a/test/js/node/test/parallel/test-http-chunk-extensions-limit.js b/test/js/node/test/parallel/test-http-chunk-extensions-limit.js new file mode 100644 index 000000000000..19faddcd30f0 --- /dev/null +++ b/test/js/node/test/parallel/test-http-chunk-extensions-limit.js @@ -0,0 +1,136 @@ +'use strict'; + +const common = require('../common'); +const http = require('http'); +const net = require('net'); +const assert = require('assert'); + +// The maximum http chunk extension size is set in `src/node_http_parser.cc`. +// These tests assert that once the extension size is reached, an HTTP 413 +// response is returned. +// Currently, the max size is set to 16KiB (16384). + +// Verify that chunk extensions are limited in size when sent all together. +{ + const server = http.createServer((req, res) => { + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('bye'); + }); + + req.resume(); + }); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const sock = net.connect(port); + let data = ''; + + sock.on('data', (chunk) => data += chunk.toString('utf-8')); + + sock.on('end', common.mustCall(function() { + assert.strictEqual(data, 'HTTP/1.1 413 Payload Too Large\r\nConnection: close\r\n\r\n'); + server.close(); + })); + + sock.end('' + + 'GET / HTTP/1.1\r\n' + + `Host: localhost:${port}\r\n` + + 'Transfer-Encoding: chunked\r\n\r\n' + + '2;' + 'a'.repeat(17000) + '\r\n' + // Chunk size + chunk ext + CRLF + 'AA\r\n' + // Chunk data + '0\r\n' + // Last chunk + '\r\n' // End of http message + ); + })); +} + +// Verify that chunk extensions are limited in size when sent in parts +{ + const server = http.createServer((req, res) => { + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('bye'); + }); + + req.resume(); + }); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const sock = net.connect(port); + let data = ''; + + sock.on('data', (chunk) => data += chunk.toString('utf-8')); + + sock.on('end', common.mustCall(function() { + assert.strictEqual(data, 'HTTP/1.1 413 Payload Too Large\r\nConnection: close\r\n\r\n'); + server.close(); + })); + + sock.write('' + + 'GET / HTTP/1.1\r\n' + + `Host: localhost:${port}\r\n` + + 'Transfer-Encoding: chunked\r\n\r\n' + + '2;' // Chunk size + start of chunk-extension + ); + + sock.write('A'.repeat(8500)); // Write half of the chunk-extension + + queueMicrotask(() => { + sock.write('A'.repeat(8500) + '\r\n' + // Remaining half of the chunk-extension + 'AA\r\n' + // Chunk data + '0\r\n' + // Last chunk + '\r\n' // End of http message + ); + }); + })); +} + +// Verify the chunk extensions is correctly reset after a chunk +{ + const server = http.createServer((req, res) => { + req.on('end', () => { + res.writeHead(200, { 'content-type': 'text/plain', 'connection': 'close', 'date': 'now' }); + res.end('bye'); + }); + + req.resume(); + }); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const sock = net.connect(port); + let data = ''; + + sock.on('data', (chunk) => data += chunk.toString('utf-8')); + + sock.on('end', common.mustCall(function() { + assert.strictEqual( + data, + 'HTTP/1.1 200 OK\r\n' + + 'content-type: text/plain\r\n' + + 'connection: close\r\n' + + 'date: now\r\n' + + 'Transfer-Encoding: chunked\r\n' + + '\r\n' + + '3\r\n' + + 'bye\r\n' + + '0\r\n' + + '\r\n', + ); + + server.close(); + })); + + sock.end('' + + 'GET / HTTP/1.1\r\n' + + `Host: localhost:${port}\r\n` + + 'Transfer-Encoding: chunked\r\n\r\n' + + '2;' + 'A'.repeat(10000) + '=bar\r\nAA\r\n' + + '2;' + 'A'.repeat(10000) + '=bar\r\nAA\r\n' + + '2;' + 'A'.repeat(10000) + '=bar\r\nAA\r\n' + + '0\r\n\r\n' + ); + })); +} diff --git a/test/js/node/test/parallel/test-http-insecure-parser.js b/test/js/node/test/parallel/test-http-insecure-parser.js new file mode 100644 index 000000000000..5262c3230870 --- /dev/null +++ b/test/js/node/test/parallel/test-http-insecure-parser.js @@ -0,0 +1,36 @@ +// Flags: --insecure-http-parser + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +const server = http.createServer(common.mustCallAtLeast((req, res) => { + assert.strictEqual(req.headers['content-type'], 'text/te\bt'); + req.pipe(res); +})); + +server.listen(0, common.mustCall(function() { + const bufs = []; + const client = net.connect( + this.address().port, + function() { + client.write( + 'GET / HTTP/1.1\r\n' + + 'Content-Type: text/te\x08t\r\n' + + 'Host: example.com' + + 'Connection: close\r\n\r\n'); + } + ); + client.on('data', function(chunk) { + bufs.push(chunk); + }); + client.on('end', common.mustCall(function() { + const head = Buffer.concat(bufs) + .toString('latin1') + .split('\r\n')[0]; + assert.strictEqual(head, 'HTTP/1.1 200 OK'); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-http-server-drop-connections-in-cluster.js b/test/js/node/test/parallel/test-http-server-drop-connections-in-cluster.js new file mode 100644 index 000000000000..490dab7b1aab --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-drop-connections-in-cluster.js @@ -0,0 +1,21 @@ +'use strict'; +const common = require('../common'); +const cluster = require('cluster'); +const http = require('http'); + +if (cluster.isPrimary) { + cluster.fork(); +} else { + const server = http.createServer(); + server.maxConnections = 0; + server.dropMaxConnection = true; + // When dropMaxConnection is false, the main process will continue to + // distribute the request to the child process, if true, the child will + // close the connection directly and emit drop event. + server.on('drop', common.mustCall((a) => { + process.exit(); + })); + server.listen(common.mustCall(() => { + http.get(`http://localhost:${server.address().port}`).on('error', console.error); + })); +} From c5a99f847d3a9d1fbaf5a31808cdb8b2fda213bb Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 18:33:49 -0700 Subject: [PATCH 02/43] http: fix framing/leniency edge cases found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The writeHead framing freeze was also firing on the implicit writeHead that end(chunk) drives through callWriteHeadIfObservable. Node assigns _contentLength before _implicitHeader reaches _storeHeader, so that path still sends a Content-Length; freezing it to chunked regressed every response from an app whose writeHead is patched (the on-headers package, so compression/morgan/serve-static). Only the two end() call sites suppress the freeze — the write() paths still have no body in hand and must stay chunked. Resolve the parser leniency through the shared calculateLenientFlags instead of a hand-rolled subset, behind one serverIsLenient helper. The inline version had no 'strict' arm and let httpValidation 'strict'/'relaxed' fall through to the global flag, so `--insecure-http-parser` made a server lenient that had explicitly asked for strict. The httpAllowHalfOpen setter pushes the same native bit and was still sending `!!this.insecureHTTPParser`, silently reverting leniency to strict when toggled after listen(); it now shares the helper. The server's httpValidation list was ["default", "insecure", "relaxed"], rejecting Node's 'strict' and accepting a 'default' Node rejects. The client already had Node's list; match it. Also restore the localPort/remoteFamily fallbacks net.Server uses when building the 'drop' payload, and unexport the new atomic. --- src/http/lib.rs | 2 +- src/js/node/_http_common.ts | 6 +-- src/js/node/_http_server.ts | 73 +++++++++++++++------------ src/runtime/node/node_http_binding.rs | 2 - 4 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index c327bf683c7f..44e2ffe3f6dc 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -270,7 +270,7 @@ pub fn set_max_http_header_size(v: usize) { /// `--insecure-http-parser`: the process-wide default for node:http's /// `insecureHTTPParser` option. Set once during single-threaded CLI parsing; /// read from JS when node:http builds its parser leniency flags. -pub static INSECURE_HTTP_PARSER: AtomicBool = AtomicBool::new(false); +static INSECURE_HTTP_PARSER: AtomicBool = AtomicBool::new(false); /// Safe accessor for `INSECURE_HTTP_PARSER`. #[inline] diff --git a/src/js/node/_http_common.ts b/src/js/node/_http_common.ts index 333e678911cb..636f979eb552 100644 --- a/src/js/node/_http_common.ts +++ b/src/js/node/_http_common.ts @@ -47,10 +47,8 @@ const validateHeaderValue = (name, value) => { } }; -// Node reads this as `getOptionValue('--insecure-http-parser')`. Bun parses the -// flag during CLI startup into a process-wide atomic, so this is a plain read of -// the same value. Bound directly rather than via `internal/http` to keep -// `_http_common` free of a require cycle (internal/http pulls in the http stack). +// Node's `getOptionValue('--insecure-http-parser')`. The flag is fixed during +// CLI parsing, so reading it once here is equivalent. const insecureHTTPParser = $newRustFunction("node_http_binding.rs", "getInsecureHTTPParser", 0)(); const kIncomingMessage = Symbol("IncomingMessage"); diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 7a77647a4bbf..cf43ba5b5c2d 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -8,7 +8,7 @@ const { validateHeaderName, validateHeaderValue, HTTPParser, - isLenient, + calculateLenientFlags, } = require("node:_http_common"); const { validateObject, @@ -1172,11 +1172,7 @@ function applyServerCustomOptions(server: Server) { handle, server.requireHostHeader, true, - // Node resolves this through calculateLenientFlags: an explicit - // `insecureHTTPParser` wins, `httpValidation: "insecure"` is equivalent to it, - // and otherwise the process-wide `--insecure-http-parser` decides. Coercing - // `undefined` straight to false here would drop the flag. - server.httpValidation === "insecure" || (server.insecureHTTPParser ?? isLenient()), + serverIsLenient(server), typeof server.maxHeaderSize !== "undefined" ? server.maxHeaderSize : getMaxHTTPHeaderSize(), onServerClientError.bind(server), onServerConnection.bind(server), @@ -1192,13 +1188,21 @@ function httpAllowHalfOpenGet(this: Server) { // assigning it after listen() has to reach the native listener too. Push the flags // alone: setServerCustomOptions() would also re-register the connection filter, which // appends rather than replaces and can reallocate the vector uWS is iterating. +// Same resolution the client applies: httpValidation wins, then an explicit +// insecureHTTPParser, then the process-wide --insecure-http-parser. Coercing the +// option straight to a boolean would drop the flag. Only the lenient-headers bit +// exists natively, so every non-strict result maps to true. +function serverIsLenient(server: Server) { + return calculateLenientFlags(server.httpValidation, server.insecureHTTPParser) !== HTTPParser.kLenientNone; +} + function httpAllowHalfOpenSet(this: Server, value) { const previous = !!this[kHttpAllowHalfOpen]; this[kHttpAllowHalfOpen] = value; const next = !!value; if (previous === next) return; const handle = this[serverSymbol]; - if (handle) setServerAppFlags(handle, this.requireHostHeader, true, !!this.insecureHTTPParser, next); + if (handle) setServerAppFlags(handle, this.requireHostHeader, true, serverIsLenient(this), next); } // Node.js keeps httpAllowHalfOpen as an own enumerable property of the server. @@ -1223,22 +1227,22 @@ function onServerConnection(this: Server, socketHandle) { const isTLS = !!this[tlsSymbol]; const socket = new NodeHTTPServerSocket(this, socketHandle, isTLS); - // Node's http.Server inherits net.Server's accept path, which refuses a - // connection once maxConnections is reached and reports it as 'drop' instead - // of 'connection'. Bun's http.Server is served by the native listener and - // never goes through that path, so apply the same gate here. The socket is - // already tracked by the constructor above, hence `>` rather than Node's `>=` - // against a count that excludes the pending connection. + // Node reaches this through net.Server's accept path, which refuses the + // connection once maxConnections is reached and reports 'drop' instead of + // 'connection'. The native listener bypasses that path, so gate it here. The + // constructor above already tracked the socket, hence `>` against a count that + // includes it rather than Node's `>=` against one that does not. const maxConnections = this.maxConnections; - if (maxConnections != null && (this[kTrackedConnections]?.size ?? 0) > maxConnections) { - this[kTrackedConnections]?.delete(socket); + const tracked = this[kTrackedConnections]; + if (maxConnections != null && (tracked?.size ?? 0) > maxConnections) { + tracked?.delete(socket); const data = { localAddress: socket.localAddress, - localPort: socket.localPort, + localPort: socket.localPort || socketHandle.localPort, localFamily: socket.localFamily, remoteAddress: socket.remoteAddress, remotePort: socket.remotePort, - remoteFamily: socket.remoteFamily, + remoteFamily: socket.remoteFamily || "IPv4", }; socket.destroy(); this.emit("drop", data); @@ -2750,7 +2754,7 @@ function bufferPipelinedWrite(res, queued, chunk, encoding, callback) { } function bufferPipelinedEnd(res, queued, chunk, encoding, callback) { - callWriteHeadIfObservable(res, res[headerStateSymbol]); + callWriteHeadIfObservable(res, res[headerStateSymbol], true); if (res[headerStateSymbol] === NodeHTTPHeaderState.none) { updateHasBody(res, res.statusCode); } @@ -3186,7 +3190,7 @@ ServerResponse.prototype.end = function (chunk, encoding, callback) { } const headerState = this[headerStateSymbol]; - callWriteHeadIfObservable(this, headerState); + callWriteHeadIfObservable(this, headerState, true); const flags = handle.flags; if (!!(flags & NodeHTTPResponseFlags.closed_or_completed)) { @@ -3574,6 +3578,9 @@ const kSnapshotStatusMessage = Symbol("kSnapshotStatusMessage"); // Set by writeHead() when it froze the framing with no length/encoding of its // own — the state Node's _storeHeader resolves to chunked. const kFramingFrozenChunked = Symbol("kFramingFrozenChunked"); +// Set while end() drives an observable writeHead: Node already knows the body +// length there, so that call must not freeze the framing. +const kImplicitHeaderFromEnd = Symbol("kImplicitHeaderFromEnd"); ServerResponse.prototype.writeHead = function (statusCode, statusMessage, headers) { if (this.headersSent) { throw $ERR_HTTP_HEADERS_SENT("writeHead"); @@ -3586,15 +3593,11 @@ ServerResponse.prototype.writeHead = function (statusCode, statusMessage, header this[kSnapshotStatusCode] = this.statusCode; this[kSnapshotStatusMessage] = this.statusMessage; - // Node's writeHead() also freezes the body framing, not just the status line: - // _storeHeader picks chunked while _contentLength is still null, and the later - // end(chunk) cannot add a Content-Length once _header exists. Rendering lazily - // means end(chunk) still has the body in hand and would derive a length Node - // never sends, so record the frozen choice here and honor it at render time. - if ( - this[kOutHeaders] === null || - (this[kOutHeaders]["content-length"] === undefined && this[kOutHeaders]["transfer-encoding"] === undefined) - ) { + // Node's writeHead() freezes the body framing too, not just the status line: + // _storeHeader runs here with _contentLength still null, and a later end(chunk) + // cannot add a Content-Length once _header exists. Headers render lazily here, + // so record the frozen choice for renderNativeHeaders to honor. + if (!this[kImplicitHeaderFromEnd] && !this.hasHeader("content-length") && !this.hasHeader("transfer-encoding")) { this[kFramingFrozenChunked] = true; } @@ -3739,12 +3742,20 @@ function emitServerSocketEOFNT(self, req) { let OriginalWriteHeadFn, OriginalImplicitHeadFn; -function callWriteHeadIfObservable(self, headerState) { +function callWriteHeadIfObservable(self, headerState, fromEnd) { if ( headerState === NodeHTTPHeaderState.none && !(self.writeHead === OriginalWriteHeadFn && self._implicitHeader === OriginalImplicitHeadFn) ) { - self.writeHead(self.statusCode, self.statusMessage); + // Node's end(chunk) assigns _contentLength before _implicitHeader reaches + // _storeHeader, so this implicit call must not freeze the framing to chunked + // the way an explicit writeHead() does — the body is already in hand. + if (fromEnd) self[kImplicitHeaderFromEnd] = true; + try { + self.writeHead(self.statusCode, self.statusMessage); + } finally { + if (fromEnd) self[kImplicitHeaderFromEnd] = false; + } } } @@ -3869,7 +3880,7 @@ function storeHTTPOptions(options) { const httpValidation = options.httpValidation; if (httpValidation !== undefined) { - validateOneOf(httpValidation, "options.httpValidation", ["default", "insecure", "relaxed"]); + validateOneOf(httpValidation, "options.httpValidation", ["strict", "relaxed", "insecure"]); if (insecureHTTPParser !== undefined) { throw $ERR_INVALID_ARG_VALUE( "options.httpValidation", diff --git a/src/runtime/node/node_http_binding.rs b/src/runtime/node/node_http_binding.rs index 6c6d58acfc0e..3f2e1a681fd1 100644 --- a/src/runtime/node/node_http_binding.rs +++ b/src/runtime/node/node_http_binding.rs @@ -47,8 +47,6 @@ pub(crate) fn get_max_http_header_size( Ok(JSValue::from(bun_http::max_http_header_size())) } -/// `--insecure-http-parser`: the process-wide default for node:http's -/// `insecureHTTPParser` option (Node's `getOptionValue('--insecure-http-parser')`). pub(crate) fn get_insecure_http_parser( _global: &JSGlobalObject, _frame: &CallFrame, From df4965e7fbf2aa4295e19711d4b31479ce386bd2 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 19:15:06 -0700 Subject: [PATCH 03/43] http: run a connectionListener so emit('connection', socket) is served MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http.Server ignored any socket the native listener did not accept itself: `server.emit('connection', socket)` was a no-op, because unlike Node — which registers connectionListener in the Server constructor — nothing was listening. Node registers 1 listener, we registered 0. The parse path for this already existed. node:http2 has served arbitrary sockets this way since it needed an HTTP/1 fallback for ALPN: connectionListenerHTTP1 drives llhttp from JS over any Duplex, and createHttp1FallbackResponseHandle stands in for the native response handle, rendering the header block to the socket from the same renderNativeHeaders() output. Move both into internal/http1_server_fallback so node:http can register the same listener, skipping the sockets its native listener already owns. That fallback was also ignoring most of the server's parser configuration. It called parser.initialize(HTTPParser.REQUEST, {}) where Node passes maxHeaderSize, the lenient flags from calculateLenientFlags, and maxHeadersCount — so an http2 server with allowHTTP1 silently dropped maxHeaderSize, insecureHTTPParser and httpValidation on every fallback connection. Pass them, and set parser.socket/socket.parser like Node does. Adds test-http-generic-streams, test-http-insecure-parser-per-stream and test-http-max-header-size-per-stream. --- src/js/internal/http1_server_fallback.ts | 361 ++++++++++++++++++ src/js/node/_http_server.ts | 18 + src/js/node/http2.ts | 337 +--------------- .../parallel/test-http-generic-streams.js | 154 ++++++++ .../test-http-insecure-parser-per-stream.js | 98 +++++ .../test-http-max-header-size-per-stream.js | 83 ++++ 6 files changed, 721 insertions(+), 330 deletions(-) create mode 100644 src/js/internal/http1_server_fallback.ts create mode 100644 test/js/node/test/parallel/test-http-generic-streams.js create mode 100644 test/js/node/test/parallel/test-http-insecure-parser-per-stream.js create mode 100644 test/js/node/test/parallel/test-http-max-header-size-per-stream.js diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts new file mode 100644 index 000000000000..fa71a3b84839 --- /dev/null +++ b/src/js/internal/http1_server_fallback.ts @@ -0,0 +1,361 @@ +// The JS HTTP/1 server path: an llhttp-driven request/response cycle over an +// arbitrary Duplex, plus a stand-in for the native NodeHTTPResponse handle that +// renders the header block to the socket itself. +// +// Two consumers: node:http2's `allowHTTP1` ALPN fallback, and node:http's +// `connectionListener`, which Node registers on every http.Server so that +// `server.emit("connection", socket)` works for a socket the native listener +// never accepted. +const { STATUS_CODES } = require("internal/http"); +const { SafeSet } = require("internal/primordials"); + +const kHttp1Connections = Symbol("http1Connections"); +const kHttp1ActiveRequests = Symbol("http1ActiveRequests"); + +function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTimeout) { + const { _checkInvalidHeaderChar: checkInvalidHeaderChar } = require("node:_http_common"); + let head = null; + let headWritten = false; + let chunked = false; + let noBody = false; + let closeDelimited = false; + + function writeHeadToSocket(contentLength) { + if (headWritten) return; + headWritten = true; + const statusCode = head?.statusCode ?? 200; + let statusMessage = head?.statusMessage; + if (typeof statusMessage !== "string" || statusMessage === "") { + statusMessage = STATUS_CODES[statusCode] || "unknown"; + } + let out = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; + let hasContentLength = false; + let hasTransferEncoding = false; + let hasDate = false; + let hasConnection = false; + let hasKeepAlive = false; + const headers = head?.headers; + if (headers) { + // ServerResponse drives this handle with renderNativeHeaders(): a flat + // [name, value, name, value, ...] array with original-case names. + for (let i = 0, end = headers.length - 1; i < end; i += 2) { + const name = headers[i]; + const value = headers[i + 1]; + if (name.length === 1 && name.charCodeAt(0) === 0) { + // node:http's NUL-named framing sentinel pair (see NodeHTTP.cpp): + // value "2" = no body (HEAD), anything else = close-delimited. + if (value === "2") noBody = true; + else closeDelimited = true; + continue; + } + switch (name.toLowerCase()) { + case "content-length": + hasContentLength = true; + break; + case "transfer-encoding": + hasTransferEncoding = true; + if (String(value).toLowerCase().includes("chunked")) chunked = true; + break; + case "date": + hasDate = true; + break; + case "connection": + hasConnection = true; + break; + case "keep-alive": + hasKeepAlive = true; + break; + } + out += `${name}: ${value}\r\n`; + } + } + if (!hasContentLength && !hasTransferEncoding && !noBody && !closeDelimited) { + if (contentLength === null) { + chunked = true; + out += "Transfer-Encoding: chunked\r\n"; + } else { + out += `Content-Length: ${contentLength}\r\n`; + } + } + if (!hasDate) { + out += `Date: ${new Date().toUTCString()}\r\n`; + } + // renderNativeHeaders reports its Connection decision through the + // auto-header bits (AUTO_HEADER_* in _http_server.ts / kAutoHeader* in + // NodeHTTP.cpp); honor an explicit close (res.shouldKeepAlive = false, + // the graceful-shutdown pattern) over the parser-derived flag. + // A close-delimited response still advertises the close it performs: only + // the keep-alive line is suppressed, since the connection ends with the + // body. When the user removed the Connection header (_removedConnection), + // renderNativeHeaders sets neither bit, so nothing is written here. + const autoBits = head?.autoHeaderBits ?? 0; + if (!hasConnection) { + if ((autoBits & 4) !== 0) { + out += "Connection: close\r\n"; + } else if (!closeDelimited) { + // No close bit and no Connection pair on a close-delimited response means + // the user removed the header (Node's _removedConnection) — write none + // rather than inventing keep-alive on a connection that ends with the + // body. Every other response still advertises its connection state. + if (shouldKeepAlive) { + out += "Connection: keep-alive\r\n"; + // A user-sent Keep-Alive header (already written by the loop above) + // suppresses the auto line, like the native writeAutoHeaders. The + // bit-carried timeout wins when present; otherwise fall back to this + // handle's configured timeout, preserving pre-bits behavior. + if (!hasKeepAlive) { + const kaSecs = + (autoBits & 8) !== 0 ? head.keepAliveTimeoutSecs : Math.floor((keepAliveTimeout || 5000) / 1000); + out += `Keep-Alive: timeout=${kaSecs}\r\n`; + } + } else { + out += "Connection: close\r\n"; + } + } + } + out += "\r\n"; + socket.write(out); + } + + function toBuffer(chunk, encoding) { + if (chunk == null) return null; + if (typeof chunk === "string") return Buffer.from(chunk, encoding || "utf8"); + return chunk; + } + + function writeBody(buf) { + const length = buf ? (buf.byteLength ?? buf.length) : 0; + if (length) { + if (chunked) { + socket.write(length.toString(16) + "\r\n"); + socket.write(buf); + socket.write("\r\n"); + } else { + socket.write(buf); + } + } + return length; + } + + const handle = { + flags: 0, + ended: false, + finished: false, + aborted: false, + bufferedAmount: 0, + shouldKeepAlive, + onfinished: null, + cork(callback) { + return callback(); + }, + writeHead(statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs) { + const originalStatusCode = statusCode; + statusCode |= 0; + if (statusCode < 100 || statusCode > 999) { + throw $ERR_HTTP_INVALID_STATUS_CODE(`${originalStatusCode}`); + } + if (typeof statusMessage === "string" && checkInvalidHeaderChar(statusMessage)) { + throw $ERR_INVALID_CHAR("statusMessage"); + } + head = { statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs }; + }, + flushHeaders() { + writeHeadToSocket(null); + }, + writeHeadAndEnd( + statusCode, + statusMessage, + headers, + chunk, + encoding, + strictContentLength, + autoHeaderBits, + keepAliveTimeoutSecs, + ) { + // The native NodeHTTPResponse batches writeHead + end into one call; + // this fallback composes the same two steps. + this.writeHead(statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs); + return this.end(chunk, encoding, undefined, strictContentLength); + }, + write(chunk, encoding, _callback, _strictContentLength) { + const buf = toBuffer(chunk, encoding); + writeHeadToSocket(null); + return writeBody(buf); + }, + end(chunk, encoding, _callback, _strictContentLength) { + if (this.ended) return 0; + const buf = toBuffer(chunk, encoding); + const length = buf ? (buf.byteLength ?? buf.length) : 0; + writeHeadToSocket(length); + writeBody(buf); + // Like Node's `_hasBody && chunkedEncoding` gate: a bodiless (HEAD) + // response never writes the terminating chunk, even when the user set + // Transfer-Encoding: chunked themselves. + if (chunked && !noBody) socket.write("0\r\n\r\n"); + this.ended = true; + this.finished = true; + const onfinished = this.onfinished; + if (onfinished) { + this.onfinished = null; + onfinished(); + } + // A close-delimited body ends at EOF, so the response ends the connection. + if (closeDelimited && !socket.destroyed) { + socket.end(); + } + return length; + }, + abort() { + this.aborted = true; + if (!socket.destroyed) socket.destroy(); + }, + }; + return handle; +} + +// HTTP/1.1 fallback for Http2SecureServer with `allowHTTP1: true`: parses the +// request from the (already decrypted) TLS socket and emits 'request' with +// http.IncomingMessage / http.ServerResponse objects, like node does by routing +// the socket to the HTTP/1 connection listener. +function connectionListenerHTTP1(server, socket, options) { + const http = require("node:http"); + const { HTTPParser, prepareError, calculateLenientFlags } = require("node:_http_common"); + const { kHandle: kHttp1ResponseHandle } = require("internal/http"); + const { allMethods } = process.binding("http_parser"); + + const http1Options = options.http1Options || {}; + const IncomingMessageClass = http1Options.IncomingMessage || http.IncomingMessage; + const ServerResponseClass = http1Options.ServerResponse || http.ServerResponse; + const keepAliveTimeout = typeof server.keepAliveTimeout === "number" ? server.keepAliveTimeout : 5000; + + const connections = (server[kHttp1Connections] ??= new SafeSet()); + connections.add(socket); + socket[kHttp1ActiveRequests] = 0; + + const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0; + const kOnBody = HTTPParser.kOnBody | 0; + const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; + + // Mirror Node's connectionListenerInternal: the parser carries the server's + // header-size cap, its leniency resolution and its header-count limit. Passing + // none of these left every fallback connection on the built-in defaults, so + // maxHeaderSize / insecureHTTPParser / httpValidation / maxHeadersCount were + // silently ignored on this path. + const lenientFlags = calculateLenientFlags(server.httpValidation, server.insecureHTTPParser); + const parser = new HTTPParser(); + parser.initialize(HTTPParser.REQUEST, {}, server.maxHeaderSize || 0, lenientFlags); + parser.socket = socket; + socket.parser = parser; + if (typeof server.maxHeadersCount === "number") { + parser.maxHeaderPairs = server.maxHeadersCount << 1; + } + + let req = null; + + parser[kOnHeadersComplete] = function onHttp1HeadersComplete( + versionMajor, + versionMinor, + rawHeaders, + methodNum, + url, + _statusCode, + _statusMessage, + upgrade, + shouldKeepAlive, + ) { + socket[kHttp1ActiveRequests]++; + + req = new IncomingMessageClass(socket); + req.socket = socket; + req.httpVersionMajor = versionMajor; + req.httpVersionMinor = versionMinor; + req.httpVersion = `${versionMajor}.${versionMinor}`; + req.url = url; + req.method = typeof methodNum === "number" ? allMethods[methodNum] : methodNum; + req.upgrade = upgrade; + req._addHeaderLines(rawHeaders, rawHeaders.length); + // The body is fed by the parser callbacks below; reading just resumes the socket. + req._read = function (_size) { + if (socket.readable) socket.resume(); + }; + + const res = new ServerResponseClass(req); + const handle = createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTimeout); + handle.onfinished = function () { + socket[kHttp1ActiveRequests] = Math.max(0, (socket[kHttp1ActiveRequests] || 1) - 1); + if (!shouldKeepAlive && !socket.destroyed) { + socket.end(); + } + }; + res[kHttp1ResponseHandle] = handle; + res.assignSocket(socket); + // node's resOnFinish: release the socket once the response completes so the next + // keep-alive request's response can attach (assignSocket throws + // ERR_HTTP_SOCKET_ASSIGNED while a previous response is still assigned). + res.on("finish", function onFallbackResponseFinish() { + this.detachSocket(socket); + }); + + server.emit("request", req, res); + return 0; + }; + parser[kOnBody] = function onHttp1Body(chunk) { + if (req && !req._dumped) req.push(chunk); + }; + parser[kOnMessageComplete] = function onHttp1MessageComplete() { + if (req) { + req.complete = true; + req.push(null); + } + }; + + function onHttp1SocketError(err, rawPacket) { + // Match Node's http _connectionListener: attach err.rawPacket and, when no + // 'clientError' listener is present, write the same raw error response + // Node's socketOnError does before destroying. + prepareError(err, parser, rawPacket); + if (!server.emit("clientError", err, socket)) { + if (socket.writable && !socket.destroyed) { + const code = err?.code; + socket.write( + code === "HPE_HEADER_OVERFLOW" + ? "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n" + : "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", + "latin1", + ); + } + socket.destroy(err); + } + } + socket.on("data", data => { + const ret = parser.execute(data); + if (ret instanceof Error) { + onHttp1SocketError(ret, data); + } + }); + socket.on("error", err => onHttp1SocketError(err, undefined)); + socket.once("close", () => { + connections.delete(socket); + try { + parser.close(); + } catch {} + }); +} + +function closeIdleHttp1Connections(server) { + const connections = server[kHttp1Connections]; + if (!connections) return; + for (const socket of connections) { + if (!socket[kHttp1ActiveRequests] && !socket.destroyed) { + socket.destroy(); + } + } +} + +export default { + createHttp1FallbackResponseHandle, + connectionListenerHTTP1, + closeIdleHttp1Connections, + kHttp1Connections, + kHttp1ActiveRequests, +}; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index cf43ba5b5c2d..4c009be8887e 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -84,6 +84,7 @@ const { } = require("node:_http_outgoing"); const OutgoingMessagePrototype = OutgoingMessage.prototype; const { kIncomingMessage } = require("node:_http_common"); +const { connectionListenerHTTP1 } = require("internal/http1_server_fallback"); const kConnectionsCheckingInterval = Symbol("http.server.connectionsCheckingInterval"); const kTrackedConnections = Symbol("http.server.trackedConnections"); const kHttpAllowHalfOpen = Symbol("http.server.httpAllowHalfOpen"); @@ -296,10 +297,27 @@ function normalizeServerTls(tls) { return tls; } +// Node registers connectionListener on every http.Server, so a socket the +// listener never accepted still gets parsed when it arrives as +// `server.emit("connection", socket)` — a plain Duplex, or a socket handed over +// from another server. The native listener drives its own sockets end to end, so +// this only has to pick up the foreign ones; node:http2 runs the same path for +// its allowHTTP1 ALPN fallback. +function connectionListener(this: Server, socket) { + if (socket instanceof NodeHTTPServerSocket) return; + connectionListenerHTTP1(this, socket, { + http1Options: { + IncomingMessage: this[kIncomingMessage], + ServerResponse: this[kServerResponse], + }, + }); +} + function Server(options, callback): void { if (!(this instanceof Server)) return new Server(options, callback); EventEmitter.$call(this); this.on("listening", setupConnectionsTracking); + this.on("connection", connectionListener); this.listening = false; this._unref = false; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 5f953cf2ca04..8d29e5b88e08 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -43,8 +43,6 @@ const kDeferWriteCallback = Symbol("deferWriteCallback"); const kProxySocket = Symbol("proxySocket"); const kSessions = Symbol("sessions"); const kOptions = Symbol("options"); -const kHttp1Connections = Symbol("http1Connections"); -const kHttp1ActiveRequests = Symbol("http1ActiveRequests"); const kQuotedString = /^[\x09\x20-\x5b\x5d-\x7e\x80-\xff]*$/; const MAX_ADDITIONAL_SETTINGS = 10; const Stream = require("node:stream"); @@ -6274,334 +6272,13 @@ function closeAllSessions(server: Http2Server | Http2SecureServer) { // Minimal HTTP/1.1 response writer used by the allowHTTP1 fallback. It mimics // the surface of the native NodeHTTPResponse handle that ServerResponse drives // (cork/writeHead/write/end/abort/...), serializing directly onto the TLS socket. -function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTimeout) { - const { _checkInvalidHeaderChar: checkInvalidHeaderChar } = require("node:_http_common"); - let head = null; - let headWritten = false; - let chunked = false; - let noBody = false; - let closeDelimited = false; - - function writeHeadToSocket(contentLength) { - if (headWritten) return; - headWritten = true; - const statusCode = head?.statusCode ?? 200; - let statusMessage = head?.statusMessage; - if (typeof statusMessage !== "string" || statusMessage === "") { - statusMessage = STATUS_CODES[statusCode] || "unknown"; - } - let out = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; - let hasContentLength = false; - let hasTransferEncoding = false; - let hasDate = false; - let hasConnection = false; - let hasKeepAlive = false; - const headers = head?.headers; - if (headers) { - // ServerResponse drives this handle with renderNativeHeaders(): a flat - // [name, value, name, value, ...] array with original-case names. - for (let i = 0, end = headers.length - 1; i < end; i += 2) { - const name = headers[i]; - const value = headers[i + 1]; - if (name.length === 1 && name.charCodeAt(0) === 0) { - // node:http's NUL-named framing sentinel pair (see NodeHTTP.cpp): - // value "2" = no body (HEAD), anything else = close-delimited. - if (value === "2") noBody = true; - else closeDelimited = true; - continue; - } - switch (name.toLowerCase()) { - case "content-length": - hasContentLength = true; - break; - case "transfer-encoding": - hasTransferEncoding = true; - if (String(value).toLowerCase().includes("chunked")) chunked = true; - break; - case "date": - hasDate = true; - break; - case "connection": - hasConnection = true; - break; - case "keep-alive": - hasKeepAlive = true; - break; - } - out += `${name}: ${value}\r\n`; - } - } - if (!hasContentLength && !hasTransferEncoding && !noBody && !closeDelimited) { - if (contentLength === null) { - chunked = true; - out += "Transfer-Encoding: chunked\r\n"; - } else { - out += `Content-Length: ${contentLength}\r\n`; - } - } - if (!hasDate) { - out += `Date: ${new Date().toUTCString()}\r\n`; - } - // renderNativeHeaders reports its Connection decision through the - // auto-header bits (AUTO_HEADER_* in _http_server.ts / kAutoHeader* in - // NodeHTTP.cpp); honor an explicit close (res.shouldKeepAlive = false, - // the graceful-shutdown pattern) over the parser-derived flag. - // A close-delimited response still advertises the close it performs: only - // the keep-alive line is suppressed, since the connection ends with the - // body. When the user removed the Connection header (_removedConnection), - // renderNativeHeaders sets neither bit, so nothing is written here. - const autoBits = head?.autoHeaderBits ?? 0; - if (!hasConnection) { - if ((autoBits & 4) !== 0) { - out += "Connection: close\r\n"; - } else if (!closeDelimited) { - // No close bit and no Connection pair on a close-delimited response means - // the user removed the header (Node's _removedConnection) — write none - // rather than inventing keep-alive on a connection that ends with the - // body. Every other response still advertises its connection state. - if (shouldKeepAlive) { - out += "Connection: keep-alive\r\n"; - // A user-sent Keep-Alive header (already written by the loop above) - // suppresses the auto line, like the native writeAutoHeaders. The - // bit-carried timeout wins when present; otherwise fall back to this - // handle's configured timeout, preserving pre-bits behavior. - if (!hasKeepAlive) { - const kaSecs = - (autoBits & 8) !== 0 ? head.keepAliveTimeoutSecs : Math.floor((keepAliveTimeout || 5000) / 1000); - out += `Keep-Alive: timeout=${kaSecs}\r\n`; - } - } else { - out += "Connection: close\r\n"; - } - } - } - out += "\r\n"; - socket.write(out); - } - - function toBuffer(chunk, encoding) { - if (chunk == null) return null; - if (typeof chunk === "string") return Buffer.from(chunk, encoding || "utf8"); - return chunk; - } - - function writeBody(buf) { - const length = buf ? (buf.byteLength ?? buf.length) : 0; - if (length) { - if (chunked) { - socket.write(length.toString(16) + "\r\n"); - socket.write(buf); - socket.write("\r\n"); - } else { - socket.write(buf); - } - } - return length; - } - - const handle = { - flags: 0, - ended: false, - finished: false, - aborted: false, - bufferedAmount: 0, - shouldKeepAlive, - onfinished: null, - cork(callback) { - return callback(); - }, - writeHead(statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs) { - const originalStatusCode = statusCode; - statusCode |= 0; - if (statusCode < 100 || statusCode > 999) { - throw $ERR_HTTP_INVALID_STATUS_CODE(`${originalStatusCode}`); - } - if (typeof statusMessage === "string" && checkInvalidHeaderChar(statusMessage)) { - throw $ERR_INVALID_CHAR("statusMessage"); - } - head = { statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs }; - }, - flushHeaders() { - writeHeadToSocket(null); - }, - writeHeadAndEnd( - statusCode, - statusMessage, - headers, - chunk, - encoding, - strictContentLength, - autoHeaderBits, - keepAliveTimeoutSecs, - ) { - // The native NodeHTTPResponse batches writeHead + end into one call; - // this fallback composes the same two steps. - this.writeHead(statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs); - return this.end(chunk, encoding, undefined, strictContentLength); - }, - write(chunk, encoding, _callback, _strictContentLength) { - const buf = toBuffer(chunk, encoding); - writeHeadToSocket(null); - return writeBody(buf); - }, - end(chunk, encoding, _callback, _strictContentLength) { - if (this.ended) return 0; - const buf = toBuffer(chunk, encoding); - const length = buf ? (buf.byteLength ?? buf.length) : 0; - writeHeadToSocket(length); - writeBody(buf); - // Like Node's `_hasBody && chunkedEncoding` gate: a bodiless (HEAD) - // response never writes the terminating chunk, even when the user set - // Transfer-Encoding: chunked themselves. - if (chunked && !noBody) socket.write("0\r\n\r\n"); - this.ended = true; - this.finished = true; - const onfinished = this.onfinished; - if (onfinished) { - this.onfinished = null; - onfinished(); - } - // A close-delimited body ends at EOF, so the response ends the connection. - if (closeDelimited && !socket.destroyed) { - socket.end(); - } - return length; - }, - abort() { - this.aborted = true; - if (!socket.destroyed) socket.destroy(); - }, - }; - return handle; -} - -// HTTP/1.1 fallback for Http2SecureServer with `allowHTTP1: true`: parses the -// request from the (already decrypted) TLS socket and emits 'request' with -// http.IncomingMessage / http.ServerResponse objects, like node does by routing -// the socket to the HTTP/1 connection listener. -function connectionListenerHTTP1(server, socket, options) { - const http = require("node:http"); - const { HTTPParser, prepareError } = require("node:_http_common"); - const { kHandle: kHttp1ResponseHandle } = require("internal/http"); - const { allMethods } = process.binding("http_parser"); - - const http1Options = options.http1Options || {}; - const IncomingMessageClass = http1Options.IncomingMessage || http.IncomingMessage; - const ServerResponseClass = http1Options.ServerResponse || http.ServerResponse; - const keepAliveTimeout = typeof server.keepAliveTimeout === "number" ? server.keepAliveTimeout : 5000; - - const connections = (server[kHttp1Connections] ??= new SafeSet()); - connections.add(socket); - socket[kHttp1ActiveRequests] = 0; - - const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0; - const kOnBody = HTTPParser.kOnBody | 0; - const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; - - const parser = new HTTPParser(); - parser.initialize(HTTPParser.REQUEST, {}); - - let req = null; - - parser[kOnHeadersComplete] = function onHttp1HeadersComplete( - versionMajor, - versionMinor, - rawHeaders, - methodNum, - url, - _statusCode, - _statusMessage, - upgrade, - shouldKeepAlive, - ) { - socket[kHttp1ActiveRequests]++; - - req = new IncomingMessageClass(socket); - req.socket = socket; - req.httpVersionMajor = versionMajor; - req.httpVersionMinor = versionMinor; - req.httpVersion = `${versionMajor}.${versionMinor}`; - req.url = url; - req.method = typeof methodNum === "number" ? allMethods[methodNum] : methodNum; - req.upgrade = upgrade; - req._addHeaderLines(rawHeaders, rawHeaders.length); - // The body is fed by the parser callbacks below; reading just resumes the socket. - req._read = function (_size) { - if (socket.readable) socket.resume(); - }; - - const res = new ServerResponseClass(req); - const handle = createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTimeout); - handle.onfinished = function () { - socket[kHttp1ActiveRequests] = Math.max(0, (socket[kHttp1ActiveRequests] || 1) - 1); - if (!shouldKeepAlive && !socket.destroyed) { - socket.end(); - } - }; - res[kHttp1ResponseHandle] = handle; - res.assignSocket(socket); - // node's resOnFinish: release the socket once the response completes so the next - // keep-alive request's response can attach (assignSocket throws - // ERR_HTTP_SOCKET_ASSIGNED while a previous response is still assigned). - res.on("finish", function onFallbackResponseFinish() { - this.detachSocket(socket); - }); - - server.emit("request", req, res); - return 0; - }; - parser[kOnBody] = function onHttp1Body(chunk) { - if (req && !req._dumped) req.push(chunk); - }; - parser[kOnMessageComplete] = function onHttp1MessageComplete() { - if (req) { - req.complete = true; - req.push(null); - } - }; - - function onHttp1SocketError(err, rawPacket) { - // Match Node's http _connectionListener: attach err.rawPacket and, when no - // 'clientError' listener is present, write the same raw error response - // Node's socketOnError does before destroying. - prepareError(err, parser, rawPacket); - if (!server.emit("clientError", err, socket)) { - if (socket.writable && !socket.destroyed) { - const code = err?.code; - socket.write( - code === "HPE_HEADER_OVERFLOW" - ? "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n" - : "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", - "latin1", - ); - } - socket.destroy(err); - } - } - socket.on("data", data => { - const ret = parser.execute(data); - if (ret instanceof Error) { - onHttp1SocketError(ret, data); - } - }); - socket.on("error", err => onHttp1SocketError(err, undefined)); - socket.once("close", () => { - connections.delete(socket); - try { - parser.close(); - } catch {} - }); -} - -function closeIdleHttp1Connections(server) { - const connections = server[kHttp1Connections]; - if (!connections) return; - for (const socket of connections) { - if (!socket[kHttp1ActiveRequests] && !socket.destroyed) { - socket.destroy(); - } - } -} +const { + createHttp1FallbackResponseHandle, + connectionListenerHTTP1, + closeIdleHttp1Connections, + kHttp1Connections, + kHttp1ActiveRequests, +} = require("internal/http1_server_fallback"); function connectionListener(socket: Socket) { const options = this[bunSocketServerOptions] || {}; diff --git a/test/js/node/test/parallel/test-http-generic-streams.js b/test/js/node/test/parallel/test-http-generic-streams.js new file mode 100644 index 000000000000..1b2bc209c971 --- /dev/null +++ b/test/js/node/test/parallel/test-http-generic-streams.js @@ -0,0 +1,154 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const { duplexPair } = require('stream'); + +// Test 1: Simple HTTP test, no keep-alive. +{ + const testData = 'Hello, World!\n'; + const server = http.createServer(common.mustCall((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end(testData); + })); + + const [ clientSide, serverSide ] = duplexPair(); + server.emit('connection', serverSide); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide) + }, common.mustCall((res) => { + res.setEncoding('utf8'); + res.on('data', common.mustCall((data) => { + assert.strictEqual(data, testData); + })); + res.on('end', common.mustCall()); + })); + req.end(); +} + +// Test 2: Keep-alive for 2 requests. +{ + const testData = 'Hello, World!\n'; + const server = http.createServer(common.mustCall((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end(testData); + }, 2)); + + const [ clientSide, serverSide ] = duplexPair(); + server.emit('connection', serverSide); + + function doRequest(cb) { + const req = http.request({ + createConnection: common.mustCall(() => clientSide), + headers: { Connection: 'keep-alive' } + }, common.mustCall((res) => { + res.setEncoding('utf8'); + res.on('data', common.mustCall((data) => { + assert.strictEqual(data, testData); + })); + res.on('end', common.mustCall(cb)); + })); + req.shouldKeepAlive = true; + req.end(); + } + + doRequest(() => { + doRequest(); + }); +} + +// Test 3: Connection: close request/response with chunked +{ + const testData = 'Hello, World!\n'; + const server = http.createServer(common.mustCall((req, res) => { + req.setEncoding('utf8'); + req.resume(); + req.on('data', common.mustCall(function test3_req_data(data) { + assert.strictEqual(data, testData); + })); + req.once('end', function() { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.write(testData); + res.end(); + }); + })); + + const [ clientSide, serverSide ] = duplexPair(); + server.emit('connection', serverSide); + clientSide.on('end', common.mustCall()); + serverSide.on('end', common.mustCall()); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide), + method: 'PUT', + headers: { 'Connection': 'close' } + }, common.mustCall((res) => { + res.setEncoding('utf8'); + res.on('data', common.mustCall(function test3_res_data(data) { + assert.strictEqual(data, testData); + })); + res.on('end', common.mustCall()); + })); + req.write(testData); + req.end(); +} + +// Test 4: Connection: close request/response with Content-Length +// The same as Test 3, but with Content-Length headers +{ + const testData = 'Hello, World!\n'; + const server = http.createServer(common.mustCall((req, res) => { + assert.strictEqual(req.headers['content-length'], testData.length + ''); + req.setEncoding('utf8'); + req.on('data', common.mustCall(function test4_req_data(data) { + assert.strictEqual(data, testData); + })); + req.once('end', function() { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Length', testData.length); + res.write(testData); + res.end(); + }); + + })); + + const [ clientSide, serverSide ] = duplexPair(); + server.emit('connection', serverSide); + clientSide.on('end', common.mustCall()); + serverSide.on('end', common.mustCall()); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide), + method: 'PUT', + headers: { 'Connection': 'close' } + }, common.mustCall((res) => { + res.setEncoding('utf8'); + assert.strictEqual(res.headers['content-length'], testData.length + ''); + res.on('data', common.mustCall(function test4_res_data(data) { + assert.strictEqual(data, testData); + })); + res.on('end', common.mustCall()); + })); + req.setHeader('Content-Length', testData.length); + req.write(testData); + req.end(); +} + +// Test 5: The client sends garbage. +{ + const server = http.createServer(common.mustNotCall()); + + const [ clientSide, serverSide ] = duplexPair(); + server.emit('connection', serverSide); + + server.on('clientError', common.mustCall()); + + // Send something that is not an HTTP request. + clientSide.end( + 'I’m reading a book about anti-gravity. It’s impossible to put down!'); +} diff --git a/test/js/node/test/parallel/test-http-insecure-parser-per-stream.js b/test/js/node/test/parallel/test-http-insecure-parser-per-stream.js new file mode 100644 index 000000000000..5024b93af3d1 --- /dev/null +++ b/test/js/node/test/parallel/test-http-insecure-parser-per-stream.js @@ -0,0 +1,98 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const { duplexPair } = require('stream'); + +// Test that setting the `maxHeaderSize` option works on a per-stream-basis. + +// Test 1: The server sends an invalid header. +{ + const [ clientSide, serverSide ] = duplexPair(); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide), + insecureHTTPParser: true + }, common.mustCall((res) => { + assert.strictEqual(res.headers.hello, 'foo\x08foo'); + res.resume(); // We don’t actually care about contents. + res.on('end', common.mustCall()); + })); + req.end(); + + serverSide.resume(); // Dump the request + serverSide.end('HTTP/1.1 200 OK\r\n' + + 'Host: example.com\r\n' + + 'Hello: foo\x08foo\r\n' + + 'Content-Length: 0\r\n' + + '\r\n\r\n'); +} + +// Test 2: The same as Test 1 except without the option, to make sure it fails. +{ + const [ clientSide, serverSide ] = duplexPair(); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide) + }, common.mustNotCall()); + req.end(); + req.on('error', common.mustCall()); + + serverSide.resume(); // Dump the request + serverSide.end('HTTP/1.1 200 OK\r\n' + + 'Host: example.com\r\n' + + 'Hello: foo\x08foo\r\n' + + 'Content-Length: 0\r\n' + + '\r\n\r\n'); +} + +// Test 3: The client sends an invalid header. +{ + const testData = 'Hello, World!\n'; + const server = http.createServer( + { insecureHTTPParser: true }, + common.mustCall((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end(testData); + })); + + server.on('clientError', common.mustNotCall()); + + const [ clientSide, serverSide ] = duplexPair(); + serverSide.server = server; + server.emit('connection', serverSide); + + clientSide.write('GET / HTTP/1.1\r\n' + + 'Host: example.com\r\n' + + 'Hello: foo\x08foo\r\n' + + '\r\n\r\n'); +} + +// Test 4: The same as Test 3 except without the option, to make sure it fails. +{ + const server = http.createServer(common.mustNotCall()); + + server.on('clientError', common.mustCall()); + + const [ clientSide, serverSide ] = duplexPair(); + serverSide.server = server; + server.emit('connection', serverSide); + + clientSide.write('GET / HTTP/1.1\r\n' + + 'Host: example.com\r\n' + + 'Hello: foo\x08foo\r\n' + + '\r\n\r\n'); +} + +// Test 5: Invalid argument type +{ + assert.throws( + () => http.request({ insecureHTTPParser: 0 }, common.mustNotCall()), + common.expectsError({ + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.insecureHTTPParser" property must be of' + + ' type boolean. Received type number (0)' + }) + ); +} diff --git a/test/js/node/test/parallel/test-http-max-header-size-per-stream.js b/test/js/node/test/parallel/test-http-max-header-size-per-stream.js new file mode 100644 index 000000000000..9ef794e71838 --- /dev/null +++ b/test/js/node/test/parallel/test-http-max-header-size-per-stream.js @@ -0,0 +1,83 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const { duplexPair } = require('stream'); + +// Test that setting the `maxHeaderSize` option works on a per-stream-basis. + +// Test 1: The server sends larger headers than what would otherwise be allowed. +{ + const [ clientSide, serverSide ] = duplexPair(); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide), + maxHeaderSize: http.maxHeaderSize * 4 + }, common.mustCall((res) => { + assert.strictEqual(res.headers.hello, 'A'.repeat(http.maxHeaderSize * 3)); + res.resume(); // We don’t actually care about contents. + res.on('end', common.mustCall()); + })); + req.end(); + + serverSide.resume(); // Dump the request + serverSide.end('HTTP/1.1 200 OK\r\n' + + 'Hello: ' + 'A'.repeat(http.maxHeaderSize * 3) + '\r\n' + + 'Content-Length: 0\r\n' + + '\r\n\r\n'); +} + +// Test 2: The same as Test 1 except without the option, to make sure it fails. +{ + const [ clientSide, serverSide ] = duplexPair(); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide) + }, common.mustNotCall()); + req.end(); + req.on('error', common.mustCall()); + + serverSide.resume(); // Dump the request + serverSide.end('HTTP/1.1 200 OK\r\n' + + 'Hello: ' + 'A'.repeat(http.maxHeaderSize * 3) + '\r\n' + + 'Content-Length: 0\r\n' + + '\r\n\r\n'); +} + +// Test 3: The client sends larger headers than what would otherwise be allowed. +{ + const testData = 'Hello, World!\n'; + const server = http.createServer( + { maxHeaderSize: http.maxHeaderSize * 4 }, + common.mustCall((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end(testData); + })); + + server.on('clientError', common.mustNotCall()); + + const [ clientSide, serverSide ] = duplexPair(); + serverSide.server = server; + server.emit('connection', serverSide); + + clientSide.write('GET / HTTP/1.1\r\n' + + 'Host: example.com\r\n' + + 'Hello: ' + 'A'.repeat(http.maxHeaderSize * 3) + '\r\n' + + '\r\n\r\n'); +} + +// Test 4: The same as Test 3 except without the option, to make sure it fails. +{ + const server = http.createServer(common.mustNotCall()); + + server.on('clientError', common.mustCall()); + + const [ clientSide, serverSide ] = duplexPair(); + serverSide.server = server; + server.emit('connection', serverSide); + + clientSide.write('GET / HTTP/1.1\r\n' + + 'Hello: ' + 'A'.repeat(http.maxHeaderSize * 3) + '\r\n' + + '\r\n\r\n'); +} From 7cf0e01ec378b8fb25486ff16b08e84f6b0d81f5 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 19:24:57 -0700 Subject: [PATCH 04/43] http2: serve the HTTP/1 half of protocol autoselection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-http2-autoselect-protocol sniffs the client preface on a raw net socket and hands it to whichever server matches: h2Server.emit('connection', socket) for the h2 preface, h1Server.emit('connection', socket) otherwise. The h2 branch already worked; the HTTP/1 branch is what http.Server now answers. Read maxHeadersCount into a local — reading a property twice trips the lint rule. --- src/js/internal/http1_server_fallback.ts | 5 +- .../test-http2-autoselect-protocol.js | 72 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 test/js/node/test/parallel/test-http2-autoselect-protocol.js diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index fa71a3b84839..37c8942858c1 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -246,8 +246,9 @@ function connectionListenerHTTP1(server, socket, options) { parser.initialize(HTTPParser.REQUEST, {}, server.maxHeaderSize || 0, lenientFlags); parser.socket = socket; socket.parser = parser; - if (typeof server.maxHeadersCount === "number") { - parser.maxHeaderPairs = server.maxHeadersCount << 1; + const { maxHeadersCount } = server; + if (typeof maxHeadersCount === "number") { + parser.maxHeaderPairs = maxHeadersCount << 1; } let req = null; diff --git a/test/js/node/test/parallel/test-http2-autoselect-protocol.js b/test/js/node/test/parallel/test-http2-autoselect-protocol.js new file mode 100644 index 000000000000..abd35d4ba75a --- /dev/null +++ b/test/js/node/test/parallel/test-http2-autoselect-protocol.js @@ -0,0 +1,72 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const net = require('net'); +const http = require('http'); +const http2 = require('http2'); + +// Example test for HTTP/1 vs HTTP/2 protocol autoselection. +// Refs: https://github.com/nodejs/node/issues/34532 + +const h1Server = http.createServer(common.mustCall((req, res) => { + res.end('HTTP/1 Response'); +})); + +const h2Server = http2.createServer(common.mustCall((req, res) => { + res.end('HTTP/2 Response'); +})); + +const rawServer = net.createServer(common.mustCall(function listener(socket) { + const data = socket.read(3); + + if (!data) { // Repeat until data is available + socket.once('readable', () => listener(socket)); + return; + } + + // Put the data back, so the real server can handle it: + socket.unshift(data); + + if (data.toString('ascii') === 'PRI') { // Very dumb preface check + h2Server.emit('connection', socket); + } else { + h1Server.emit('connection', socket); + } +}, 2)); + +rawServer.listen(common.mustCall(() => { + const { port } = rawServer.address(); + + let done = 0; + { + // HTTP/2 Request + const client = http2.connect(`http://localhost:${port}`); + const req = client.request({ ':path': '/' }); + req.end(); + + let content = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => content += chunk); + req.on('end', common.mustCall(() => { + assert.strictEqual(content, 'HTTP/2 Response'); + if (++done === 2) rawServer.close(); + client.close(); + })); + } + + { + // HTTP/1 Request + http.get(`http://localhost:${port}`, common.mustCall((res) => { + let content = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => content += chunk); + res.on('end', common.mustCall(() => { + assert.strictEqual(content, 'HTTP/1 Response'); + if (++done === 2) rawServer.close(); + })); + })); + } +})); From 7540792221c0d3d8c00d883611a0f4f240b82d39 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 20:24:48 -0700 Subject: [PATCH 05/43] http: bound request headers the way llhttp does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maxHeaderSize is Node's llhttp budget, and llhttp only charges what it hands to its callbacks: on_url, then each field name and each field value. It never charges the method, " HTTP/1.1\r\n", the ": " separators or the "\r\n" line endings. uWS compared a raw offset from the start of the request line against the same number, so it rejected header blocks Node accepts — measured against the v26.3.0 binary, Node took a 16376-byte value where we stopped at 16325. Count name + value like llhttp's TrackHeader and fail at >=, seeded with the URL from the request line. Two bounds still guard raw bytes (the fallback buffer, and the in-loop check for a value whose terminator has not arrived yet); those get the framing back as explicit slack, since the framing llhttp ignores is finite — at most 4 bytes per header, and headers are capped at 200. The in-loop check skips leading OWS rather than taking the slack: llhttp is handed the value with that whitespace already removed, and a value that never terminates has to overflow exactly where llhttp would instead of waiting for more data (test-http-header-overflow). Verified against Node v26.3.0 on both the boundary and the basis: the largest accepted value now matches exactly (16376), and adding 10 filler headers shifts that boundary by 60 — 10x name+value — where counting raw bytes would shift it by 100. Adds test-http-max-http-headers. --- packages/bun-uws/src/HttpParser.h | 43 ++++- .../parallel/test-http-max-http-headers.js | 180 ++++++++++++++++++ 2 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 test/js/node/test/parallel/test-http-max-http-headers.js diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index b584d5c2489e..8ea623e21b91 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -572,6 +572,14 @@ struct HttpResponseData; bool nodeHttpSawConnectionClose = false; const size_t MAX_FALLBACK_SIZE = BUN_DEFAULT_MAX_HTTP_HEADER_SIZE; + /* maxHeaderSize bounds what llhttp counts — the URL plus each field name and + * value — but the raw block also carries framing llhttp never charges: the + * method and " HTTP/1.1\r\n", a ": " and "\r\n" per header, and the terminating + * "\r\n". Bounding raw bytes by maxHeaderSize itself would reject a request + * Node accepts, so the raw bounds get exactly that framing as slack. It stays + * finite: at most UWS_HTTP_MAX_HEADERS_COUNT headers contribute 4 bytes each, + * and a field value's raw span is already bounded by the in-loop check. */ + static constexpr size_t MAX_HEADER_FRAMING_SLACK = UWS_HTTP_MAX_HEADERS_COUNT * 4 + 64; /* Maximum size of the chunk extensions of a single chunk, matching Node's * kMaxChunkExtensionsSize in src/node_http_parser.cc (16 KiB). Enforced @@ -940,6 +948,20 @@ struct HttpResponseData; } /* No request headers found */ const char * headerStart = (headers[0].key.length() > 0) ? headers[0].key.data() : end; + (void) headerStart; + + /* llhttp — and therefore Node — bounds the header block by the bytes it hands + * to its callbacks: on_url, then each field name and field value. It does not + * charge the method, " HTTP/1.1\r\n", the ": " separators or the "\r\n" line + * endings against that budget, so counting the raw offset into the buffer + * rejects requests Node accepts. Mirror llhttp's TrackHeader: accumulate + * name + value lengths and fail once the total reaches maxHeaderSize. The + * fallback buffer keeps its own bound (maxBufferedHeaderSize below), which is + * what caps how much raw data a fragmented request may buffer. */ + uint64_t headerNread = headers[0].value.length(); + if (maxHeaderSize && headerNread >= maxHeaderSize) { + return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); + } /* Check if we can see if headers follow or not */ if (postPaddedBuffer + 2 > end) { @@ -961,7 +983,8 @@ struct HttpResponseData; preliminaryKey = postPaddedBuffer; postPaddedBuffer = consumeFieldName(postPaddedBuffer); headers->key = std::string_view(preliminaryKey, (size_t) (postPaddedBuffer - preliminaryKey)); - if(maxHeaderSize && (uintptr_t)(postPaddedBuffer - headerStart) > maxHeaderSize) { + headerNread += headers->key.length(); + if(maxHeaderSize && headerNread >= maxHeaderSize) { return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); } /* We should not accept whitespace between key and colon, so colon must foloow immediately */ @@ -1004,7 +1027,16 @@ struct HttpResponseData; } break; } - if(maxHeaderSize && (uintptr_t)(postPaddedBuffer - headerStart) > maxHeaderSize) { + /* Bound the value before its terminator is found — a value that never + * terminates must still overflow here, exactly where llhttp would, or an + * oversized unterminated header just waits for more data instead of + * failing. llhttp is handed the value with leading OWS already skipped, + * so that OWS is not charged. */ + const char *countedValueStart = preliminaryValue; + while (countedValueStart < postPaddedBuffer && isHTTPHeaderValueWhitespace((unsigned char) *countedValueStart)) { + countedValueStart++; + } + if(maxHeaderSize && headerNread + (uintptr_t)(postPaddedBuffer - countedValueStart) >= maxHeaderSize) { return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); } if (end - postPaddedBuffer < 2) { @@ -1026,7 +1058,8 @@ struct HttpResponseData; headers->value.remove_prefix(1); } - if(maxHeaderSize && (uintptr_t)(postPaddedBuffer - headerStart) > maxHeaderSize) { + headerNread += headers->value.length(); + if(maxHeaderSize && headerNread >= maxHeaderSize) { return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); } headers++; @@ -1118,7 +1151,7 @@ struct HttpResponseData; consumedTotal += consumed; /* Even if we could parse it, check for length here as well */ - const uint64_t maxBufferedHeaderSize = maxHeaderSize ? maxHeaderSize : MAX_FALLBACK_SIZE; + const uint64_t maxBufferedHeaderSize = maxHeaderSize ? (maxHeaderSize + MAX_HEADER_FRAMING_SLACK) : MAX_FALLBACK_SIZE; if (consumed > maxBufferedHeaderSize) { return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); } @@ -1340,7 +1373,7 @@ struct HttpResponseData; HttpParserResult consumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { /* The fallback buffer may not exceed the configured per-request header * limit (per-server maxHeaderSize can raise it above the default). */ - const size_t maxFallbackSize = maxHeaderSize ? (size_t) maxHeaderSize : MAX_FALLBACK_SIZE; + const size_t maxFallbackSize = maxHeaderSize ? (size_t) (maxHeaderSize + MAX_HEADER_FRAMING_SLACK) : MAX_FALLBACK_SIZE; /* This resets BloomFilter by construction, but later we also reset it again. * Optimize this to skip resetting twice (req could be made global) */ HttpRequest req; diff --git a/test/js/node/test/parallel/test-http-max-http-headers.js b/test/js/node/test/parallel/test-http-max-http-headers.js new file mode 100644 index 000000000000..67ecc8c4654b --- /dev/null +++ b/test/js/node/test/parallel/test-http-max-http-headers.js @@ -0,0 +1,180 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); +const MAX = +(process.argv[2] || 16 * 1024); // Command line option, or 16KB. + +const { getOptionValue } = require('internal/options'); + +console.log('pid is', process.pid); +console.log('max header size is', getOptionValue('--max-http-header-size')); + +// Verify that we cannot receive more than 16KB of headers. + +function once(cb) { + let called = false; + return () => { + if (!called) { + called = true; + cb(); + } + }; +} + +function finished(client, callback) { + ['abort', 'error', 'end'].forEach((e) => { + client.on(e, once(() => setImmediate(callback))); + }); +} + +function fillHeaders(headers, currentSize, valid = false) { + // `llhttp` counts actual header name/value sizes, excluding the whitespace + // and stripped chars. + // OK, Content-Length, 0, X-CRASH, aaa... + headers += 'a'.repeat(MAX - currentSize); + + // Generate valid headers + if (valid) { + headers = headers.slice(0, -1); + } + return headers + '\r\n\r\n'; +} + +function writeHeaders(socket, headers) { + const array = []; + const chunkSize = 100; + let last = 0; + + for (let i = 0; i < headers.length / chunkSize; i++) { + const current = (i + 1) * chunkSize; + array.push(headers.slice(last, current)); + last = current; + } + + // Safety check we are chunking correctly + assert.strictEqual(array.join(''), headers); + + next(); + + function next() { + if (socket.destroyed) { + console.log('socket was destroyed early, data left to write:', + array.join('').length); + return; + } + + const chunk = array.shift(); + + if (chunk) { + console.log('writing chunk of size', chunk.length); + socket.write(chunk, next); + } else { + socket.end(); + } + } +} + +function test1() { + console.log('test1'); + let headers = + 'HTTP/1.1 200 OK\r\n' + + 'Content-Length: 0\r\n' + + 'X-CRASH: '; + + // OK, Content-Length, 0, X-CRASH, aaa... + const currentSize = 2 + 14 + 1 + 7; + headers = fillHeaders(headers, currentSize); + + const server = net.createServer((sock) => { + sock.once('data', () => { + writeHeaders(sock, headers); + sock.resume(); + }); + + // The socket might error but that's ok + sock.on('error', () => {}); + }); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const client = http.get({ port: port }, common.mustNotCall()); + + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'HPE_HEADER_OVERFLOW'); + server.close(test2); + })); + })); +} + +const test2 = common.mustCall(() => { + console.log('test2'); + let headers = + 'GET / HTTP/1.1\r\n' + + 'Host: localhost\r\n' + + 'Agent: nod2\r\n' + + 'X-CRASH: '; + + // /, Host, localhost, Agent, node, X-CRASH, a... + const currentSize = 1 + 4 + 9 + 5 + 4 + 7; + headers = fillHeaders(headers, currentSize); + + const server = http.createServer(common.mustNotCall()); + + server.once('clientError', common.mustCall((err) => { + assert.strictEqual(err.code, 'HPE_HEADER_OVERFLOW'); + })); + + server.listen(0, common.mustCall(() => { + const client = net.connect(server.address().port); + client.on('connect', () => { + writeHeaders(client, headers); + client.resume(); + }); + + finished(client, common.mustCall(() => { + server.close(test3); + })); + })); +}); + +const test3 = common.mustCall(() => { + console.log('test3'); + let headers = + 'GET / HTTP/1.1\r\n' + + 'Host: localhost\r\n' + + 'Agent: nod3\r\n' + + 'X-CRASH: '; + + // /, Host, localhost, Agent, node, X-CRASH, a... + const currentSize = 1 + 4 + 9 + 5 + 4 + 7; + headers = fillHeaders(headers, currentSize, true); + + console.log('writing', headers.length); + + const server = http.createServer(common.mustCall((req, res) => { + res.end('hello from test3 server'); + server.close(); + })); + + server.on('clientError', (err) => { + console.log(err.code); + if (err.code === 'HPE_HEADER_OVERFLOW') { + console.log(err.rawPacket.toString('hex')); + } + }); + server.on('clientError', common.mustNotCall()); + + server.listen(0, common.mustCall(() => { + const client = net.connect(server.address().port); + client.on('connect', () => { + writeHeaders(client, headers); + client.resume(); + }); + + client.pipe(process.stdout); + })); +}); + +test1(); From e800d5dc7c30b3d2abd91350277902fd406c18c7 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 21:04:10 -0700 Subject: [PATCH 06/43] http: render the chunked Transfer-Encoding after Connection, like Node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's _storeHeader writes Connection (and Keep-Alive) first and the chunked Transfer-Encoding after them. We pushed Transfer-Encoding into the flat header array, which writeHead sends before writeAutoHeaders renders Date/Connection/ Keep-Alive, so it went out too early: ours: Content-Type | Transfer-Encoding | Date | Connection | Keep-Alive node: Content-Type | Date | Connection | Keep-Alive | Transfer-Encoding Carry it as an auto-header bit instead and render it last, where Node puts it, still setting HTTP_WROTE_TRANSFER_ENCODING_HEADER so uWS chunk-frames the body. Fixes five tests that were already failing before this branch: test-http-1.0, test-http-keep-alive-max-requests, test-http-keep-alive-pipeline-max-requests, test-http-server-keep-alive-defaults and test-http-server-keep-alive-max-requests-null. Four of them read like a truncated response — `"...Connection: close"` with no trailing CRLF against a /Connection: close\r\n/m regex — but nothing was truncated; the header was simply last in the block. --- src/js/node/_http_server.ts | 12 +++++++++--- src/jsc/bindings/NodeHTTP.cpp | 12 ++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 4c009be8887e..a1824d1808fe 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -2272,6 +2272,10 @@ const AUTO_HEADER_DATE = 1 << 0; const AUTO_HEADER_CONN_KEEP_ALIVE = 1 << 1; const AUTO_HEADER_CONN_CLOSE = 1 << 2; const AUTO_HEADER_KEEP_ALIVE_TIMEOUT = 1 << 3; +// Node's _storeHeader writes the chunked Transfer-Encoding after the Connection +// line, so it is rendered natively with the other auto headers rather than being +// pushed into the flat array (which goes out first). +const AUTO_HEADER_TRANSFER_ENCODING_CHUNKED = 1 << 4; // Out-parameters of renderNativeHeaders, read by its callers in the same // tick (no JS can run in between). let renderedAutoHeaders = 0; @@ -2445,9 +2449,11 @@ function renderNativeHeaders(res) { // response (it is not a real header). flat.push("\u0000", "1"); } else if (forceChunked) { - // The user removed Content-Length (only): advertise chunked so the native - // side frames the body instead of auto-writing the removed header back. - flat.push("Transfer-Encoding", "chunked"); + // Advertise chunked so the native side frames the body instead of + // auto-writing a Content-Length. Not pushed into the flat array: Node's + // _storeHeader emits this after the Connection line, and the flat array is + // written before the auto headers. + autoHeaders |= AUTO_HEADER_TRANSFER_ENCODING_CHUNKED; } } catch (e) { // String(value) above can run user toString() that throws; release the diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 9baf0b8d436c..93755843998c 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -856,6 +856,11 @@ static constexpr uint32_t kAutoHeaderDate = 1 << 0; static constexpr uint32_t kAutoHeaderConnKeepAlive = 1 << 1; static constexpr uint32_t kAutoHeaderConnClose = 1 << 2; static constexpr uint32_t kAutoHeaderKeepAliveTimeout = 1 << 3; +// Node's _storeHeader emits the chunked Transfer-Encoding *after* the Connection +// (and Keep-Alive) line, so it cannot ride along in the flat header array, which +// is written before these. Carry it as an auto-header bit instead and render it +// last, in Node's order. +static constexpr uint32_t kAutoHeaderTransferEncodingChunked = 1 << 4; // "Date: \r\n", rebuilt at most once per second. Hand-rolled // (not strftime) so the day/month names are locale-independent. @@ -914,6 +919,13 @@ static void writeAutoHeaders(uWS::HttpResponse* response, uint32_t autoHe static constexpr const char cl[] = "Connection: close\r\n"; response->uWS::template AsyncSocket::write(cl, sizeof(cl) - 1); } + if (autoHeaderBits & kAutoHeaderTransferEncodingChunked) { + static constexpr const char te[] = "Transfer-Encoding: chunked\r\n"; + response->uWS::template AsyncSocket::write(te, sizeof(te) - 1); + // Same state the flat-array path sets when it sees the header, so uWS + // chunk-frames the body. + response->getHttpResponseData()->state |= uWS::HttpResponseData::HTTP_WROTE_TRANSFER_ENCODING_HEADER; + } } // Returns false when a JS exception is pending (header conversion or From 1d5e39f4ef486e0d7e1748607ba56a5ee53c7bc3 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 21:41:38 -0700 Subject: [PATCH 07/43] test: the 200 after a 304 is chunked, like Node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This test pinned `Content-Length: 5` for `writeHead(200); end("hello")`, which was our own divergence — Node freezes the framing in writeHead() while _contentLength is still null and sends Transfer-Encoding: chunked. Now that we match, the test's wait condition (`data.endsWith("hello")`) can never fire, because a chunked body ends with the terminator, so it timed out instead of failing. Assert the bytes Node actually sends, verified against the v26.3.0 binary: HTTP/1.1 200 OK ... Transfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n The point of the test is unchanged: the 200 that follows a 304 on a reused socket still carries framing and a body, so the per-request reset really does clear the no-body flag. The sibling 204 / HEAD tests keep Content-Length — those responses have no body, and the freeze deliberately skips them. --- test/js/node/http/node-http.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 75e98a16c786..71c4d974fba2 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -2518,7 +2518,9 @@ it("ClientRequest.destroy(err) with a throwing error listener still tears down; it("keep-alive socket reused after a 304 response still frames the next response body", async () => { // The native per-request reset must clear the 204/304 no-body flag, or the // 200 that follows a 304 on the same connection is sent with no framing - // and no body. + // and no body. That 200 is chunked rather than Content-Length: writeHead() + // freezes the framing while _contentLength is still null, which is what Node + // sends here too (verified against the v26.3.0 binary). const server = createServer((req, res) => { if (req.url === "/cached") { res.writeHead(304); @@ -2543,7 +2545,7 @@ it("keep-alive socket reused after a 304 response still frames the next response sentSecond = true; socket.write("GET /fresh HTTP/1.1\r\nHost: localhost\r\n\r\n"); } - if (sentSecond && data.endsWith("hello")) { + if (sentSecond && data.endsWith("0\r\n\r\n")) { socket.end(); resolve(data); } @@ -2555,8 +2557,8 @@ it("keep-alive socket reused after a 304 response still frames the next response expect(out).toContain("HTTP/1.1 304"); const second = out.slice(out.indexOf("HTTP/1.1 200")); expect(second).toContain("HTTP/1.1 200"); - expect(second).toContain("Content-Length: 5"); - expect(second).toEndWith("\r\n\r\nhello"); + expect(second).toContain("Transfer-Encoding: chunked"); + expect(second).toEndWith("\r\n\r\n5\r\nhello\r\n0\r\n\r\n"); } finally { server.close(); } From 013e52f91178ccffa0cecc9cef54ff432d0768aa Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 11:28:40 -0700 Subject: [PATCH 08/43] http: honor the chunked Transfer-Encoding bit in the JS fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving Transfer-Encoding out of the flat header array and onto an auto-header bit left the JS HTTP/1 fallback behind: it detects chunking by scanning the flat array for the header name, so it stopped seeing it and framed with Content-Length instead. That hit both consumers — http2's allowHTTP1 responses and any socket http.Server serves through connectionListener. Read the bit where the framing is decided, so it suppresses the Content-Length this path would otherwise invent, and render the header last, next to the Connection line, like the native writeAutoHeaders and Node's _storeHeader. Emitting both a Content-Length and a chunked Transfer-Encoding would be a smuggling shape (RFC 9112 6.1), not just a cosmetic difference. The fallback's response for `writeHead(200); end("hello")` is now byte-identical to Node v26.3.0's. --- src/js/internal/http1_server_fallback.ts | 18 ++++++++++++++++-- test/js/node/http/node-http.test.ts | 12 +++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 37c8942858c1..c79c45993101 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -69,8 +69,19 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim out += `${name}: ${value}\r\n`; } } + // renderNativeHeaders carries its framing and Connection decisions in the + // auto-header bits (AUTO_HEADER_* in _http_server.ts / kAutoHeader* in + // NodeHTTP.cpp) rather than the flat array. + const autoBits = head?.autoHeaderBits ?? 0; + // Node emits the chunked Transfer-Encoding after the Connection line, so the + // bit is rendered further down — but the framing is decided here, and it has + // to suppress the Content-Length this block would otherwise invent. Writing + // both is a smuggling shape (RFC 9112 6.1), not a cosmetic slip. + const chunkedFromAutoBits = (autoBits & 16) !== 0; if (!hasContentLength && !hasTransferEncoding && !noBody && !closeDelimited) { - if (contentLength === null) { + if (chunkedFromAutoBits) { + chunked = true; + } else if (contentLength === null) { chunked = true; out += "Transfer-Encoding: chunked\r\n"; } else { @@ -88,7 +99,6 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim // the keep-alive line is suppressed, since the connection ends with the // body. When the user removed the Connection header (_removedConnection), // renderNativeHeaders sets neither bit, so nothing is written here. - const autoBits = head?.autoHeaderBits ?? 0; if (!hasConnection) { if ((autoBits & 4) !== 0) { out += "Connection: close\r\n"; @@ -113,6 +123,10 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim } } } + // Last, where Node's _storeHeader puts it — after Connection/Keep-Alive. + if (chunkedFromAutoBits && chunked) { + out += "Transfer-Encoding: chunked\r\n"; + } out += "\r\n"; socket.write(out); } diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 71c4d974fba2..eb78cb92a012 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -2882,6 +2882,8 @@ it("standalone ServerResponse discards body writes to a no-body response without it("flushHeaders on a 204 response carries no chunked framing", async () => { // noBodyStatus must suppress the Transfer-Encoding header in flushHeaders() + // The /second GET is chunked, not Content-Length: writeHead() freezes the + // framing while _contentLength is still null, exactly as Node does. // and the terminating chunk in internalEnd(), like the one-shot end() path. const server = createServer((req, res) => { if (req.url === "/nobody") { @@ -2908,7 +2910,7 @@ it("flushHeaders on a 204 response carries no chunked framing", async () => { sentSecond = true; socket.write("GET /second HTTP/1.1\r\nHost: localhost\r\n\r\n"); } - if (sentSecond && data.endsWith("hello")) { + if (sentSecond && data.endsWith("0\r\n\r\n")) { socket.end(); resolve(data); } @@ -2923,8 +2925,8 @@ it("flushHeaders on a 204 response carries no chunked framing", async () => { expect(first).not.toContain("0\r\n\r\n"); // The keep-alive connection still serves the next request correctly. const second = out.slice(out.indexOf("HTTP/1.1 200")); - expect(second).toContain("Content-Length: 5"); - expect(second).toEndWith("\r\n\r\nhello"); + expect(second).toContain("Transfer-Encoding: chunked"); + expect(second).toEndWith("\r\n\r\n5\r\nhello\r\n0\r\n\r\n"); } finally { server.close(); } @@ -3216,7 +3218,7 @@ it("HEAD response with explicit writeHead(200) carries no body bytes", async () sentSecond = true; socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); } - if (sentSecond && data.endsWith("hello")) { + if (sentSecond && data.endsWith("0\r\n\r\n")) { socket.end(); resolve(data); } @@ -3229,7 +3231,7 @@ it("HEAD response with explicit writeHead(200) carries no body bytes", async () expect(first).toStartWith("HTTP/1.1 200"); // No body on the HEAD response; the GET on the same connection has one. expect(first).toEndWith("\r\n\r\n"); - expect(out).toEndWith("\r\n\r\nhello"); + expect(out).toEndWith("\r\n\r\n5\r\nhello\r\n0\r\n\r\n"); } finally { server.close(); } From a733f96516f5f230fde94d8d113c8b66beaa06cf Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 11:51:57 -0700 Subject: [PATCH 09/43] http: charge a header value's trailing whitespace, like llhttp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accumulator added the value length after the OWS trims, so a value padded with trailing spaces was undercharged and a header block Node answers with 431 was accepted. llhttp charges the value as it hands it to on_header_value: leading OWS skipped, trailing OWS still counted. Measure it there instead. Verified against the v26.3.0 binary with 400 trailing spaces per header: both now flip at the same request — 40 headers 200 OK, 41 headers 431. Also set socket.server in the HTTP/1 listener, which Node's connectionListenerInternal does so a handler can reach the server through req.socket.server, and drop the request-line pointer the accumulator replaced. --- packages/bun-uws/src/HttpParser.h | 15 ++++++++++----- src/js/internal/http1_server_fallback.ts | 4 ++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 8ea623e21b91..9cce3148284d 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -946,10 +946,6 @@ struct HttpResponseData; if(requestLineResult.isConnect) { isConnectRequest = true; } - /* No request headers found */ - const char * headerStart = (headers[0].key.length() > 0) ? headers[0].key.data() : end; - (void) headerStart; - /* llhttp — and therefore Node — bounds the header block by the bytes it hands * to its callbacks: on_url, then each field name and field value. It does not * charge the method, " HTTP/1.1\r\n", the ": " separators or the "\r\n" line @@ -1047,6 +1043,15 @@ struct HttpResponseData; if (postPaddedBuffer[1] == '\n') { /* Store this header, it is valid */ headers->value = std::string_view(preliminaryValue, (size_t) (postPaddedBuffer - preliminaryValue)); + /* Charge the value the way llhttp hands it to on_header_value: leading + * OWS skipped, trailing OWS still counted. Measure before the trims + * below, or a value padded with trailing spaces is undercharged and we + * accept a header block Node answers with 431. */ + const char *chargedValueStart = preliminaryValue; + while (chargedValueStart < postPaddedBuffer && isHTTPHeaderValueWhitespace((unsigned char) *chargedValueStart)) { + chargedValueStart++; + } + const size_t chargedValueLength = (size_t) (postPaddedBuffer - chargedValueStart); postPaddedBuffer += 2; /* Trim trailing whitespace (SP, HTAB) per RFC 9110 Section 5.5 */ while (headers->value.length() && isHTTPHeaderValueWhitespace(headers->value.back())) { @@ -1058,7 +1063,7 @@ struct HttpResponseData; headers->value.remove_prefix(1); } - headerNread += headers->value.length(); + headerNread += chargedValueLength; if(maxHeaderSize && headerNread >= maxHeaderSize) { return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); } diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index c79c45993101..8b947c84f9e2 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -242,6 +242,10 @@ function connectionListenerHTTP1(server, socket, options) { const ServerResponseClass = http1Options.ServerResponse || http.ServerResponse; const keepAliveTimeout = typeof server.keepAliveTimeout === "number" ? server.keepAliveTimeout : 5000; + // Node's connectionListenerInternal sets this so handlers can reach the server + // through req.socket.server (nodejs/node#13435). + socket.server = server; + const connections = (server[kHttp1Connections] ??= new SafeSet()); connections.add(socket); socket[kHttp1ActiveRequests] = 0; From d0c03546d960581683cea02b64688beeadb0be3d Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 12:58:47 -0700 Subject: [PATCH 10/43] http: split parser leniency into llhttp's two bits The server passed native a single "insecure" bool, so httpValidation "relaxed" and "insecure" were indistinguishable past the JS layer. llhttp separates them: LENIENT_HEADERS relaxes control bytes in field values, LENIENT_TRANSFER_ENCODING accepts a chunked coding with another value after it (e.g. a duplicate Transfer-Encoding: chunked header). Node's "relaxed" enables only the former; relaxing Transfer-Encoding for it would open request smuggling on a mode whose contract is header values only. Thread a two-bit leniency field through setServerCustomOptions/setServerAppFlags -> Server__setAppFlags -> uws_app_set_flags -> App::setFlags -> HttpContextData -> the parser: bit 0 is the existing lenient-headers behaviour, set for both relaxed and insecure; bit 1 is lenient transfer-encoding, set only for the kLenientAll surface (insecureHTTPParser / httpValidation "insecure" / --insecure-http-parser). The Transfer-Encoding + Content-Length conflict is still rejected under both, matching llhttp, which does not relax it. Adds test-http-header-value-relaxed, which asserts exactly this split: relaxed still answers a duplicate Transfer-Encoding with 400 while insecure accepts it, and both accept control bytes in header values. --- packages/bun-uws/src/App.h | 7 +- packages/bun-uws/src/HttpContext.h | 2 +- packages/bun-uws/src/HttpContextData.h | 15 +- packages/bun-uws/src/HttpParser.h | 18 +- src/js/internal/http.ts | 4 +- src/js/node/_http_server.ts | 19 +- src/jsc/bindings/NodeHTTP.cpp | 10 +- src/runtime/server/mod.rs | 4 +- src/runtime/server/server_body.rs | 14 +- src/uws_sys/App.rs | 6 +- src/uws_sys/libuwsockets.cpp | 6 +- .../test-http-header-value-relaxed.js | 429 ++++++++++++++++++ 12 files changed, 493 insertions(+), 41 deletions(-) create mode 100644 test/js/node/test/parallel/test-http-header-value-relaxed.js diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 2a2c5c6a5250..30143aca3efd 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -772,10 +772,13 @@ struct TemplatedApp { return std::move(*this); } - TemplatedApp &&setFlags(bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, bool httpAllowHalfOpen) { + /* lenientHttpFlags: bit 0 = lenient header values (llhttp LENIENT_HEADERS), + * bit 1 = lenient transfer-encoding (llhttp LENIENT_TRANSFER_ENCODING). */ + TemplatedApp &&setFlags(bool requireHostHeader, bool useStrictMethodValidation, uint8_t lenientHttpFlags, bool httpAllowHalfOpen) { httpContext->getSocketContextData()->flags.requireHostHeader = requireHostHeader; httpContext->getSocketContextData()->flags.useStrictMethodValidation = useStrictMethodValidation; - httpContext->getSocketContextData()->flags.useInsecureHTTPParser = useInsecureHTTPParser; + httpContext->getSocketContextData()->flags.useInsecureHTTPParser = (lenientHttpFlags & 1) != 0; + httpContext->getSocketContextData()->flags.useLenientTransferEncoding = (lenientHttpFlags & 2) != 0; httpContext->getSocketContextData()->flags.httpAllowHalfOpen = httpAllowHalfOpen; return std::move(*this); } diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 86fff4f1bda2..9adb0c90524a 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -339,7 +339,7 @@ struct HttpContext { nodeHttpChunkedExtensionsByteCount = &nodeHttpResponseData->chunkedExtensionsByteCount; } - auto result = httpResponseData->template consumePostPadded(httpContextData->maxHeaderSize, httpResponseData->isConnectRequest, httpContextData->flags.requireHostHeader,httpContextData->flags.useStrictMethodValidation, httpContextData->flags.useInsecureHTTPParser, nodeHttpRequestTrailers, nodeHttpChunkedExtensionsByteCount, data, (unsigned int) length, s, proxyParser, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { + auto result = httpResponseData->template consumePostPadded(httpContextData->maxHeaderSize, httpResponseData->isConnectRequest, httpContextData->flags.requireHostHeader,httpContextData->flags.useStrictMethodValidation, httpContextData->flags.useInsecureHTTPParser, httpContextData->flags.useLenientTransferEncoding, nodeHttpRequestTrailers, nodeHttpChunkedExtensionsByteCount, data, (unsigned int) length, s, proxyParser, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { /* For every request we reset the timeout and hang until user makes action */ diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index 0abbd62c0689..f52be50f7c1d 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -34,12 +34,17 @@ struct HttpFlags { bool requireHostHeader: 1 = true; bool isAuthorized: 1 = false; bool useStrictMethodValidation: 1 = false; - /* node:http insecureHTTPParser server option. NOTE: unlike Node's server - * (which fans kLenientAll out to all 10 llhttp lenient setters), the uWS - * parser only implements the LENIENT_HEADERS bit (control bytes accepted - * in field values); TE+CL conflict, chunked-size/CRLF strictness, version - * and header-token checks are still enforced. */ + /* node:http parser leniency. Two of llhttp's lenient bits are implemented: + * useInsecureHTTPParser is LENIENT_HEADERS (control bytes accepted in field + * values) — set for both httpValidation "relaxed" and "insecure" — + * and useLenientTransferEncoding is LENIENT_TRANSFER_ENCODING (a chunked + * coding with another value after it, e.g. a duplicate Transfer-Encoding: + * chunked header, is accepted) — set only for "insecure" / + * --insecure-http-parser, never for "relaxed", which must relax header + * values alone. The TE+CL conflict, chunked-size/CRLF strictness, version + * and header-token checks are still enforced under both. */ bool useInsecureHTTPParser: 1 = false; + bool useLenientTransferEncoding: 1 = false; /* node:http server.httpAllowHalfOpen: when true, a peer FIN with in-flight * or queued responses keeps the connection open until they drain (Node's * socketOnEnd); when false (the default), the connection ends right away. */ diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 9cce3148284d..afbc6389f54f 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -1100,7 +1100,7 @@ struct HttpResponseData; /* This is the only caller of getHeaders and is thus the deepest part of the parser. */ template - HttpParserResult fenceAndConsumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, void *reserved, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { + HttpParserResult fenceAndConsumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, void *reserved, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { /* How much data we CONSUMED (to throw away) */ unsigned int consumedTotal = 0; @@ -1218,6 +1218,16 @@ struct HttpResponseData; bool deferredTransferEncodingError = IsNodeHttp && transferEncoding.has && !transferEncoding.invalid && !transferEncoding.chunked && !contentLengthStringLen; + /* llhttp's LENIENT_TRANSFER_ENCODING (part of Node's kLenientAll — the + * insecureHTTPParser / httpValidation: "insecure" surface, never + * "relaxed") accepts a chunked coding with another value after it, + * e.g. a duplicate Transfer-Encoding: chunked header. It does not + * relax the Transfer-Encoding + Content-Length conflict, so only the + * coding-shape verdict is cleared; the conflicts folded in below + * still reject. */ + if (useLenientTransferEncoding) { + transferEncoding.invalid = false; + } transferEncoding.invalid = transferEncoding.invalid || (transferEncoding.has && (contentLengthStringLen || !transferEncoding.chunked)); if (transferEncoding.invalid && !deferredTransferEncodingError) [[unlikely]] { @@ -1375,7 +1385,7 @@ struct HttpResponseData; public: template - HttpParserResult consumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { + HttpParserResult consumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool useInsecureHTTPParser, bool useLenientTransferEncoding, std::string *nodeHttpRequestTrailers, uint64_t *chunkedExtensionsByteCount, char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { /* The fallback buffer may not exceed the configured per-request header * limit (per-server maxHeaderSize can raise it above the default). */ const size_t maxFallbackSize = maxHeaderSize ? (size_t) (maxHeaderSize + MAX_HEADER_FRAMING_SLACK) : MAX_FALLBACK_SIZE; @@ -1449,7 +1459,7 @@ struct HttpResponseData; fallback.append(data, maxCopyDistance); // break here on break - HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, isConnectRequest, requireHostHeader, useStrictMethodValidation, useInsecureHTTPParser, nodeHttpRequestTrailers, chunkedExtensionsByteCount, fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, isConnectRequest, requireHostHeader, useStrictMethodValidation, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error */ if (consumed.returnedData != user) { return consumed; @@ -1529,7 +1539,7 @@ struct HttpResponseData; } } - HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, isConnectRequest, requireHostHeader, useStrictMethodValidation, useInsecureHTTPParser, nodeHttpRequestTrailers, chunkedExtensionsByteCount, data, length, user, reserved, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, isConnectRequest, requireHostHeader, useStrictMethodValidation, useInsecureHTTPParser, useLenientTransferEncoding, nodeHttpRequestTrailers, chunkedExtensionsByteCount, data, length, user, reserved, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error */ if (consumed.returnedData != user) { return consumed; diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index f8e26db96fad..0631e199df5b 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -25,7 +25,7 @@ const { server: any, requireHostHeader: boolean, useStrictMethodValidation: boolean, - insecureHTTPParser: boolean, + lenientHttpFlags: number, maxHeaderSize: number, onClientError: (ssl: boolean, socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, onConnection?: (socketHandle: any) => undefined, @@ -34,7 +34,7 @@ const { server: any, requireHostHeader: boolean, useStrictMethodValidation: boolean, - insecureHTTPParser: boolean, + lenientHttpFlags: number, httpAllowHalfOpen: boolean, ) => void; getCompleteWebRequestOrResponseBodyValueAsArrayBuffer: (arg: any) => ArrayBuffer | undefined; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index a1824d1808fe..a44a671e5bc5 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -1190,7 +1190,7 @@ function applyServerCustomOptions(server: Server) { handle, server.requireHostHeader, true, - serverIsLenient(server), + serverLenientFlags(server), typeof server.maxHeaderSize !== "undefined" ? server.maxHeaderSize : getMaxHTTPHeaderSize(), onServerClientError.bind(server), onServerConnection.bind(server), @@ -1207,11 +1207,16 @@ function httpAllowHalfOpenGet(this: Server) { // alone: setServerCustomOptions() would also re-register the connection filter, which // appends rather than replaces and can reallocate the vector uWS is iterating. // Same resolution the client applies: httpValidation wins, then an explicit -// insecureHTTPParser, then the process-wide --insecure-http-parser. Coercing the -// option straight to a boolean would drop the flag. Only the lenient-headers bit -// exists natively, so every non-strict result maps to true. -function serverIsLenient(server: Server) { - return calculateLenientFlags(server.httpValidation, server.insecureHTTPParser) !== HTTPParser.kLenientNone; +// insecureHTTPParser, then the process-wide --insecure-http-parser. Native +// implements two of llhttp's lenient bits, addressed here as bit 0 = lenient +// header values (LENIENT_HEADERS) and bit 1 = lenient transfer-encoding +// (LENIENT_TRANSFER_ENCODING). "relaxed" relaxes header values only; the full +// kLenientAll surface (insecureHTTPParser / httpValidation: "insecure" / +// --insecure-http-parser) gets both. +function serverLenientFlags(server: Server) { + const lenient = calculateLenientFlags(server.httpValidation, server.insecureHTTPParser); + if (lenient === HTTPParser.kLenientNone) return 0; + return lenient === HTTPParser.kLenientAll ? 0b11 : 0b01; } function httpAllowHalfOpenSet(this: Server, value) { @@ -1220,7 +1225,7 @@ function httpAllowHalfOpenSet(this: Server, value) { const next = !!value; if (previous === next) return; const handle = this[serverSymbol]; - if (handle) setServerAppFlags(handle, this.requireHostHeader, true, serverIsLenient(this), next); + if (handle) setServerAppFlags(handle, this.requireHostHeader, true, serverLenientFlags(this), next); } // Node.js keeps httpAllowHalfOpen as an own enumerable property of the server. diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 93755843998c..e3383c600b41 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -38,7 +38,7 @@ extern "C" void Request__setInternalEventCallback(void*, EncodedJSValue, JSC::JS extern "C" void Request__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" bool NodeHTTPResponse__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" void Server__setIdleTimeout(EncodedJSValue, EncodedJSValue, JSC::JSGlobalObject*); -extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, bool use_strict_method_validation, bool use_insecure_http_parser, bool http_allow_half_open); +extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, bool use_strict_method_validation, uint8_t lenient_http_flags, bool http_allow_half_open); extern "C" EncodedJSValue Server__setOnClientError(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); extern "C" EncodedJSValue Server__setOnConnection(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); extern "C" EncodedJSValue Server__setMaxHTTPHeaderSize(JSC::JSGlobalObject*, EncodedJSValue, uint64_t); @@ -1275,7 +1275,7 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, JSValue serverValue = callFrame->uncheckedArgument(0); JSValue requireHostHeader = callFrame->uncheckedArgument(1); JSValue useStrictMethodValidation = callFrame->uncheckedArgument(2); - JSValue useInsecureHTTPParser = callFrame->uncheckedArgument(3); + JSValue lenientHttpFlags = callFrame->uncheckedArgument(3); JSValue maxHeaderSize = callFrame->uncheckedArgument(4); JSValue callback = callFrame->uncheckedArgument(5); JSValue onConnectionCallback = callFrame->argument(6); @@ -1284,7 +1284,7 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, double maxHeaderSizeNumber = maxHeaderSize.toNumber(globalObject); RETURN_IF_EXCEPTION(scope, {}); - Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), useInsecureHTTPParser.toBoolean(globalObject), httpAllowHalfOpen.toBoolean(globalObject)); + Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientHttpFlags.toInt32(globalObject) & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); RETURN_IF_EXCEPTION(scope, {}); Server__setMaxHTTPHeaderSize(globalObject, JSValue::encode(serverValue), maxHeaderSizeNumber); @@ -1313,10 +1313,10 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetAppFlags, (JSGlobalObject * globalObject, Call JSValue serverValue = callFrame->uncheckedArgument(0); JSValue requireHostHeader = callFrame->uncheckedArgument(1); JSValue useStrictMethodValidation = callFrame->uncheckedArgument(2); - JSValue useInsecureHTTPParser = callFrame->uncheckedArgument(3); + JSValue lenientHttpFlags = callFrame->uncheckedArgument(3); JSValue httpAllowHalfOpen = callFrame->argument(4); - Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), useInsecureHTTPParser.toBoolean(globalObject), httpAllowHalfOpen.toBoolean(globalObject)); + Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientHttpFlags.toInt32(globalObject) & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 9e3f744692bb..acfd16e067de 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1476,7 +1476,7 @@ impl NewServer { &mut self, require_host_header: bool, use_strict_method_validation: bool, - use_insecure_http_parser: bool, + lenient_http_flags: u8, http_allow_half_open: bool, ) { if let Some(app) = self.app { @@ -1484,7 +1484,7 @@ impl NewServer { bun_opaque::opaque_deref_mut(app).set_flags( require_host_header, use_strict_method_validation, - use_insecure_http_parser, + lenient_http_flags, http_allow_half_open, ); } diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index c058a048d2f1..c4015fef29c6 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3773,7 +3773,7 @@ pub(super) fn server_set_app_flags_( server: JSValue, require_host_header: bool, use_strict_method_validation: bool, - use_insecure_http_parser: bool, + lenient_http_flags: u8, http_allow_half_open: bool, ) -> JsResult { if !server.is_object() { @@ -3787,7 +3787,7 @@ pub(super) fn server_set_app_flags_( unsafe { &mut *this }.set_flags( require_host_header, use_strict_method_validation, - use_insecure_http_parser, + lenient_http_flags, http_allow_half_open, ); } else if let Some(this) = server.as_::() { @@ -3795,7 +3795,7 @@ pub(super) fn server_set_app_flags_( unsafe { &mut *this }.set_flags( require_host_header, use_strict_method_validation, - use_insecure_http_parser, + lenient_http_flags, http_allow_half_open, ); } else if let Some(this) = server.as_::() { @@ -3803,7 +3803,7 @@ pub(super) fn server_set_app_flags_( unsafe { &mut *this }.set_flags( require_host_header, use_strict_method_validation, - use_insecure_http_parser, + lenient_http_flags, http_allow_half_open, ); } else if let Some(this) = server.as_::() { @@ -3811,7 +3811,7 @@ pub(super) fn server_set_app_flags_( unsafe { &mut *this }.set_flags( require_host_header, use_strict_method_validation, - use_insecure_http_parser, + lenient_http_flags, http_allow_half_open, ); } else { @@ -3869,7 +3869,7 @@ extern "C" fn server_set_app_flags_shim( server: JSValue, require_host_header: bool, use_strict_method_validation: bool, - use_insecure_http_parser: bool, + lenient_http_flags: u8, http_allow_half_open: bool, ) -> JSValue { host_fn::to_js_host_fn_result( @@ -3879,7 +3879,7 @@ extern "C" fn server_set_app_flags_shim( server, require_host_header, use_strict_method_validation, - use_insecure_http_parser, + lenient_http_flags, http_allow_half_open, ), ) diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 6d3b03ca9f78..e8775f1c05e5 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -127,7 +127,7 @@ impl App { &mut self, require_host_header: bool, use_strict_method_validation: bool, - use_insecure_http_parser: bool, + lenient_http_flags: u8, http_allow_half_open: bool, ) { c::uws_app_set_flags( @@ -135,7 +135,7 @@ impl App { self.as_raw(), require_host_header, use_strict_method_validation, - use_insecure_http_parser, + lenient_http_flags, http_allow_half_open, ) } @@ -529,7 +529,7 @@ pub mod c { app: &mut uws_app_t, require_host_header: bool, use_strict_method_validation: bool, - use_insecure_http_parser: bool, + lenient_http_flags: u8, http_allow_half_open: bool, ); pub(crate) safe fn uws_app_set_max_http_header_size( diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 1d0766198f7e..993482f2e4c3 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -541,13 +541,13 @@ extern "C" uwsApp->setMaxHTTPHeaderSize(max_header_size); } } - void uws_app_set_flags(int ssl, uws_app_t *app, bool require_host_header, bool use_strict_method_validation, bool use_insecure_http_parser, bool http_allow_half_open) { + void uws_app_set_flags(int ssl, uws_app_t *app, bool require_host_header, bool use_strict_method_validation, uint8_t lenient_http_flags, bool http_allow_half_open) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->setFlags(require_host_header, use_strict_method_validation, use_insecure_http_parser, http_allow_half_open); + uwsApp->setFlags(require_host_header, use_strict_method_validation, lenient_http_flags, http_allow_half_open); } else { uWS::App *uwsApp = (uWS::App *)app; - uwsApp->setFlags(require_host_header, use_strict_method_validation, use_insecure_http_parser, http_allow_half_open); + uwsApp->setFlags(require_host_header, use_strict_method_validation, lenient_http_flags, http_allow_half_open); } } diff --git a/test/js/node/test/parallel/test-http-header-value-relaxed.js b/test/js/node/test/parallel/test-http-header-value-relaxed.js new file mode 100644 index 000000000000..d002b8089637 --- /dev/null +++ b/test/js/node/test/parallel/test-http-header-value-relaxed.js @@ -0,0 +1,429 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); +const { duplexPair } = require('stream'); +const { HTTPParser } = require('_http_common'); +// llhttp_set_lenient_header_value_relaxed() was added in llhttp 9.4.0. +// On shared-library builds using an older system llhttp the constant is +// exported as 0, so inbound-parsing tests must be skipped there. +const kRelaxedInboundSupported = HTTPParser.kLenientHeaderValueRelaxed > 0; + +// Integration tests for relaxed header value validation. +// When httpValidation is 'relaxed' or 'insecure', outgoing headers with control +// characters (0x01-0x1f except HTAB, and DEL 0x7f) are allowed per Fetch spec. +// NUL (0x00), CR (0x0d), and LF (0x0a) are always rejected. +// httpValidation: 'relaxed' - only enables relaxed header value parsing, not +// other insecure lenient behaviours (e.g. duplicate Transfer-Encoding). +// httpValidation: 'insecure' - enables all lenient parsing (same as insecureHTTPParser). +// httpValidation and insecureHTTPParser are mutually exclusive. + +// Helper: create a request that won't actually connect (for setHeader tests) +function dummyRequest(opts) { + const req = http.request({ host: '127.0.0.1', port: 1, ...opts }); + req.on('error', () => {}); // Suppress connection errors + return req; +} + +// ============================================================================ +// Test 1: Client setHeader with control chars in strict mode (default) - throws +// ============================================================================ +{ + const req = dummyRequest(); + assert.throws(() => { + req.setHeader('X-Test', 'value\x01here'); + }, { code: 'ERR_INVALID_CHAR' }); + req.destroy(); +} + +// ============================================================================ +// Test 2: Client setHeader with control chars in relaxed mode - allowed +// ============================================================================ +{ + const req = dummyRequest({ httpValidation: 'relaxed' }); + // Should not throw - control chars allowed in relaxed mode + req.setHeader('X-Test', 'value\x01here'); + req.setHeader('X-Bel', 'ding\x07'); + req.setHeader('X-Esc', 'esc\x1b'); + req.setHeader('X-Del', 'del\x7f'); + req.destroy(); +} + +// ============================================================================ +// Test 3: NUL, CR, LF always rejected even in relaxed mode (client) +// ============================================================================ +{ + const req = dummyRequest({ httpValidation: 'relaxed' }); + assert.throws(() => { + req.setHeader('X-Test', 'value\x00here'); + }, { code: 'ERR_INVALID_CHAR' }); + assert.throws(() => { + req.setHeader('X-Test', 'value\rhere'); + }, { code: 'ERR_INVALID_CHAR' }); + assert.throws(() => { + req.setHeader('X-Test', 'value\nhere'); + }, { code: 'ERR_INVALID_CHAR' }); + req.destroy(); +} + +// ============================================================================ +// Test 4: Server response setHeader with control chars in relaxed mode +// ============================================================================ +{ + const server = http.createServer({ + httpValidation: 'relaxed', + }, common.mustCall((req, res) => { + // Should not throw - control chars allowed in relaxed mode + res.setHeader('X-Custom', 'value\x01here'); + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + // Use a raw TCP connection to read the response headers directly, + // since http.get would fail to parse the control char in the header. + const client = net.connect(port, common.mustCall(() => { + client.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'); + })); + let data = ''; + client.on('data', (chunk) => { data += chunk; }); + client.on('end', common.mustCall(() => { + // eslint-disable-next-line no-control-regex + assert.match(data, /X-Custom: value\x01here/); + server.close(); + })); + })); +} + +// ============================================================================ +// Test 5: Server response NUL/CR/LF always rejected in relaxed mode +// ============================================================================ +{ + const server = http.createServer({ + httpValidation: 'relaxed', + }, common.mustCall((req, res) => { + assert.throws(() => { + res.setHeader('X-Test', 'value\x00here'); + }, { code: 'ERR_INVALID_CHAR' }); + assert.throws(() => { + res.setHeader('X-Test', 'value\rhere'); + }, { code: 'ERR_INVALID_CHAR' }); + assert.throws(() => { + res.setHeader('X-Test', 'value\nhere'); + }, { code: 'ERR_INVALID_CHAR' }); + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + server.close(); + })); + })); + })); +} + +// ============================================================================ +// Test 6: Server response strict mode (default) rejects control chars +// ============================================================================ +{ + const server = http.createServer(common.mustCall((req, res) => { + assert.throws(() => { + res.setHeader('X-Test', 'value\x01here'); + }, { code: 'ERR_INVALID_CHAR' }); + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + server.close(); + })); + })); + })); +} + +// ============================================================================ +// Test 7: appendHeader also respects relaxed mode +// ============================================================================ +{ + const req = dummyRequest({ httpValidation: 'relaxed' }); + // Should not throw in relaxed mode + req.appendHeader('X-Test', 'value\x01here'); + req.destroy(); +} + +// ============================================================================ +// Test 8: appendHeader strict mode rejects control chars +// ============================================================================ +{ + const req = dummyRequest(); + assert.throws(() => { + req.appendHeader('X-Test', 'value\x01here'); + }, { code: 'ERR_INVALID_CHAR' }); + req.destroy(); +} + +// ============================================================================ +// Test 9: Explicit insecureHTTPParser: false overrides global flag +// ============================================================================ +{ + const req = dummyRequest({ insecureHTTPParser: false }); + assert.throws(() => { + req.setHeader('X-Test', 'value\x01here'); + }, { code: 'ERR_INVALID_CHAR' }); + req.destroy(); +} + +// ============================================================================ +// Test 10: Inbound response header with control char accepted in lenient mode +// (exercises the new llhttp_set_lenient_header_value_relaxed path) +// Only runs on builds with llhttp >= 9.4 (kLenientHeaderValueRelaxed > 0). +// ============================================================================ +if (kRelaxedInboundSupported) { + const [clientSide, serverSide] = duplexPair(); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide), + httpValidation: 'relaxed', + }, common.mustCall((res) => { + assert.strictEqual(res.headers['x-ctrl'], 'value\x01here'); + res.resume(); + res.on('end', common.mustCall()); + })); + req.end(); + + serverSide.resume(); + serverSide.end( + 'HTTP/1.1 200 OK\r\n' + + 'X-Ctrl: value\x01here\r\n' + + 'Content-Length: 0\r\n' + + '\r\n', + ); +} + +// Test 10b: Same inbound header without insecureHTTPParser — parser must error +{ + const [clientSide, serverSide] = duplexPair(); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide), + }, common.mustNotCall()); + req.end(); + req.on('error', common.mustCall()); + + serverSide.resume(); + serverSide.end( + 'HTTP/1.1 200 OK\r\n' + + 'X-Ctrl: value\x01here\r\n' + + 'Content-Length: 0\r\n' + + '\r\n', + ); +} + +// ============================================================================ +// Test 11: httpValidation: 'insecure' outbound - same as insecureHTTPParser +// ============================================================================ +{ + const req = dummyRequest({ httpValidation: 'insecure' }); + // Should not throw - control chars allowed in insecure mode + req.setHeader('X-Test', 'value\x01here'); + req.setHeader('X-Bel', 'ding\x07'); + req.destroy(); +} + +// ============================================================================ +// Test 12: Mutual exclusion - client throws when both options are set +// ============================================================================ +{ + assert.throws(() => { + dummyRequest({ httpValidation: 'relaxed', insecureHTTPParser: true }); + }, { code: 'ERR_INVALID_ARG_VALUE' }); +} + +// ============================================================================ +// Test 13: Mutual exclusion - server throws when both options are set +// ============================================================================ +{ + assert.throws(() => { + http.createServer({ httpValidation: 'relaxed', insecureHTTPParser: true }); + }, { code: 'ERR_INVALID_ARG_VALUE' }); +} + +// ============================================================================ +// Test 14: httpValidation: 'relaxed' accepts inbound REQUEST headers with +// control chars (server side - exercises kLenientHeaderValueRelaxed) +// Only runs on builds with llhttp >= 9.4 (kLenientHeaderValueRelaxed > 0). +// ============================================================================ +if (kRelaxedInboundSupported) { + const server = http.createServer({ + httpValidation: 'relaxed', + }, common.mustCall((req, res) => { + assert.strictEqual(req.headers['x-ctrl'], 'value\x01here'); + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + // Use a raw TCP connection to send a request with a control char header. + const client = net.connect(port, common.mustCall(() => { + client.write('GET / HTTP/1.1\r\nHost: localhost\r\nX-Ctrl: value\x01here\r\nConnection: close\r\n\r\n'); + })); + let data = ''; + client.on('data', (chunk) => { data += chunk; }); + client.on('end', common.mustCall(() => { + assert.match(data, /^HTTP\/1\.1 200/); + server.close(); + })); + })); +} + +// ============================================================================ +// Test 15: httpValidation: 'relaxed' inbound REQUEST - strict mode (default) +// rejects request with control char in header value +// ============================================================================ +{ + const server = http.createServer( + common.mustNotCall(), + ); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const client = net.connect(port, common.mustCall(() => { + client.write('GET / HTTP/1.1\r\nHost: localhost\r\nX-Ctrl: value\x01here\r\nConnection: close\r\n\r\n'); + })); + let data = ''; + client.on('data', (chunk) => { data += chunk; }); + client.on('end', common.mustCall(() => { + // Server should respond with 400 Bad Request or close the connection + assert.match(data, /^HTTP\/1\.1 400|^$/); + server.close(); + })); + })); +} + +// ============================================================================ +// Test 16: httpValidation: 'relaxed' does NOT enable all insecure lenient +// flags - duplicate Transfer-Encoding is still rejected in relaxed +// mode but accepted in insecure mode. +// (kLenientTransferEncoding is only in kLenientAll, not kLenientHeaderValueRelaxed) +// ============================================================================ +{ + // A request where Transfer-Encoding: chunked appears twice (joined internally + // as "chunked, chunked"), which llhttp rejects without kLenientTransferEncoding. + const doubleTE = + 'GET / HTTP/1.1\r\n' + + 'Host: localhost\r\n' + + 'Connection: close\r\n' + + 'Transfer-Encoding: chunked\r\n' + + 'Transfer-Encoding: chunked\r\n' + + '\r\n' + + '0\r\n\r\n'; + + // With httpValidation: 'relaxed', duplicate T-E should be rejected (400). + { + const server = http.createServer({ + httpValidation: 'relaxed', + }, common.mustNotCall()); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const client = net.connect(port, common.mustCall(() => { + client.write(doubleTE); + })); + client.resume(); + client.on('close', common.mustCall(() => { + server.close(); + })); + })); + } + + // With httpValidation: 'insecure', duplicate T-E is accepted (kLenientAll + // includes kLenientTransferEncoding). + { + const server = http.createServer({ + httpValidation: 'insecure', + }, common.mustCall((req, res) => { + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const client = net.connect(port, common.mustCall(() => { + client.write(doubleTE); + })); + let data = ''; + client.on('data', (chunk) => { data += chunk; }); + client.on('end', common.mustCall(() => { + assert.match(data, /^HTTP\/1\.1 200/); + server.close(); + })); + })); + } +} + +// ============================================================================ +// Test 17: writeHead respects httpValidation: 'relaxed' +// (exercises the storeHeader/validateHeaderValue path, not setHeader) +// ============================================================================ +{ + const server = http.createServer({ + httpValidation: 'relaxed', + }, common.mustCall((req, res) => { + // writeHead calls _storeHeader which calls storeHeader/validateHeaderValue. + // With httpValidation: 'relaxed', control chars should be allowed. + res.writeHead(200, { 'X-Custom': 'value\x01here' }); + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const client = net.connect(port, common.mustCall(() => { + client.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'); + })); + let data = ''; + client.on('data', (chunk) => { data += chunk; }); + client.on('end', common.mustCall(() => { + // eslint-disable-next-line no-control-regex + assert.match(data, /X-Custom: value\x01here/); + server.close(); + })); + })); +} + +// ============================================================================ +// Test 18: writeHead strict mode (default) rejects control chars +// ============================================================================ +{ + const server = http.createServer(common.mustCall((req, res) => { + assert.throws(() => { + res.writeHead(200, { 'X-Custom': 'value\x01here' }); + }, { code: 'ERR_INVALID_CHAR' }); + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + server.close(); + })); + })); + })); +} + +// ============================================================================ +// Test 19: httpValidation: 'strict' (explicit) rejects control chars even +// when insecureHTTPParser would otherwise be lenient +// ============================================================================ +{ + const req = dummyRequest({ httpValidation: 'strict' }); + // 'strict' must always reject control chars, regardless of any global setting + assert.throws(() => { + req.setHeader('X-Test', 'value\x01here'); + }, { code: 'ERR_INVALID_CHAR' }); + req.destroy(); +} From ac379e9d11fe62e8a44890e1ee72e1eaf33128e1 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 14:32:28 -0700 Subject: [PATCH 11/43] http: honor the auto-header bits exactly in the JS fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback always wrote a Date line, invented a Connection header when neither connection bit was set, and emitted Keep-Alive without its bit — so res.sendDate = false, a removed Date or Connection header, and a suppressed keep-alive timeout all diverged from the native writeAutoHeaders on this path. Each line is now written iff its bit is set, and the HTTP/1 listener seeds res._keepAliveTimeout the way the native dispatcher does, so the timeout bit is actually produced. A head-less write keeps the old defaults; node:http's ServerResponse always renders the bits. Verified over a duplex against the v26.3.0 binary: sendDate = false, removeHeader("connection"), the keep-alive default and both close variants now match byte-for-byte. --- src/js/internal/http1_server_fallback.ts | 57 +++++++++++++----------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 8b947c84f9e2..97b34ca38e6c 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -88,40 +88,40 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim out += `Content-Length: ${contentLength}\r\n`; } } - if (!hasDate) { - out += `Date: ${new Date().toUTCString()}\r\n`; - } - // renderNativeHeaders reports its Connection decision through the - // auto-header bits (AUTO_HEADER_* in _http_server.ts / kAutoHeader* in - // NodeHTTP.cpp); honor an explicit close (res.shouldKeepAlive = false, - // the graceful-shutdown pattern) over the parser-derived flag. - // A close-delimited response still advertises the close it performs: only - // the keep-alive line is suppressed, since the connection ends with the - // body. When the user removed the Connection header (_removedConnection), - // renderNativeHeaders sets neither bit, so nothing is written here. - if (!hasConnection) { - if ((autoBits & 4) !== 0) { - out += "Connection: close\r\n"; - } else if (!closeDelimited) { - // No close bit and no Connection pair on a close-delimited response means - // the user removed the header (Node's _removedConnection) — write none - // rather than inventing keep-alive on a connection that ends with the - // body. Every other response still advertises its connection state. + // Mirror the native writeAutoHeaders exactly: each line is written iff its + // bit is set, so res.sendDate = false, removeHeader("date"), a removed + // Connection header (neither connection bit) and a suppressed Keep-Alive + // timeout all round-trip identically through this path. A head-less write + // (nothing called writeHead on this handle) keeps the old defaults — that + // only happens off node:http's ServerResponse, which always renders bits. + if (head === null) { + if (!hasDate) { + out += `Date: ${new Date().toUTCString()}\r\n`; + } + if (!hasConnection && !closeDelimited) { if (shouldKeepAlive) { out += "Connection: keep-alive\r\n"; - // A user-sent Keep-Alive header (already written by the loop above) - // suppresses the auto line, like the native writeAutoHeaders. The - // bit-carried timeout wins when present; otherwise fall back to this - // handle's configured timeout, preserving pre-bits behavior. if (!hasKeepAlive) { - const kaSecs = - (autoBits & 8) !== 0 ? head.keepAliveTimeoutSecs : Math.floor((keepAliveTimeout || 5000) / 1000); - out += `Keep-Alive: timeout=${kaSecs}\r\n`; + out += `Keep-Alive: timeout=${Math.floor((keepAliveTimeout || 5000) / 1000)}\r\n`; } } else { out += "Connection: close\r\n"; } } + } else { + if (!hasDate && (autoBits & 1) !== 0) { + out += `Date: ${new Date().toUTCString()}\r\n`; + } + if (!hasConnection) { + if ((autoBits & 2) !== 0) { + out += "Connection: keep-alive\r\n"; + if (!hasKeepAlive && (autoBits & 8) !== 0) { + out += `Keep-Alive: timeout=${head.keepAliveTimeoutSecs}\r\n`; + } + } else if ((autoBits & 4) !== 0) { + out += "Connection: close\r\n"; + } + } } // Last, where Node's _storeHeader puts it — after Connection/Keep-Alive. if (chunkedFromAutoBits && chunked) { @@ -299,6 +299,11 @@ function connectionListenerHTTP1(server, socket, options) { }; const res = new ServerResponseClass(req); + // The native dispatcher seeds these from the server; renderNativeHeaders + // reads them to decide the Keep-Alive auto-header bits, so the fallback + // path must carry them too or keep-alive responses lose their timeout line. + res._keepAliveTimeout = keepAliveTimeout; + res._maxRequestsPerSocket = server.maxRequestsPerSocket; const handle = createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTimeout); handle.onfinished = function () { socket[kHttp1ActiveRequests] = Math.max(0, (socket[kHttp1ActiveRequests] || 1) - 1); From 437116335863433aefc35e05452e72e044eebd21 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 14:48:28 -0700 Subject: [PATCH 12/43] http: bound pipelined dispatch like Node's flood prevention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client that floods pipelined requests while never reading responses made the server consume and dispatch every one of them: handlers that write()+end() synchronously complete each exchange before the next dispatch, so the pipelined branch's existing gate never ran, and the JS gate's response.pause() was a silent no-op — doPause refuses ENDED responses, and the in-flight response has always ended by the time the pipeline backs up. Meanwhile one recv buffer can hold thousands of pipelined requests, and the parse loop dispatched them all in a single synchronous burst; pausing the socket cannot bound work that has already been received. Dispatch is now gated on outgoing backpressure in both branches, pausing stops the request loop at the next request boundary — the unconsumed remainder is parked on the parser and replayed, in order, before the socket reads fresh bytes — and the JS gate pauses through a pauseReads op that skips the body-flow-control guards. Reads resume only once the outgoing bytes, the response queue and the parked spill have all drained; an incidental resume (writeHead re-arming the poll, req.resume()) holds instead of reopening the flood. Making the replay correct exposed three pre-existing bugs in pipelined request handling, each of which could fire without the flood machinery: - A request body delivered before its reader was armed was dropped outright; it is now parked in the same buffer the pause path uses and drained when the reader arms. - Body delivery resolved the JS wrapper through the socket's current response, which for a pipelined request is some other response — the armed ondata callback lives on this request's wrapper, so delivery read the wrong (empty) slot and lost the body. The response now remembers the wrapper that armed it. - A response queued after its predecessor had already finished and detached had nothing in flight to advance the pipeline from, so it sat queued forever. The dispatch now kicks the pipeline when it queues onto an idle connection. Verified against the v26.3.0 binary: test-http-pipeline-flood passes, a 300-request pipelined burst against a slow reader arrives complete and in order, and 100 pipelined POSTs echo their bodies byte-for-byte in order under the same backpressure. Adds test-http-pipeline-flood. --- packages/bun-uws/src/HttpContext.h | 38 ++++- packages/bun-uws/src/HttpParser.h | 24 +++ src/js/node/_http_server.ts | 23 ++- .../bindings/node/JSNodeHTTPServerSocket.cpp | 142 ++++++++++++++++-- src/runtime/server/NodeHTTPResponse.rs | 85 ++++++++++- src/runtime/server/server.classes.ts | 4 + .../test/parallel/test-http-pipeline-flood.js | 83 ++++++++++ 7 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 test/js/node/test/parallel/test-http-pipeline-flood.js diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 9adb0c90524a..599a209f2d6b 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -38,6 +38,8 @@ #include +extern "C" void Bun__NodeHTTP__onReadsResumable(int ssl, struct us_socket_t *s); + namespace uWS { namespace detail { @@ -130,6 +132,13 @@ struct HttpContext { static unsigned char socketKind() { return SSL ? US_SOCKET_KIND_UWS_HTTP_TLS : US_SOCKET_KIND_UWS_HTTP; } public: + /* node:http flood prevention: re-feed parked request bytes through the same + * parse path fresh socket data takes. The caller guarantees the buffer has + * LIBUS_RECV_BUFFER_PADDING of writable slack past `length`. */ + static us_socket_t *feedNodeHttpData(us_socket_t *s, char *data, int length) { + return onData(s, data, length); + } + us_socket_group_t *getSocketGroup() { return &group; } @@ -397,6 +406,9 @@ struct HttpContext { httpResponseData->nodeHttpQueuedPipelinedCount++; if (((AsyncSocket *) s)->getBufferedAmount() > 0) { httpResponseData->state |= HttpResponseData::HTTP_NODE_READS_PAUSED; + /* Also stop the request loop over the buffer being parsed + * right now — pausing the socket alone cannot bound it. */ + httpResponseData->nodeHttpReadsPausedSignal = true; ((HttpResponse *) s)->pause(); } } @@ -423,6 +435,22 @@ struct HttpContext { * on this keep-alive connection (the flag itself was cleared above). */ if constexpr (IsNodeHttp) { ((HttpResponseData *) httpResponseData)->nodeHttpResponseTrailers.clear(); + + /* Node's flood prevention applies here too: a handler that + * write()s and end()s synchronously completes each exchange + * before the next dispatch, so the pipelined branch above + * never runs — yet unflushed response bytes pile up on the + * connection all the same. Once the socket carries outgoing + * backpressure, stop reading (and stop consuming the + * already-received requests: the signal parks them) until + * onWritable drains it. This request still dispatches, like + * Node, which pauses from within parserOnIncoming. */ + if (((AsyncSocket *) s)->getBufferedAmount() > 0 + && !(httpResponseData->state & HttpResponseData::HTTP_NODE_READS_PAUSED)) { + httpResponseData->state |= HttpResponseData::HTTP_NODE_READS_PAUSED; + httpResponseData->nodeHttpReadsPausedSignal = true; + ((HttpResponse *) s)->pause(); + } } } @@ -713,10 +741,12 @@ struct HttpContext { * backpressure when the queue drained; now that it has flushed, read * new requests again. */ if constexpr (IsNodeHttp) { - if ((httpResponseData->state & HttpResponseData::HTTP_NODE_READS_PAUSED) && httpResponseData->nodeHttpQueuedPipelinedCount == 0 - && asyncSocket->getBufferedAmount() == 0) { - httpResponseData->state &= ~HttpResponseData::HTTP_NODE_READS_PAUSED; - reinterpret_cast *>(s)->resume(); + if (httpResponseData->state & HttpResponseData::HTTP_NODE_READS_PAUSED) { + /* Parked pipelined requests must be replayed before the socket + * reads fresh bytes, or the stream reorders; the hook holds + * under outgoing backpressure and resumes raw reads only once + * the queue and the spill drain (JSNodeHTTPServerSocket.cpp). */ + Bun__NodeHTTP__onReadsResumable(SSL, s); } } diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index afbc6389f54f..a8b4ac754343 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -564,6 +564,18 @@ struct HttpResponseData; private: std::string fallback; + public: + /* node:http flood prevention. The dispatch of a pipelined request that + * finds outgoing backpressure pauses reads (HttpContext), but the recv + * buffer being parsed can still hold thousands of already-received + * pipelined requests, and Node stops consuming those too (its parser is + * paused alongside the socket). The signal makes the request loop stop + * at the next request boundary; the unconsumed remainder is parked here + * and replayed, in order, when reads resume. */ + bool nodeHttpReadsPausedSignal = false; + bool nodeHttpSpillReplayScheduled = false; + std::string nodeHttpPausedSpill; + private: /* This guy really has only 30 bits since we reserve two highest bits to chunked encoding parsing state */ uint64_t remainingStreamingBytes = 0; /* node:http compat: a completed request on this connection forbade keep-alive @@ -1121,6 +1133,18 @@ struct HttpResponseData; consumedTotal += length; return HttpParserResult::success(consumedTotal, returnedUser); } + /* node:http flood prevention: a dispatch earlier in this buffer + * paused reads. Stop at this request boundary (the previous + * request's body is fully consumed here by construction) and park + * the rest. Reported as consumed so the caller does not spill it + * into the size-capped header fallback buffer. */ + if constexpr (IsNodeHttp) { + if (nodeHttpReadsPausedSignal) [[unlikely]] { + nodeHttpPausedSpill.append(data, length); + consumedTotal += length; + return HttpParserResult::success(consumedTotal, user); + } + } /* RFC 9112 2.2: ignore empty lines (CRLF) received prior to the * request-line, like Node/llhttp - e.g. a stray "\r\n" sent on an * idle keep-alive connection must not be treated as a bad request. diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index a44a671e5bc5..cdb1e5501401 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -949,6 +949,15 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort socket, }; (socket[kPipelinedResponses] ??= []).push(http_res); + // A pipelined dispatch can arrive after the previous response already + // finished and detached — its bytes still flushing natively keep the + // connection marked pending — so nothing is in flight to advance the + // queue from and this response would sit queued forever. Kick the + // pipeline once this dispatch settles. + if (socket._httpMessage == null && !socket[kPipelineKickScheduled]) { + socket[kPipelineKickScheduled] = true; + process.nextTick(advancePipelineIfIdleNT, server, socket); + } // Node's parserOnIncoming stops reading the connection once the bytes // queued on responses that do not own the socket yet reach the // socket's high water mark, so pipelined requests cannot flood it. @@ -2577,7 +2586,11 @@ function pausePipelineReads(socket) { const response = socket[kHandle]?.response; if (!response) return; socket._paused = true; - response.pause(); + // Not response.pause(): that is request-body flow control and refuses to act + // once the in-flight response has ended — which it always has by the time the + // pipeline backs up. pauseReads() pauses the connection's reads regardless, + // and native stops consuming already-received pipelined requests with it. + response.pauseReads(); } function addPipelineOutgoingData(queued, bytes) { @@ -2601,6 +2614,14 @@ function releasePipelineOutgoingData(socket, bytes) { // pipelined responses are queued behind it, the next one becomes the // connection's current response, is assigned the socket, and its buffered // output is flushed. +const kPipelineKickScheduled = Symbol("kPipelineKickScheduled"); +function advancePipelineIfIdleNT(server, socket) { + socket[kPipelineKickScheduled] = false; + if (socket._httpMessage == null && socket[kPipelinedResponses]?.length) { + advanceResponsePipeline(server, socket); + } +} + function advanceResponsePipeline(server, socket) { // The previous response on this connection closed it (Connection: close, // HTTP/1.0, maxRequestsPerSocket): like Node.js's resOnFinish, advancing diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index 17c281e4a6eb..c6b81b51b905 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -382,6 +382,133 @@ void JSNodeHTTPServerSocket::appendPipelinedResponse(JSC::VM& vm, WebCore::JSNod m_pipelinedResponses.last().set(vm, this, response); } +/* node:http flood prevention, resume half. Reads paused mid-buffer parked the + * unconsumed pipelined requests on the parser (HttpParser::nodeHttpPausedSpill). + * They must be replayed before the socket reads fresh bytes or the stream + * reorders, and replaying dispatches request handlers — so it must not run + * synchronously inside whatever JS operation made the connection resumable. + * Defer it as an event-loop task holding the JS socket wrapper alive; reads + * only actually resume once the spill has drained without re-pausing. */ +template +static void replayNodeHttpPausedSpill(us_socket_t* socket) +{ + auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); + httpResponseData->nodeHttpSpillReplayScheduled = false; + httpResponseData->nodeHttpReadsPausedSignal = false; + std::string spill = std::move(httpResponseData->nodeHttpPausedSpill); + httpResponseData->nodeHttpPausedSpill.clear(); + if (!spill.empty()) { + /* The parser's post-padded fence writes two bytes past the end. */ + spill.reserve(spill.size() + LIBUS_RECV_BUFFER_PADDING); + us_socket_t* returned = uWS::HttpContext::feedNodeHttpData(socket, spill.data(), (int)spill.size()); + if (!returned || us_socket_is_closed(returned)) { + return; + } + socket = returned; + httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); + } + if (httpResponseData->nodeHttpReadsPausedSignal) { + /* A dispatch during the replay hit backpressure again and re-parked + * the rest; stay paused until the next resumable event. */ + return; + } + if (httpResponseData->nodeHttpQueuedPipelinedCount > 0 + || reinterpret_cast*>(socket)->getBufferedAmount() > 0) { + /* The spill drained, but the pipeline has not: keep raw reads paused — + * the queue-drain / writable events re-enter the hook. */ + return; + } + httpResponseData->state &= ~uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; + reinterpret_cast*>(socket)->resume(); +} + +template +static void onNodeHttpReadsResumable(us_socket_t* socket) +{ + auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); + if (httpResponseData->state & uWS::HttpResponseData::HTTP_NODE_READS_PAUSED) { + /* Flood prevention owns the pause. Outgoing backpressure holds + * everything — an incidental resume (writeHead re-arming the poll, + * req.resume()) must not reopen the flood or race fresh reads past the + * spill. Queued responses alone must NOT hold the spill replay: the + * body a queued response is waiting on may be sitting in the spill, + * and holding it would deadlock the pipeline. Raw reads still resume + * only once the queue and the spill have both drained. */ + if (reinterpret_cast*>(socket)->getBufferedAmount() > 0) { + return; + } + if (httpResponseData->nodeHttpPausedSpill.empty() + && httpResponseData->nodeHttpQueuedPipelinedCount > 0) { + return; + } + } + if (httpResponseData->nodeHttpPausedSpill.empty()) { + httpResponseData->nodeHttpReadsPausedSignal = false; + httpResponseData->state &= ~uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; + reinterpret_cast*>(socket)->resume(); + return; + } + if (httpResponseData->nodeHttpSpillReplayScheduled) { + return; + } + auto* cell = reinterpret_cast(httpResponseData->socketData); + if (!cell) { + /* No JS wrapper to root a task on; replay in place. Reads are still + * paused, so ordering holds. */ + replayNodeHttpPausedSpill(socket); + return; + } + auto* globalObject = defaultGlobalObject(cell->globalObject()); + WebCore::ScriptExecutionContext* scriptExecutionContext = globalObject->scriptExecutionContext(); + if (!scriptExecutionContext) { + replayNodeHttpPausedSpill(socket); + return; + } + httpResponseData->nodeHttpSpillReplayScheduled = true; + JSC::Strong protectedSocket(globalObject->vm(), cell); + scriptExecutionContext->postTask([protectedSocket = std::move(protectedSocket)](WebCore::ScriptExecutionContext&) { + auto* self = protectedSocket.get(); + us_socket_t* sock = self->socket; + if (!sock || us_socket_is_closed(sock)) { + return; + } + if (self->is_ssl) { + replayNodeHttpPausedSpill(sock); + } else { + replayNodeHttpPausedSpill(sock); + } + }); +} + +template +static void setReadsPausedSignalImpl(us_socket_t* socket) +{ + auto* d = reinterpret_cast*>(us_socket_ext(socket)); + d->nodeHttpReadsPausedSignal = true; + d->state |= uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; +} + +extern "C" void Bun__NodeHTTP__setReadsPausedSignal(int ssl, us_socket_t* socket) +{ + if (!socket || us_socket_is_closed(socket)) { + return; + } + if (ssl) { + setReadsPausedSignalImpl(socket); + } else { + setReadsPausedSignalImpl(socket); + } +} + +extern "C" void Bun__NodeHTTP__onReadsResumable(int ssl, us_socket_t* socket) +{ + if (ssl) { + onNodeHttpReadsResumable(socket); + } else { + onNodeHttpReadsResumable(socket); + } +} + template static bool startPipelinedResponseImpl(us_socket_t* socket, bool isAncient, bool connectionClose, bool hasMoreQueued) { @@ -406,16 +533,11 @@ static bool startPipelinedResponseImpl(us_socket_t* socket, bool isAncient, bool if (httpResponseData->nodeHttpQueuedPipelinedCount > 0) { httpResponseData->nodeHttpQueuedPipelinedCount--; } - if (!hasMoreQueued && httpResponseData->nodeHttpQueuedPipelinedCount == 0 - && (httpResponseData->state & uWS::HttpResponseData::HTTP_NODE_READS_PAUSED)) { - // The pipeline backlog drained. Resume reading new requests only once - // the socket has no outgoing backpressure left (Node's flood - // prevention keeps the socket paused while responses back up); - // otherwise HttpContext::onWritable resumes after the drain. - if (reinterpret_cast*>(socket)->getBufferedAmount() == 0) { - httpResponseData->state &= ~uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; - reinterpret_cast*>(socket)->resume(); - } + if (httpResponseData->state & uWS::HttpResponseData::HTTP_NODE_READS_PAUSED) { + // A pipeline advance while flood-paused: the hook replays parked + // request bytes once outgoing backpressure has flushed, and resumes + // raw reads only after both the queue and the spill drain. + onNodeHttpReadsResumable(socket); } return true; } diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 06af69978ed7..f78709dfc541 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -57,6 +57,13 @@ pub struct NodeHTTPResponse { /// body finishes (still inside the parser), because a pipelined request's /// parse would otherwise overwrite it before this request's JS reads it. pub request_trailers: JsCell>, + /// The JS wrapper whose `ondata` slot was armed for THIS response's request + /// body. `get_this_value()` resolves through the socket's *current* response + /// object, which for a pipelined request is some other response — delivering + /// through it reads the wrong (empty) cache and the body is lost. The + /// wrapper is kept alive by req[kHandle] on the JS side; cleared when the + /// slot is cleared and when the wrapper finalizes. + pub armed_this_value: Cell, /// node:http: this request's header section captured at dispatch as /// [u32 nameLen][u32 valueLen][name][value]... so req.rawHeaders / /// req.headers materialize lazily (takeRawHeaders) instead of paying @@ -190,6 +197,13 @@ unsafe extern "C" { // `*out` points into a C++ thread-local that stays valid until the next // call on this thread; the caller copies it immediately. Returns 0 when // there is nothing captured or the socket is closed. + // node:http flood prevention (JSNodeHTTPServerSocket.cpp). The signal makes + // the uWS request loop stop consuming pipelined requests at the next request + // boundary while the socket is paused; the resumable hook replays anything + // it parked, in order, before actually resuming reads. + safe fn Bun__NodeHTTP__setReadsPausedSignal(ssl: core::ffi::c_int, socket: *mut c_void); + safe fn Bun__NodeHTTP__onReadsResumable(ssl: core::ffi::c_int, socket: *mut c_void); + safe fn Bun__NodeHTTP__takeRequestTrailerBytes( is_ssl: bool, socket: *mut c_void, @@ -429,6 +443,30 @@ impl NodeHTTPResponse { raw.pause(); } + /* Pipelined flood prevention pauses READS on the connection, which is + * legal — and necessary — after the in-flight response has ended, so this + * intentionally skips doPause's ENDED/REQUEST_HAS_COMPLETED guards (those + * exist for request-body flow control). */ + pub(crate) fn pause_socket_reads( + &self, + _global: &JSGlobalObject, + _frame: &CallFrame, + ) -> JsResult { + let flags = self.flags.get(); + let Some(raw) = self.raw_response.get() else { + return Ok(JSValue::UNDEFINED); + }; + if flags.contains(Flags::SOCKET_CLOSED) + || flags.contains(Flags::UPGRADED) + || raw.is_connect_request() + { + return Ok(JSValue::UNDEFINED); + } + raw.pause(); + Bun__NodeHTTP__setReadsPausedSignal(any_response_is_ssl(&raw) as core::ffi::c_int, raw.socket().cast()); + Ok(JSValue::UNDEFINED) + } + pub(crate) fn resume_socket(&self) { scoped_log!(NodeHTTPResponse, "resumeSocket"); let flags = self.flags.get(); @@ -441,7 +479,9 @@ impl NodeHTTPResponse { { return; } - raw.resume_(); + // Not a bare resume: parked pipelined requests replay first so the + // stream cannot reorder around them. + Bun__NodeHTTP__onReadsResumable(any_response_is_ssl(&raw) as core::ffi::c_int, raw.socket().cast()); } pub(crate) fn upgrade( @@ -1575,6 +1615,7 @@ impl NodeHTTPResponse { chunk.len(), last ); + let body_was_pending = self.body_read_state.get() == BodyReadState::Pending; if last { self.ref_(); self.body_read_state.set(BodyReadState::Done); @@ -1582,7 +1623,27 @@ impl NodeHTTPResponse { // defer { if last { ... } } — moved to tail. - if let Some(callback) = js::on_data_get_cached(this_value) { + // "Armed" means a callable is cached — the slot holds an explicit + // `undefined` between the dispatch reset and the reader's _read() arming + // it, and a body arriving in that window used to be dropped outright. + let on_data_armed = js::on_data_get_cached(this_value).is_some_and(|cb| cb.is_cell()); + if !on_data_armed && body_was_pending && event == AbortEvent::None { + // No reader armed yet. This is a pipelined request whose body sat in + // the same parse burst as its headers: the response does not own the + // socket, so the JS side has not run _read() to install ondata when + // the parser reaches the body. Dropping it loses the body outright — + // park it where the pause path parks, and the drain that runs when + // the reader arms picks it up. A dumped request never gets here: its + // teardown moved body_read_state to Done first. + self.buffered_request_body_data_during_pause + .with_mut(|b| b.append_slice(chunk)); + self.update_flags(|f| { + f.insert(Flags::IS_DATA_BUFFERED_DURING_PAUSE); + if last { + f.insert(Flags::IS_DATA_BUFFERED_DURING_PAUSE_LAST); + } + }); + } else if let Some(callback) = js::on_data_get_cached(this_value) { if callback.is_cell() { let vm = vm_get(); let global_this = vm.global(); @@ -1624,7 +1685,18 @@ impl NodeHTTPResponse { if last { self.capture_request_trailers(); } - self.on_data_or_aborted(chunk, last, AbortEvent::None, self.get_this_value()); + // Deliver through the wrapper that armed ondata for THIS response's + // request; the socket-current wrapper is a different (already-finished) + // response while requests are pipelined. + let this_value = { + let armed = self.armed_this_value.get(); + if armed.is_empty() { + self.get_this_value() + } else { + armed + } + }; + self.on_data_or_aborted(chunk, last, AbortEvent::None, this_value); } fn on_drain_corked(&self, offset: u64) { @@ -1968,6 +2040,7 @@ impl NodeHTTPResponse { fn clear_on_data_callback(&self, this_value: JSValue, global_object: &JSGlobalObject) { scoped_log!(NodeHTTPResponse, "clearOnDataCallback"); + self.armed_this_value.set(JSValue::ZERO); if self.body_read_state.get() != BodyReadState::None { if !this_value.is_empty() { js::on_data_set_cached(this_value, global_object, JSValue::UNDEFINED); @@ -2003,6 +2076,7 @@ impl NodeHTTPResponse { || flags.contains(Flags::UPGRADED) { js::on_data_set_cached(this_value, global_object, JSValue::UNDEFINED); + self.armed_this_value.set(JSValue::ZERO); // defer { if body_read_ref.has { unref } } — moved to tail of this branch. match self.body_read_state.get() { BodyReadState::Pending | BodyReadState::Done => { @@ -2031,6 +2105,7 @@ impl NodeHTTPResponse { global_object, value.with_async_context_if_needed(global_object), ); + self.armed_this_value.set(this_value); self.update_flags(|f| f.insert(Flags::HAS_CUSTOM_ON_DATA)); if let Some(raw_response) = self.raw_response.get() { raw_response.on_data(on_data_shim, self.as_ctx_ptr()); @@ -2291,6 +2366,9 @@ impl NodeHTTPResponse { } pub(crate) fn finalize(self: Box) { + // The JS wrapper is being collected; drop the raw backref so a late + // body delivery cannot read through a dead cell. + self.armed_this_value.set(JSValue::ZERO); bun_ptr::finalize_js_box_noop(self); } @@ -2461,6 +2539,7 @@ pub unsafe extern "C" fn NodeHTTPResponse__createForJS( promise: JsCell::new(StrongOptional::empty()), buffered_request_body_data_during_pause: JsCell::new(Vec::new()), request_trailers: JsCell::new(Vec::new()), + armed_this_value: Cell::new(JSValue::ZERO), raw_request_headers: JsCell::new(Vec::new()), bytes_written: Cell::new(0), auto_flusher: JsCell::new(AutoFlusher::default()), diff --git a/src/runtime/server/server.classes.ts b/src/runtime/server/server.classes.ts index 3e2af3471080..337b7650ed7f 100644 --- a/src/runtime/server/server.classes.ts +++ b/src/runtime/server/server.classes.ts @@ -151,6 +151,10 @@ export default [ length: 0, passThis: true, }, + pauseReads: { + fn: "pauseSocketReads", + length: 0, + }, drainRequestBody: { fn: "drainRequestBody", length: 0, diff --git a/test/js/node/test/parallel/test-http-pipeline-flood.js b/test/js/node/test/parallel/test-http-pipeline-flood.js new file mode 100644 index 000000000000..448ff1636905 --- /dev/null +++ b/test/js/node/test/parallel/test-http-pipeline-flood.js @@ -0,0 +1,83 @@ +'use strict'; +const common = require('../common'); + +// Here we are testing the HTTP server module's flood prevention mechanism. +// When writeable.write returns false (ie the underlying send() indicated the +// native buffer is full), the HTTP server cork()s the readable part of the +// stream. This means that new requests will not be read (however request which +// have already been read, but are awaiting processing will still be +// processed). + +// Normally when the writable stream emits a 'drain' event, the server then +// uncorks the readable stream, although we aren't testing that part here. + +// The issue being tested exists in Node.js 0.10.20 and is resolved in 0.10.21 +// and newer. + +switch (process.argv[2]) { + case undefined: + return parent(); + case 'child': + return child(); + default: + throw new Error(`Unexpected value: ${process.argv[2]}`); +} + +function parent() { + const http = require('http'); + const bigResponse = Buffer.alloc(10240, 'x'); + let backloggedReqs = 0; + + const server = http.createServer(function(req, res) { + res.setHeader('content-length', bigResponse.length); + if (!res.write(bigResponse)) { + if (backloggedReqs === 0) { + // Once the native buffer fills (ie write() returns false), the flood + // prevention should kick in. + // This means the stream should emit no more 'data' events. However we + // may still be asked to process more requests if they were read before + // the flood-prevention mechanism activated. + setImmediate(() => { + req.socket.on('data', common.mustNotCall('Unexpected data received')); + }); + } + backloggedReqs++; + } + res.end(); + }); + + server.on('connection', common.mustCall()); + + server.listen(0, common.mustCall(function() { + const spawn = require('child_process').spawn; + const args = [__filename, 'child', this.address().port]; + const child = spawn(process.execPath, args, { stdio: 'inherit' }); + child.on('close', common.mustCall(function() { + server.close(); + })); + + server.setTimeout(200, common.mustCallAtLeast(function() { + child.kill(); + }, 1)); + })); +} + +function child() { + const net = require('net'); + + const port = +process.argv[3]; + const conn = net.connect({ port }); + + let req = `GET / HTTP/1.1\r\nHost: localhost:${port}\r\nAccept: */*\r\n\r\n`; + + req = req.repeat(10240); + + conn.on('connect', write); + + // `drain` should fire once and only once + conn.on('drain', common.mustCall(write)); + + function write() { + while (false !== conn.write(req, 'ascii')); + } +} From a91f5210eaaf45728a1bf76e16d4edee242d8a10 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:50:16 +0000 Subject: [PATCH 13/43] [autofix.ci] apply automated fixes --- src/runtime/server/NodeHTTPResponse.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index f78709dfc541..c8659dc6dc12 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -463,7 +463,10 @@ impl NodeHTTPResponse { return Ok(JSValue::UNDEFINED); } raw.pause(); - Bun__NodeHTTP__setReadsPausedSignal(any_response_is_ssl(&raw) as core::ffi::c_int, raw.socket().cast()); + Bun__NodeHTTP__setReadsPausedSignal( + any_response_is_ssl(&raw) as core::ffi::c_int, + raw.socket().cast(), + ); Ok(JSValue::UNDEFINED) } @@ -481,7 +484,10 @@ impl NodeHTTPResponse { } // Not a bare resume: parked pipelined requests replay first so the // stream cannot reorder around them. - Bun__NodeHTTP__onReadsResumable(any_response_is_ssl(&raw) as core::ffi::c_int, raw.socket().cast()); + Bun__NodeHTTP__onReadsResumable( + any_response_is_ssl(&raw) as core::ffi::c_int, + raw.socket().cast(), + ); } pub(crate) fn upgrade( From b8b6be5e562a1dfd7c4186d0ba523f2f3e989a1c Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 14:55:20 -0700 Subject: [PATCH 14/43] http: hand off Upgrade and CONNECT in the JS fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback parsed an Upgrade or CONNECT request and dispatched it as an ordinary 'request', so a WebSocket handshake over a foreign socket got a normal HTTP response instead of a protocol switch, and the tunnel bytes that followed were parsed as HTTP. Follow Node's parserOnIncoming/onParserExecuteCommon: llhttp's upgrade verdict only sticks for CONNECT or when someone will handle the 'upgrade' event — otherwise the request falls through to normal dispatch with req.upgrade cleared. When it sticks, the headers-complete callback returns 2 so llhttp stops at the end of the message, the parser is freed, and the connection is handed to the 'upgrade'/'connect' listener with the first tunnel bytes as bodyHead — or destroyed when nobody is listening, which is only reachable for CONNECT. Also write the derived Content-Length and Transfer-Encoding after the Date/Connection/Keep-Alive block, where Node's _storeHeader puts them. Verified over a duplex against the v26.3.0 binary: Upgrade with a listener (101 + bodyHead + tunnel echo), Upgrade without one (normal dispatch, req.upgrade false), CONNECT with a listener (200 + tunnel), and CONNECT without one (destroyed) all match byte-for-byte modulo Date. --- src/js/internal/http1_server_fallback.ts | 72 ++++++++++++++++++++---- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 97b34ca38e6c..aa8254d43ef6 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -78,14 +78,17 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim // to suppress the Content-Length this block would otherwise invent. Writing // both is a smuggling shape (RFC 9112 6.1), not a cosmetic slip. const chunkedFromAutoBits = (autoBits & 16) !== 0; + // Decide the framing here, but write it after Date/Connection/Keep-Alive: + // Node's _storeHeader emits Content-Length and the chunked Transfer-Encoding + // after its automatic connection block. + let autoContentLength = null; + let autoChunked = false; if (!hasContentLength && !hasTransferEncoding && !noBody && !closeDelimited) { - if (chunkedFromAutoBits) { + if (chunkedFromAutoBits || contentLength === null) { chunked = true; - } else if (contentLength === null) { - chunked = true; - out += "Transfer-Encoding: chunked\r\n"; + autoChunked = !chunkedFromAutoBits; } else { - out += `Content-Length: ${contentLength}\r\n`; + autoContentLength = contentLength; } } // Mirror the native writeAutoHeaders exactly: each line is written iff its @@ -123,8 +126,10 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim } } } - // Last, where Node's _storeHeader puts it — after Connection/Keep-Alive. - if (chunkedFromAutoBits && chunked) { + // Last, where Node's _storeHeader puts them — after Connection/Keep-Alive. + if (autoContentLength !== null) { + out += `Content-Length: ${autoContentLength}\r\n`; + } else if (autoChunked || (chunkedFromAutoBits && chunked)) { out += "Transfer-Encoding: chunked\r\n"; } out += "\r\n"; @@ -270,6 +275,7 @@ function connectionListenerHTTP1(server, socket, options) { } let req = null; + let pendingUpgrade = null; parser[kOnHeadersComplete] = function onHttp1HeadersComplete( versionMajor, @@ -293,6 +299,23 @@ function connectionListenerHTTP1(server, socket, options) { req.method = typeof methodNum === "number" ? allMethods[methodNum] : methodNum; req.upgrade = upgrade; req._addHeaderLines(rawHeaders, rawHeaders.length); + + // Node's parserOnIncoming: llhttp's upgrade verdict only sticks for CONNECT + // or when someone will actually handle the 'upgrade' event; otherwise the + // request falls through to normal dispatch with req.upgrade cleared. + // Returning 2 makes llhttp stop at the end of this message, so the bytes + // after it — the tunnel payload — are never parsed as HTTP. + if (upgrade) { + req.upgrade = + req.method === "CONNECT" || + (typeof server.shouldUpgradeCallback === "function" + ? !!server.shouldUpgradeCallback(req) + : server.listenerCount("upgrade") > 0); + if (req.upgrade) { + pendingUpgrade = req; + return 2; + } + } // The body is fed by the parser callbacks below; reading just resumes the socket. req._read = function (_size) { if (socket.readable) socket.resume(); @@ -351,13 +374,42 @@ function connectionListenerHTTP1(server, socket, options) { socket.destroy(err); } } - socket.on("data", data => { + function onHttp1SocketData(data) { const ret = parser.execute(data); if (ret instanceof Error) { onHttp1SocketError(ret, data); + return; } - }); - socket.on("error", err => onHttp1SocketError(err, undefined)); + if (pendingUpgrade) { + // Node's onParserExecuteCommon: this connection stops being HTTP here. + // Free the parser, hand the socket over with whatever followed the + // request head (the first tunnel bytes), and destroy when nobody is + // listening — reachable only for CONNECT, since a listener-less Upgrade + // already fell through to normal dispatch above. + const upgradeReq = pendingUpgrade; + pendingUpgrade = null; + socket.removeListener("data", onHttp1SocketData); + socket.removeListener("error", onHttp1SocketErrorListener); + connections.delete(socket); + try { + parser.close(); + } catch {} + socket.parser = null; + const eventName = upgradeReq.method === "CONNECT" ? "connect" : "upgrade"; + const bodyHead = typeof ret === "number" ? data.slice(ret) : Buffer.alloc(0); + if (server.listenerCount(eventName) > 0) { + socket.readableFlowing = null; + server.emit(eventName, upgradeReq, socket, bodyHead); + } else { + socket.destroy(); + } + } + } + function onHttp1SocketErrorListener(err) { + onHttp1SocketError(err, undefined); + } + socket.on("data", onHttp1SocketData); + socket.on("error", onHttp1SocketErrorListener); socket.once("close", () => { connections.delete(socket); try { From 09c0380e72c12f6cd6d574a25f8e1317cb535e1d Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 15:03:42 -0700 Subject: [PATCH 15/43] test: cover the fallback's Upgrade/CONNECT handoff The vendored upstream coverage for this (test-http2-allow-http1-upgrade-ws) needs node's bundled undici as a WebSocket dispatcher, so cover the three dispositions directly: an Upgrade with a listener switches protocols and hands over the first tunnel bytes, an Upgrade without one falls through as a normal request with req.upgrade false, and CONNECT without a listener destroys the socket. --- test/js/node/http/node-http.test.ts | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index eb78cb92a012..621b474cfb93 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -3925,3 +3925,68 @@ it("OutgoingMessage outputData is per-instance and _flushOutput is defined", () c.outputData.push({ data: "y", encoding: "utf8", callback: null }); expect(d.outputData.length).toBe(0); }); + +it("connectionListener hands off Upgrade and CONNECT like Node", async () => { + // A socket served through server.emit("connection", ...) takes the JS + // fallback parser. Its Upgrade/CONNECT dispatch must match Node's + // parserOnIncoming: an Upgrade with a listener switches protocols with the + // first tunnel bytes as bodyHead; without one it falls through as a normal + // request with req.upgrade cleared; CONNECT without a listener destroys. + const { duplexPair } = await import("node:stream"); + + { + const server = createServer(() => { + throw new Error("request handler must not run for a handled upgrade"); + }); + server.on("upgrade", (req, socket, head) => { + socket.write("HTTP/1.1 101 Switching Protocols\r\n\r\nHEAD:" + head.toString()); + socket.on("data", d => socket.write("TUNNEL:" + d)); + }); + const [clientSide, serverSide] = duplexPair(); + server.emit("connection", serverSide); + const out = await new Promise(resolve => { + let buf = ""; + clientSide.on("data", d => { + buf += d; + if (buf.includes("HEAD:early")) clientSide.write("more"); + if (buf.includes("TUNNEL:more")) resolve(buf); + }); + clientSide.write("GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: ws\r\nConnection: Upgrade\r\n\r\nearly"); + }); + expect(out).toStartWith("HTTP/1.1 101 Switching Protocols"); + expect(out).toContain("HEAD:early"); + expect(out).toContain("TUNNEL:more"); + } + + { + const server = createServer((req, res) => { + res.end("normal:" + req.upgrade); + }); + const [clientSide, serverSide] = duplexPair(); + server.emit("connection", serverSide); + const out = await new Promise(resolve => { + let buf = ""; + clientSide.on("data", d => { + buf += d; + if (buf.includes("normal:")) resolve(buf); + }); + clientSide.write("GET / HTTP/1.1\r\nHost: x\r\nUpgrade: ws\r\nConnection: Upgrade\r\n\r\n"); + }); + expect(out).toContain("HTTP/1.1 200"); + expect(out).toEndWith("normal:false"); + } + + { + const server = createServer(() => { + throw new Error("request handler must not run for CONNECT"); + }); + const [clientSide, serverSide] = duplexPair(); + server.emit("connection", serverSide); + // A duplexPair does not propagate destroy() to the other side; watch the + // server half directly. + const closed = new Promise(resolve => serverSide.on("close", () => resolve())); + clientSide.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n"); + await closed; + expect(serverSide.destroyed).toBe(true); + } +}); From ce59cfedfafb299616f88c9f6df23fe75f461558 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:17:08 +0000 Subject: [PATCH 16/43] address review: CONNECT double-delivery, spill-replay backpressure, fallback close/Expect, owner_symbol sharing CI fixes: - do_resume: don't arm inStream on a CONNECT tunnel; onSocketData already delivers those bytes, and the new park branch in on_data_or_aborted would re-deliver them from the body buffer (node-http-connect.test.ts). - HttpContext else-branch backpressure gate: drop the already-paused guard so spill replay re-parks on fresh backpressure (the replay clears the signal but not the state bit). - Fix the duplexPair Upgrade/CONNECT test's infinite echo loop that hung node-http.test.ts on Windows/macOS. Review feedback: - http.Server close/closeIdleConnections/closeAllConnections now drain the fallback's kHttp1Connections set. - Fallback handles Expect (checkContinue/checkExpectation/417) and exposes writeContinue; ServerResponse.prototype.writeContinue reaches it. - Fallback calls parser.finish() on socket 'end'. - Http2SecureServer with allowHTTP1 stores maxHeaderSize / insecureHTTPParser / httpValidation so the fallback parser picks them up. - Share owner_symbol between net.ts and cluster/child.ts via internal/async_hooks (matches Node), so the round-robin onconnection maxConnections gate actually resolves the server. - http2.ts: drop unused fallback destructure bindings + stale comment. - HttpParser.h: reuse countedValueStart for chargedValueLength instead of re-scanning leading OWS. - Untangle the serverLenientFlags / httpAllowHalfOpenSet comments and the flushHeaders/204 test comment. - test common getOptionValue('--insecure-http-parser') derives from the runtime. --- packages/bun-uws/src/HttpContext.h | 8 ++++-- packages/bun-uws/src/HttpParser.h | 13 ++++----- src/js/internal/async_hooks.ts | 5 ++++ src/js/internal/cluster/child.ts | 2 +- src/js/internal/http1_server_fallback.ts | 36 ++++++++++++++++++++++++ src/js/node/_http_server.ts | 29 ++++++++++++------- src/js/node/http2.ts | 16 ++++------- src/js/node/net.ts | 2 +- src/runtime/server/NodeHTTPResponse.rs | 3 ++ test/js/node/http/node-http.test.ts | 16 +++++++---- test/js/node/test/common/index.js | 2 +- 11 files changed, 93 insertions(+), 39 deletions(-) diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 599a209f2d6b..1eb7cc85294a 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -444,9 +444,11 @@ struct HttpContext { * backpressure, stop reading (and stop consuming the * already-received requests: the signal parks them) until * onWritable drains it. This request still dispatches, like - * Node, which pauses from within parserOnIncoming. */ - if (((AsyncSocket *) s)->getBufferedAmount() > 0 - && !(httpResponseData->state & HttpResponseData::HTTP_NODE_READS_PAUSED)) { + * Node, which pauses from within parserOnIncoming. No + * already-paused guard: the replay clears the signal but not + * the state bit, so gating on the bit would let the whole + * spill dispatch unbounded on the first replay. */ + if (((AsyncSocket *) s)->getBufferedAmount() > 0) { httpResponseData->state |= HttpResponseData::HTTP_NODE_READS_PAUSED; httpResponseData->nodeHttpReadsPausedSignal = true; ((HttpResponse *) s)->pause(); diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index a8b4ac754343..0edc6b9004fe 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -1056,14 +1056,11 @@ struct HttpResponseData; /* Store this header, it is valid */ headers->value = std::string_view(preliminaryValue, (size_t) (postPaddedBuffer - preliminaryValue)); /* Charge the value the way llhttp hands it to on_header_value: leading - * OWS skipped, trailing OWS still counted. Measure before the trims - * below, or a value padded with trailing spaces is undercharged and we - * accept a header block Node answers with 431. */ - const char *chargedValueStart = preliminaryValue; - while (chargedValueStart < postPaddedBuffer && isHTTPHeaderValueWhitespace((unsigned char) *chargedValueStart)) { - chargedValueStart++; - } - const size_t chargedValueLength = (size_t) (postPaddedBuffer - chargedValueStart); + * OWS skipped (countedValueStart already sits past it), trailing OWS + * still counted. Measure before the trims below, or a value padded with + * trailing spaces is undercharged and we accept a header block Node + * answers with 431. */ + const size_t chargedValueLength = (size_t) (postPaddedBuffer - countedValueStart); postPaddedBuffer += 2; /* Trim trailing whitespace (SP, HTAB) per RFC 9110 Section 5.5 */ while (headers->value.length() && isHTTPHeaderValueWhitespace(headers->value.back())) { diff --git a/src/js/internal/async_hooks.ts b/src/js/internal/async_hooks.ts index 3b62298c1f08..178bd6bd2c08 100644 --- a/src/js/internal/async_hooks.ts +++ b/src/js/internal/async_hooks.ts @@ -16,8 +16,13 @@ function markHookDisabled() { if (activeHooks > 0) activeHooks -= 1; } +// Node keeps owner_symbol here; net.ts writes it onto a server handle and +// cluster/child.ts reads it back off that same handle, so both must share one key. +const owner_symbol = Symbol("owner_symbol"); + export default { enabledHooksExist, markHookEnabled, markHookDisabled, + symbols: { owner_symbol }, }; diff --git a/src/js/internal/cluster/child.ts b/src/js/internal/cluster/child.ts index f2fd8a177021..56fc5d7ee0bc 100644 --- a/src/js/internal/cluster/child.ts +++ b/src/js/internal/cluster/child.ts @@ -15,7 +15,7 @@ const indexes = new Map(); const noop = FunctionPrototype; const TIMEOUT_MAX = 2 ** 31 - 1; const kNoFailure = 0; -const owner_symbol = Symbol("owner_symbol"); +const { owner_symbol } = require("internal/async_hooks").symbols; export default cluster; diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index aa8254d43ef6..f296fc3d5121 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -167,6 +167,9 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim cork(callback) { return callback(); }, + writeContinue() { + socket.write("HTTP/1.1 100 Continue\r\n\r\n"); + }, writeHead(statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs) { const originalStatusCode = statusCode; statusCode |= 0; @@ -343,6 +346,25 @@ function connectionListenerHTTP1(server, socket, options) { this.detachSocket(socket); }); + // Node's parserOnIncoming Expect routing (the native dispatcher applies the + // same at _http_server.ts's DISPATCH_HAS_EXPECT branch). + const expect = req.headers.expect; + if (expect !== undefined) { + if (String(expect).trim().toLowerCase() === "100-continue") { + if (server.listenerCount("checkContinue") > 0) { + server.emit("checkContinue", req, res); + } else { + res.writeContinue(); + server.emit("request", req, res); + } + } else if (server.listenerCount("checkExpectation") > 0) { + server.emit("checkExpectation", req, res); + } else { + res.writeHead(417); + res.end(); + } + return 0; + } server.emit("request", req, res); return 0; }; @@ -410,6 +432,11 @@ function connectionListenerHTTP1(server, socket, options) { } socket.on("data", onHttp1SocketData); socket.on("error", onHttp1SocketErrorListener); + socket.once("end", function onHttp1SocketEnd() { + // Node's socketOnEnd: let llhttp detect a message cut short by EOF. + const ret = parser.finish(); + if (ret instanceof Error) onHttp1SocketError(ret, undefined); + }); socket.once("close", () => { connections.delete(socket); try { @@ -428,10 +455,19 @@ function closeIdleHttp1Connections(server) { } } +function closeAllHttp1Connections(server) { + const connections = server[kHttp1Connections]; + if (!connections) return; + for (const socket of connections) { + if (!socket.destroyed) socket.destroy(); + } +} + export default { createHttp1FallbackResponseHandle, connectionListenerHTTP1, closeIdleHttp1Connections, + closeAllHttp1Connections, kHttp1Connections, kHttp1ActiveRequests, }; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index cdb1e5501401..2343f6501f26 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -84,7 +84,11 @@ const { } = require("node:_http_outgoing"); const OutgoingMessagePrototype = OutgoingMessage.prototype; const { kIncomingMessage } = require("node:_http_common"); -const { connectionListenerHTTP1 } = require("internal/http1_server_fallback"); +const { + connectionListenerHTTP1, + closeIdleHttp1Connections, + closeAllHttp1Connections, +} = require("internal/http1_server_fallback"); const kConnectionsCheckingInterval = Symbol("http.server.connectionsCheckingInterval"); const kTrackedConnections = Symbol("http.server.trackedConnections"); const kHttpAllowHalfOpen = Symbol("http.server.httpAllowHalfOpen"); @@ -490,6 +494,7 @@ Server.prototype.unref = function () { }; Server.prototype.closeAllConnections = function () { + closeAllHttp1Connections(this); const server = this[serverSymbol]; if (!server) { return; @@ -512,6 +517,7 @@ Server.prototype.getConnections = function (callback) { }; Server.prototype.closeIdleConnections = function () { + closeIdleHttp1Connections(this); const server = this[serverSymbol]; server?.closeIdleConnections(); }; @@ -521,6 +527,7 @@ Server.prototype.close = function (optionalCallback?) { // Node.js's httpServerPreClose clears the connections-checking interval // even when the server was never listening. clearInterval(this[kConnectionsCheckingInterval]); + closeIdleHttp1Connections(this); if (!server) { if (typeof optionalCallback === "function") process.nextTick(optionalCallback, $ERR_SERVER_NOT_RUNNING()); // Like Node.js's net.Server#close, close() returns the server. @@ -1207,14 +1214,6 @@ function applyServerCustomOptions(server: Server) { ); } -function httpAllowHalfOpenGet(this: Server) { - return this[kHttpAllowHalfOpen]; -} - -// Node reads `server.httpAllowHalfOpen` when the peer's FIN arrives (socketOnEnd), so -// assigning it after listen() has to reach the native listener too. Push the flags -// alone: setServerCustomOptions() would also re-register the connection filter, which -// appends rather than replaces and can reallocate the vector uWS is iterating. // Same resolution the client applies: httpValidation wins, then an explicit // insecureHTTPParser, then the process-wide --insecure-http-parser. Native // implements two of llhttp's lenient bits, addressed here as bit 0 = lenient @@ -1228,6 +1227,14 @@ function serverLenientFlags(server: Server) { return lenient === HTTPParser.kLenientAll ? 0b11 : 0b01; } +function httpAllowHalfOpenGet(this: Server) { + return this[kHttpAllowHalfOpen]; +} + +// Node reads `server.httpAllowHalfOpen` when the peer's FIN arrives (socketOnEnd), so +// assigning it after listen() has to reach the native listener too. Push the flags +// alone: setServerCustomOptions() would also re-register the connection filter, which +// appends rather than replaces and can reallocate the vector uWS is iterating. function httpAllowHalfOpenSet(this: Server, value) { const previous = !!this[kHttpAllowHalfOpen]; this[kHttpAllowHalfOpen] = value; @@ -3151,7 +3158,9 @@ ServerResponse.prototype.writeContinue = function (cb) { this._sent100 = true; return; } - this.socket?.[kHandle]?.response?.writeContinue(); + const native = this.socket?.[kHandle]?.response; + if (native) native.writeContinue(); + else this[kHandle]?.writeContinue?.(); this._sent100 = true; cb?.(); }; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 8a6d41b9060d..877fef6ae176 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -6294,16 +6294,7 @@ function closeAllSessions(server: Http2Server | Http2SecureServer) { } } -// Minimal HTTP/1.1 response writer used by the allowHTTP1 fallback. It mimics -// the surface of the native NodeHTTPResponse handle that ServerResponse drives -// (cork/writeHead/write/end/abort/...), serializing directly onto the TLS socket. -const { - createHttp1FallbackResponseHandle, - connectionListenerHTTP1, - closeIdleHttp1Connections, - kHttp1Connections, - kHttp1ActiveRequests, -} = require("internal/http1_server_fallback"); +const { connectionListenerHTTP1, closeIdleHttp1Connections, kHttp1Connections } = require("internal/http1_server_fallback"); function connectionListener(socket: Socket) { const options = this[bunSocketServerOptions] || {}; @@ -6534,6 +6525,11 @@ class Http2SecureServer extends tls.Server { this.requestTimeout = http1Options.requestTimeout ?? 300000; this.maxHeadersCount = http1Options.maxHeadersCount ?? null; this.maxRequestsPerSocket = http1Options.maxRequestsPerSocket ?? 0; + // connectionListenerHTTP1 reads these off the server when initializing + // the per-connection parser, matching Node's storeHTTP1Options. + this.maxHeaderSize = http1Options.maxHeaderSize; + this.insecureHTTPParser = http1Options.insecureHTTPParser; + this.httpValidation = http1Options.httpValidation; } if (typeof onRequestHandler === "function") { this.on("request", onRequestHandler); diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 890dcec10900..eda9692e4b02 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -117,7 +117,7 @@ const getBufferedAmount = $newRustFunction("runtime/socket/socket.rs", "jsGetBuf const bunTlsSymbol = Symbol.for("::buntls::"); const bunSocketServerOptions = Symbol.for("::bunnetserveroptions::"); -const owner_symbol = Symbol("owner_symbol"); +const { owner_symbol } = require("internal/async_hooks").symbols; const kServerSocket = Symbol("kServerSocket"); const kBytesWritten = Symbol("kBytesWritten"); diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index c8659dc6dc12..65a05ee894b9 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -1403,6 +1403,9 @@ impl NodeHTTPResponse { || flags.contains(Flags::SOCKET_CLOSED) || flags.contains(Flags::ENDED) || flags.contains(Flags::UPGRADED) + // A CONNECT tunnel's bytes reach JS via onSocketData; arming inStream + // here would deliver them twice (and park them in the body buffer). + || raw.is_connect_request() { return JSValue::FALSE; } diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 621b474cfb93..81097b86ee51 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -27,7 +27,7 @@ import type { AddressInfo } from "node:net"; import { connect, createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import * as path from "node:path"; -import { PassThrough, Writable } from "node:stream"; +import { PassThrough, Writable, duplexPair } from "node:stream"; import { connect as tlsConnect } from "node:tls"; import tunnel from "tunnel"; import { run as runHTTPProxyTest } from "./node-http-proxy.js"; @@ -2882,9 +2882,9 @@ it("standalone ServerResponse discards body writes to a no-body response without it("flushHeaders on a 204 response carries no chunked framing", async () => { // noBodyStatus must suppress the Transfer-Encoding header in flushHeaders() + // and the terminating chunk in internalEnd(), like the one-shot end() path. // The /second GET is chunked, not Content-Length: writeHead() freezes the // framing while _contentLength is still null, exactly as Node does. - // and the terminating chunk in internalEnd(), like the one-shot end() path. const server = createServer((req, res) => { if (req.url === "/nobody") { res.writeHead(204); @@ -3932,8 +3932,6 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { // parserOnIncoming: an Upgrade with a listener switches protocols with the // first tunnel bytes as bodyHead; without one it falls through as a normal // request with req.upgrade cleared; CONNECT without a listener destroys. - const { duplexPair } = await import("node:stream"); - { const server = createServer(() => { throw new Error("request handler must not run for a handled upgrade"); @@ -3946,9 +3944,13 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { server.emit("connection", serverSide); const out = await new Promise(resolve => { let buf = ""; + let sentMore = false; clientSide.on("data", d => { buf += d; - if (buf.includes("HEAD:early")) clientSide.write("more"); + if (!sentMore && buf.includes("HEAD:early")) { + sentMore = true; + clientSide.write("more"); + } if (buf.includes("TUNNEL:more")) resolve(buf); }); clientSide.write("GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: ws\r\nConnection: Upgrade\r\n\r\nearly"); @@ -3956,6 +3958,8 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { expect(out).toStartWith("HTTP/1.1 101 Switching Protocols"); expect(out).toContain("HEAD:early"); expect(out).toContain("TUNNEL:more"); + clientSide.destroy(); + serverSide.destroy(); } { @@ -3974,6 +3978,8 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { }); expect(out).toContain("HTTP/1.1 200"); expect(out).toEndWith("normal:false"); + clientSide.destroy(); + serverSide.destroy(); } { diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index bd895f25b471..e839e4f48c10 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -1371,7 +1371,7 @@ function installBunExposeInternalsShim() { case "--max-http-header-size": return require("node:http").maxHeaderSize; case "--insecure-http-parser": - return false; + return process.execArgv.includes("--insecure-http-parser"); default: return undefined; } From 6641668f8d0346c14bbde585e4e7b59bd10ca3cf Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:32:32 +0000 Subject: [PATCH 17/43] [autofix.ci] apply automated fixes --- src/js/node/http2.ts | 6 +++++- test/js/node/http/node-http.test.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 877fef6ae176..634edd7ae197 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -6294,7 +6294,11 @@ function closeAllSessions(server: Http2Server | Http2SecureServer) { } } -const { connectionListenerHTTP1, closeIdleHttp1Connections, kHttp1Connections } = require("internal/http1_server_fallback"); +const { + connectionListenerHTTP1, + closeIdleHttp1Connections, + kHttp1Connections, +} = require("internal/http1_server_fallback"); function connectionListener(socket: Socket) { const options = this[bunSocketServerOptions] || {}; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 66fc27b3b032..48c3880991eb 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -27,7 +27,7 @@ import type { AddressInfo } from "node:net"; import { connect, createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import * as path from "node:path"; -import { PassThrough, Writable, duplexPair } from "node:stream"; +import { duplexPair, PassThrough, Writable } from "node:stream"; import { connect as tlsConnect } from "node:tls"; import tunnel from "tunnel"; import { run as runHTTPProxyTest } from "./node-http-proxy.js"; From 363c67cf9bad3573acf802faf91c07c9409ab652 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 14:00:08 -0700 Subject: [PATCH 18/43] http2: fail the session when an outbound header block cannot be encoded A header block the HPACK encoder cannot emit was reported as a per-stream error. nghttp2 fails the whole session with COMPRESSION_ERROR (9), so node surfaces ERR_HTTP2_SESSION_ERROR on the session and on the in-flight request. Three changes, each checked against the node v26.3.0 binary: - The encode-failure paths now schedule a session COMPRESSION_ERROR instead of resetting the stream, and leave the stream open so the session teardown is what errors it (node reports the session error on the request too). - ERR_HTTP2_SESSION_ERROR carries the numeric code again. Node passes the raw code (lib/internal/http2/core.js) and uses nameForErrorCode only for ERR_HTTP2_STREAM_ERROR; every existing assertion already expected a number. - The dispatch waits for the deferred tick. It is detected inside the caller's own submit(), and node delivers session errors from the event loop, so additionalHeaders() still returns with its stream usable for the rest of the tick. A write that drains the buffer must not cancel that tick. Updates the one test that pinned the old stream-scoped shape, and covers both the error scope and the delivery timing. --- src/js/node/http2.ts | 14 +----- src/runtime/api/bun/h2_frame_parser.rs | 55 +++++++++++++--------- test/js/node/http2/node-http2.test.js | 64 +++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 36 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 634edd7ae197..e288e6b492f1 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2184,18 +2184,6 @@ function sessionErrorFromCode(code: number) { return $ERR_HTTP2_SESSION_ERROR(code); } hideFromStack(sessionErrorFromCode); -// Used for the legacy native error dispatches that still carry a positive HTTP/2 error code -// (e.g. MAX_PENDING_SETTINGS_ACK, ENHANCE_YOUR_CALM from the outbound paths): the message carries -// the NGHTTP2_* constant name. Violations detected by the inbound engine arrive as negative -// nghttp2 library codes and are surfaced as NghttpError (ERR_HTTP2_ERROR), exactly like node. -// GOAWAY-received errors stay numeric (sessionErrorFromCode) to match node's message exactly. -function sessionErrorFromCodeNamed(code: number) { - if (code === 0xe) { - return $ERR_HTTP2_MAX_PENDING_SETTINGS_ACK(); - } - return $ERR_HTTP2_SESSION_ERROR(nameForErrorCode[code] || code); -} -hideFromStack(sessionErrorFromCodeNamed); function assertSession(session) { if (!session) { @@ -5192,7 +5180,7 @@ class ClientHttp2Session extends Http2Session { ? $ERR_HTTP2_TOO_MANY_INVALID_FRAMES() : typeof errorCode === "number" && errorCode < 0 ? new NghttpError(errorCode) - : sessionErrorFromCodeNamed(errorCode as number); + : sessionErrorFromCode(errorCode as number); self.destroy(error_instance); }, diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 76da70c00701..18b4582e9375 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1465,6 +1465,9 @@ pub struct H2FrameParser { /// A native write returned a terminal result (socket closed, shut down, or the kernel /// rejected the send). Latched once; the deferred tick closes the transport. transport_write_fatal: Cell, + /// An outbound header block the HPACK encoder could not emit. Latched once; the deferred + /// tick reports it, because it is detected inside a user submit call. + pending_header_compression_error: Cell, ref_count: bun_ptr::RefCount, // intrusive — bun.ptr.RefCount(@This(), "ref_count", deinit, .{}) /// Number of live `Keepalive` guards: the `+1`s held by native frames currently on the stack. /// Read only by `release_refs_stranded_by_exit()`. @@ -2982,6 +2985,19 @@ impl H2FrameParser { ); } + /// A header block the HPACK encoder cannot emit fails the whole session in nghttp2, so node + /// reports ERR_HTTP2_SESSION_ERROR (COMPRESSION_ERROR) rather than resetting the stream. + /// The stream is left open for the session teardown to error, matching node's request error. + fn schedule_header_compression_session_error(&self) { + if self.pending_header_compression_error.get() { + return; + } + // Detected inside the caller's own submit(): node delivers session errors from the + // event loop, so that call still returns with its stream usable for the rest of the tick. + self.pending_header_compression_error.set(true); + self.register_auto_flush(); + } + fn cork(&self) { if let Some(corked) = CORKED_H2.with(|c| c.get()) { if std::ptr::eq(corked, self.as_ctx_ptr()) { @@ -3336,6 +3352,11 @@ impl H2FrameParser { if !self.auto_flusher.get().registered.get() { return; } + // A write that drains the buffer must not cancel the deferred tick a pending session + // error is waiting on; on_auto_flush unregisters once it has reported it. + if self.pending_header_compression_error.get() { + return; + } debug_assert!(self.auto_flusher.get().registered.get()); let ctx = NonNull::new(self.as_ctx_ptr().cast::()); let removed = self @@ -3432,6 +3453,15 @@ impl H2FrameParser { } return false; } + if self.pending_header_compression_error.get() { + self.pending_header_compression_error.set(false); + self.dispatch_with_2_extra( + JSH2FrameParser::Gc::onError, + JSValue::js_number(ErrorCode::COMPRESSION_ERROR.0 as f64), + JSValue::js_number(self.last_stream_id.get() as f64), + JSValue::UNDEFINED, + ); + } let _ = self.flush(); // we will unregister ourselves when the buffer is empty true @@ -8846,18 +8876,12 @@ impl H2FrameParser { }; // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists let stream = unsafe { &mut *stream }; - stream.state = StreamState::CLOSED; if !stream_ctx_arg.is_empty_or_undefined_or_null() && stream_ctx_arg.is_object() { stream.set_context(stream_ctx_arg, global_object); } - stream.rst_code = ErrorCode::COMPRESSION_ERROR.0; - this.dispatch_with_extra( - JSH2FrameParser::Gc::onStreamError, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); + this.schedule_header_compression_session_error(); return Ok(JSValue::js_number(stream_id as f64)); } } @@ -9038,13 +9062,7 @@ impl H2FrameParser { { stream.set_context(stream_ctx_arg, global_object); } - stream.state = StreamState::CLOSED; - stream.rst_code = ErrorCode::COMPRESSION_ERROR.0; - this.dispatch_with_extra( - JSH2FrameParser::Gc::onStreamError, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); + this.schedule_header_compression_session_error(); return Ok(JSValue::UNDEFINED); } } @@ -9123,18 +9141,12 @@ impl H2FrameParser { }; // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists let stream = unsafe { &mut *stream }; - stream.state = StreamState::CLOSED; if !stream_ctx_arg.is_empty_or_undefined_or_null() && stream_ctx_arg.is_object() { stream.set_context(stream_ctx_arg, global_object); } - stream.rst_code = ErrorCode::COMPRESSION_ERROR.0; - this.dispatch_with_extra( - JSH2FrameParser::Gc::onStreamError, - stream.get_identifier(), - JSValue::js_number(stream.rst_code as f64), - ); + this.schedule_header_compression_session_error(); return Ok(JSValue::js_number(stream_id as f64)); } } @@ -9778,6 +9790,7 @@ impl H2FrameParser { hpack: JsCell::new(None), has_nonnative_backpressure: Cell::new(false), transport_write_fatal: Cell::new(false), + pending_header_compression_error: Cell::new(false), auto_flusher: JsCell::new(AutoFlusher::default()), padding_strategy: Cell::new(PaddingStrategy::None), engine: core::cell::RefCell::new(None), diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 3111293662ec..1430378af2df 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -578,8 +578,10 @@ for (const nodeExecutable of [nodeExe(), bunExe()]) { await doHttp2Request(HTTPS_SERVER, HTTPS_SERVER, { ":path": "/", "test-header": "A".repeat(90000) }); expect("unreachable").toBe(true); } catch (err) { - expect(err.code).toBe("ERR_HTTP2_STREAM_ERROR"); - expect(err.message).toBe("Stream closed with error code NGHTTP2_COMPRESSION_ERROR"); + // Verified against node v26.3.0: a header block the encoder cannot emit fails the + // session with COMPRESSION_ERROR (9), it does not just reset the stream. + expect(err.code).toBe("ERR_HTTP2_SESSION_ERROR"); + expect(err.message).toBe("Session closed with error code 9"); } }); it("should be destroyed after close", async () => { @@ -3489,3 +3491,61 @@ it("http2 server sends each session's frames to its own peer under interleaved r server.close(); } }); + +it("fails the whole session when an outbound header block cannot be encoded", async () => { + // A header block the HPACK encoder cannot emit is a COMPRESSION_ERROR (9) against the + // session in node, so the session and the in-flight request both see + // ERR_HTTP2_SESSION_ERROR rather than a per-stream error. + const server = http2.createServer(); + try { + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`, { maxSendHeaderBlockLength: 100000 }); + const sessionError = new Promise(resolve => client.on("error", resolve)); + const requestError = new Promise(resolve => { + const req = client.request({ "test-header": "A".repeat(90000) }); + req.on("error", resolve); + req.end(); + }); + + const [sessionErr, reqErr] = await Promise.all([sessionError, requestError]); + expect(sessionErr.code).toBe("ERR_HTTP2_SESSION_ERROR"); + expect(sessionErr.message).toBe("Session closed with error code 9"); + expect(reqErr.code).toBe("ERR_HTTP2_SESSION_ERROR"); + } finally { + server.close(); + } +}); + +it("delivers a session error from the event loop, not inside the call that detected it", async () => { + // node surfaces session errors from the event loop, so the submit call that tripped one + // still returns and its stream stays usable for the rest of the tick. + const server = http2.createServer({ maxSendHeaderBlockLength: 100000 }); + try { + const observed = {}; + const sessionError = new Promise(resolve => server.on("sessionError", resolve)); + server.on("stream", stream => { + stream.on("error", () => {}); + stream.additionalHeaders({ "test-header": "A".repeat(90000) }); + observed.destroyedAfterSubmit = stream.destroyed; + stream.respond(); + stream.end(); + observed.submitsReturned = true; + }); + + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + const req = client.request(); + req.on("error", () => {}); + req.end(); + + const err = await sessionError; + expect(err.code).toBe("ERR_HTTP2_SESSION_ERROR"); + expect(err.message).toBe("Session closed with error code 9"); + expect(observed.destroyedAfterSubmit).toBe(false); + expect(observed.submitsReturned).toBe(true); + client.destroy(); + } finally { + server.close(); + } +}); From 73065c574f6c1e9482b43cb16331531e0b5a07be Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 16:01:47 -0700 Subject: [PATCH 19/43] http2: perf_hooks entries, END_STREAM on the final DATA frame, and close/push parity Five node v26.3.0 compat gaps, each verified against the node binary: - PerformanceObserver({ type: "http2" }) now receives Http2Session and Http2Stream entries with node's detail shape. Frame counts come from the engine (counted per accepted inbound frame and per written frame header, pushed to the embedder through a new Sink method so reading them never contends with the engine borrow) and are snapshotted when the graceful close schedules its deferred destroy - the point where node's own accounting stops. - A final write made after end() now carries END_STREAM on its DATA frame instead of being followed by an empty END_STREAM frame, matching node's framing (and its frame counts). Trailers still move END_STREAM onto the trailer HEADERS. - close() extends the unACKed-SETTINGS grace to outstanding pings: node always delivers a ping callback its RTT on a healthy session, never a cancel error. - session.socket reads undefined once the socket detached, even when the proxy had been handed out earlier. - pushStream() with a header block the encoder cannot emit no longer throws: the callback receives the reserved stream (so the caller can attach handlers) and the session fails with COMPRESSION_ERROR, exactly like node, including the uncaught-error behavior when no handlers are attached. Vendors test-http2-perf_hooks.js from node v26.3.0, byte-identical. --- src/js/internal/shared.ts | 2 +- src/js/node/http2.ts | 249 +++++++++++++++++- src/runtime/api/bun/h2/connection.rs | 18 ++ src/runtime/api/bun/h2_frame_parser.rs | 114 +++++--- src/runtime/api/h2.classes.ts | 4 + test/js/node/http2/node-http2.test.js | 79 ++++++ .../test/parallel/test-http2-perf_hooks.js | 104 ++++++++ 7 files changed, 523 insertions(+), 47 deletions(-) create mode 100644 test/js/node/test/parallel/test-http2-perf_hooks.js diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index 749d7eb4dff2..6f9174132994 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -167,7 +167,7 @@ const observerCounts = new Map(); const kObservers = new Set(); /** Entry types routed through this JS-side registry instead of the native observer. */ -const kNodeEntryTypes = new Set(["net", "dns", "http", "function"]); +const kNodeEntryTypes = new Set(["net", "dns", "http", "http2", "function"]); function hasObserver(type) { return (observerCounts.get(type) ?? 0) > 0; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index e288e6b492f1..9fe81c6e13cb 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -27,7 +27,8 @@ * Modifications were made to the original code. */ const { isTypedArray } = require("node:util/types"); -const { hideFromStack, throwNotImplemented } = require("internal/shared"); +const { hideFromStack, throwNotImplemented, hasObserver, enqueueNodeEntry, PerformanceNodeEntry } = + require("internal/shared"); const { STATUS_CODES } = require("internal/http"); const { kTimeout, getTimerDuration } = require("internal/timers"); const tls = require("node:tls"); @@ -2226,6 +2227,11 @@ function pushToStream(stream, data) { // past the buffer's high-water mark instead of stalling on the receive window. // setStreamReading(id, false) only records the paused bit natively, so calling it // from inside the dispatch that delivered `data` cannot re-enter the engine. + const perf = stream[kPerfState]; + if (perf !== undefined && data !== null) { + if (perf.firstByte === 0) perf.firstByte = performance.now() - perf.start; + perf.bytesRead += data.length; + } if (!stream.push(data) && data !== null) { streamOnPause.$call(stream); } @@ -2241,6 +2247,9 @@ enum StreamState { // The native side fully closed and freed the stream (state 7 delivered): there is // nothing left to send on the wire for it. NativeClosed = 1 << 6, // 1000000 = 64 + // END_STREAM already rode the final DATA frame from _write/_writev; _final must not + // emit the empty END_STREAM frame on top of it. + EndStreamSent = 1 << 7, // 10000000 = 128 } // native.writeStream() return-value flag (mirrors WRITE_FLUSHED_WITHOUT_CALLBACK in // h2_frame_parser.rs): the chunk was handed to the socket without queueing and the engine did @@ -2258,6 +2267,25 @@ const kWriteFlushedWithoutCallback = 0x10; function deferWriteCallbackForSocket(nativeSocket) { return nativeSocket ? process.nextTick : setImmediate; } +// Whether this chunk is the writable's last: end() already ran and nothing is queued +// behind it (writableLength still includes the in-flight chunk, in the writable's own +// units — string chunks count code units), and no trailers are pending (END_STREAM must +// ride the trailer HEADERS instead). node packs END_STREAM onto that final DATA frame +// rather than emitting an empty one after it. +function isFinalWrite(stream: Http2Stream, pendingLength: number) { + return stream._writableState.ending && stream.writableLength === pendingLength && !stream[bunHTTP2WaitForTrailers]; +} + +// writeStream settled the stream to HALF_CLOSED_LOCAL synchronously with the dispatch +// suppressed: the JS side runs the bookkeeping the onStreamEnd(5) handler would have. +function onEndStreamSettled(stream: Http2Stream) { + markWritableDone(stream); + if ((stream.id & 1) === 0 && stream[bunHTTP2Session]?.type === constants.NGHTTP2_SESSION_SERVER) { + if (!stream.rstCode) stream.rstCode = 0; + markStreamClosed(stream); + } +} + function markWritableDone(stream: Http2Stream) { const _final = stream[bunHTTP2StreamFinal]; if (typeof _final === "function") { @@ -2280,6 +2308,97 @@ function publishStreamCloseChannel(stream: Http2Stream) { onServerStreamCloseChannel.publish({ stream }); } } +const kPerfStats = Symbol("http2PerfStats"); +const kPerfState = Symbol("http2PerfState"); + +// perf_hooks 'http2' entries, mirroring node's Http2Session/Http2Stream statistics. +// All tracking is armed at construction only while an 'http2' observer exists, so +// unobserved sessions pay a single hasObserver() check. +function initHttp2SessionPerf(session, type: "server" | "client") { + if (!hasObserver("http2")) return; + session[kPerfStats] = { + type, + start: performance.now(), + streamCount: 0, + streamDurationSum: 0, + closedStreams: 0, + live: 0, + maxConcurrentStreams: 0, + pingRTT: 0, + emitted: false, + }; +} + +function trackHttp2StreamStart(stream, session) { + const stats = session?.[kPerfStats]; + if (stats === undefined) return; + stats.streamCount++; + stats.live++; + if (stats.live > stats.maxConcurrentStreams) stats.maxConcurrentStreams = stats.live; + stream[kPerfState] = { + start: performance.now(), + bytesRead: 0, + bytesWritten: 0, + firstHeader: 0, + firstByte: 0, + firstByteSent: 0, + }; +} + +function emitHttp2StreamPerf(stream) { + const state = stream[kPerfState]; + if (state === undefined) return; + stream[kPerfState] = undefined; + const now = performance.now(); + const stats = stream[bunHTTP2Session]?.[kPerfStats]; + if (stats !== undefined) { + stats.live--; + stats.closedStreams++; + stats.streamDurationSum += now - state.start; + } + enqueueNodeEntry( + new PerformanceNodeEntry("Http2Stream", "http2", state.start, now - state.start, { + id: typeof stream.id === "number" ? stream.id : 0, + timeToFirstByte: state.firstByte, + timeToFirstByteSent: state.firstByteSent, + timeToFirstHeader: state.firstHeader, + bytesWritten: state.bytesWritten, + bytesRead: state.bytesRead, + }), + ); +} + +// Counters as of the moment the session's graceful close completed and the destroy was +// scheduled: frames arriving during the deferral (e.g. the peer's GOAWAY reply) belong +// to teardown, which is where node's own accounting stops. +function captureHttp2PerfFrameSnapshot(session, parser) { + const stats = session[kPerfStats]; + if (stats !== undefined && stats.frameSnapshot === undefined && parser) { + stats.frameSnapshot = parser.getFrameCounters(); + } +} + +function emitHttp2SessionPerf(session, parser, socket) { + const stats = session[kPerfStats]; + if (stats === undefined || stats.emitted) return; + stats.emitted = true; + const now = performance.now(); + const counters = stats.frameSnapshot ?? parser?.getFrameCounters() ?? { framesReceived: 0, framesSent: 0 }; + enqueueNodeEntry( + new PerformanceNodeEntry("Http2Session", "http2", stats.start, now - stats.start, { + bytesWritten: socket?.bytesWritten ?? 0, + bytesRead: socket?.bytesRead ?? 0, + framesReceived: counters.framesReceived, + framesSent: counters.framesSent, + maxConcurrentStreams: stats.maxConcurrentStreams, + pingRTT: stats.pingRTT, + streamAverageDuration: stats.closedStreams > 0 ? stats.streamDurationSum / stats.closedStreams : 0, + streamCount: stats.streamCount, + type: stats.type, + }), + ); +} + function markStreamClosed(stream: Http2Stream) { const status = stream[bunHTTP2StreamStatus]; @@ -2390,6 +2509,7 @@ class Http2Stream extends Duplex { this.#id = streamId; this[bunHTTP2Session] = session; this[bunHTTP2Headers] = headers; + trackHttp2StreamStart(this, session); // node ties the stream's receive window to JS-side consumption (readStart/readStop on the // native handle): while the readable is paused the peer is not granted more window, so it // backpressures instead of the session buffering the whole body. @@ -2688,6 +2808,7 @@ class Http2Stream extends Duplex { } } this.rstCode = rstCode; + emitHttp2StreamPerf(this); // node closes the stream from inside _destroy, so the close-channel publish observes // closed === true and destroyed === true with the final rstCode. The non-error close path // (streamEnd state=7) calls markStreamClosed BEFORE destroy(), where the channel observes @@ -2743,6 +2864,17 @@ class Http2Stream extends Duplex { if (onClientStreamBodySentChannel.hasSubscribers && this instanceof ClientHttp2Stream) { onClientStreamBodySentChannel.publish({ stream: this }); } + if ((status & StreamState.EndStreamSent) !== 0) { + this[bunHTTP2StreamStatus] = status | StreamState.FinalCalled; + if ((status & (StreamState.WritableClosed | StreamState.Closed)) !== 0) { + callback(); + } else { + // The final DATA is still in flight; the drain-side streamEnd(5) dispatch + // completes the writable through markWritableDone. + this[bunHTTP2StreamFinal] = callback; + } + return; + } const session = this[bunHTTP2Session]; if (session) { const native = session[bunHTTP2Native]; @@ -2879,10 +3011,22 @@ class Http2Stream extends Duplex { this.once("ready", this._writev.bind(this, data, callback)); return; } + const writevPerf = this[kPerfState]; + if (writevPerf !== undefined) { + if (writevPerf.firstByteSent === 0) writevPerf.firstByteSent = performance.now() - writevPerf.start; + for (let i = 0; i < data.length; i++) { + const { chunk, encoding } = data[i]; + writevPerf.bytesWritten += typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; + } + } const session = this[bunHTTP2Session]; if (session) { const native = session[bunHTTP2Native]; if (native) { + let batchLength = 0; + for (let i = 0; i < data.length; i++) { + batchLength += data[i].chunk.length; + } const allBuffers = data.allBuffers; let chunks; if (allBuffers) { @@ -2906,8 +3050,13 @@ class Http2Stream extends Duplex { } const chunk = Buffer.concat(chunks || []); if (session[kTimeout]) session[kTimeout].refresh(); - const status = native.writeStream(this.#id, chunk, undefined, false, callback, true); + const endStream = isFinalWrite(this, batchLength); + const status = native.writeStream(this.#id, chunk, undefined, endStream, callback, true); if (status & kWriteFlushedWithoutCallback) session[kDeferWriteCallback](callback); + if (endStream) { + this[bunHTTP2StreamStatus] |= StreamState.EndStreamSent; + if ((status & ~kWriteFlushedWithoutCallback) === 5) onEndStreamSettled(this); + } if (onClientStreamBodyChunkSentChannel.hasSubscribers && this instanceof ClientHttp2Stream) { onClientStreamBodyChunkSentChannel.publish({ stream: this, writev: true, data, encoding: "" }); } @@ -2924,6 +3073,11 @@ class Http2Stream extends Duplex { this.once("ready", this._write.bind(this, chunk, encoding, callback)); return; } + const writePerf = this[kPerfState]; + if (writePerf !== undefined) { + if (writePerf.firstByteSent === 0) writePerf.firstByteSent = performance.now() - writePerf.start; + writePerf.bytesWritten += typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length; + } const session = this[bunHTTP2Session]; if (session) { const native = session[bunHTTP2Native]; @@ -2937,8 +3091,13 @@ class Http2Stream extends Duplex { wireEncoding = undefined; } if (session[kTimeout]) session[kTimeout].refresh(); - const status = native.writeStream(this.#id, wireChunk, wireEncoding, false, callback, true); + const endStream = isFinalWrite(this, chunk.length); + const status = native.writeStream(this.#id, wireChunk, wireEncoding, endStream, callback, true); if (status & kWriteFlushedWithoutCallback) session[kDeferWriteCallback](callback); + if (endStream) { + this[bunHTTP2StreamStatus] |= StreamState.EndStreamSent; + if ((status & ~kWriteFlushedWithoutCallback) === 5) onEndStreamSettled(this); + } if (onClientStreamBodyChunkSentChannel.hasSubscribers && this instanceof ClientHttp2Stream) { onClientStreamBodyChunkSentChannel.publish({ stream: this, writev: false, data: chunk, encoding }); } @@ -3295,8 +3454,9 @@ class ServerHttp2Stream extends Http2Stream { if (onServerStreamStartChannel.hasSubscribers) { onServerStreamStartChannel.publish({ stream: pushedStream, headers }); } + let pushResult; try { - parser.pushPromise(this.id, pushId, headers, sensitiveNames); + pushResult = parser.pushPromise(this.id, pushId, headers, sensitiveNames); } catch (err) { // pushPromise() can throw synchronously (invalid token, invalid pseudo-header, oversized // block). The pushed stream was already created by getNextStream's streamStart; tear it @@ -3313,6 +3473,15 @@ class ServerHttp2Stream extends Http2Stream { process.nextTick(callback, err); return; } + if (pushResult === -1) { + // The block could not be encoded: the session is failing with a COMPRESSION_ERROR. + // node still delivers the reserved stream to the callback (the caller gets to + // attach handlers) and lets the session teardown error it. The PUSH_PROMISE never + // reached the wire, so teardown must not send an RST for the reserved id. + if (pushedStream) pushedStream[kNeverAnnounced] = true; + process.nextTick(callback, null, pushedStream, headers); + return; + } // node: a HEAD push (or options.endStream) carries no response body, so the pushed stream's // writable side is already ended when the callback runs; respond() then forces endStream so // END_STREAM rides on the response HEADERS frame. @@ -4208,7 +4377,21 @@ class ServerHttp2Session extends Http2Session { const callbackInfo = callbacks.shift(); if (callbackInfo) { const [callback, start] = callbackInfo; - callback(null, Date.now() - start, payload); + const rtt = Date.now() - start; + const stats = self[kPerfStats]; + if (stats !== undefined) stats.pingRTT = rtt; + callback(null, rtt, payload); + if (callbacks.length === 0 && self.#pendingSettingsAckCount === 0) { + if (self[kSettingsAckGraceTimer] !== undefined) { + clearTimeout(self[kSettingsAckGraceTimer]); + self[kSettingsAckGraceTimer] = undefined; + } + // A server session has no pending-request queue. + if (self.#closed && self.#connections === 0 && !self.destroyed) { + captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); + setImmediate(destroyIfNotDestroyedNT, self); + } + } } } } @@ -4468,6 +4651,7 @@ class ServerHttp2Session extends Http2Session { socket.on("close", this.#onClose.bind(this)); socket.on("error", this.#onError.bind(this)); socket.on("timeout", this.#onTimeout.bind(this)); + initHttp2SessionPerf(this, "server"); socket.on("data", this.#onRead.bind(this)); socket.on("drain", this.#onDrain.bind(this)); @@ -4520,9 +4704,11 @@ class ServerHttp2Session extends Http2Session { } get socket() { - if (this.#socket_proxy) return this.#socket_proxy; + // After the session detaches from its socket node reports undefined, even + // when the proxy had been handed out earlier. const socket = this[bunHTTP2Socket]; - if (!socket) return null; + if (!socket) return undefined; + if (this.#socket_proxy) return this.#socket_proxy; this.#socket_proxy = new Proxy(this, proxySocketHandler); return this.#socket_proxy; } @@ -4671,6 +4857,7 @@ class ServerHttp2Session extends Http2Session { return; } this.#destroying = true; + emitHttp2SessionPerf(this, this.#parser, this[bunHTTP2Socket]); try { const server = this[kServer]; if (server) { @@ -5120,6 +5307,10 @@ class ClientHttp2Session extends Http2Session { } else { // Node's ClientHttp2Session emits 'stream' only for pushed streams; a normal request's // response arrives solely via the stream's own 'response' event. + const responsePerf = stream[kPerfState]; + if (responsePerf !== undefined && responsePerf.firstHeader === 0) { + responsePerf.firstHeader = performance.now() - responsePerf.start; + } stream.emit("response", headers, flags, rawheaders); } } @@ -5132,11 +5323,13 @@ class ClientHttp2Session extends Http2Session { self.#pendingSettingsAck = false; if (self.#pendingSettingsAckCount > 0) self.#pendingSettingsAckCount--; // This ACK may be the only thing a gracefully closed, idle session was waiting for. - if (self.#pendingSettingsAckCount === 0 && self[kSettingsAckGraceTimer] !== undefined) { + const pingsDrained = self.#pingCallbacks === null || self.#pingCallbacks.length === 0; + if (self.#pendingSettingsAckCount === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { clearTimeout(self[kSettingsAckGraceTimer]); self[kSettingsAckGraceTimer] = undefined; } - if (self.#closed && self.#connections === 0 && self.#pendingSettingsAckCount === 0 && !self.destroyed) { + if (self.#closed && self.#connections === 0 && self.#pendingSettingsAckCount === 0 && pingsDrained && !self.destroyed) { + captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); setImmediate(destroyIfNotDestroyedNT, self); } const queued = self.#pendingSettingsCallbacks.shift(); @@ -5164,7 +5357,25 @@ class ClientHttp2Session extends Http2Session { const callbackInfo = callbacks.shift(); if (callbackInfo) { const [callback, start] = callbackInfo; - callback(null, Date.now() - start, payload); + const rtt = Date.now() - start; + const stats = self[kPerfStats]; + if (stats !== undefined) stats.pingRTT = rtt; + callback(null, rtt, payload); + if (callbacks.length === 0 && self.#pendingSettingsAckCount === 0) { + if (self[kSettingsAckGraceTimer] !== undefined) { + clearTimeout(self[kSettingsAckGraceTimer]); + self[kSettingsAckGraceTimer] = undefined; + } + if ( + self.#closed && + self.#connections === 0 && + (self.#pendingRequests === null || self.#pendingRequests.length === 0) && + !self.destroyed + ) { + captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); + setImmediate(destroyIfNotDestroyedNT, self); + } + } } } } @@ -5477,9 +5688,11 @@ class ClientHttp2Session extends Http2Session { return this.#parser?.setLocalWindowSize?.(windowSize); } get socket() { - if (this.#socket_proxy) return this.#socket_proxy; + // After the session detaches from its socket node reports undefined, even + // when the proxy had been handed out earlier. const socket = this[bunHTTP2Socket]; - if (!socket) return null; + if (!socket) return undefined; + if (this.#socket_proxy) return this.#socket_proxy; this.#socket_proxy = new Proxy(this, proxySocketHandler); return this.#socket_proxy; } @@ -5641,6 +5854,7 @@ class ClientHttp2Session extends Http2Session { socket.on("close", this.#onClose.bind(this)); socket.on("error", this.#onError.bind(this)); socket.on("timeout", this.#onTimeout.bind(this)); + initHttp2SessionPerf(this, "client"); } // Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created. Once closed, http2session.destroy() might be called if there are no open Http2Stream instances. @@ -5671,8 +5885,14 @@ class ClientHttp2Session extends Http2Session { // localSettings dispatch finishes the deferred destroy, and a dead socket still tears // the session down. destroy() remains immediate. if (this.#connections === 0 && (this.#pendingRequests === null || this.#pendingRequests.length === 0)) { - if (this.#pendingSettingsAckCount > 0) scheduleSettingsAckGraceNT(this); - else setImmediate(destroyIfNotDestroyedNT, this); + // An unACKed ping gets the same bounded grace as an unACKed SETTINGS: node always + // delivers the ping callback its RTT on a healthy session, never a cancel error. + if (this.#pendingSettingsAckCount > 0 || (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0)) { + scheduleSettingsAckGraceNT(this); + } else { + captureHttp2PerfFrameSnapshot(this, this.#parser); + setImmediate(destroyIfNotDestroyedNT, this); + } } } @@ -5698,6 +5918,7 @@ class ClientHttp2Session extends Http2Session { return; } this.#destroying = true; + emitHttp2SessionPerf(this, this.#parser, socket); try { if (this[kTimeout]) { clearTimeout(this[kTimeout]); diff --git a/src/runtime/api/bun/h2/connection.rs b/src/runtime/api/bun/h2/connection.rs index c8afea6e69b1..ccda098f7ae7 100644 --- a/src/runtime/api/bun/h2/connection.rs +++ b/src/runtime/api/bun/h2/connection.rs @@ -154,6 +154,10 @@ pub trait Sink { /// The peer exceeded the session's invalid-frame allowance (node's maxSessionInvalidFrames): /// the embedder should destroy the session with ERR_HTTP2_TOO_MANY_INVALID_FRAMES. fn on_too_many_invalid_frames(&self) {} + /// Frame-counter update (perf_hooks http2 session stats). Called whenever either + /// counter moves, while the connection is mutably borrowed — the embedder must only + /// store the values. + fn on_frame_counters(&self, _received: u64, _sent: u64) {} /// Transition shim while the outbound path still flows through the embedder's legacy encoder: /// returns true if `stream_id` was initiated locally (HEADERS already sent by the embedder), so /// inbound frames for it are not treated as frames on an idle stream. @@ -179,6 +183,10 @@ pub trait Sink { pub struct Connection { pub is_server: bool, + /// Wire frames fully accepted from the peer (perf_hooks http2 session stats). + pub frames_received: u64, + /// Wire frames this engine itself has written (the embedder counts its own). + pub frames_sent: u64, pub local_settings: Settings, pub remote_settings: Settings, @@ -269,6 +277,8 @@ impl Connection { remote_settings: Settings::default(), local_settings_acked: false, max_header_list_pairs: 128, + frames_received: 0, + frames_sent: 0, max_invalid_frames: 1000, acked_local_initial_window: 65_535, enforced_max_header_list_size: local.max_header_list_size, @@ -309,6 +319,8 @@ impl Connection { stream_id: u32, payload: &[u8], ) { + self.frames_sent += 1; + sink.on_frame_counters(self.frames_received, self.frames_sent); let mut hdr_buf = [0u8; wire::FRAME_HEADER_SIZE]; let hdr = FrameHeader { length: payload.len() as u32, @@ -578,6 +590,8 @@ impl Connection { /// Dispatch one fully-buffered frame. Returns true if the connection is now fatally closing. fn dispatch(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { + self.frames_received += 1; + sink.on_frame_counters(self.frames_received, self.frames_sent); // RFC 9113 §4.3 / §6.10: once a HEADERS/PUSH_PROMISE without END_HEADERS is received, the // ONLY permitted frame is a CONTINUATION for that same stream until the block completes. // Checked before structural validation: a malformed non-CONTINUATION frame mid-block is a @@ -1344,6 +1358,10 @@ impl Connection { }; debug_assert!(inflight.payload_remaining > 0); self.data_in_flight = Some(inflight); + // An incrementally-streamed DATA frame never reaches dispatch(); count it here, + // once, when its header is accepted. + self.frames_received += 1; + sink.on_frame_counters(self.frames_received, self.frames_sent); StreamedDataStart::Consumed(wire::FRAME_HEADER_SIZE + consumed_payload) } diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 18b4582e9375..2c4c3c0223bc 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -418,7 +418,8 @@ impl Default for FrameHeader { impl FrameHeader { pub const BYTE_SIZE: usize = 9; #[inline] - fn write(&self, writer: &mut impl WireWriter) -> bool { + fn write(&self, writer: &mut impl WireWriter, frames_sent: &Cell) -> bool { + frames_sent.set(frames_sent.get() + 1); let mut buf = [0u8; Self::BYTE_SIZE]; buf[0] = ((self.length >> 16) & 0xFF) as u8; buf[1] = ((self.length >> 8) & 0xFF) as u8; @@ -1468,6 +1469,12 @@ pub struct H2FrameParser { /// An outbound header block the HPACK encoder could not emit. Latched once; the deferred /// tick reports it, because it is detected inside a user submit call. pending_header_compression_error: Cell, + /// Frames written by the legacy outbound encoder (perf_hooks http2 session stats). + frames_sent_legacy: Cell, + /// Engine counters mirrored at the end of each rewrite_read batch, so reading them + /// never contends with the engine borrow. + engine_frames_received: Cell, + engine_frames_sent: Cell, ref_count: bun_ptr::RefCount, // intrusive — bun.ptr.RefCount(@This(), "ref_count", deinit, .{}) /// Number of live `Keepalive` guards: the `+1`s held by native frames currently on the stack. /// Read only by `release_refs_stranded_by_exit()`. @@ -1826,7 +1833,7 @@ impl Stream { length: 0, }; owned_frame = Some(frame); - break 'brk data_header.write(&mut writer); + break 'brk data_header.write(&mut writer, &client.frames_sent_legacy); } else { let max_size = frame_remaining .min( @@ -1906,7 +1913,7 @@ impl Stream { stream_identifier: self.id, length: u32::try_from(payload_size).expect("int cast"), }; - let _ = data_header.write(&mut writer); + let _ = data_header.write(&mut writer, &client.frames_sent_legacy); if padding != 0 { break 'brk SHARED_REQUEST_BUFFER.with_borrow_mut(|buffer| { // SAFETY: src/dst may overlap — use ptr::copy (memmove) @@ -1970,7 +1977,7 @@ impl Stream { stream_identifier: self.id, length: u32::try_from(payload_size).expect("int cast"), }; - let _ = data_header.write(&mut writer); + let _ = data_header.write(&mut writer, &client.frames_sent_legacy); if padding != 0 { break 'brk SHARED_REQUEST_BUFFER.with_borrow_mut(|buffer| { // SAFETY: src/dst may overlap — ptr::copy is memmove; dst capacity covers payload_size @@ -2561,7 +2568,7 @@ impl H2FrameParser { stream_identifier: 0, length: payload_len as u32, }; - let _ = settings_header.write(&mut stream); + let _ = settings_header.write(&mut stream, &self.frames_sent_legacy); let _ = stream.write_all(&payload[..payload_len]); self.outstanding_settings @@ -2595,7 +2602,7 @@ impl H2FrameParser { stream_identifier: stream.id, length: 4, }; - let _ = frame.write(&mut writer_stream); + let _ = frame.write(&mut writer_stream, &self.frames_sent_legacy); let mut value: u32 = ErrorCode::CANCEL.0; stream.rst_code = value; value = value.swap_bytes(); @@ -2633,7 +2640,7 @@ impl H2FrameParser { stream_identifier: stream.id, length: 4, }; - let _ = frame.write(&mut writer_stream); + let _ = frame.write(&mut writer_stream, &self.frames_sent_legacy); let mut value: u32 = rst_code.0; stream.rst_code = value; value = value.swap_bytes(); @@ -2690,7 +2697,7 @@ impl H2FrameParser { stream_identifier: 0, length: u32::try_from(8 + debug_data.len()).expect("int cast"), }; - let _ = frame.write(&mut stream); + let _ = frame.write(&mut stream, &self.frames_sent_legacy); let last_id = UInt31WithReserved::init(last_stream_id, false); let _ = last_id.write(&mut stream); let mut value: u32 = rst_code.0; @@ -2748,7 +2755,7 @@ impl H2FrameParser { stream_identifier, length: u32::try_from(origin_str.len() + alt.len() + 2).expect("int cast"), }; - let _ = frame.write(&mut stream); + let _ = frame.write(&mut stream, &self.frames_sent_legacy); let _ = stream.write_all( &u16::try_from(origin_str.len()) .expect("int cast") @@ -2783,7 +2790,7 @@ impl H2FrameParser { stream_identifier: 0, length: 8, }; - let _ = frame.write(&mut stream); + let _ = frame.write(&mut stream, &self.frames_sent_legacy); let _ = stream.write_all(payload); let _ = self.write(&buffer); } @@ -2809,7 +2816,7 @@ impl H2FrameParser { settings: self.local_settings.get().to_engine_settings(), }) }); - let _ = settings_header.write(&mut preface_stream); + let _ = settings_header.write(&mut preface_stream, &self.frames_sent_legacy); let _ = preface_stream.write_all(&payload[..payload_len]); let _ = self.write(&preface_buffer[..24 + FrameHeader::BYTE_SIZE + payload_len]); } @@ -2824,7 +2831,7 @@ impl H2FrameParser { stream_identifier: 0, length: 0, }; - let _ = settings_header.write(&mut stream); + let _ = settings_header.write(&mut stream, &self.frames_sent_legacy); let _ = self.write(&buffer); } @@ -2847,7 +2854,7 @@ impl H2FrameParser { stream_identifier, length: 4, }; - let _ = settings_header.write(&mut stream); + let _ = settings_header.write(&mut stream, &self.frames_sent_legacy); let _ = window_size.write(&mut stream); let _ = self.write(&buffer); } @@ -5696,6 +5703,17 @@ impl H2FrameParser { } /// Feed inbound bytes through the rewrite engine, buffering the unconsumed tail (design B). + /// Mirror the engine's frame counters into plain Cells so getFrameCounters() never + /// contends with the engine borrow (destroy can run inside a dispatch). + fn sync_engine_frame_counters(&self) { + if let Ok(guard) = self.engine.try_borrow() { + if let Some(engine) = guard.as_ref() { + self.engine_frames_received.set(engine.frames_received); + self.engine_frames_sent.set(engine.frames_sent); + } + } + } + fn rewrite_read(&self, bytes: &[u8]) { bun_output::scoped_log!(H2FrameParser, "rewriteRead {}", bytes.len()); // Re-entrancy guard: receive() dispatches into JS between frames, and user code can feed @@ -5848,6 +5866,11 @@ impl H2FrameParser { /// The from-scratch engine calls back into H2FrameParser (the embedder) through this. impl crate::api::h2::connection::Sink for H2FrameParser { + fn on_frame_counters(&self, received: u64, sent: u64) { + self.engine_frames_received.set(received); + self.engine_frames_sent.set(sent); + } + fn write(&self, bytes: &[u8]) -> crate::api::h2::connection::WriteResult { if self.write(bytes) { crate::api::h2::connection::WriteResult::Sent @@ -6705,6 +6728,29 @@ impl H2FrameParser { Ok(JSValue::UNDEFINED) } + #[bun_jsc::host_fn(method)] + pub(crate) fn get_frame_counters( + this: &Self, + global_object: &JSGlobalObject, + _callframe: &CallFrame, + ) -> JsResult { + this.sync_engine_frame_counters(); + let result = JSValue::create_empty_object(global_object, 2); + result.put( + global_object, + b"framesReceived", + JSValue::js_number(this.engine_frames_received.get() as f64), + ); + result.put( + global_object, + b"framesSent", + JSValue::js_number( + (this.frames_sent_legacy.get() + this.engine_frames_sent.get()) as f64, + ), + ); + Ok(result) + } + #[bun_jsc::host_fn(method)] pub(crate) fn get_current_state( this: &Self, @@ -6869,7 +6915,7 @@ impl H2FrameParser { stream_identifier: 0, length: 0, }; - let _ = frame.write(&mut stream); + let _ = frame.write(&mut stream, &this.frames_sent_legacy); let _ = this.write(&buffer); return Ok(JSValue::UNDEFINED); } @@ -6894,7 +6940,7 @@ impl H2FrameParser { stream_identifier: 0, length: u32::try_from(slice.len() + 2).expect("int cast"), }; - let _ = frame.write(&mut stream); + let _ = frame.write(&mut stream, &this.frames_sent_legacy); let _ = stream.write_all(&u16::try_from(slice.len()).expect("int cast").to_be_bytes()); let _ = this.write(&buffer); if !slice.is_empty() { @@ -6943,7 +6989,7 @@ impl H2FrameParser { length: total_length - FrameHeader::BYTE_SIZE as u32, // payload length }; stream.reset(); - let _ = frame.write(&mut stream); + let _ = frame.write(&mut stream, &this.frames_sent_legacy); let _ = this.write(&buffer[0..total_length as usize]); } Ok(JSValue::UNDEFINED) @@ -7240,7 +7286,7 @@ impl H2FrameParser { }; let mut writer = this.to_writer(); - let _ = frame.write(&mut writer); + let _ = frame.write(&mut writer, &this.frames_sent_legacy); let _ = priority.write(&mut writer); } Ok(JSValue::TRUE) @@ -7411,7 +7457,7 @@ impl H2FrameParser { stream.queue_frame(self, b"", callback, close); } else { let mut writer = self.to_writer(); - let _ = data_header.write(&mut writer); + let _ = data_header.write(&mut writer, &self.frames_sent_legacy); } } else { let mut offset: usize = 0; @@ -7501,7 +7547,7 @@ impl H2FrameParser { if payload.len() <= MAX_PAYLOAD_SIZE_WITHOUT_FRAME { // Single-frame payload: the cork coalesces it with neighbors. let mut writer = self.to_writer(); - let _ = data_header.write(&mut writer); + let _ = data_header.write(&mut writer, &self.frames_sent_legacy); if padding != 0 { SHARED_REQUEST_BUFFER.with_borrow_mut(|buffer| { // SAFETY: src/dst may overlap — ptr::copy is memmove; dst capacity covers payload_size @@ -7550,7 +7596,7 @@ impl H2FrameParser { } } let header_off = batch.len(); - let _ = data_header.write(batch); + let _ = data_header.write(batch, &self.frames_sent_legacy); if padding != 0 { batch.push(padding); batch.extend_from_slice(slice); @@ -8080,7 +8126,7 @@ impl H2FrameParser { stream_identifier: stream.id, length: u32::try_from(encoded_size).expect("int cast"), }; - let _ = frame.write(&mut writer); + let _ = frame.write(&mut writer, &this.frames_sent_legacy); let _ = writer.write_all(encoded_data); } else { bun_output::scoped_log!( @@ -8098,7 +8144,7 @@ impl H2FrameParser { stream_identifier: stream.id, length: u32::try_from(first_chunk_size).expect("int cast"), }; - let _ = headers_frame.write(&mut writer); + let _ = headers_frame.write(&mut writer, &this.frames_sent_legacy); let _ = writer.write_all(&encoded_data[0..first_chunk_size]); let mut offset: usize = first_chunk_size; @@ -8117,7 +8163,7 @@ impl H2FrameParser { stream_identifier: stream.id, length: u32::try_from(chunk_size).expect("int cast"), }; - let _ = cont_frame.write(&mut writer); + let _ = cont_frame.write(&mut writer, &this.frames_sent_legacy); let _ = writer.write_all(&encoded_data[offset..offset + chunk_size]); offset += chunk_size; @@ -8433,9 +8479,10 @@ impl H2FrameParser { ) .is_err() { - return Err( - global_object.throw(format_args!("Failed to encode push promise headers")) - ); + // Same as the request/respond encode failures: nghttp2 fails the whole + // session, and node never surfaces this through the pushStream callback. + this.schedule_header_compression_session_error(); + return Ok(JSValue::js_number(-1.0)); } } } @@ -8456,7 +8503,7 @@ impl H2FrameParser { stream_identifier: parent_id, length: payload_size as u32, }; - let _ = frame.write(&mut ws); + let _ = frame.write(&mut ws, &this.frames_sent_legacy); let promised_be = (promised_id & 0x7fff_ffff).swap_bytes(); let _ = ws.write_all(&promised_be.to_ne_bytes()); let _ = this.write(&hdr_buf); @@ -8475,7 +8522,7 @@ impl H2FrameParser { stream_identifier: parent_id, length: max_frame as u32, }; - let _ = frame.write(&mut ws); + let _ = frame.write(&mut ws, &this.frames_sent_legacy); let promised_be = (promised_id & 0x7fff_ffff).swap_bytes(); let _ = ws.write_all(&promised_be.to_ne_bytes()); let _ = this.write(&hdr_buf); @@ -8493,7 +8540,7 @@ impl H2FrameParser { stream_identifier: parent_id, length: chunk as u32, }; - let _ = cont.write(&mut cs); + let _ = cont.write(&mut cs, &this.frames_sent_legacy); let _ = this.write(&cont_buf); let _ = this.write(&encoded_headers[offset..offset + chunk]); offset += chunk; @@ -9437,7 +9484,7 @@ impl H2FrameParser { stream_identifier: stream.id, length: u32::try_from(payload_size).expect("int cast"), }; - let _ = frame.write(&mut writer); + let _ = frame.write(&mut writer, &this.frames_sent_legacy); // Write priority data if present if has_priority { @@ -9493,7 +9540,7 @@ impl H2FrameParser { stream_identifier: stream.id, length: u32::try_from(first_chunk_size + priority_overhead).expect("int cast"), }; - let _ = headers_frame.write(&mut writer); + let _ = headers_frame.write(&mut writer, &this.frames_sent_legacy); if has_priority { let stream_identifier = @@ -9524,7 +9571,7 @@ impl H2FrameParser { stream_identifier: stream.id, length: u32::try_from(chunk_size).expect("int cast"), }; - let _ = cont_frame.write(&mut writer); + let _ = cont_frame.write(&mut writer, &this.frames_sent_legacy); let _ = writer.write_all(&encoded_headers[offset..offset + chunk_size]); offset += chunk_size; @@ -9791,6 +9838,9 @@ impl H2FrameParser { has_nonnative_backpressure: Cell::new(false), transport_write_fatal: Cell::new(false), pending_header_compression_error: Cell::new(false), + frames_sent_legacy: Cell::new(0), + engine_frames_received: Cell::new(0), + engine_frames_sent: Cell::new(0), auto_flusher: JsCell::new(AutoFlusher::default()), padding_strategy: Cell::new(PaddingStrategy::None), engine: core::cell::RefCell::new(None), diff --git a/src/runtime/api/h2.classes.ts b/src/runtime/api/h2.classes.ts index b2e8737c824d..69a3281d23ed 100644 --- a/src/runtime/api/h2.classes.ts +++ b/src/runtime/api/h2.classes.ts @@ -33,6 +33,10 @@ export default [ fn: "getCurrentState", length: 0, }, + getFrameCounters: { + fn: "getFrameCounters", + length: 0, + }, settings: { fn: "updateSettings", length: 1, diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 1430378af2df..559ecb4606b0 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3549,3 +3549,82 @@ it("delivers a session error from the event loop, not inside the call that detec server.close(); } }); + +it("delivers the reserved push stream and fails the session when its headers cannot be encoded", async () => { + // Verified against node v26.3.0: pushStream's callback still receives the reserved + // stream (so the caller can attach handlers), and the session then dies with + // COMPRESSION_ERROR (9); the callback never sees an error. + const server = http2.createServer({ maxSendHeaderBlockLength: 100000 }); + try { + const sessionError = new Promise(resolve => server.on("sessionError", resolve)); + const pushCallback = Promise.withResolvers(); + server.on("stream", stream => { + stream.on("error", () => {}); + stream.pushStream({ ":path": "/pushed", "x-big": Buffer.alloc(90000, "A").toString() }, (err, push) => { + push?.on("error", () => {}); + pushCallback.resolve(err ?? null); + }); + stream.respond(); + stream.end("x"); + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + const req = client.request(); + req.on("error", () => {}); + req.resume(); + req.end(); + + const [cbErr, err] = await Promise.all([pushCallback.promise, sessionError]); + expect(cbErr).toBeNull(); + expect(err.code).toBe("ERR_HTTP2_SESSION_ERROR"); + expect(err.message).toBe("Session closed with error code 9"); + client.destroy(); + } finally { + server.close(); + } +}); + +it("PerformanceObserver receives http2 session and stream entries", async () => { + const { PerformanceObserver } = require("node:perf_hooks"); + const entries = []; + const observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) entries.push(entry); + }); + observer.observe({ type: "http2" }); + const server = http2.createServer(); + try { + server.on("stream", stream => { + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`); + const req = client.request({ ":path": "/" }); + req.resume(); + await new Promise(resolve => req.on("end", resolve)); + await new Promise(resolve => { + client.on("close", resolve); + client.close(); + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + const sessions = entries.filter(e => e.name === "Http2Session"); + const streams = entries.filter(e => e.name === "Http2Stream"); + expect(sessions.length).toBeGreaterThanOrEqual(2); + expect(streams.length).toBeGreaterThanOrEqual(2); + const clientSession = sessions.find(e => e.detail.type === "client"); + expect(clientSession.entryType).toBe("http2"); + expect(clientSession.detail.streamCount).toBe(1); + expect(clientSession.detail.framesReceived).toBeGreaterThanOrEqual(4); + expect(typeof clientSession.detail.framesSent).toBe("number"); + expect(typeof clientSession.detail.streamAverageDuration).toBe("number"); + const streamEntry = streams[0]; + expect(typeof streamEntry.detail.bytesRead).toBe("number"); + expect(typeof streamEntry.detail.bytesWritten).toBe("number"); + expect(typeof streamEntry.detail.timeToFirstHeader).toBe("number"); + } finally { + observer.disconnect(); + server.close(); + } +}); diff --git a/test/js/node/test/parallel/test-http2-perf_hooks.js b/test/js/node/test/parallel/test-http2-perf_hooks.js new file mode 100644 index 000000000000..1e72801147a7 --- /dev/null +++ b/test/js/node/test/parallel/test-http2-perf_hooks.js @@ -0,0 +1,104 @@ +// Flags: --no-warnings +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const h2 = require('http2'); + +const { PerformanceObserver } = require('perf_hooks'); + +const obs = new PerformanceObserver(common.mustCallAtLeast((items) => { + const entry = items.getEntries()[0]; + assert.strictEqual(entry.entryType, 'http2'); + assert.strictEqual(typeof entry.startTime, 'number'); + assert.strictEqual(typeof entry.duration, 'number'); + switch (entry.name) { + case 'Http2Session': + assert.strictEqual(typeof entry.detail.pingRTT, 'number'); + assert.strictEqual(typeof entry.detail.streamAverageDuration, 'number'); + assert.strictEqual(typeof entry.detail.streamCount, 'number'); + assert.strictEqual(typeof entry.detail.framesReceived, 'number'); + assert.strictEqual(typeof entry.detail.framesSent, 'number'); + assert.strictEqual(typeof entry.detail.bytesWritten, 'number'); + assert.strictEqual(typeof entry.detail.bytesRead, 'number'); + assert.strictEqual(typeof entry.detail.maxConcurrentStreams, 'number'); + switch (entry.detail.type) { + case 'server': + assert.strictEqual(entry.detail.streamCount, 1); + assert(entry.detail.framesReceived >= 3); + break; + case 'client': + assert.strictEqual(entry.detail.streamCount, 1); + assert.strictEqual(entry.detail.framesReceived, 7); + break; + default: + assert.fail('invalid Http2Session type'); + } + break; + case 'Http2Stream': + assert.strictEqual(typeof entry.detail.timeToFirstByte, 'number'); + assert.strictEqual(typeof entry.detail.timeToFirstByteSent, 'number'); + assert.strictEqual(typeof entry.detail.timeToFirstHeader, 'number'); + assert.strictEqual(typeof entry.detail.bytesWritten, 'number'); + assert.strictEqual(typeof entry.detail.bytesRead, 'number'); + break; + default: + assert.fail('invalid entry name'); + } +})); + +obs.observe({ type: 'http2' }); + +const body = + '

this is some data

'; + +const server = h2.createServer(); + +// We use the lower-level API here +server.on('stream', common.mustCall(onStream)); + +function onStream(stream, headers, flags) { + assert.strictEqual(headers[':scheme'], 'http'); + assert.ok(headers[':authority']); + assert.strictEqual(headers[':method'], 'GET'); + assert.strictEqual(flags, 5); + stream.respond({ + 'content-type': 'text/html', + ':status': 200 + }); + stream.write(body.slice(0, 20)); + stream.end(body.slice(20)); +} + +server.on('session', common.mustCall((session) => { + session.ping(common.mustCall()); +})); + +server.listen(0); + +server.on('listening', common.mustCall(() => { + + const client = h2.connect(`http://localhost:${server.address().port}`); + + client.on('connect', common.mustCall(() => { + client.ping(common.mustCall()); + })); + + const req = client.request(); + + req.on('response', common.mustCall()); + + let data = ''; + req.setEncoding('utf8'); + req.on('data', (d) => data += d); + req.on('end', common.mustCall(() => { + assert.strictEqual(body, data); + })); + req.on('close', common.mustCall(() => { + client.close(); + server.close(); + })); + +})); From b38891ccaff3067281278a0fa76709b30b111cf7 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 16:23:42 -0700 Subject: [PATCH 20/43] http2: pack END_STREAM onto end(chunk) and match node's frame accounting Follow-up to the same PR: the END_STREAM packing only reached deferred writes, because Writable marks the stream ending after end()'s own synchronous write has already dispatched - so end(chunk), the common case, still emitted a separate empty END_STREAM frame. Bridge that window explicitly. Frame accounting gets two corrections found while checking the counts against node's own pack-end-stream-flag expectations: - GOAWAY is not counted as a received frame. It terminates the session, so the statistics node reads off a session that stopped processing at that frame never include it. - The destroy path taken when the last stream closes on an already-closed session snapshots the counters like the other paths do; without it the peer's GOAWAY reply raced in before the entry was emitted. Client frame counts for stream.end(data) and write(data, cb => end()) now match node exactly (4 and 5). write(data); end() still emits the trailing empty frame: packing it needs same-tick frame coalescing, which the cork design does not allow without rewriting a corked frame in place. --- src/js/node/http2.ts | 37 +++++++++++++++++++++----- src/runtime/api/bun/h2/connection.rs | 8 ++++-- test/js/node/http2/node-http2.test.js | 38 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 9fe81c6e13cb..e1f3355603a4 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2273,7 +2273,13 @@ function deferWriteCallbackForSocket(nativeSocket) { // ride the trailer HEADERS instead). node packs END_STREAM onto that final DATA frame // rather than emitting an empty one after it. function isFinalWrite(stream: Http2Stream, pendingLength: number) { - return stream._writableState.ending && stream.writableLength === pendingLength && !stream[bunHTTP2WaitForTrailers]; + // `ending` is set only after end()'s own synchronous write has already dispatched, so + // end(chunk) — the common case — needs kEndingWithChunk to see the last chunk as final. + return ( + (stream._writableState.ending || stream[kEndingWithChunk] === true) && + stream.writableLength === pendingLength && + !stream[bunHTTP2WaitForTrailers] + ); } // writeStream settled the stream to HALF_CLOSED_LOCAL synchronously with the dispatch @@ -2308,6 +2314,9 @@ function publishStreamCloseChannel(stream: Http2Stream) { onServerStreamCloseChannel.publish({ stream }); } } +// Set across end(chunk)'s synchronous super.end() only: bridges the window where the final +// chunk dispatches before Writable marks the stream ending. +const kEndingWithChunk = Symbol("http2EndingWithChunk"); const kPerfStats = Symbol("http2PerfStats"); const kPerfState = Symbol("http2PerfState"); @@ -3002,7 +3011,15 @@ class Http2Stream extends Duplex { // Don't create an empty buffer for end() without data - let the Duplex stream // handle it naturally (just calls _final without _write for empty data). // Creating an empty buffer here causes an extra empty DATA frame to be sent. - return super.end(chunk, encoding, callback); + const hasChunk = chunk !== undefined && chunk !== null && chunk.length > 0; + if (hasChunk) this[kEndingWithChunk] = true; + try { + return super.end(chunk, encoding, callback); + } finally { + // Only the synchronous window is bridged: a chunk buffered behind an in-flight + // write dispatches later, when `ending` is already set. + if (hasChunk) this[kEndingWithChunk] = false; + } } _writev(data, callback) { @@ -4282,6 +4299,7 @@ class ServerHttp2Session extends Http2Session { stream.destroy(); } if (self.#connections === 0 && self.#closed) { + captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); self.destroy(); } } else if (state === 5) { @@ -5243,10 +5261,17 @@ class ClientHttp2Session extends Http2Session { if (self.#connections === 0 && self.#closed) { // Deferred like close()'s own destroy: this runs inside a native dispatch // batch, and frames the engine already received but has not dispatched yet - // must still reach JS. An outstanding settings() ACK gets a bounded grace - // (see scheduleSettingsAckGraceNT); its arrival completes the destroy. - if (self.#pendingSettingsAckCount > 0) scheduleSettingsAckGraceNT(self); - else setImmediate(destroyIfNotDestroyedNT, self); + // must still reach JS. An outstanding settings() ACK or ping gets a bounded + // grace (see scheduleSettingsAckGraceNT); its arrival completes the destroy. + if ( + self.#pendingSettingsAckCount > 0 || + (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0) + ) { + scheduleSettingsAckGraceNT(self); + } else { + captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); + setImmediate(destroyIfNotDestroyedNT, self); + } } } else if (state === 5) { // 5 = local closed aka write is closed diff --git a/src/runtime/api/bun/h2/connection.rs b/src/runtime/api/bun/h2/connection.rs index ccda098f7ae7..3c5ca47a55a0 100644 --- a/src/runtime/api/bun/h2/connection.rs +++ b/src/runtime/api/bun/h2/connection.rs @@ -590,8 +590,12 @@ impl Connection { /// Dispatch one fully-buffered frame. Returns true if the connection is now fatally closing. fn dispatch(&mut self, sink: &impl Sink, hdr: &FrameHeader, payload: &[u8]) -> bool { - self.frames_received += 1; - sink.on_frame_counters(self.frames_received, self.frames_sent); + // GOAWAY is excluded: it terminates the session, so node's statistics — which are + // read off a session that stopped processing at that frame — never include it. + if !matches!(hdr.typ(), Some(FrameType::GoAway)) { + self.frames_received += 1; + sink.on_frame_counters(self.frames_received, self.frames_sent); + } // RFC 9113 §4.3 / §6.10: once a HEADERS/PUSH_PROMISE without END_HEADERS is received, the // ONLY permitted frame is a CONTINUATION for that same stream until the block completes. // Checked before structural validation: a malformed non-CONTINUATION frame mid-block is a diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 559ecb4606b0..ab45e532a187 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3628,3 +3628,41 @@ it("PerformanceObserver receives http2 session and stream entries", async () => server.close(); } }); + +it("packs END_STREAM onto the DATA frame produced by end(chunk)", async () => { + // node sends one DATA frame with END_STREAM for stream.end(data); bun used to append a + // separate empty END_STREAM frame after it. Counted through the client's own + // perf_hooks frame stats (which exclude the GOAWAY, as node's do). + const { PerformanceObserver } = require("node:perf_hooks"); + const server = http2.createServer(); + try { + server.on("stream", stream => { + stream.respond({ ":status": 200 }); + stream.end("OK"); + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + + const received = Promise.withResolvers(); + const observer = new PerformanceObserver((list, obs) => { + for (const entry of list.getEntries()) { + if (entry.name !== "Http2Session" || entry.detail.type !== "client") continue; + obs.disconnect(); + received.resolve(entry.detail.framesReceived); + return; + } + }); + observer.observe({ type: "http2" }); + + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + const req = client.request({ ":path": "/" }); + req.resume(); + req.on("end", () => client.close()); + req.end(); + + // SETTINGS + SETTINGS ack + HEADERS + one DATA carrying END_STREAM. + expect(await received.promise).toBe(4); + } finally { + server.close(); + } +}); From 958112fd56f27ac7b752cff1f48b8a936b5507ee Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 14:45:05 -0700 Subject: [PATCH 21/43] http2: fix silent client stall when the socket flows before connect completes Attaching a 'data' listener to a user Duplex before http2.connect() put the stream in flowing mode, so the peer's first frames could arrive before the connect callback ran. The session then hung forever without an error: the connection preface was silently destroyed. Three fixes, verified against node v26.3.0 in every listener ordering: - flush()'s JS-backed-socket arm dispatched the queued bytes to the onWrite handler and then cleared the buffer unconditionally, re-latching backpressure only on a boolean false. The handlers return -1/0/1, never booleans, so a refusal (-1, socket not ready yet) read as success and the preface was dropped. Honor the same numeric contract _write uses, and consume only the prefix actually handed to JS so writes made re-entrantly during the dispatch are kept. - flush() can be re-entered while its onWrite call is still on the stack (a synchronous transport like duplexPair delivers inline), which sent the retained bytes twice - the peer saw a duplicate preface. Guard the whole function while a dispatch is in flight. - The client constructor queued onConnect on nextTick before assigning #parser. The parser's construction re-enters JS, which can drain the tick queue, so onConnect could run against a half-built session and destroy it. Queue it after construction, and re-defer if a connect event still lands early. --- src/js/node/http2.ts | 15 +++++++- src/runtime/api/bun/h2_frame_parser.rs | 53 ++++++++++++++++++++------ test/js/node/http2/node-http2.test.js | 32 ++++++++++++++++ 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index e1f3355603a4..54437d5434d2 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -5816,6 +5816,12 @@ class ClientHttp2Session extends Http2Session { } function onConnect() { + // The parser's construction re-enters JS and can drain the tick queue, so a + // connect that fires from that drain arrives before the constructor finished. + if (this.#parser === undefined) { + process.nextTick(onConnect.bind(this)); + return; + } try { this.#onConnect(arguments); listener?.$call(this, this); @@ -5829,6 +5835,7 @@ class ClientHttp2Session extends Http2Session { if (typeof options?.maxOutstandingSettings === "number" && options.maxOutstandingSettings >= 1) { this.#maxOutstandingSettings = options.maxOutstandingSettings; } + let connectOnNextTick = false; if (typeof options?.createConnection === "function") { socket = options.createConnection(url, options); this[bunHTTP2Socket] = socket; @@ -5837,7 +5844,7 @@ class ClientHttp2Session extends Http2Session { const connectEvent = socket instanceof tls.TLSSocket ? "secureConnect" : "connect"; socket.once(connectEvent, onConnect.bind(this)); } else { - process.nextTick(onConnect.bind(this)); + connectOnNextTick = true; } } else { socket = connectWithProtocol( @@ -5880,6 +5887,12 @@ class ClientHttp2Session extends Http2Session { socket.on("error", this.#onError.bind(this)); socket.on("timeout", this.#onTimeout.bind(this)); initHttp2SessionPerf(this, "client"); + if (connectOnNextTick) { + // Queued only now that the session is fully built: the parser's construction + // re-enters JS, which can drain the tick queue, and an earlier-queued onConnect + // would then run against a session whose #parser is not assigned yet. + process.nextTick(onConnect.bind(this)); + } } // Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created. Once closed, http2session.destroy() might be called if there are no open Http2Stream instances. diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 2c4c3c0223bc..7249dfc952e2 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1463,6 +1463,8 @@ pub struct H2FrameParser { hpack: JsCell>, has_nonnative_backpressure: Cell, + /// True while flush() has bytes out in an onWrite dispatch to a JS-backed socket. + js_socket_flushing: Cell, /// A native write returned a terminal result (socket closed, shut down, or the kernel /// rejected the send). Latched once; the deferred tick closes the transport. transport_write_fatal: Cell, @@ -3182,6 +3184,13 @@ impl H2FrameParser { pub(crate) fn flush(&self) -> usize { bun_output::scoped_log!(H2FrameParser, "flush"); + // The onWrite dispatch below re-enters JS, and a synchronous transport + // (duplexPair) can re-enter flush() from inside it: bail so the in-flight + // bytes are not sent a second time (through any arm — a connect callback + // running inside the dispatch may have attached a native socket). + if self.js_socket_flushing.get() { + return 0; + } // Keep `self` alive across the re-entrant JS calls below. let _keepalive = self.keepalive(); @@ -3196,7 +3205,8 @@ impl H2FrameParser { BunSocket::None => { // consider that backpressure is gone and flush data queue self.has_nonnative_backpressure.set(false); - let bytes_len = self.write_buffer.get().slice().len(); + let offset = self.write_buffer_offset.get(); + let bytes_len = self.write_buffer.get().slice()[offset..].len(); if bytes_len > 0 { let global = self.handlers.get().global(); // A failed conversion means the VM is terminating (or OOM): report @@ -3205,22 +3215,42 @@ impl H2FrameParser { .handlers .get() .binary_type - .to_js(self.write_buffer.get().slice(), &global) + .to_js(&self.write_buffer.get().slice()[offset..], &global) else { return 0; }; + self.js_socket_flushing.set(true); let result = self.call(JSH2FrameParser::Gc::onWrite, output_value); + self.js_socket_flushing.set(false); + + // Same contract as _write: -1 dropped, 0 queued by the socket, else sent. + let code = if result.is_number() { result.to_int32() } else { -1 }; + if code == -1 { + // JS did not take the bytes (e.g. the session's socket is not ready + // yet). Keep them queued for the next flush — clearing here loses + // the connection preface when the peer's first frames arrive before + // the connect callback has run. + self.has_nonnative_backpressure.set(true); + return 0; + } - // defer block - self.write_buffer_offset.set(0); - self.write_buffer.with_mut(|wb| { - wb.clear(); - if wb.capacity() > MAX_BUFFER_SIZE as usize { - wb.shrink_to(MAX_BUFFER_SIZE as usize); - } - }); + // Consume exactly what was handed to JS: writes made re-entrantly during + // the dispatch sit after it in the buffer and wait for the next flush. + // `>=` also covers the buffer having been cleared (detach) during the + // dispatch, where advancing would strand the offset past the end. + if offset + bytes_len >= self.write_buffer.get().slice().len() { + self.write_buffer_offset.set(0); + self.write_buffer.with_mut(|wb| { + wb.clear(); + if wb.capacity() > MAX_BUFFER_SIZE as usize { + wb.shrink_to(MAX_BUFFER_SIZE as usize); + } + }); + } else { + self.write_buffer_offset.set(offset + bytes_len); + } - if result.is_boolean() && !result.to_boolean() { + if code == 0 { self.has_nonnative_backpressure.set(true); return bytes_len; } @@ -9836,6 +9866,7 @@ impl H2FrameParser { streams: JsCell::new(BunHashMap::default()), hpack: JsCell::new(None), has_nonnative_backpressure: Cell::new(false), + js_socket_flushing: Cell::new(false), transport_write_fatal: Cell::new(false), pending_header_compression_error: Cell::new(false), frames_sent_legacy: Cell::new(0), diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index ab45e532a187..89cdafc5d7d5 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3666,3 +3666,35 @@ it("packs END_STREAM onto the DATA frame produced by end(chunk)", async () => { server.close(); } }); + +it("client connects over a user Duplex that already has a 'data' listener", async () => { + // A 'data' listener attached before connect() puts the stream in flowing mode, so the + // peer's first frames can arrive before the connect callback has run. The preface must + // survive that: it used to be dropped, silently stalling the session. + const { duplexPair } = require("node:stream"); + const [clientSide, serverSide] = duplexPair(); + const server = http2.createServer(); + server.on("stream", stream => { + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + server.emit("connection", serverSide); + + clientSide.on("data", () => {}); + const client = http2.connect("http://localhost", { createConnection: () => clientSide }); + client.on("error", () => {}); + + const req = client.request({ ":path": "/" }); + let status = 0; + let body = ""; + req.setEncoding("utf8"); + req.on("response", headers => { + status = headers[":status"]; + }); + req.on("data", chunk => (body += chunk)); + await new Promise(resolve => req.on("end", resolve)); + expect(status).toBe(200); + expect(body).toBe("ok"); + client.close(); + server.close(); +}); From f4a9f71e19ea42af64cddbf7acf3b1fa5fafbc30 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:37:45 +0000 Subject: [PATCH 22/43] [autofix.ci] apply automated fixes --- src/js/node/http2.ts | 22 +++++++++++++++------- src/runtime/api/bun/h2_frame_parser.rs | 6 +++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 54437d5434d2..5170800eac8b 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -27,8 +27,13 @@ * Modifications were made to the original code. */ const { isTypedArray } = require("node:util/types"); -const { hideFromStack, throwNotImplemented, hasObserver, enqueueNodeEntry, PerformanceNodeEntry } = - require("internal/shared"); +const { + hideFromStack, + throwNotImplemented, + hasObserver, + enqueueNodeEntry, + PerformanceNodeEntry, +} = require("internal/shared"); const { STATUS_CODES } = require("internal/http"); const { kTimeout, getTimerDuration } = require("internal/timers"); const tls = require("node:tls"); @@ -5263,10 +5268,7 @@ class ClientHttp2Session extends Http2Session { // batch, and frames the engine already received but has not dispatched yet // must still reach JS. An outstanding settings() ACK or ping gets a bounded // grace (see scheduleSettingsAckGraceNT); its arrival completes the destroy. - if ( - self.#pendingSettingsAckCount > 0 || - (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0) - ) { + if (self.#pendingSettingsAckCount > 0 || (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0)) { scheduleSettingsAckGraceNT(self); } else { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); @@ -5353,7 +5355,13 @@ class ClientHttp2Session extends Http2Session { clearTimeout(self[kSettingsAckGraceTimer]); self[kSettingsAckGraceTimer] = undefined; } - if (self.#closed && self.#connections === 0 && self.#pendingSettingsAckCount === 0 && pingsDrained && !self.destroyed) { + if ( + self.#closed && + self.#connections === 0 && + self.#pendingSettingsAckCount === 0 && + pingsDrained && + !self.destroyed + ) { captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); setImmediate(destroyIfNotDestroyedNT, self); } diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 7249dfc952e2..d8274748a56c 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -3224,7 +3224,11 @@ impl H2FrameParser { self.js_socket_flushing.set(false); // Same contract as _write: -1 dropped, 0 queued by the socket, else sent. - let code = if result.is_number() { result.to_int32() } else { -1 }; + let code = if result.is_number() { + result.to_int32() + } else { + -1 + }; if code == -1 { // JS did not take the bytes (e.g. the session's socket is not ready // yet). Keep them queued for the next flush — clearing here loses From 05ed257b379605884ddac787e668e225178258f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:48:28 +0000 Subject: [PATCH 23/43] h2: release auto-flush registration after deferred COMPRESSION_ERROR; event-driven http2 test waits on_auto_flush: after dispatching the deferred header-compression session error, flush() and then release the registration's flag+ref (only if uncork() did not already) and return false, like the transport-fatal path. Leaving registered untouched before the dispatch keeps re-entrant cork()->register_auto_flush() (the GOAWAY the JS teardown writes) a no-op instead of a found_existing panic. node-http2.test.js: the PerformanceObserver test waits on the observer delivering the expected entries instead of a 50 ms sleep; the END_STREAM and flowing-Duplex tests reject on request/session error instead of hanging. --- src/runtime/api/bun/h2_frame_parser.rs | 12 +++++++++++- test/js/node/http2/node-http2.test.js | 23 ++++++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index d8274748a56c..ee837447b25b 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -3394,7 +3394,7 @@ impl H2FrameParser { return; } // A write that drains the buffer must not cancel the deferred tick a pending session - // error is waiting on; on_auto_flush unregisters once it has reported it. + // error is waiting on; on_auto_flush releases the registration once it has reported it. if self.pending_header_compression_error.get() { return; } @@ -3502,6 +3502,16 @@ impl H2FrameParser { JSValue::js_number(self.last_stream_id.get() as f64), JSValue::UNDEFINED, ); + let _ = self.flush(); + // Terminal: the dispatch's teardown usually corks a GOAWAY, which + // flush() -> uncork() -> unregister_auto_flush() releases. If nothing + // corked (session already gone), release the registration's flag+ref + // here so the task and its retained parser ref do not persist. + if self.auto_flusher.get().registered.get() { + self.auto_flusher.get().registered.set(false); + self.deref(); + } + return false; } let _ = self.flush(); // we will unregister ourselves when the buffer is empty diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 89cdafc5d7d5..f4236a27e779 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3588,8 +3588,12 @@ it("delivers the reserved push stream and fails the session when its headers can it("PerformanceObserver receives http2 session and stream entries", async () => { const { PerformanceObserver } = require("node:perf_hooks"); const entries = []; + // Two streams (client+server) + two sessions (client+server): resolve once + // the observer has delivered at least four entries instead of sleeping. + const observed = Promise.withResolvers(); const observer = new PerformanceObserver(list => { for (const entry of list.getEntries()) entries.push(entry); + if (entries.length >= 4) observed.resolve(); }); observer.observe({ type: "http2" }); const server = http2.createServer(); @@ -3600,14 +3604,19 @@ it("PerformanceObserver receives http2 session and stream entries", async () => }); const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); const client = http2.connect(`http://localhost:${port}`); + client.on("error", observed.reject); const req = client.request({ ":path": "/" }); + req.on("error", observed.reject); req.resume(); - await new Promise(resolve => req.on("end", resolve)); + await new Promise((resolve, reject) => { + req.on("end", resolve); + req.on("error", reject); + }); await new Promise(resolve => { client.on("close", resolve); client.close(); }); - await new Promise(resolve => setTimeout(resolve, 50)); + await observed.promise; const sessions = entries.filter(e => e.name === "Http2Session"); const streams = entries.filter(e => e.name === "Http2Stream"); @@ -3654,8 +3663,9 @@ it("packs END_STREAM onto the DATA frame produced by end(chunk)", async () => { observer.observe({ type: "http2" }); const client = http2.connect(`http://localhost:${port}`); - client.on("error", () => {}); + client.on("error", received.reject); const req = client.request({ ":path": "/" }); + req.on("error", received.reject); req.resume(); req.on("end", () => client.close()); req.end(); @@ -3682,7 +3692,6 @@ it("client connects over a user Duplex that already has a 'data' listener", asyn clientSide.on("data", () => {}); const client = http2.connect("http://localhost", { createConnection: () => clientSide }); - client.on("error", () => {}); const req = client.request({ ":path": "/" }); let status = 0; @@ -3692,7 +3701,11 @@ it("client connects over a user Duplex that already has a 'data' listener", asyn status = headers[":status"]; }); req.on("data", chunk => (body += chunk)); - await new Promise(resolve => req.on("end", resolve)); + await new Promise((resolve, reject) => { + req.on("end", resolve); + req.on("error", reject); + client.on("error", reject); + }); expect(status).toBe(200); expect(body).toBe("ok"); client.close(); From 754d353886123be5c0de01b5cd6d5171a446f240 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:10:26 +0000 Subject: [PATCH 24/43] ci: retrigger From 9dd71e1099bcb87d66d57534408d8579016f0307 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:42:10 +0000 Subject: [PATCH 25/43] h2: keep pending_header_compression_error latched across the onError dispatch; count hand-serialized RST; test harness conventions on_auto_flush: clear pending_header_compression_error AFTER the dispatch and flush, not before. The re-entrant detach() -> uncork()/unregister_auto_flush() that the JS session teardown drives must hit the pending guard and early-return instead of mutating the deferred-task map while run() is iterating it. rst_stream: the hand-serialized unknown-stream RST bypasses FrameHeader::write, so bump frames_sent_legacy directly so getFrameCounters().framesSent is exact. node-http2.test.js: Buffer.alloc(n, fill).toString() instead of repeat(n); hoist PerformanceObserver / duplexPair to module-scope imports. --- src/runtime/api/bun/h2_frame_parser.rs | 18 +++++++++++++----- test/js/node/http2/node-http2.test.js | 10 ++++------ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index ee837447b25b..0ee7b4479d77 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -3495,7 +3495,11 @@ impl H2FrameParser { return false; } if self.pending_header_compression_error.get() { - self.pending_header_compression_error.set(false); + // Keep the pending latch set across the dispatch and flush: the + // re-entrant detach() -> uncork()/unregister_auto_flush() the JS + // teardown drives must early-return at the pending guard instead of + // mutating the task map run() is iterating (aliasing UB). The latch + // is cleared once we are back here with no re-entry on the stack. self.dispatch_with_2_extra( JSH2FrameParser::Gc::onError, JSValue::js_number(ErrorCode::COMPRESSION_ERROR.0 as f64), @@ -3503,10 +3507,10 @@ impl H2FrameParser { JSValue::UNDEFINED, ); let _ = self.flush(); - // Terminal: the dispatch's teardown usually corks a GOAWAY, which - // flush() -> uncork() -> unregister_auto_flush() releases. If nothing - // corked (session already gone), release the registration's flag+ref - // here so the task and its retained parser ref do not persist. + self.pending_header_compression_error.set(false); + // Terminal: release the registration's flag+ref so the retained + // parser ref does not persist. Returning false lets run() drop the + // map entry it owns. if self.auto_flusher.get().registered.get() { self.auto_flusher.get().registered.set(false); self.deref(); @@ -7399,6 +7403,10 @@ impl H2FrameParser { frame[3] = 3; // RST_STREAM frame[5..9].copy_from_slice(&stream_id.to_be_bytes()); frame[9..13].copy_from_slice(&error_code.to_be_bytes()); + // Hand-serialized (bypasses FrameHeader::write), so account for + // it the way every other outbound frame site does. + this.frames_sent_legacy + .set(this.frames_sent_legacy.get() + 1); this.write(&frame); let _ = this.flush(); } diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index f4236a27e779..2187671df578 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -8,7 +8,8 @@ import net from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; import tls from "node:tls"; -import { Duplex } from "stream"; +import { PerformanceObserver } from "node:perf_hooks"; +import { Duplex, duplexPair } from "stream"; import http2utils from "./helpers"; import { nodeEchoServer, TLS_CERT, TLS_OPTIONS } from "./http2-helpers"; const { describe, expect, it, beforeAll, afterAll, createCallCheckCtx } = createTest(import.meta.path); @@ -3502,7 +3503,7 @@ it("fails the whole session when an outbound header block cannot be encoded", as const client = http2.connect(`http://localhost:${port}`, { maxSendHeaderBlockLength: 100000 }); const sessionError = new Promise(resolve => client.on("error", resolve)); const requestError = new Promise(resolve => { - const req = client.request({ "test-header": "A".repeat(90000) }); + const req = client.request({ "test-header": Buffer.alloc(90000, "A").toString() }); req.on("error", resolve); req.end(); }); @@ -3525,7 +3526,7 @@ it("delivers a session error from the event loop, not inside the call that detec const sessionError = new Promise(resolve => server.on("sessionError", resolve)); server.on("stream", stream => { stream.on("error", () => {}); - stream.additionalHeaders({ "test-header": "A".repeat(90000) }); + stream.additionalHeaders({ "test-header": Buffer.alloc(90000, "A").toString() }); observed.destroyedAfterSubmit = stream.destroyed; stream.respond(); stream.end(); @@ -3586,7 +3587,6 @@ it("delivers the reserved push stream and fails the session when its headers can }); it("PerformanceObserver receives http2 session and stream entries", async () => { - const { PerformanceObserver } = require("node:perf_hooks"); const entries = []; // Two streams (client+server) + two sessions (client+server): resolve once // the observer has delivered at least four entries instead of sleeping. @@ -3642,7 +3642,6 @@ it("packs END_STREAM onto the DATA frame produced by end(chunk)", async () => { // node sends one DATA frame with END_STREAM for stream.end(data); bun used to append a // separate empty END_STREAM frame after it. Counted through the client's own // perf_hooks frame stats (which exclude the GOAWAY, as node's do). - const { PerformanceObserver } = require("node:perf_hooks"); const server = http2.createServer(); try { server.on("stream", stream => { @@ -3681,7 +3680,6 @@ it("client connects over a user Duplex that already has a 'data' listener", asyn // A 'data' listener attached before connect() puts the stream in flowing mode, so the // peer's first frames can arrive before the connect callback has run. The preface must // survive that: it used to be dropped, silently stalling the session. - const { duplexPair } = require("node:stream"); const [clientSide, serverSide] = duplexPair(); const server = http2.createServer(); server.on("stream", stream => { From 3a54f488d015455ae05efd48a9429552c5a2973a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:44:15 +0000 Subject: [PATCH 26/43] [autofix.ci] apply automated fixes --- test/js/node/http2/node-http2.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 2187671df578..a044aa5dbd8f 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -7,8 +7,8 @@ import https from "node:https"; import net from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; -import tls from "node:tls"; import { PerformanceObserver } from "node:perf_hooks"; +import tls from "node:tls"; import { Duplex, duplexPair } from "stream"; import http2utils from "./helpers"; import { nodeEchoServer, TLS_CERT, TLS_OPTIONS } from "./http2-helpers"; From 4ce300b9f67cfdec90055f5453cd53d2146f3da2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:08:57 +0000 Subject: [PATCH 27/43] fallback: add writeInformational so writeEarlyHints/writeProcessing reach the socket; fix #4295 proxy test's localhost v4/v6 mismatch _writeRaw calls this[kHandle].writeInformational without optional chaining, and every 1xx helper except writeContinue routes through it. The fallback handle gained writeContinue earlier in this PR; add writeInformational alongside so writeEarlyHints / writeProcessing / writeInformation work over server.emit('connection', duplex) and the http2 allowHTTP1 path. node-http-proxy.js: listen on 127.0.0.1 and connect to address.address so the client reaches the same family the server bound; listening on 'localhost' can bind ::1 while the client resolves 127.0.0.1. --- src/js/internal/http1_server_fallback.ts | 5 +++++ test/js/node/http/node-http-proxy.js | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 860dc1917669..7b96be499e09 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -170,6 +170,11 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim writeContinue() { socket.write("HTTP/1.1 100 Continue\r\n\r\n"); }, + writeInformational(chunk, encoding) { + // _writeRaw hands the fully-rendered 1xx block here (writeEarlyHints / + // writeProcessing / writeInformation all route through it). + socket.write(chunk, encoding); + }, writeHead(statusCode, statusMessage, headers, autoHeaderBits, keepAliveTimeoutSecs) { const originalStatusCode = statusCode; statusCode |= 0; diff --git a/test/js/node/http/node-http-proxy.js b/test/js/node/http/node-http-proxy.js index 8b82678ae9e4..9ca8df38e47c 100644 --- a/test/js/node/http/node-http-proxy.js +++ b/test/js/node/http/node-http-proxy.js @@ -32,12 +32,12 @@ export async function run() { req.pipe(proxyRequest); // Use pipe instead of manual data handling }); - proxyServer.listen(0, "localhost", async () => { + proxyServer.listen(0, "127.0.0.1", async () => { const address = proxyServer.address(); const options = { protocol: "http:", - hostname: "localhost", + hostname: address.address, port: address.port, path: "/", // Change path to / headers: { From be93d722289fb7c48588ad96f995e2e147166546 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:57:53 +0000 Subject: [PATCH 28/43] h2/fallback review: send_trailers encode failure -> session COMPRESSION_ERROR; server close()/streamEnd honor ping/SETTINGS grace; socketOnEnd connection-end half - send_trailers: the 5th encode_header_into_list site now routes encode failures through schedule_header_compression_session_error like the other four (trailers are HEADERS frames from the same connection-scoped deflater; nghttp2 surfaces NGHTTP2_ERR_HEADER_COMP from mem_send regardless of block type). - ServerHttp2Session close()/streamEnd(7)/localSettings now apply the same bounded SETTINGS-ACK/ping grace the client already does, so the onPing completion half added earlier is reachable and a server-side ping callback gets its RTT instead of a cancel. - onHttp1SocketEnd: after parser.finish(), end the connection the way Node's socketOnEnd does (httpAllowHalfOpen / _last on in-flight response / end an idle writable socket). --- src/js/internal/http1_server_fallback.ts | 16 ++++++++++-- src/js/node/http2.ts | 33 +++++++++++++++++++++--- src/runtime/api/bun/h2_frame_parser.rs | 26 ++++--------------- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 7b96be499e09..e2d4584c937c 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -436,10 +436,22 @@ function connectionListenerHTTP1(server, socket, options) { function onHttp1SocketErrorListener(err) { onHttp1SocketError(err, undefined); } - // Node's socketOnEnd: let llhttp detect a message cut short by EOF. + // Node's socketOnEnd: let llhttp detect a message cut short by EOF, then end + // the connection the way Node does (httpAllowHalfOpen / _last / idle end). function onHttp1SocketEnd() { const ret = parser.finish(); - if (ret instanceof Error) onHttp1SocketError(ret, undefined); + if (ret instanceof Error) { + onHttp1SocketError(ret, undefined); + return; + } + if (!server.httpAllowHalfOpen) { + if (req && !req.complete) req.destroy(); + if (socket.writable) socket.end(); + } else if (socket._httpMessage) { + socket._httpMessage._last = true; + } else if (socket.writable) { + socket.end(); + } } socket.on("data", onHttp1SocketData); socket.on("error", onHttp1SocketErrorListener); diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 5170800eac8b..bc85fe78188b 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -4304,8 +4304,12 @@ class ServerHttp2Session extends Http2Session { stream.destroy(); } if (self.#connections === 0 && self.#closed) { - captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); - self.destroy(); + if (self.#pendingSettingsAckCount > 0 || (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0)) { + scheduleSettingsAckGraceNT(self); + } else { + captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); + setImmediate(destroyIfNotDestroyedNT, self); + } } } else if (state === 5) { // 5 = local closed aka write is closed @@ -4377,6 +4381,22 @@ class ServerHttp2Session extends Http2Session { self.#localSettings = settings; self.#pendingSettingsAck = false; if (self.#pendingSettingsAckCount > 0) self.#pendingSettingsAckCount--; + // This ACK may be the only thing a gracefully closed, idle session was waiting for. + const pingsDrained = self.#pingCallbacks === null || self.#pingCallbacks.length === 0; + if (self.#pendingSettingsAckCount === 0 && pingsDrained && self[kSettingsAckGraceTimer] !== undefined) { + clearTimeout(self[kSettingsAckGraceTimer]); + self[kSettingsAckGraceTimer] = undefined; + } + if ( + self.#closed && + self.#connections === 0 && + self.#pendingSettingsAckCount === 0 && + pingsDrained && + !self.destroyed + ) { + captureHttp2PerfFrameSnapshot(self, self[bunHTTP2Native]); + setImmediate(destroyIfNotDestroyedNT, self); + } const queued = self.#pendingSettingsCallbacks.shift(); // Node's settingsCallback (lib/internal/http2/core.js) invokes the settings() // callback first and emits 'localSettings' after it. @@ -4862,7 +4882,14 @@ class ServerHttp2Session extends Http2Session { this[kGoawaySent] = true; this.#parser?.flush?.(); if (this.#connections === 0) { - setImmediate(destroyIfNotDestroyedNT, this); + // Same bounded grace the client applies: an unACKed SETTINGS or ping still + // reaches its callback/'localSettings' before the session is destroyed. + if (this.#pendingSettingsAckCount > 0 || (this.#pingCallbacks !== null && this.#pingCallbacks.length > 0)) { + scheduleSettingsAckGraceNT(this); + } else { + captureHttp2PerfFrameSnapshot(this, this.#parser); + setImmediate(destroyIfNotDestroyedNT, this); + } } } diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 0ee7b4479d77..c8e220c3268c 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -8012,27 +8012,11 @@ impl H2FrameParser { Err(global_object.throw(format_args!("Failed to allocate header buffer"))) } Err(_) => { - let identifier = stream.get_identifier(); - identifier.ensure_still_alive(); - this.dispatch_with_2_extra( - JSH2FrameParser::Gc::onFrameError, - identifier, - JSValue::js_number(FrameType::HTTP_FRAME_HEADERS as u8 as f64), - JSValue::js_number(ErrorCode::FRAME_SIZE_ERROR.0 as f64), - ); - // The trailer block cannot be encoded into a legal frame: reset the - // stream so the peer sees RST_STREAM(FRAME_SIZE_ERROR), then shut the - // session down gracefully — the encoder state is no longer trustworthy - // (node/nghttp2 treat this as fatal and close with a NO_ERROR GOAWAY). - let triggering_id = stream.id; - this.end_stream(&mut stream, ErrorCode::FRAME_SIZE_ERROR); - this.send_go_away( - triggering_id, - ErrorCode::NO_ERROR, - b"", - this.last_stream_id.get(), - true, - ); + // Same connection-scoped deflater as every other header block: nghttp2 + // surfaces the failure from nghttp2_session_mem_send as + // NGHTTP2_ERR_HEADER_COMP regardless of block type, and node reports + // ERR_HTTP2_SESSION_ERROR(COMPRESSION_ERROR) on the session. + this.schedule_header_compression_session_error(); Ok(Some(JSValue::UNDEFINED)) } } From cb5609684fc3f22bb4e768592f705175beb1e039 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:44:15 +0000 Subject: [PATCH 29/43] test: wire must-not-run request handlers to reject/flag instead of throwing in the callback --- test/js/node/http/node-http.test.ts | 44 ++++++++++++++++------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 48c3880991eb..4436250eb41c 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -3933,30 +3933,34 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { // first tunnel bytes as bodyHead; without one it falls through as a normal // request with req.upgrade cleared; CONNECT without a listener destroys. { - const server = createServer(() => { - throw new Error("request handler must not run for a handled upgrade"); - }); + const unexpectedRequest = Promise.withResolvers(); + const server = createServer(() => + unexpectedRequest.reject(new Error("request handler must not run for a handled upgrade")), + ); server.on("upgrade", (req, socket, head) => { socket.write("HTTP/1.1 101 Switching Protocols\r\n\r\nHEAD:" + head.toString()); socket.on("data", d => socket.write("TUNNEL:" + d)); }); const [clientSide, serverSide] = duplexPair(); server.emit("connection", serverSide); - const out = await new Promise((resolve, reject) => { - let buf = ""; - let sentMore = false; - clientSide.on("data", d => { - buf += d; - if (!sentMore && buf.includes("HEAD:early")) { - sentMore = true; - clientSide.write("more"); - } - if (buf.includes("TUNNEL:more")) resolve(buf); - }); - clientSide.on("error", reject); - clientSide.on("close", () => reject(new Error("closed before expected output: " + buf))); - clientSide.write("GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: ws\r\nConnection: Upgrade\r\n\r\nearly"); - }); + const out = await Promise.race([ + unexpectedRequest.promise, + new Promise((resolve, reject) => { + let buf = ""; + let sentMore = false; + clientSide.on("data", d => { + buf += d; + if (!sentMore && buf.includes("HEAD:early")) { + sentMore = true; + clientSide.write("more"); + } + if (buf.includes("TUNNEL:more")) resolve(buf); + }); + clientSide.on("error", reject); + clientSide.on("close", () => reject(new Error("closed before expected output: " + buf))); + clientSide.write("GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: ws\r\nConnection: Upgrade\r\n\r\nearly"); + }), + ]); expect(out).toStartWith("HTTP/1.1 101 Switching Protocols"); expect(out).toContain("HEAD:early"); expect(out).toContain("TUNNEL:more"); @@ -3987,8 +3991,9 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { } { + let requestHandlerRan = false; const server = createServer(() => { - throw new Error("request handler must not run for CONNECT"); + requestHandlerRan = true; }); const [clientSide, serverSide] = duplexPair(); server.emit("connection", serverSide); @@ -3997,6 +4002,7 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { const closed = new Promise(resolve => serverSide.on("close", () => resolve())); clientSide.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n"); await closed; + expect(requestHandlerRan).toBe(false); expect(serverSide.destroyed).toBe(true); } }); From 6d0f17bb862f5d58ffb03a4d5f6dae16a0e322a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:32:51 +0000 Subject: [PATCH 30/43] revert send_trailers to per-stream FRAME_SIZE_ERROR; fix onHttp1SocketEnd lint Node's vendored test-http2-exceeds-server-trailer-size.js asserts per-stream 'frameError'(NGHTTP2_FRAME_SIZE_ERROR) + ERR_HTTP2_STREAM_ERROR for an oversized trailer block: nghttp2 checks maxSendHeaderBlockLength before deflation and fires on_frame_not_send_callback, which node maps to a stream error (not the session-level HEADER_COMP the earlier review assumed). Restore the original per-stream handling with a comment naming the test. onHttp1SocketEnd: read socket._httpMessage into a local before branching on it (no-duplicate-conditional-property-access). --- src/js/internal/http1_server_fallback.ts | 7 +++++-- src/runtime/api/bun/h2_frame_parser.rs | 26 +++++++++++++++++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index e2d4584c937c..4cce528788d7 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -447,8 +447,11 @@ function connectionListenerHTTP1(server, socket, options) { if (!server.httpAllowHalfOpen) { if (req && !req.complete) req.destroy(); if (socket.writable) socket.end(); - } else if (socket._httpMessage) { - socket._httpMessage._last = true; + return; + } + const httpMessage = socket._httpMessage; + if (httpMessage) { + httpMessage._last = true; } else if (socket.writable) { socket.end(); } diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index c8e220c3268c..ee06f197fa45 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -8012,11 +8012,27 @@ impl H2FrameParser { Err(global_object.throw(format_args!("Failed to allocate header buffer"))) } Err(_) => { - // Same connection-scoped deflater as every other header block: nghttp2 - // surfaces the failure from nghttp2_session_mem_send as - // NGHTTP2_ERR_HEADER_COMP regardless of block type, and node reports - // ERR_HTTP2_SESSION_ERROR(COMPRESSION_ERROR) on the session. - this.schedule_header_compression_session_error(); + // nghttp2 checks maxSendHeaderBlockLength before deflation and fires + // on_frame_not_send_callback(NGHTTP2_ERR_FRAME_SIZE_ERROR), which node + // surfaces as 'frameError' + ERR_HTTP2_STREAM_ERROR (vendored + // test-http2-exceeds-server-trailer-size.js asserts exactly this). + let identifier = stream.get_identifier(); + identifier.ensure_still_alive(); + this.dispatch_with_2_extra( + JSH2FrameParser::Gc::onFrameError, + identifier, + JSValue::js_number(FrameType::HTTP_FRAME_HEADERS as u8 as f64), + JSValue::js_number(ErrorCode::FRAME_SIZE_ERROR.0 as f64), + ); + let triggering_id = stream.id; + this.end_stream(&mut stream, ErrorCode::FRAME_SIZE_ERROR); + this.send_go_away( + triggering_id, + ErrorCode::NO_ERROR, + b"", + this.last_stream_id.get(), + true, + ); Ok(Some(JSValue::UNDEFINED)) } } From d361c0259cf6b61de3564c7264eac9fd42378d07 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:36:50 +0000 Subject: [PATCH 31/43] docs: move rewrite_read doc comment back above its fn (sync_engine_frame_counters was inserted between them) --- src/runtime/api/bun/h2_frame_parser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 86eaa2f57766..ee9ef3a5b61b 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -5588,7 +5588,6 @@ impl H2FrameParser { }); } - /// Feed inbound bytes through the rewrite engine, buffering the unconsumed tail (design B). /// Mirror the engine's frame counters into plain Cells so getFrameCounters() never /// contends with the engine borrow (destroy can run inside a dispatch). fn sync_engine_frame_counters(&self) { @@ -5600,6 +5599,7 @@ impl H2FrameParser { } } + /// Feed inbound bytes through the rewrite engine, buffering the unconsumed tail (design B). fn rewrite_read(&self, bytes: &[u8]) { bun_output::scoped_log!(H2FrameParser, "rewriteRead {}", bytes.len()); // Re-entrancy guard: receive() dispatches into JS between frames, and user code can feed From f641f77d3165c7c46b0c4698bf079f4f53c16e0a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:03:40 +0000 Subject: [PATCH 32/43] fallback: map HPE_CHUNK_EXTENSIONS_OVERFLOW to 413 like Node's socketOnError and the native onServerClientError --- src/js/internal/http1_server_fallback.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 4cce528788d7..1b6bbbb245ae 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -394,7 +394,9 @@ function connectionListenerHTTP1(server, socket, options) { socket.write( code === "HPE_HEADER_OVERFLOW" ? "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n" - : "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", + : code === "HPE_CHUNK_EXTENSIONS_OVERFLOW" + ? "HTTP/1.1 413 Payload Too Large\r\nConnection: close\r\n\r\n" + : "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", "latin1", ); } From 77cbe93f24e4c508e754eb1a5e8a9b72892b4d6a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:31:25 +0000 Subject: [PATCH 33/43] http2: defer streamEnd(7) destroy when end(chunk)'s own _write drove the CLOSED transition When the final chunk's _write passes close=true on a HALF_CLOSED_REMOTE stream, send_data transitions to CLOSED and dispatches streamEnd(7) synchronously, re-entering JS before Writable.end() has set kEnding. The mid-finish guard read writableEnded (false in that window) and fell through to destroy(), so 'finish' never fired. Widen the guard to also accept kEndingWithChunk, the latch isFinalWrite already bridges for the same window. --- src/js/node/http2.ts | 18 +++++++++-------- test/js/node/http2/node-http2.test.js | 29 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index f031e20d3d9b..e3a8f582cba8 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -4260,10 +4260,11 @@ class ServerHttp2Session extends Http2Session { // ended before the request body was consumed): node defers the destroy until the // consumer drains it ('end'), so the buffered request body is not lost. stream.once("end", destroySelfOnEnd); - } else if (stream.writableEnded && !stream.writableFinished && !stream.destroyed) { - // The writable side is mid-finish (an in-flight _final settled the native stream - // synchronously): destroying now would swallow 'finish'. Node's kMaybeDestroy waits - // for the writable side to finish before destroying a cleanly closed stream. + } else if ((stream.writableEnded || stream[kEndingWithChunk]) && !stream.writableFinished && !stream.destroyed) { + // The writable side is mid-finish (an in-flight _final or _write carrying + // END_STREAM settled the native stream synchronously, re-entering here before + // Writable.end() has set kEnding): destroying now would swallow 'finish'. + // Node's kMaybeDestroy waits for the writable side to finish first. stream.once("finish", destroySelfOnEnd); } else { stream.destroy(); @@ -5240,10 +5241,11 @@ class ClientHttp2Session extends Http2Session { // destroy until the consumer drains it ('end'), so a late-attaching reader does not // lose data. stream.once("end", destroySelfOnEnd); - } else if (stream.writableEnded && !stream.writableFinished && !stream.destroyed) { - // The writable side is mid-finish (an in-flight _final settled the native stream - // synchronously): destroying now would swallow 'finish'. Node's kMaybeDestroy waits - // for the writable side to finish before destroying a cleanly closed stream. + } else if ((stream.writableEnded || stream[kEndingWithChunk]) && !stream.writableFinished && !stream.destroyed) { + // The writable side is mid-finish (an in-flight _final or _write carrying + // END_STREAM settled the native stream synchronously, re-entering here before + // Writable.end() has set kEnding): destroying now would swallow 'finish'. + // Node's kMaybeDestroy waits for the writable side to finish first. stream.once("finish", destroySelfOnEnd); } else { stream.destroy(); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index a5ab02d2bd60..b96fad23f49f 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3728,6 +3728,35 @@ it("packs END_STREAM onto the DATA frame produced by end(chunk)", async () => { } }); +it("end(chunk) on a HALF_CLOSED_REMOTE stream still emits 'finish'", async () => { + // _write carries END_STREAM on the final chunk; when the peer's END_STREAM has already + // arrived that transitions the native stream to CLOSED and re-enters streamEnd(7) before + // Writable.end() has set kEnding. The mid-finish guard must defer destroy on 'finish'. + const server = http2.createServer(); + try { + const serverFinish = Promise.withResolvers(); + server.on("stream", async stream => { + stream.on("error", serverFinish.reject); + stream.respond({ ":status": 200 }); + for await (const _ of stream); + stream.on("finish", () => serverFinish.resolve(true)); + stream.on("close", () => serverFinish.resolve(false)); + stream.end("ok"); + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://127.0.0.1:${port}`); + client.on("error", serverFinish.reject); + const req = client.request({ ":method": "POST", ":path": "/" }); + req.on("error", serverFinish.reject); + req.resume(); + req.end("body"); + expect(await serverFinish.promise).toBe(true); + client.close(); + } finally { + server.close(); + } +}); + it("client connects over a user Duplex that already has a 'data' listener", async () => { // A 'data' listener attached before connect() puts the stream in flowing mode, so the // peer's first frames can arrive before the connect callback has run. The preface must From 3c6783ef441d117e6d888863c85bca95a8628b50 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:33:54 +0000 Subject: [PATCH 34/43] [autofix.ci] apply automated fixes --- src/js/node/http2.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index e3a8f582cba8..e0c5cf15be70 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -4260,7 +4260,11 @@ class ServerHttp2Session extends Http2Session { // ended before the request body was consumed): node defers the destroy until the // consumer drains it ('end'), so the buffered request body is not lost. stream.once("end", destroySelfOnEnd); - } else if ((stream.writableEnded || stream[kEndingWithChunk]) && !stream.writableFinished && !stream.destroyed) { + } else if ( + (stream.writableEnded || stream[kEndingWithChunk]) && + !stream.writableFinished && + !stream.destroyed + ) { // The writable side is mid-finish (an in-flight _final or _write carrying // END_STREAM settled the native stream synchronously, re-entering here before // Writable.end() has set kEnding): destroying now would swallow 'finish'. @@ -5241,7 +5245,11 @@ class ClientHttp2Session extends Http2Session { // destroy until the consumer drains it ('end'), so a late-attaching reader does not // lose data. stream.once("end", destroySelfOnEnd); - } else if ((stream.writableEnded || stream[kEndingWithChunk]) && !stream.writableFinished && !stream.destroyed) { + } else if ( + (stream.writableEnded || stream[kEndingWithChunk]) && + !stream.writableFinished && + !stream.destroyed + ) { // The writable side is mid-finish (an in-flight _final or _write carrying // END_STREAM settled the native stream synchronously, re-entering here before // Writable.end() has set kEnding): destroying now would swallow 'finish'. From 4485e5c238d8580a85885b9e490b582bc9b405e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:59:04 +0000 Subject: [PATCH 35/43] http2: stamp timeToFirstHeader in server streamHeaders too; fallback Expect routing matches Node's version gate + continueExpression --- src/js/internal/http1_server_fallback.ts | 6 +++--- src/js/node/http2.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 1b6bbbb245ae..1855565ba398 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -246,7 +246,7 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim // the socket to the HTTP/1 connection listener. function connectionListenerHTTP1(server, socket, options) { const http = require("node:http"); - const { HTTPParser, prepareError, calculateLenientFlags } = require("node:_http_common"); + const { HTTPParser, prepareError, calculateLenientFlags, continueExpression } = require("node:_http_common"); const { kHandle: kHttp1ResponseHandle } = require("internal/http"); const { allMethods } = process.binding("http_parser"); @@ -354,8 +354,8 @@ function connectionListenerHTTP1(server, socket, options) { // Node's parserOnIncoming Expect routing (the native dispatcher applies the // same at _http_server.ts's DISPATCH_HAS_EXPECT branch). const expect = req.headers.expect; - if (expect !== undefined) { - if (String(expect).trim().toLowerCase() === "100-continue") { + if (expect !== undefined && versionMajor === 1 && versionMinor === 1) { + if (continueExpression.test(String(expect))) { if (server.listenerCount("checkContinue") > 0) { server.emit("checkContinue", req, res); } else { diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index e0c5cf15be70..f18ddb2571c2 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -4300,6 +4300,10 @@ class ServerHttp2Session extends Http2Session { flags: number, ) { if (!self || typeof stream !== "object" || self.closed || stream.closed) return; + const requestPerf = stream[kPerfState]; + if (requestPerf !== undefined && requestPerf.firstHeader === 0) { + requestPerf.firstHeader = performance.now() - requestPerf.start; + } let rawheaders = headersTuple[0]; let headers = headersTuple[1]; if (self.#strictFieldWhitespaceValidation) { From 4227bbfa9b094b85fdf95aee22913c033cbcb467 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:41:44 +0000 Subject: [PATCH 36/43] NodeHTTP.cpp: hoist lenientHttpFlags.toInt32 and exception-check before passing into Server__setAppFlags --- src/jsc/bindings/NodeHTTP.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index e3383c600b41..90c460de80a7 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -1283,8 +1283,10 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, double maxHeaderSizeNumber = maxHeaderSize.toNumber(globalObject); RETURN_IF_EXCEPTION(scope, {}); + int32_t lenientBits = lenientHttpFlags.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); - Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientHttpFlags.toInt32(globalObject) & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); + Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientBits & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); RETURN_IF_EXCEPTION(scope, {}); Server__setMaxHTTPHeaderSize(globalObject, JSValue::encode(serverValue), maxHeaderSizeNumber); @@ -1316,7 +1318,10 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetAppFlags, (JSGlobalObject * globalObject, Call JSValue lenientHttpFlags = callFrame->uncheckedArgument(3); JSValue httpAllowHalfOpen = callFrame->argument(4); - Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientHttpFlags.toInt32(globalObject) & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); + int32_t lenientBits = lenientHttpFlags.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), static_cast(lenientBits & 0x3), httpAllowHalfOpen.toBoolean(globalObject)); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); From b87e3961a99c698e98e7d691d548eeb969e4f466 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:20:19 +0000 Subject: [PATCH 37/43] trim comments to <=3 lines, cite spec/node source --- packages/bun-uws/src/HttpContext.h | 22 ++----- packages/bun-uws/src/HttpContextData.h | 12 +--- packages/bun-uws/src/HttpParser.h | 65 ++++++------------- src/js/internal/http1_server_fallback.ts | 55 +++++----------- src/js/node/_http_server.ts | 64 +++++++----------- src/js/node/http2.ts | 36 +++++----- src/jsc/bindings/NodeHTTP.cpp | 6 +- .../bindings/node/JSNodeHTTPServerSocket.cpp | 20 ++---- src/runtime/api/bun/h2_frame_parser.rs | 36 +++++----- src/runtime/server/NodeHTTPResponse.rs | 40 +++++------- 10 files changed, 124 insertions(+), 232 deletions(-) diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index d135e6286343..76d93fbe7bbc 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -435,18 +435,9 @@ struct HttpContext { if constexpr (IsNodeHttp) { ((HttpResponseData *) httpResponseData)->nodeHttpResponseTrailers.clear(); - /* Node's flood prevention applies here too: a handler that - * write()s and end()s synchronously completes each exchange - * before the next dispatch, so the pipelined branch above - * never runs — yet unflushed response bytes pile up on the - * connection all the same. Once the socket carries outgoing - * backpressure, stop reading (and stop consuming the - * already-received requests: the signal parks them) until - * onWritable drains it. This request still dispatches, like - * Node, which pauses from within parserOnIncoming. No - * already-paused guard: the replay clears the signal but not - * the state bit, so gating on the bit would let the whole - * spill dispatch unbounded on the first replay. */ + /* Node's flood prevention: sync write()+end() handlers bypass the pipelined + * branch yet still back up the socket. On outgoing backpressure, pause reads + * and park already-received requests. No already-paused guard (replay clears signal only). */ if (((AsyncSocket *) s)->getBufferedAmount() > 0) { httpResponseData->state |= HttpResponseData::HTTP_NODE_READS_PAUSED; httpResponseData->nodeHttpReadsPausedSignal = true; @@ -762,10 +753,9 @@ struct HttpContext { * new requests again. */ if constexpr (IsNodeHttp) { if (httpResponseData->state & HttpResponseData::HTTP_NODE_READS_PAUSED) { - /* Parked pipelined requests must be replayed before the socket - * reads fresh bytes, or the stream reorders; the hook holds - * under outgoing backpressure and resumes raw reads only once - * the queue and the spill drain (JSNodeHTTPServerSocket.cpp). */ + /* Parked pipelined requests must replay before fresh reads or the stream + * reorders; the hook holds under backpressure and resumes raw reads only once + * the queue and spill drain (JSNodeHTTPServerSocket.cpp). */ Bun__NodeHTTP__onReadsResumable(SSL, s); } } diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index f52be50f7c1d..5c3d55664174 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -34,15 +34,9 @@ struct HttpFlags { bool requireHostHeader: 1 = true; bool isAuthorized: 1 = false; bool useStrictMethodValidation: 1 = false; - /* node:http parser leniency. Two of llhttp's lenient bits are implemented: - * useInsecureHTTPParser is LENIENT_HEADERS (control bytes accepted in field - * values) — set for both httpValidation "relaxed" and "insecure" — - * and useLenientTransferEncoding is LENIENT_TRANSFER_ENCODING (a chunked - * coding with another value after it, e.g. a duplicate Transfer-Encoding: - * chunked header, is accepted) — set only for "insecure" / - * --insecure-http-parser, never for "relaxed", which must relax header - * values alone. The TE+CL conflict, chunked-size/CRLF strictness, version - * and header-token checks are still enforced under both. */ + /* node:http parser leniency. Two llhttp lenient bits: useInsecureHTTPParser = LENIENT_HEADERS + * ("relaxed"+"insecure"); useLenientTransferEncoding = LENIENT_TRANSFER_ENCODING ("insecure" + * only). TE+CL conflict, chunked-size/CRLF, version, header-token checks stay enforced. */ bool useInsecureHTTPParser: 1 = false; bool useLenientTransferEncoding: 1 = false; /* node:http server.httpAllowHalfOpen: when true, a peer FIN with in-flight diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 75890472c373..afd5340f5374 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -588,13 +588,9 @@ struct HttpResponseData; private: std::string fallback; public: - /* node:http flood prevention. The dispatch of a pipelined request that - * finds outgoing backpressure pauses reads (HttpContext), but the recv - * buffer being parsed can still hold thousands of already-received - * pipelined requests, and Node stops consuming those too (its parser is - * paused alongside the socket). The signal makes the request loop stop - * at the next request boundary; the unconsumed remainder is parked here - * and replayed, in order, when reads resume. */ + /* node:http flood prevention: when a pipelined dispatch finds outgoing backpressure, + * Node pauses the parser alongside the socket. The signal stops the request loop at the + * next boundary; the unconsumed remainder is parked here and replayed when reads resume. */ bool nodeHttpReadsPausedSignal = false; bool nodeHttpSpillReplayScheduled = false; std::string nodeHttpPausedSpill; @@ -607,13 +603,9 @@ struct HttpResponseData; bool nodeHttpSawConnectionClose = false; const size_t MAX_FALLBACK_SIZE = BUN_DEFAULT_MAX_HTTP_HEADER_SIZE; - /* maxHeaderSize bounds what llhttp counts — the URL plus each field name and - * value — but the raw block also carries framing llhttp never charges: the - * method and " HTTP/1.1\r\n", a ": " and "\r\n" per header, and the terminating - * "\r\n". Bounding raw bytes by maxHeaderSize itself would reject a request - * Node accepts, so the raw bounds get exactly that framing as slack. It stays - * finite: at most UWS_HTTP_MAX_HEADERS_COUNT headers contribute 4 bytes each, - * and a field value's raw span is already bounded by the in-loop check. */ + /* maxHeaderSize bounds what llhttp counts (URL + field names/values), not framing + * (method, " HTTP/1.1\r\n", ": ", "\r\n"). Raw bounds get that framing as slack so + * we don't reject requests Node accepts. Finite: ≤UWS_HTTP_MAX_HEADERS_COUNT*4 + 64. */ static constexpr size_t MAX_HEADER_FRAMING_SLACK = UWS_HTTP_MAX_HEADERS_COUNT * 4 + 64; /* Maximum chunk-extension bytes per chunk, matching Node/llhttp's @@ -987,14 +979,9 @@ struct HttpResponseData; if(requestLineResult.isConnect) { isConnectRequest = true; } - /* llhttp — and therefore Node — bounds the header block by the bytes it hands - * to its callbacks: on_url, then each field name and field value. It does not - * charge the method, " HTTP/1.1\r\n", the ": " separators or the "\r\n" line - * endings against that budget, so counting the raw offset into the buffer - * rejects requests Node accepts. Mirror llhttp's TrackHeader: accumulate - * name + value lengths and fail once the total reaches maxHeaderSize. The - * fallback buffer keeps its own bound (maxBufferedHeaderSize below), which is - * what caps how much raw data a fragmented request may buffer. */ + /* Mirror llhttp's TrackHeader: accumulate URL + name + value lengths only (llhttp + * never charges method/separators/CRLF) and fail at maxHeaderSize. The fallback + * buffer keeps its own raw bound (maxBufferedHeaderSize). github.com/nodejs/llhttp */ uint64_t headerNread = headers[0].value.length(); if (maxHeaderSize && headerNread >= maxHeaderSize) { return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); @@ -1064,11 +1051,9 @@ struct HttpResponseData; } break; } - /* Bound the value before its terminator is found — a value that never - * terminates must still overflow here, exactly where llhttp would, or an - * oversized unterminated header just waits for more data instead of - * failing. llhttp is handed the value with leading OWS already skipped, - * so that OWS is not charged. */ + /* Bound the value before its terminator is found — an unterminated oversized + * value must overflow here (where llhttp would), not wait for more data. + * llhttp sees the value with leading OWS skipped, so that OWS is not charged. */ const char *countedValueStart = preliminaryValue; while (countedValueStart < postPaddedBuffer && isHTTPHeaderValueWhitespace((unsigned char) *countedValueStart)) { countedValueStart++; @@ -1084,11 +1069,9 @@ struct HttpResponseData; if (postPaddedBuffer[1] == '\n') { /* Store this header, it is valid */ headers->value = std::string_view(preliminaryValue, (size_t) (postPaddedBuffer - preliminaryValue)); - /* Charge the value the way llhttp hands it to on_header_value: leading - * OWS skipped (countedValueStart already sits past it), trailing OWS - * still counted. Measure before the trims below, or a value padded with - * trailing spaces is undercharged and we accept a header block Node - * answers with 431. */ + /* Charge like llhttp's on_header_value: leading OWS skipped, trailing OWS + * counted. Measure before the trims below or trailing-space-padded values + * are undercharged and we accept a header block Node answers with 431. */ const size_t chargedValueLength = (size_t) (postPaddedBuffer - countedValueStart); postPaddedBuffer += 2; /* Trim trailing whitespace (SP, HTAB) per RFC 9110 Section 5.5 */ @@ -1159,11 +1142,9 @@ struct HttpResponseData; consumedTotal += length; return HttpParserResult::success(consumedTotal, returnedUser); } - /* node:http flood prevention: a dispatch earlier in this buffer - * paused reads. Stop at this request boundary (the previous - * request's body is fully consumed here by construction) and park - * the rest. Reported as consumed so the caller does not spill it - * into the size-capped header fallback buffer. */ + /* node:http flood prevention: a dispatch earlier in this buffer paused reads. + * Stop at this request boundary, park the rest, report it as consumed so the + * caller does not spill it into the size-capped header fallback buffer. */ if constexpr (IsNodeHttp) { if (nodeHttpReadsPausedSignal) [[unlikely]] { nodeHttpPausedSpill.append(data, length); @@ -1268,13 +1249,9 @@ struct HttpResponseData; bool deferredTransferEncodingError = IsNodeHttp && transferEncoding.has && !transferEncoding.invalid && !transferEncoding.chunked && !contentLengthStringLen; - /* llhttp's LENIENT_TRANSFER_ENCODING (part of Node's kLenientAll — the - * insecureHTTPParser / httpValidation: "insecure" surface, never - * "relaxed") accepts a chunked coding with another value after it, - * e.g. a duplicate Transfer-Encoding: chunked header. It does not - * relax the Transfer-Encoding + Content-Length conflict, so only the - * coding-shape verdict is cleared; the conflicts folded in below - * still reject. */ + /* llhttp LENIENT_TRANSFER_ENCODING (kLenientAll / "insecure", never "relaxed") + * accepts chunked with another value after it. It does not relax the TE+CL + * conflict, so only the coding-shape verdict is cleared; conflicts below still reject. */ if (useLenientTransferEncoding) { transferEncoding.invalid = false; } diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index 1855565ba398..32f240d8ac9b 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -1,11 +1,6 @@ -// The JS HTTP/1 server path: an llhttp-driven request/response cycle over an -// arbitrary Duplex, plus a stand-in for the native NodeHTTPResponse handle that -// renders the header block to the socket itself. -// -// Two consumers: node:http2's `allowHTTP1` ALPN fallback, and node:http's -// `connectionListener`, which Node registers on every http.Server so that -// `server.emit("connection", socket)` works for a socket the native listener -// never accepted. +// JS HTTP/1 server path over an arbitrary Duplex with a JS stand-in for NodeHTTPResponse. +// Used by http2's `allowHTTP1` ALPN fallback and http's `server.emit("connection", socket)`. +// See https://github.com/nodejs/node/blob/main/lib/_http_server.js connectionListener. const { STATUS_CODES } = require("internal/http"); const { SafeSet } = require("internal/primordials"); @@ -73,10 +68,8 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim // auto-header bits (AUTO_HEADER_* in _http_server.ts / kAutoHeader* in // NodeHTTP.cpp) rather than the flat array. const autoBits = head?.autoHeaderBits ?? 0; - // Node emits the chunked Transfer-Encoding after the Connection line, so the - // bit is rendered further down — but the framing is decided here, and it has - // to suppress the Content-Length this block would otherwise invent. Writing - // both is a smuggling shape (RFC 9112 6.1), not a cosmetic slip. + // Framing decided here but Transfer-Encoding rendered after Connection (Node _storeHeader + // order); suppress the Content-Length it would otherwise invent (RFC 9112 §6.1 smuggling). const chunkedFromAutoBits = (autoBits & 16) !== 0; // Decide the framing here, but write it after Date/Connection/Keep-Alive: // Node's _storeHeader emits Content-Length and the chunked Transfer-Encoding @@ -91,12 +84,9 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim autoContentLength = contentLength; } } - // Mirror the native writeAutoHeaders exactly: each line is written iff its - // bit is set, so res.sendDate = false, removeHeader("date"), a removed - // Connection header (neither connection bit) and a suppressed Keep-Alive - // timeout all round-trip identically through this path. A head-less write - // (nothing called writeHead on this handle) keeps the old defaults — that - // only happens off node:http's ServerResponse, which always renders bits. + // Mirror native writeAutoHeaders: each line written iff its bit is set, so sendDate=false / + // removeHeader / suppressed Keep-Alive round-trip identically. A head-less write (no writeHead + // on this handle — only off node:http's ServerResponse) keeps the old defaults. if (head === null) { if (!hasDate) { out += `Date: ${new Date().toUTCString()}\r\n`; @@ -240,10 +230,8 @@ function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTim return handle; } -// HTTP/1.1 fallback for Http2SecureServer with `allowHTTP1: true`: parses the -// request from the (already decrypted) TLS socket and emits 'request' with -// http.IncomingMessage / http.ServerResponse objects, like node does by routing -// the socket to the HTTP/1 connection listener. +// HTTP/1.1 fallback for Http2SecureServer `allowHTTP1: true`: parse the TLS socket and emit +// 'request' with http.IncomingMessage/ServerResponse, like Node's httpConnectionListener routing. function connectionListenerHTTP1(server, socket, options) { const http = require("node:http"); const { HTTPParser, prepareError, calculateLenientFlags, continueExpression } = require("node:_http_common"); @@ -267,11 +255,8 @@ function connectionListenerHTTP1(server, socket, options) { const kOnBody = HTTPParser.kOnBody | 0; const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; - // Mirror Node's connectionListenerInternal: the parser carries the server's - // header-size cap, its leniency resolution and its header-count limit. Passing - // none of these left every fallback connection on the built-in defaults, so - // maxHeaderSize / insecureHTTPParser / httpValidation / maxHeadersCount were - // silently ignored on this path. + // Mirror Node's connectionListenerInternal: carry maxHeaderSize / leniency / maxHeadersCount + // into the parser. https://github.com/nodejs/node/blob/main/lib/_http_server.js const lenientFlags = calculateLenientFlags(server.httpValidation, server.insecureHTTPParser); const parser = new HTTPParser(); parser.initialize(HTTPParser.REQUEST, {}, server.maxHeaderSize || 0, lenientFlags); @@ -308,11 +293,9 @@ function connectionListenerHTTP1(server, socket, options) { req.upgrade = upgrade; req._addHeaderLines(rawHeaders, rawHeaders.length); - // Node's parserOnIncoming: llhttp's upgrade verdict only sticks for CONNECT - // or when someone will actually handle the 'upgrade' event; otherwise the - // request falls through to normal dispatch with req.upgrade cleared. - // Returning 2 makes llhttp stop at the end of this message, so the bytes - // after it — the tunnel payload — are never parsed as HTTP. + // Node's parserOnIncoming: upgrade only sticks for CONNECT or when an 'upgrade' listener + // exists; otherwise fall through to normal dispatch. Returning 2 makes llhttp stop after + // this message so tunnel bytes are never parsed as HTTP. if (upgrade) { req.upgrade = req.method === "CONNECT" || @@ -410,11 +393,9 @@ function connectionListenerHTTP1(server, socket, options) { return; } if (pendingUpgrade) { - // Node's onParserExecuteCommon: this connection stops being HTTP here. - // Free the parser, hand the socket over with whatever followed the - // request head (the first tunnel bytes), and destroy when nobody is - // listening — reachable only for CONNECT, since a listener-less Upgrade - // already fell through to normal dispatch above. + // Node's onParserExecuteCommon: connection stops being HTTP here. Free parser, hand the + // socket over with the first tunnel bytes, destroy when nobody is listening (only CONNECT + // reaches here listener-less; Upgrade already fell through above). const upgradeReq = pendingUpgrade; pendingUpgrade = null; socket.removeListener("data", onHttp1SocketData); diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index de2849c4a4f2..7ea18c049190 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -302,12 +302,9 @@ function normalizeServerTls(tls) { return tls; } -// Node registers connectionListener on every http.Server, so a socket the -// listener never accepted still gets parsed when it arrives as -// `server.emit("connection", socket)` — a plain Duplex, or a socket handed over -// from another server. The native listener drives its own sockets end to end, so -// this only has to pick up the foreign ones; node:http2 runs the same path for -// its allowHTTP1 ALPN fallback. +// Node registers connectionListener on every http.Server so `server.emit("connection", socket)` +// works for foreign Duplex sockets. The native listener handles its own sockets end to end; +// this picks up the rest. https://github.com/nodejs/node/blob/main/lib/_http_server.js function connectionListener(this: Server, socket) { if (socket instanceof NodeHTTPServerSocket) return; connectionListenerHTTP1(this, socket, { @@ -961,11 +958,9 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort socket, }; (socket[kPipelinedResponses] ??= []).push(http_res); - // A pipelined dispatch can arrive after the previous response already - // finished and detached — its bytes still flushing natively keep the - // connection marked pending — so nothing is in flight to advance the - // queue from and this response would sit queued forever. Kick the - // pipeline once this dispatch settles. + // A pipelined dispatch can arrive after the previous response finished and detached + // (bytes still flushing keep it pending), leaving nothing in flight to advance the + // queue. Kick the pipeline once this dispatch settles. if (socket._httpMessage == null && !socket[kPipelineKickScheduled]) { socket[kPipelineKickScheduled] = true; process.nextTick(advancePipelineIfIdleNT, server, socket); @@ -1163,13 +1158,9 @@ function applyServerCustomOptions(server: Server) { ); } -// Same resolution the client applies: httpValidation wins, then an explicit -// insecureHTTPParser, then the process-wide --insecure-http-parser. Native -// implements two of llhttp's lenient bits, addressed here as bit 0 = lenient -// header values (LENIENT_HEADERS) and bit 1 = lenient transfer-encoding -// (LENIENT_TRANSFER_ENCODING). "relaxed" relaxes header values only; the full -// kLenientAll surface (insecureHTTPParser / httpValidation: "insecure" / -// --insecure-http-parser) gets both. +// Resolution: httpValidation > explicit insecureHTTPParser > --insecure-http-parser. Native +// implements two llhttp lenient bits: bit 0 = LENIENT_HEADERS ("relaxed" gets this only), +// bit 1 = LENIENT_TRANSFER_ENCODING (kLenientAll / "insecure" gets both). function serverLenientFlags(server: Server) { const lenient = calculateLenientFlags(server.httpValidation, server.insecureHTTPParser); if (lenient === HTTPParser.kLenientNone) return 0; @@ -1215,11 +1206,9 @@ function onServerConnection(this: Server, socketHandle) { const isTLS = !!this[tlsSymbol]; const socket = new NodeHTTPServerSocket(this, socketHandle, isTLS); - // Node reaches this through net.Server's accept path, which refuses the - // connection once maxConnections is reached and reports 'drop' instead of - // 'connection'. The native listener bypasses that path, so gate it here. The - // constructor above already tracked the socket, hence `>` against a count that - // includes it rather than Node's `>=` against one that does not. + // Node's net.Server accept path refuses at maxConnections and emits 'drop'; the native + // listener bypasses that, so gate it here. `>` (not Node's `>=`) because the constructor + // above already tracked this socket in the count. const maxConnections = this.maxConnections; const tracked = this[kTrackedConnections]; if (maxConnections != null && (tracked?.size ?? 0) > maxConnections) { @@ -2363,12 +2352,9 @@ function renderNativeHeaders(res) { closeDelimited = true; res[kMustCloseConnection] = true; } else if (res._removedContLen || res[kFramingFrozenChunked]) { - // Node's _storeHeader only falls through to chunked when - // useChunkedEncodingByDefault is set (false for HTTP/1.0 requests), - // and the native writer never chunk-frames an HTTP/1.0 response, so - // everything else is close-delimited like the _removedTE case. - // An explicit writeHead() reaches the same fallthrough with the same - // null _contentLength, so it is gated identically. + // Node's _storeHeader falls through to chunked only when useChunkedEncodingByDefault + // (false for HTTP/1.0); the native writer never chunk-frames HTTP/1.0, so the rest is + // close-delimited. An explicit writeHead() reaches the same null-_contentLength fallthrough. const req = res.req; if (res.useChunkedEncodingByDefault && req.httpVersionMajor >= 1 && req.httpVersionMinor >= 1) { forceChunked = true; @@ -2435,10 +2421,8 @@ function renderNativeHeaders(res) { // response (it is not a real header). flat.push("\u0000", "1"); } else if (forceChunked) { - // Advertise chunked so the native side frames the body instead of - // auto-writing a Content-Length. Not pushed into the flat array: Node's - // _storeHeader emits this after the Connection line, and the flat array is - // written before the auto headers. + // Advertise chunked so native frames the body instead of auto-writing Content-Length. + // Not in the flat array: Node's _storeHeader emits this after Connection, flat array goes first. autoHeaders |= AUTO_HEADER_TRANSFER_ENCODING_CHUNKED; } } catch (e) { @@ -2547,10 +2531,9 @@ function pausePipelineReads(socket) { const response = socket[kHandle]?.response; if (!response) return; socket._paused = true; - // Not response.pause(): that is request-body flow control and refuses to act - // once the in-flight response has ended — which it always has by the time the - // pipeline backs up. pauseReads() pauses the connection's reads regardless, - // and native stops consuming already-received pipelined requests with it. + // Not response.pause(): that is request-body flow control and no-ops once the in-flight + // response has ended (always true when the pipeline backs up). pauseReads() pauses the + // connection regardless and native stops consuming already-received pipelined requests. response.pauseReads(); } @@ -3608,10 +3591,9 @@ ServerResponse.prototype.writeHead = function (statusCode, statusMessage, header this[kSnapshotStatusCode] = this.statusCode; this[kSnapshotStatusMessage] = this.statusMessage; - // Node's writeHead() freezes the body framing too, not just the status line: - // _storeHeader runs here with _contentLength still null, and a later end(chunk) - // cannot add a Content-Length once _header exists. Headers render lazily here, - // so record the frozen choice for renderNativeHeaders to honor. + // Node's writeHead() freezes body framing: _storeHeader runs with _contentLength null and + // a later end(chunk) cannot add Content-Length once _header exists. Headers render lazily + // here, so record the frozen choice for renderNativeHeaders. if (!this[kImplicitHeaderFromEnd] && !this.hasHeader("content-length") && !this.hasHeader("transfer-encoding")) { this[kFramingFrozenChunked] = true; } diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 7b3da71c4244..61d331a88914 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2249,11 +2249,9 @@ const kWriteFlushedWithoutCallback = 0x10; function deferWriteCallbackForSocket(nativeSocket) { return nativeSocket ? process.nextTick : setImmediate; } -// Whether this chunk is the writable's last: end() already ran and nothing is queued -// behind it (writableLength still includes the in-flight chunk, in the writable's own -// units — string chunks count code units), and no trailers are pending (END_STREAM must -// ride the trailer HEADERS instead). node packs END_STREAM onto that final DATA frame -// rather than emitting an empty one after it. +// Whether this chunk is the writable's last: end() ran, nothing queued behind it (writableLength +// includes the in-flight chunk, string chunks in code units), no trailers pending (END_STREAM +// rides trailer HEADERS). Node packs END_STREAM onto the final DATA frame, not a trailing empty one. function isFinalWrite(stream: Http2Stream, pendingLength: number) { // `ending` is set only after end()'s own synchronous write has already dispatched, so // end(chunk) — the common case — needs kEndingWithChunk to see the last chunk as final. @@ -3472,10 +3470,9 @@ class ServerHttp2Stream extends Http2Stream { return; } if (pushResult === -1) { - // The block could not be encoded: the session is failing with a COMPRESSION_ERROR. - // node still delivers the reserved stream to the callback (the caller gets to - // attach handlers) and lets the session teardown error it. The PUSH_PROMISE never - // reached the wire, so teardown must not send an RST for the reserved id. + // Block not encodable: session failing with COMPRESSION_ERROR. Node still delivers the + // reserved stream to the callback and lets session teardown error it. PUSH_PROMISE never + // reached the wire, so teardown must not RST the reserved id. if (pushedStream) pushedStream[kNeverAnnounced] = true; process.nextTick(callback, null, pushedStream, headers); return; @@ -4264,10 +4261,9 @@ class ServerHttp2Session extends Http2Session { !stream.writableFinished && !stream.destroyed ) { - // The writable side is mid-finish (an in-flight _final or _write carrying - // END_STREAM settled the native stream synchronously, re-entering here before - // Writable.end() has set kEnding): destroying now would swallow 'finish'. - // Node's kMaybeDestroy waits for the writable side to finish first. + // Writable side is mid-finish (an in-flight _final/_write carrying END_STREAM settled + // native synchronously, re-entering before Writable.end() set kEnding): destroying now + // swallows 'finish'. Node's kMaybeDestroy waits for writable to finish first. stream.once("finish", destroySelfOnEnd); } else { stream.destroy(); @@ -5253,19 +5249,17 @@ class ClientHttp2Session extends Http2Session { !stream.writableFinished && !stream.destroyed ) { - // The writable side is mid-finish (an in-flight _final or _write carrying - // END_STREAM settled the native stream synchronously, re-entering here before - // Writable.end() has set kEnding): destroying now would swallow 'finish'. - // Node's kMaybeDestroy waits for the writable side to finish first. + // Writable side is mid-finish (an in-flight _final/_write carrying END_STREAM settled + // native synchronously, re-entering before Writable.end() set kEnding): destroying now + // swallows 'finish'. Node's kMaybeDestroy waits for writable to finish first. stream.once("finish", destroySelfOnEnd); } else { stream.destroy(); } if (self.#connections === 0 && self.#closed) { - // Deferred like close()'s own destroy: this runs inside a native dispatch - // batch, and frames the engine already received but has not dispatched yet - // must still reach JS. An outstanding settings() ACK or ping gets a bounded - // grace (see scheduleSettingsAckGraceNT); its arrival completes the destroy. + // Deferred like close()'s own destroy: runs inside a native dispatch batch and + // not-yet-dispatched frames must still reach JS. An outstanding settings() ACK or + // ping gets bounded grace (scheduleSettingsAckGraceNT); its arrival completes destroy. if (self.#pendingSettingsAckCount > 0 || (self.#pingCallbacks !== null && self.#pingCallbacks.length > 0)) { scheduleSettingsAckGraceNT(self); } else { diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 90c460de80a7..367d067c759c 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -856,10 +856,8 @@ static constexpr uint32_t kAutoHeaderDate = 1 << 0; static constexpr uint32_t kAutoHeaderConnKeepAlive = 1 << 1; static constexpr uint32_t kAutoHeaderConnClose = 1 << 2; static constexpr uint32_t kAutoHeaderKeepAliveTimeout = 1 << 3; -// Node's _storeHeader emits the chunked Transfer-Encoding *after* the Connection -// (and Keep-Alive) line, so it cannot ride along in the flat header array, which -// is written before these. Carry it as an auto-header bit instead and render it -// last, in Node's order. +// Node's _storeHeader emits chunked Transfer-Encoding after Connection/Keep-Alive, so it +// cannot ride in the flat array (written first). Carry as an auto-header bit, rendered last. static constexpr uint32_t kAutoHeaderTransferEncodingChunked = 1 << 4; // "Date: \r\n", rebuilt at most once per second. Hand-rolled diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index 825a1e40db40..f14bf665570e 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -400,13 +400,9 @@ void JSNodeHTTPServerSocket::appendPipelinedResponse(JSC::VM& vm, WebCore::JSNod m_pipelinedResponses.last().set(vm, this, response); } -/* node:http flood prevention, resume half. Reads paused mid-buffer parked the - * unconsumed pipelined requests on the parser (HttpParser::nodeHttpPausedSpill). - * They must be replayed before the socket reads fresh bytes or the stream - * reorders, and replaying dispatches request handlers — so it must not run - * synchronously inside whatever JS operation made the connection resumable. - * Defer it as an event-loop task holding the JS socket wrapper alive; reads - * only actually resume once the spill has drained without re-pausing. */ +/* node:http flood prevention, resume half. Parked pipelined requests (HttpParser::nodeHttpPausedSpill) + * must replay before fresh reads (ordering) and not synchronously inside the resuming JS operation. + * Deferred as an event-loop task rooting the JS socket; reads resume once the spill drains without re-pausing. */ template static void replayNodeHttpPausedSpill(us_socket_t* socket) { @@ -445,13 +441,9 @@ static void onNodeHttpReadsResumable(us_socket_t* socket) { auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); if (httpResponseData->state & uWS::HttpResponseData::HTTP_NODE_READS_PAUSED) { - /* Flood prevention owns the pause. Outgoing backpressure holds - * everything — an incidental resume (writeHead re-arming the poll, - * req.resume()) must not reopen the flood or race fresh reads past the - * spill. Queued responses alone must NOT hold the spill replay: the - * body a queued response is waiting on may be sitting in the spill, - * and holding it would deadlock the pipeline. Raw reads still resume - * only once the queue and the spill have both drained. */ + /* Flood prevention owns the pause: outgoing backpressure holds everything (incidental + * resumes must not race fresh reads past the spill). Queued responses alone must NOT hold + * spill replay (their body may be in the spill — deadlock). Raw reads resume once both drain. */ if (reinterpret_cast*>(socket)->getBufferedAmount() > 0) { return; } diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index ecd6acfb8344..3a2a387b4087 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -3017,10 +3017,9 @@ impl H2FrameParser { pub(crate) fn flush(&self) -> usize { bun_output::scoped_log!(H2FrameParser, "flush"); - // The onWrite dispatch below re-enters JS, and a synchronous transport - // (duplexPair) can re-enter flush() from inside it: bail so the in-flight - // bytes are not sent a second time (through any arm — a connect callback - // running inside the dispatch may have attached a native socket). + // onWrite re-enters JS; a synchronous transport (duplexPair) can re-enter flush(): + // bail so in-flight bytes are not sent twice (through any arm — a connect callback + // inside the dispatch may have attached a native socket). if self.js_socket_flushing.get() { return 0; } @@ -3063,18 +3062,16 @@ impl H2FrameParser { -1 }; if code == -1 { - // JS did not take the bytes (e.g. the session's socket is not ready - // yet). Keep them queued for the next flush — clearing here loses - // the connection preface when the peer's first frames arrive before - // the connect callback has run. + // JS did not take the bytes (socket not ready). Keep them queued; + // clearing here loses the connection preface when peer frames arrive + // before the connect callback has run. self.has_nonnative_backpressure.set(true); return 0; } - // Consume exactly what was handed to JS: writes made re-entrantly during - // the dispatch sit after it in the buffer and wait for the next flush. - // `>=` also covers the buffer having been cleared (detach) during the - // dispatch, where advancing would strand the offset past the end. + // Consume exactly what was handed to JS; re-entrant writes during dispatch + // sit after it and wait for the next flush. `>=` also covers the buffer + // being cleared (detach) mid-dispatch, where advancing would strand the offset. if offset + bytes_len >= self.write_buffer.get().slice().len() { self.write_buffer_offset.set(0); self.write_buffer.with_mut(|wb| { @@ -3328,11 +3325,9 @@ impl H2FrameParser { return false; } if self.pending_header_compression_error.get() { - // Keep the pending latch set across the dispatch and flush: the - // re-entrant detach() -> uncork()/unregister_auto_flush() the JS - // teardown drives must early-return at the pending guard instead of - // mutating the task map run() is iterating (aliasing UB). The latch - // is cleared once we are back here with no re-entry on the stack. + // Keep the pending latch set across dispatch+flush: re-entrant detach() -> + // uncork()/unregister_auto_flush() must early-return at the guard instead of + // mutating the task map run() iterates (aliasing UB). Cleared once back here. self.dispatch_with_2_extra( JSH2FrameParser::Gc::onError, JSValue::js_number(ErrorCode::COMPRESSION_ERROR.0 as f64), @@ -7825,10 +7820,9 @@ impl H2FrameParser { Err(global_object.throw(format_args!("Failed to allocate header buffer"))) } Err(_) => { - // nghttp2 checks maxSendHeaderBlockLength before deflation and fires - // on_frame_not_send_callback(NGHTTP2_ERR_FRAME_SIZE_ERROR), which node - // surfaces as 'frameError' + ERR_HTTP2_STREAM_ERROR (vendored - // test-http2-exceeds-server-trailer-size.js asserts exactly this). + // nghttp2 checks maxSendHeaderBlockLength pre-deflation and fires + // on_frame_not_send_callback(NGHTTP2_ERR_FRAME_SIZE_ERROR); Node surfaces + // 'frameError' + ERR_HTTP2_STREAM_ERROR (test-http2-exceeds-server-trailer-size.js). let identifier = stream.get_identifier(); identifier.ensure_still_alive(); this.dispatch_with_2_extra( diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index a85f3b261644..517566fcc827 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -57,12 +57,9 @@ pub struct NodeHTTPResponse { /// body finishes (still inside the parser), because a pipelined request's /// parse would otherwise overwrite it before this request's JS reads it. pub(crate) request_trailers: JsCell>, - /// The JS wrapper whose `ondata` slot was armed for THIS response's request - /// body. `get_this_value()` resolves through the socket's *current* response - /// object, which for a pipelined request is some other response — delivering - /// through it reads the wrong (empty) cache and the body is lost. The - /// wrapper is kept alive by req[kHandle] on the JS side; cleared when the - /// slot is cleared and when the wrapper finalizes. + /// The JS wrapper whose `ondata` slot was armed for THIS request body. `get_this_value()` + /// resolves through the socket's current response, which under pipelining is a different + /// one; delivering through it loses the body. Kept alive by req[kHandle]; cleared on finalize. pub(crate) armed_this_value: Cell, /// node:http: this request's header section captured at dispatch as /// [u32 nameLen][u32 valueLen][name][value]... so req.rawHeaders / @@ -200,17 +197,15 @@ unsafe extern "C" { safe fn Bun__getNodeHTTPResponseThisValue(is_ssl: bool, socket: *mut c_void) -> JSValue; safe fn Bun__getNodeHTTPServerSocketThisValue(is_ssl: bool, socket: *mut c_void) -> JSValue; - // Moves the connection's captured node:http request-trailer section out. - // `*out` points into a C++ thread-local that stays valid until the next - // call on this thread; the caller copies it immediately. Returns 0 when - // there is nothing captured or the socket is closed. - // node:http flood prevention (JSNodeHTTPServerSocket.cpp). The signal makes - // the uWS request loop stop consuming pipelined requests at the next request - // boundary while the socket is paused; the resumable hook replays anything - // it parked, in order, before actually resuming reads. + // node:http flood prevention (JSNodeHTTPServerSocket.cpp): the signal makes the uWS request + // loop stop consuming pipelined requests at the next boundary while paused; the resumable + // hook replays what it parked, in order, before resuming reads. safe fn Bun__NodeHTTP__setReadsPausedSignal(ssl: core::ffi::c_int, socket: *mut c_void); safe fn Bun__NodeHTTP__onReadsResumable(ssl: core::ffi::c_int, socket: *mut c_void); + // Moves the connection's captured node:http request-trailer section out. `*out` points into + // a C++ thread-local valid until the next call on this thread; caller copies immediately. + // Returns 0 when nothing captured or socket closed. safe fn Bun__NodeHTTP__takeRequestTrailerBytes( is_ssl: bool, socket: *mut c_void, @@ -494,10 +489,9 @@ impl NodeHTTPResponse { raw.pause(); } - /* Pipelined flood prevention pauses READS on the connection, which is - * legal — and necessary — after the in-flight response has ended, so this - * intentionally skips doPause's ENDED/REQUEST_HAS_COMPLETED guards (those - * exist for request-body flow control). */ + /* Pipelined flood prevention pauses READS on the connection, legal after the in-flight + * response has ended — so this intentionally skips doPause's ENDED/REQUEST_HAS_COMPLETED + * guards (those exist for request-body flow control). */ pub(crate) fn pause_socket_reads( &self, _global: &JSGlobalObject, @@ -1718,13 +1712,9 @@ impl NodeHTTPResponse { // it, and a body arriving in that window used to be dropped outright. let on_data_armed = js::on_data_get_cached(this_value).is_some_and(|cb| cb.is_cell()); if !on_data_armed && body_was_pending && event == AbortEvent::None { - // No reader armed yet. This is a pipelined request whose body sat in - // the same parse burst as its headers: the response does not own the - // socket, so the JS side has not run _read() to install ondata when - // the parser reaches the body. Dropping it loses the body outright — - // park it where the pause path parks, and the drain that runs when - // the reader arms picks it up. A dumped request never gets here: its - // teardown moved body_read_state to Done first. + // No reader armed yet: pipelined request whose body arrived in the same parse burst + // as its headers, before JS ran _read() to install ondata. Park it where pause parks; + // the reader-arm drain picks it up. (Dumped requests move to Done first, never here.) self.buffered_request_body_data_during_pause .with_mut(|b| b.append_slice(chunk)); self.update_flags(|f| { From b85baf4c5817fcf6a433fb87205ff67bd6f425da Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:34:32 +0000 Subject: [PATCH 38/43] test(spawn-maxbuf): widen killWindow on ASAN builds too release-asan on debian 13 hit 102-104ms vs the 100ms release budget; ASAN instrumentation adds startup overhead similar to debug builds. --- test/js/bun/spawn/spawn-maxbuf.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/js/bun/spawn/spawn-maxbuf.test.ts b/test/js/bun/spawn/spawn-maxbuf.test.ts index bc6c84bc36a6..c17f20a47383 100644 --- a/test/js/bun/spawn/spawn-maxbuf.test.ts +++ b/test/js/bun/spawn/spawn-maxbuf.test.ts @@ -1,4 +1,4 @@ -import { bunEnv, bunExe, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug } from "harness"; const { isWindows } = require("../../node/test/common"); @@ -14,11 +14,11 @@ async function toUtf8(out: ReadableStream): Promise { describe("yes is killed", () => { // The wall-clock window below includes the child's startup (bunExe() has to - // boot before `yes` writes its first byte). On a debug+ASAN build that startup + // boot before `yes` writes its first byte). On a debug/ASAN build that startup // alone is ~150-250ms, so the release 100ms budget is spent before maxBuffer // has anything to measure. Byte-level promptness is asserted by the "caps the // buffer" tests below; this is the coarse "didn't wait a full tick" sanity check. - const killWindow = isDebug ? 1000 : 100; + const killWindow = isDebug || isASAN ? 1000 : 100; test("Bun.spawn", async () => { const timeStart = Date.now(); From 64bcf706fc9db6cd76441e93bd0e65f712c14d25 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:54:24 +0000 Subject: [PATCH 39/43] node:http flood prevention: rename nodeHttpReadsPausedSignal -> nodeHttpParkAtNextBoundary, setReadsPausedSignal -> onReadsPaused The old name read like a duplicate of the HTTP_NODE_READS_PAUSED state bit. They coincide on the pause edge but differ during spill replay: the bit is the socket-level 'raw reads are paused' state and stays set for the whole window; the flag is the parser-level 'stop at the next request boundary and park the rest' instruction that replay clears so its own parse loop can make progress (and that a mid-replay backpressure dispatch re-sets). Name the flag for what it instructs, name the C entry point to pair with onReadsResumable, and spell the split out at the declaration. --- packages/bun-uws/src/HttpContext.h | 6 +++--- packages/bun-uws/src/HttpParser.h | 10 +++++----- .../bindings/node/JSNodeHTTPServerSocket.cpp | 20 +++++++++++-------- src/runtime/server/NodeHTTPResponse.rs | 10 +++++----- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index b61a476d1976..298f194b4834 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -411,7 +411,7 @@ struct HttpContext { httpResponseData->state |= HttpResponseData::HTTP_NODE_READS_PAUSED; /* Also stop the request loop over the buffer being parsed * right now — pausing the socket alone cannot bound it. */ - httpResponseData->nodeHttpReadsPausedSignal = true; + httpResponseData->nodeHttpParkAtNextBoundary = true; ((HttpResponse *) s)->pause(); } } @@ -441,10 +441,10 @@ struct HttpContext { /* Node's flood prevention: sync write()+end() handlers bypass the pipelined * branch yet still back up the socket. On outgoing backpressure, pause reads - * and park already-received requests. No already-paused guard (replay clears signal only). */ + * and park already-received requests. No already-paused guard (replay clears the park flag only). */ if (((AsyncSocket *) s)->getBufferedAmount() > 0) { httpResponseData->state |= HttpResponseData::HTTP_NODE_READS_PAUSED; - httpResponseData->nodeHttpReadsPausedSignal = true; + httpResponseData->nodeHttpParkAtNextBoundary = true; ((HttpResponse *) s)->pause(); } } diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 5370acc3379d..421f92c0eb18 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -605,10 +605,10 @@ struct HttpResponseData; private: std::string fallback; public: - /* node:http flood prevention: when a pipelined dispatch finds outgoing backpressure, - * Node pauses the parser alongside the socket. The signal stops the request loop at the - * next boundary; the unconsumed remainder is parked here and replayed when reads resume. */ - bool nodeHttpReadsPausedSignal = false; + /* node:http flood prevention. HTTP_NODE_READS_PAUSED (state bit) = the socket's raw reads are + * paused and stays set through spill replay; this flag = "the parse loop running now must stop + * at the next request boundary and park the rest", cleared for replay so it can make progress. */ + bool nodeHttpParkAtNextBoundary = false; bool nodeHttpSpillReplayScheduled = false; std::string nodeHttpPausedSpill; private: @@ -1163,7 +1163,7 @@ struct HttpResponseData; * Stop at this request boundary, park the rest, report it as consumed so the * caller does not spill it into the size-capped header fallback buffer. */ if constexpr (IsNodeHttp) { - if (nodeHttpReadsPausedSignal) [[unlikely]] { + if (nodeHttpParkAtNextBoundary) [[unlikely]] { nodeHttpPausedSpill.append(data, length); consumedTotal += length; return HttpParserResult::success(consumedTotal, user); diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index f14bf665570e..e71254a45ea3 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -408,7 +408,9 @@ static void replayNodeHttpPausedSpill(us_socket_t* socket) { auto* httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); httpResponseData->nodeHttpSpillReplayScheduled = false; - httpResponseData->nodeHttpReadsPausedSignal = false; + /* Let the replay's own parse loop run; HTTP_NODE_READS_PAUSED stays set so + * fresh socket bytes cannot race ahead of the spill. */ + httpResponseData->nodeHttpParkAtNextBoundary = false; std::string spill = std::move(httpResponseData->nodeHttpPausedSpill); httpResponseData->nodeHttpPausedSpill.clear(); if (!spill.empty()) { @@ -421,7 +423,7 @@ static void replayNodeHttpPausedSpill(us_socket_t* socket) socket = returned; httpResponseData = reinterpret_cast*>(us_socket_ext(socket)); } - if (httpResponseData->nodeHttpReadsPausedSignal) { + if (httpResponseData->nodeHttpParkAtNextBoundary) { /* A dispatch during the replay hit backpressure again and re-parked * the rest; stay paused until the next resumable event. */ return; @@ -453,7 +455,7 @@ static void onNodeHttpReadsResumable(us_socket_t* socket) } } if (httpResponseData->nodeHttpPausedSpill.empty()) { - httpResponseData->nodeHttpReadsPausedSignal = false; + httpResponseData->nodeHttpParkAtNextBoundary = false; httpResponseData->state &= ~uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; reinterpret_cast*>(socket)->resume(); return; @@ -490,23 +492,25 @@ static void onNodeHttpReadsResumable(us_socket_t* socket) }); } +/* Pause edge (JS-driven, after the caller paused the socket): mark the socket-level + * pause and tell the in-progress parse loop to park at the next request boundary. */ template -static void setReadsPausedSignalImpl(us_socket_t* socket) +static void onNodeHttpReadsPaused(us_socket_t* socket) { auto* d = reinterpret_cast*>(us_socket_ext(socket)); - d->nodeHttpReadsPausedSignal = true; + d->nodeHttpParkAtNextBoundary = true; d->state |= uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; } -extern "C" void Bun__NodeHTTP__setReadsPausedSignal(int ssl, us_socket_t* socket) +extern "C" void Bun__NodeHTTP__onReadsPaused(int ssl, us_socket_t* socket) { if (!socket || us_socket_is_closed(socket)) { return; } if (ssl) { - setReadsPausedSignalImpl(socket); + onNodeHttpReadsPaused(socket); } else { - setReadsPausedSignalImpl(socket); + onNodeHttpReadsPaused(socket); } } diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 517566fcc827..674bccdcb0c1 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -197,10 +197,10 @@ unsafe extern "C" { safe fn Bun__getNodeHTTPResponseThisValue(is_ssl: bool, socket: *mut c_void) -> JSValue; safe fn Bun__getNodeHTTPServerSocketThisValue(is_ssl: bool, socket: *mut c_void) -> JSValue; - // node:http flood prevention (JSNodeHTTPServerSocket.cpp): the signal makes the uWS request - // loop stop consuming pipelined requests at the next boundary while paused; the resumable - // hook replays what it parked, in order, before resuming reads. - safe fn Bun__NodeHTTP__setReadsPausedSignal(ssl: core::ffi::c_int, socket: *mut c_void); + // node:http flood prevention (JSNodeHTTPServerSocket.cpp): onReadsPaused marks the socket + // paused and tells the uWS request loop to park pipelined requests at the next boundary; + // onReadsResumable replays what was parked, in order, before resuming reads. + safe fn Bun__NodeHTTP__onReadsPaused(ssl: core::ffi::c_int, socket: *mut c_void); safe fn Bun__NodeHTTP__onReadsResumable(ssl: core::ffi::c_int, socket: *mut c_void); // Moves the connection's captured node:http request-trailer section out. `*out` points into @@ -508,7 +508,7 @@ impl NodeHTTPResponse { return Ok(JSValue::UNDEFINED); } raw.pause(); - Bun__NodeHTTP__setReadsPausedSignal( + Bun__NodeHTTP__onReadsPaused( any_response_is_ssl(&raw) as core::ffi::c_int, raw.socket().cast(), ); From 59c93f1f40bdbb47f906c44c904d5c5ff525376b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:11:25 +0000 Subject: [PATCH 40/43] http2.ts: drop unused throwNotImplemented import and unused bunTLSConnectOptions symbol --- src/js/node/http2.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 4b1d3795b87a..49e1f90e13bc 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -27,13 +27,7 @@ * Modifications were made to the original code. */ const { isTypedArray } = require("node:util/types"); -const { - hideFromStack, - throwNotImplemented, - hasObserver, - enqueueNodeEntry, - PerformanceNodeEntry, -} = require("internal/shared"); +const { hideFromStack, hasObserver, enqueueNodeEntry, PerformanceNodeEntry } = require("internal/shared"); const { STATUS_CODES } = require("internal/http"); const { kTimeout, getTimerDuration } = require("internal/timers"); const tls = require("node:tls"); @@ -41,7 +35,6 @@ const net = require("node:net"); const fs = require("node:fs"); const { $data } = require("node:fs/promises"); const FileHandle = $data.FileHandle; -const bunTLSConnectOptions = Symbol.for("::buntlsconnectoptions::"); const bunSocketServerOptions = Symbol.for("::bunnetserveroptions::"); const kInfoHeaders = Symbol("sent-info-headers"); const kStrictSingleValueFields = Symbol("strictSingleValueFields"); From 86d0bdab93810e8c0e9182c57519a6bad057d463 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:57:52 +0000 Subject: [PATCH 41/43] node:http flood prevention: hold the parked-request spill in a WTF::Vector instead of std::string The replay grows the vector to cover the parser's two fence bytes instead of relying on reserve(): WTF::Vector poisons [size, capacity) under ASAN. --- packages/bun-uws/src/HttpParser.h | 5 +++-- src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp | 16 ++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 421f92c0eb18..8228670cc1c7 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -33,6 +33,7 @@ #include #include #include +#include #include "MoveOnlyFunction.h" #include "ChunkedEncoding.h" @@ -610,7 +611,7 @@ struct HttpResponseData; * at the next request boundary and park the rest", cleared for replay so it can make progress. */ bool nodeHttpParkAtNextBoundary = false; bool nodeHttpSpillReplayScheduled = false; - std::string nodeHttpPausedSpill; + WTF::Vector nodeHttpPausedSpill; private: /* This guy really has only 30 bits since we reserve two highest bits to chunked encoding parsing state */ uint64_t remainingStreamingBytes = 0; @@ -1164,7 +1165,7 @@ struct HttpResponseData; * caller does not spill it into the size-capped header fallback buffer. */ if constexpr (IsNodeHttp) { if (nodeHttpParkAtNextBoundary) [[unlikely]] { - nodeHttpPausedSpill.append(data, length); + nodeHttpPausedSpill.append(std::span(data, length)); consumedTotal += length; return HttpParserResult::success(consumedTotal, user); } diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index e71254a45ea3..e7d895f5e3b8 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -411,12 +411,12 @@ static void replayNodeHttpPausedSpill(us_socket_t* socket) /* Let the replay's own parse loop run; HTTP_NODE_READS_PAUSED stays set so * fresh socket bytes cannot race ahead of the spill. */ httpResponseData->nodeHttpParkAtNextBoundary = false; - std::string spill = std::move(httpResponseData->nodeHttpPausedSpill); - httpResponseData->nodeHttpPausedSpill.clear(); - if (!spill.empty()) { - /* The parser's post-padded fence writes two bytes past the end. */ - spill.reserve(spill.size() + LIBUS_RECV_BUFFER_PADDING); - us_socket_t* returned = uWS::HttpContext::feedNodeHttpData(socket, spill.data(), (int)spill.size()); + WTF::Vector spill = std::exchange(httpResponseData->nodeHttpPausedSpill, {}); + if (!spill.isEmpty()) { + /* The parser's post-padded fence writes two bytes past the logical end. */ + size_t spillLength = spill.size(); + spill.grow(spillLength + LIBUS_RECV_BUFFER_PADDING); + us_socket_t* returned = uWS::HttpContext::feedNodeHttpData(socket, spill.mutableSpan().data(), (int)spillLength); if (!returned || us_socket_is_closed(returned)) { return; } @@ -449,12 +449,12 @@ static void onNodeHttpReadsResumable(us_socket_t* socket) if (reinterpret_cast*>(socket)->getBufferedAmount() > 0) { return; } - if (httpResponseData->nodeHttpPausedSpill.empty() + if (httpResponseData->nodeHttpPausedSpill.isEmpty() && httpResponseData->nodeHttpQueuedPipelinedCount > 0) { return; } } - if (httpResponseData->nodeHttpPausedSpill.empty()) { + if (httpResponseData->nodeHttpPausedSpill.isEmpty()) { httpResponseData->nodeHttpParkAtNextBoundary = false; httpResponseData->state &= ~uWS::HttpResponseData::HTTP_NODE_READS_PAUSED; reinterpret_cast*>(socket)->resume(); From 70833a7a73da49e54fb4b50f38cc431c7ef8f2ac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:57:52 +0000 Subject: [PATCH 42/43] http2: mark the push stream handed out on the unencodable-block path as Delivered pushStream()'s pushResult === -1 branch gives the reserved stream to the callback, so user code can hold an 'error' listener on it; without the Delivered bit _destroy() dropped the session error for it. Node v26.3.0 emits ERR_HTTP2_SESSION_ERROR on that stream (uncaught with no listener); the test now pins that. --- src/js/node/http2.ts | 10 +++++++--- test/js/node/http2/node-http2.test.js | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 49e1f90e13bc..86e278a77c7f 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3478,9 +3478,13 @@ class ServerHttp2Stream extends Http2Stream { } if (pushResult === -1) { // Block not encodable: session failing with COMPRESSION_ERROR. Node still delivers the - // reserved stream to the callback and lets session teardown error it. PUSH_PROMISE never - // reached the wire, so teardown must not RST the reserved id. - if (pushedStream) pushedStream[kNeverAnnounced] = true; + // reserved stream to the callback and lets session teardown error it (so it is Delivered: + // the session error must reach its 'error' listener). PUSH_PROMISE never reached the wire, + // so teardown must not RST the reserved id. + if (pushedStream) { + pushedStream[kNeverAnnounced] = true; + pushedStream[bunHTTP2StreamStatus] |= StreamState.Delivered; + } process.nextTick(callback, null, pushedStream, headers); return; } diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 477c6f439921..b0d5a4628af3 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3703,16 +3703,23 @@ it("delivers a session error from the event loop, not inside the call that detec it("delivers the reserved push stream and fails the session when its headers cannot be encoded", async () => { // Verified against node v26.3.0: pushStream's callback still receives the reserved // stream (so the caller can attach handlers), and the session then dies with - // COMPRESSION_ERROR (9); the callback never sees an error. + // COMPRESSION_ERROR (9); the callback never sees an error, the pushed stream's + // 'error' listener receives the session error. const server = http2.createServer({ maxSendHeaderBlockLength: 100000 }); try { const sessionError = new Promise(resolve => server.on("sessionError", resolve)); const pushCallback = Promise.withResolvers(); + const pushStreamError = Promise.withResolvers(); server.on("stream", stream => { stream.on("error", () => {}); stream.pushStream({ ":path": "/pushed", "x-big": Buffer.alloc(90000, "A").toString() }, (err, push) => { - push?.on("error", () => {}); pushCallback.resolve(err ?? null); + if (push) { + push.on("error", pushStreamError.resolve); + push.on("close", () => pushStreamError.reject(new Error("pushed stream closed without an error"))); + } else { + pushStreamError.reject(new Error("callback received no pushed stream")); + } }); stream.respond(); stream.end("x"); @@ -3725,10 +3732,12 @@ it("delivers the reserved push stream and fails the session when its headers can req.resume(); req.end(); - const [cbErr, err] = await Promise.all([pushCallback.promise, sessionError]); + const [cbErr, err, pushErr] = await Promise.all([pushCallback.promise, sessionError, pushStreamError.promise]); expect(cbErr).toBeNull(); expect(err.code).toBe("ERR_HTTP2_SESSION_ERROR"); expect(err.message).toBe("Session closed with error code 9"); + expect(pushErr.code).toBe("ERR_HTTP2_SESSION_ERROR"); + expect(pushErr.message).toBe("Session closed with error code 9"); client.destroy(); } finally { server.close(); From e758733ab4a36a39682da84812808feb08542d59 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:57:55 +0000 Subject: [PATCH 43/43] test: restore node-http-proxy.js to its original localhost form --- test/js/node/http/node-http-proxy.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/js/node/http/node-http-proxy.js b/test/js/node/http/node-http-proxy.js index 9ca8df38e47c..8b82678ae9e4 100644 --- a/test/js/node/http/node-http-proxy.js +++ b/test/js/node/http/node-http-proxy.js @@ -32,12 +32,12 @@ export async function run() { req.pipe(proxyRequest); // Use pipe instead of manual data handling }); - proxyServer.listen(0, "127.0.0.1", async () => { + proxyServer.listen(0, "localhost", async () => { const address = proxyServer.address(); const options = { protocol: "http:", - hostname: address.address, + hostname: "localhost", port: address.port, path: "/", // Change path to / headers: {