From 85c887acac984c3d2306cb56742db772c1f0c8b1 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Tue, 7 Jul 2026 15:14:41 -0400 Subject: [PATCH] http: cut per-message outgoing overhead Cache the lenient-header-validation decision per message instead of re-deriving it through the six-branch req/socket/server walk on every setHeader/appendHeader call (and per addTrailers key); the inputs are fixed once the message is constructed. Pre-filter the known-field matcher on (length, first character) so ordinary headers skip the per-header toLowerCase() allocation; the filter only rejects names that cannot match. Load the `socket` prototype accessor once per body write instead of three times, hoist the repeated _header/_keepAliveTimeout/ _maxRequestsPerSocket/_contentLength/headers.length loads the engine cannot fold across calls, and flush corked chunked buffers through a shared callback runner instead of allocating a closure per flush. The hoists mirror what Bun's fork of these files carries on top of the shared lineage (bun/src/js/node/_http_outgoing.ts: write_ msgSocket, _send header local, _storeHeader/processHeader length locals, runChunkCallbacks, flat one-shot setHeader validation). Mechanism benchmarks driving the shipped classes through the public API (fresh process per sample, 30 interleaved samples per binary, Welch t-test): flushHeaders with five headers +8.25% (p=6.2e-11), a 24-header response +4.26% (p=6.7e-7), tight setHeader loop +2.64% (p=2.0e-3); sign-stable in an independent 15-sample repeat. No statistically significant regression across the captured benchmark/http suite (headers, incoming_headers, chunked, end-vs-write-end, client-request-body, create-clientrequest, check_*). The new test locks the matcher semantics: known fields keep being recognized in any casing, and unknown names sharing a known field's length and first letter are emitted verbatim. Refs: https://github.com/oven-sh/bun/blob/main/src/js/node/_http_outgoing.ts Assisted-by: Grok Signed-off-by: Yagiz Nizipli --- lib/_http_outgoing.js | 113 +++++++++++++----- .../test-http-outgoing-known-header-casing.js | 65 ++++++++++ 2 files changed, 145 insertions(+), 33 deletions(-) create mode 100644 test/parallel/test-http-outgoing-known-header-casing.js diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 7694fe4b5b3f3e..6d6ebc5f77f005 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -79,6 +79,7 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => { }); const kCorked = Symbol('corked'); +const kLenientValidation = Symbol('kLenientValidation'); const kSocket = Symbol('kSocket'); const kChunkedBuffer = Symbol('kChunkedBuffer'); const kChunkedLength = Symbol('kChunkedLength'); @@ -143,6 +144,7 @@ function OutgoingMessage(options) { this[kChunkedBuffer] = []; this[kChunkedLength] = 0; this._closed = false; + this[kLenientValidation] = undefined; this[kSocket] = null; this._header = null; @@ -163,28 +165,40 @@ ObjectSetPrototypeOf(OutgoingMessage, Stream); // For ClientRequest: checks this.httpValidation or this.insecureHTTPParser // For ServerResponse: checks the server's httpValidation or insecureHTTPParser // Falls back to global --insecure-http-parser flag. +// The answer is invariant for the lifetime of the message (the options are +// fixed at construction time), but this runs on every setHeader/appendHeader +// call and once per stored header block, so the multi-step lookup chain is +// resolved once per message and cached. OutgoingMessage.prototype._isLenientHeaderValidation = function() { + return (this[kLenientValidation] ??= lenientHeaderValidation(this)); +}; + +function lenientHeaderValidation(msg) { + const { + httpValidation, + insecureHTTPParser, + } = msg; // New httpValidation option takes priority (ClientRequest case) - if (this.httpValidation !== undefined) { - return this.httpValidation !== 'strict'; + if (httpValidation !== undefined) { + return httpValidation !== 'strict'; } // ServerResponse: check server's httpValidation option - const serverHttpValidation = this.req?.socket?.server?.httpValidation; + const serverHttpValidation = msg.req?.socket?.server?.httpValidation; if (serverHttpValidation !== undefined) { return serverHttpValidation !== 'strict'; } // Legacy insecureHTTPParser - ClientRequest has it directly - if (typeof this.insecureHTTPParser === 'boolean') { - return this.insecureHTTPParser; + if (typeof insecureHTTPParser === 'boolean') { + return insecureHTTPParser; } // ServerResponse can access via req.socket.server - const serverOption = this.req?.socket?.server?.insecureHTTPParser; + const serverOption = msg.req?.socket?.server?.insecureHTTPParser; if (typeof serverOption === 'boolean') { return serverOption; } // Fall back to global option return isLenient(); -}; +} ObjectDefineProperty(OutgoingMessage.prototype, 'errored', { __proto__: null, @@ -315,11 +329,12 @@ OutgoingMessage.prototype.uncork = function uncork() { callbacks.push(buf[n + 2]); } } - this._send(crlf_buf, null, callbacks.length ? (err) => { - for (const callback of callbacks) { - callback(err); - } - } : null); + // The runner is a shared top-level function so flushing a corked + // chunked buffer does not allocate a fresh closure environment per + // flush. + this._send(crlf_buf, null, callbacks?.length ? + runChunkCallbacks.bind(undefined, callbacks) : + null); this[kChunkedBuffer].length = 0; this[kChunkedLength] = 0; @@ -381,14 +396,14 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL // This is a shameful hack to get the headers and first body chunk onto // the same packet. Future versions of Node are going to take care of // this at a lower level and in a more general way. - if (!this._headerSent && this._header !== null) { + let header; + if (!this._headerSent && (header = this._header) !== null) { // `this._header` can be null if OutgoingMessage is used without a proper Socket // See: /test/parallel/test-http-outgoing-message-inheritance.js if (typeof data === 'string' && (encoding === 'utf8' || encoding === 'latin1' || !encoding)) { - data = this._header + data; + data = header + data; } else { - const header = this._header; this.outputData.unshift({ data: header, encoding: 'latin1', @@ -454,17 +469,21 @@ function _storeHeader(firstLine, headers) { processHeader(this, state, entry[0], entry[1], false, lenient); } } else if (ArrayIsArray(headers)) { - if (headers.length && ArrayIsArray(headers[0])) { - for (let i = 0; i < headers.length; i++) { + // The length is hoisted into a local because the engine cannot fold + // the reload across the processHeader calls; the array is never + // mutated while this loop runs. + const headersLength = headers.length; + if (headersLength && ArrayIsArray(headers[0])) { + for (let i = 0; i < headersLength; i++) { const entry = headers[i]; processHeader(this, state, entry[0], entry[1], true, lenient); } } else { - if (headers.length % 2 !== 0) { + if (headersLength % 2 !== 0) { throw new ERR_INVALID_ARG_VALUE('headers', headers); } - for (let n = 0; n < headers.length; n += 2) { + for (let n = 0; n < headersLength; n += 2) { processHeader(this, state, headers[n + 0], headers[n + 1], true, lenient); } } @@ -515,11 +534,13 @@ function _storeHeader(firstLine, headers) { header += 'Connection: close\r\n'; } else if (shouldSendKeepAlive) { header += 'Connection: keep-alive\r\n'; - if (this._keepAliveTimeout && this._defaultKeepAlive) { - const timeoutSeconds = MathFloor(this._keepAliveTimeout / 1000); + const keepAliveTimeout = this._keepAliveTimeout; + if (keepAliveTimeout && this._defaultKeepAlive) { + const timeoutSeconds = MathFloor(keepAliveTimeout / 1000); + const maxRequestsPerSocket = this._maxRequestsPerSocket; let max = ''; - if (~~this._maxRequestsPerSocket > 0) { - max = `, max=${this._maxRequestsPerSocket}`; + if (~~maxRequestsPerSocket > 0) { + max = `, max=${maxRequestsPerSocket}`; } header += `Keep-Alive: timeout=${timeoutSeconds}${max}\r\n`; } @@ -529,6 +550,7 @@ function _storeHeader(firstLine, headers) { } } + let contentLength; if (!state.contLen && !state.te) { if (!this._hasBody) { // Make sure we don't end the 0\r\n\r\n at the end of the message. @@ -537,8 +559,8 @@ function _storeHeader(firstLine, headers) { this._last = true; } else if (!state.trailer && !this._removedContLen && - typeof this._contentLength === 'number') { - header += 'Content-Length: ' + this._contentLength + '\r\n'; + typeof (contentLength = this._contentLength) === 'number') { + header += 'Content-Length: ' + contentLength + '\r\n'; } else if (!this._removedTE) { header += 'Transfer-Encoding: chunked\r\n'; this.chunkedEncoding = true; @@ -590,13 +612,14 @@ function processHeader(self, state, key, value, validate, lenient) { } if (ArrayIsArray(value)) { + const valueLength = value.length; if ( - (value.length < 2 || !isCookieField(key)) && + (valueLength < 2 || !isCookieField(key)) && (!self[kUniqueHeaders] || !self[kUniqueHeaders].has(key.toLowerCase())) ) { // Retain for(;;) loop for performance reasons // Refs: https://github.com/nodejs/node/pull/30958 - for (let i = 0; i < value.length; i++) + for (let i = 0; i < valueLength; i++) storeHeader(self, state, key, value[i], validate, lenient); return; } @@ -613,8 +636,23 @@ function storeHeader(self, state, key, value, validate, lenient) { } function matchHeader(self, state, field, value) { - if (field.length < 4 || field.length > 17) - return; + const len = field.length; + // Cheap (length, first character) pre-filter so the common case, a + // header that is not one of the eight known fields below, returns + // without paying the per-header toLowerCase() allocation. The filter + // only rejects names that cannot possibly match the switch: `| 0x20` + // lower-cases ASCII letters and maps no other token character onto a + // letter, and every known field length is enumerated. + const c = field.charCodeAt(0) | 0x20; + switch (len) { + case 4: if (c !== 0x64) return; break; // Date + case 6: if (c !== 0x65) return; break; // Expect + case 7: if (c !== 0x74) return; break; // Trailer + case 10: if (c !== 0x63 && c !== 0x6b) return; break; // Connection, Keep-Alive + case 14: if (c !== 0x63) return; break; // Content-Length + case 17: if (c !== 0x74) return; break; // Transfer-Encoding + default: return; // No known field has this length + } field = field.toLowerCase(); switch (field) { case 'connection': @@ -997,9 +1035,12 @@ function write_(msg, chunk, encoding, callback, fromEnd) { } } - if (!fromEnd && msg.socket && !msg.socket.writableCorked) { - msg.socket.cork(); - process.nextTick(connectionCorkNT, msg.socket); + // `socket` is an accessor on the prototype: load it once instead of + // paying the getter three times on every body write. + let socket; + if (!fromEnd && (socket = msg.socket) && !socket.writableCorked) { + socket.cork(); + process.nextTick(connectionCorkNT, socket); } let ret; @@ -1028,6 +1069,12 @@ function connectionCorkNT(conn) { conn.uncork(); } +function runChunkCallbacks(callbacks, err) { + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](err); + } +} + OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { if (this.finished) { throw new ERR_HTTP_HEADERS_SENT('set trailing'); @@ -1036,6 +1083,7 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { this._trailer = ''; const keys = ObjectKeys(headers); const isArray = ArrayIsArray(headers); + const lenient = this._isLenientHeaderValidation(); // Retain for(;;) loop for performance reasons // Refs: https://github.com/nodejs/node/pull/30958 for (let i = 0, l = keys.length; i < l; i++) { @@ -1052,7 +1100,6 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { // Check if the field must be sent several times const isArrayValue = ArrayIsArray(value); - const lenient = this._isLenientHeaderValidation(); if ( isArrayValue && value.length > 1 && (!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase())) diff --git a/test/parallel/test-http-outgoing-known-header-casing.js b/test/parallel/test-http-outgoing-known-header-casing.js new file mode 100644 index 00000000000000..3bbc7e76436b8b --- /dev/null +++ b/test/parallel/test-http-outgoing-known-header-casing.js @@ -0,0 +1,65 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +// The outgoing-header known-field matcher short-circuits on +// (length, first character) before lower-casing the name. Verify that +// known connection-relevant headers are still recognized in any casing, +// and that unknown headers sharing a known field's length and first +// letter are still sent through untouched. + +const server = http.createServer(common.mustCall((req, res) => { + switch (req.url) { + case '/upper': + // Must be recognized: response uses identity framing, no chunking. + res.writeHead(200, { + 'CONTENT-LENGTH': '2', + 'CoNnEcTiOn': 'close', + }); + res.end('ok'); + break; + case '/nearmiss': + // Same length/first letter as known fields ('date', 'connection', + // 'content-length', 'transfer-encoding') but NOT known: they must + // be emitted verbatim and must not affect framing decisions. + res.writeHead(200, { + 'Dote': 'x', // Len 4, 'd' (like date) + 'Xonnection': 'y', // Len 10, wrong first char + 'Continues-Len': 'z', // Len 13, no known field + 'Content-Lengthy': 'w', // Len 15, no known field + 'Transfer-Encoders': 'v', // Len 17 minus... 18: unknown + }); + res.end('hi'); + break; + default: + res.writeHead(404); + res.end(); + } +}, 2)); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + + http.get({ port, path: '/upper' }, common.mustCall((res) => { + // Content-Length was recognized despite the casing: no chunking. + assert.strictEqual(res.headers['content-length'], '2'); + assert.strictEqual(res.headers['transfer-encoding'], undefined); + assert.strictEqual(res.headers.connection, 'close'); + res.resume(); + res.on('end', common.mustCall(() => { + http.get({ port, path: '/nearmiss' }, common.mustCall((res2) => { + assert.strictEqual(res2.headers.dote, 'x'); + assert.strictEqual(res2.headers.xonnection, 'y'); + assert.strictEqual(res2.headers['continues-len'], 'z'); + assert.strictEqual(res2.headers['content-lengthy'], 'w'); + assert.strictEqual(res2.headers['transfer-encoders'], 'v'); + // None of them are Content-Length/TE: chunked framing applies. + assert.strictEqual(res2.headers['transfer-encoding'], 'chunked'); + res2.resume(); + res2.on('end', common.mustCall(() => server.close())); + })); + })); + })); +}));