Skip to content

Commit 85c887a

Browse files
committed
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 <yagiz@nizipli.com>
1 parent 4ee2117 commit 85c887a

2 files changed

Lines changed: 145 additions & 33 deletions

File tree

‎lib/_http_outgoing.js‎

Lines changed: 80 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
7979
});
8080

8181
const kCorked = Symbol('corked');
82+
const kLenientValidation = Symbol('kLenientValidation');
8283
const kSocket = Symbol('kSocket');
8384
const kChunkedBuffer = Symbol('kChunkedBuffer');
8485
const kChunkedLength = Symbol('kChunkedLength');
@@ -143,6 +144,7 @@ function OutgoingMessage(options) {
143144
this[kChunkedBuffer] = [];
144145
this[kChunkedLength] = 0;
145146
this._closed = false;
147+
this[kLenientValidation] = undefined;
146148

147149
this[kSocket] = null;
148150
this._header = null;
@@ -163,28 +165,40 @@ ObjectSetPrototypeOf(OutgoingMessage, Stream);
163165
// For ClientRequest: checks this.httpValidation or this.insecureHTTPParser
164166
// For ServerResponse: checks the server's httpValidation or insecureHTTPParser
165167
// Falls back to global --insecure-http-parser flag.
168+
// The answer is invariant for the lifetime of the message (the options are
169+
// fixed at construction time), but this runs on every setHeader/appendHeader
170+
// call and once per stored header block, so the multi-step lookup chain is
171+
// resolved once per message and cached.
166172
OutgoingMessage.prototype._isLenientHeaderValidation = function() {
173+
return (this[kLenientValidation] ??= lenientHeaderValidation(this));
174+
};
175+
176+
function lenientHeaderValidation(msg) {
177+
const {
178+
httpValidation,
179+
insecureHTTPParser,
180+
} = msg;
167181
// New httpValidation option takes priority (ClientRequest case)
168-
if (this.httpValidation !== undefined) {
169-
return this.httpValidation !== 'strict';
182+
if (httpValidation !== undefined) {
183+
return httpValidation !== 'strict';
170184
}
171185
// ServerResponse: check server's httpValidation option
172-
const serverHttpValidation = this.req?.socket?.server?.httpValidation;
186+
const serverHttpValidation = msg.req?.socket?.server?.httpValidation;
173187
if (serverHttpValidation !== undefined) {
174188
return serverHttpValidation !== 'strict';
175189
}
176190
// Legacy insecureHTTPParser - ClientRequest has it directly
177-
if (typeof this.insecureHTTPParser === 'boolean') {
178-
return this.insecureHTTPParser;
191+
if (typeof insecureHTTPParser === 'boolean') {
192+
return insecureHTTPParser;
179193
}
180194
// ServerResponse can access via req.socket.server
181-
const serverOption = this.req?.socket?.server?.insecureHTTPParser;
195+
const serverOption = msg.req?.socket?.server?.insecureHTTPParser;
182196
if (typeof serverOption === 'boolean') {
183197
return serverOption;
184198
}
185199
// Fall back to global option
186200
return isLenient();
187-
};
201+
}
188202

189203
ObjectDefineProperty(OutgoingMessage.prototype, 'errored', {
190204
__proto__: null,
@@ -315,11 +329,12 @@ OutgoingMessage.prototype.uncork = function uncork() {
315329
callbacks.push(buf[n + 2]);
316330
}
317331
}
318-
this._send(crlf_buf, null, callbacks.length ? (err) => {
319-
for (const callback of callbacks) {
320-
callback(err);
321-
}
322-
} : null);
332+
// The runner is a shared top-level function so flushing a corked
333+
// chunked buffer does not allocate a fresh closure environment per
334+
// flush.
335+
this._send(crlf_buf, null, callbacks?.length ?
336+
runChunkCallbacks.bind(undefined, callbacks) :
337+
null);
323338

324339
this[kChunkedBuffer].length = 0;
325340
this[kChunkedLength] = 0;
@@ -381,14 +396,14 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL
381396
// This is a shameful hack to get the headers and first body chunk onto
382397
// the same packet. Future versions of Node are going to take care of
383398
// this at a lower level and in a more general way.
384-
if (!this._headerSent && this._header !== null) {
399+
let header;
400+
if (!this._headerSent && (header = this._header) !== null) {
385401
// `this._header` can be null if OutgoingMessage is used without a proper Socket
386402
// See: /test/parallel/test-http-outgoing-message-inheritance.js
387403
if (typeof data === 'string' &&
388404
(encoding === 'utf8' || encoding === 'latin1' || !encoding)) {
389-
data = this._header + data;
405+
data = header + data;
390406
} else {
391-
const header = this._header;
392407
this.outputData.unshift({
393408
data: header,
394409
encoding: 'latin1',
@@ -454,17 +469,21 @@ function _storeHeader(firstLine, headers) {
454469
processHeader(this, state, entry[0], entry[1], false, lenient);
455470
}
456471
} else if (ArrayIsArray(headers)) {
457-
if (headers.length && ArrayIsArray(headers[0])) {
458-
for (let i = 0; i < headers.length; i++) {
472+
// The length is hoisted into a local because the engine cannot fold
473+
// the reload across the processHeader calls; the array is never
474+
// mutated while this loop runs.
475+
const headersLength = headers.length;
476+
if (headersLength && ArrayIsArray(headers[0])) {
477+
for (let i = 0; i < headersLength; i++) {
459478
const entry = headers[i];
460479
processHeader(this, state, entry[0], entry[1], true, lenient);
461480
}
462481
} else {
463-
if (headers.length % 2 !== 0) {
482+
if (headersLength % 2 !== 0) {
464483
throw new ERR_INVALID_ARG_VALUE('headers', headers);
465484
}
466485

467-
for (let n = 0; n < headers.length; n += 2) {
486+
for (let n = 0; n < headersLength; n += 2) {
468487
processHeader(this, state, headers[n + 0], headers[n + 1], true, lenient);
469488
}
470489
}
@@ -515,11 +534,13 @@ function _storeHeader(firstLine, headers) {
515534
header += 'Connection: close\r\n';
516535
} else if (shouldSendKeepAlive) {
517536
header += 'Connection: keep-alive\r\n';
518-
if (this._keepAliveTimeout && this._defaultKeepAlive) {
519-
const timeoutSeconds = MathFloor(this._keepAliveTimeout / 1000);
537+
const keepAliveTimeout = this._keepAliveTimeout;
538+
if (keepAliveTimeout && this._defaultKeepAlive) {
539+
const timeoutSeconds = MathFloor(keepAliveTimeout / 1000);
540+
const maxRequestsPerSocket = this._maxRequestsPerSocket;
520541
let max = '';
521-
if (~~this._maxRequestsPerSocket > 0) {
522-
max = `, max=${this._maxRequestsPerSocket}`;
542+
if (~~maxRequestsPerSocket > 0) {
543+
max = `, max=${maxRequestsPerSocket}`;
523544
}
524545
header += `Keep-Alive: timeout=${timeoutSeconds}${max}\r\n`;
525546
}
@@ -529,6 +550,7 @@ function _storeHeader(firstLine, headers) {
529550
}
530551
}
531552

553+
let contentLength;
532554
if (!state.contLen && !state.te) {
533555
if (!this._hasBody) {
534556
// 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) {
537559
this._last = true;
538560
} else if (!state.trailer &&
539561
!this._removedContLen &&
540-
typeof this._contentLength === 'number') {
541-
header += 'Content-Length: ' + this._contentLength + '\r\n';
562+
typeof (contentLength = this._contentLength) === 'number') {
563+
header += 'Content-Length: ' + contentLength + '\r\n';
542564
} else if (!this._removedTE) {
543565
header += 'Transfer-Encoding: chunked\r\n';
544566
this.chunkedEncoding = true;
@@ -590,13 +612,14 @@ function processHeader(self, state, key, value, validate, lenient) {
590612
}
591613

592614
if (ArrayIsArray(value)) {
615+
const valueLength = value.length;
593616
if (
594-
(value.length < 2 || !isCookieField(key)) &&
617+
(valueLength < 2 || !isCookieField(key)) &&
595618
(!self[kUniqueHeaders] || !self[kUniqueHeaders].has(key.toLowerCase()))
596619
) {
597620
// Retain for(;;) loop for performance reasons
598621
// Refs: https://github.com/nodejs/node/pull/30958
599-
for (let i = 0; i < value.length; i++)
622+
for (let i = 0; i < valueLength; i++)
600623
storeHeader(self, state, key, value[i], validate, lenient);
601624
return;
602625
}
@@ -613,8 +636,23 @@ function storeHeader(self, state, key, value, validate, lenient) {
613636
}
614637

615638
function matchHeader(self, state, field, value) {
616-
if (field.length < 4 || field.length > 17)
617-
return;
639+
const len = field.length;
640+
// Cheap (length, first character) pre-filter so the common case, a
641+
// header that is not one of the eight known fields below, returns
642+
// without paying the per-header toLowerCase() allocation. The filter
643+
// only rejects names that cannot possibly match the switch: `| 0x20`
644+
// lower-cases ASCII letters and maps no other token character onto a
645+
// letter, and every known field length is enumerated.
646+
const c = field.charCodeAt(0) | 0x20;
647+
switch (len) {
648+
case 4: if (c !== 0x64) return; break; // Date
649+
case 6: if (c !== 0x65) return; break; // Expect
650+
case 7: if (c !== 0x74) return; break; // Trailer
651+
case 10: if (c !== 0x63 && c !== 0x6b) return; break; // Connection, Keep-Alive
652+
case 14: if (c !== 0x63) return; break; // Content-Length
653+
case 17: if (c !== 0x74) return; break; // Transfer-Encoding
654+
default: return; // No known field has this length
655+
}
618656
field = field.toLowerCase();
619657
switch (field) {
620658
case 'connection':
@@ -997,9 +1035,12 @@ function write_(msg, chunk, encoding, callback, fromEnd) {
9971035
}
9981036
}
9991037

1000-
if (!fromEnd && msg.socket && !msg.socket.writableCorked) {
1001-
msg.socket.cork();
1002-
process.nextTick(connectionCorkNT, msg.socket);
1038+
// `socket` is an accessor on the prototype: load it once instead of
1039+
// paying the getter three times on every body write.
1040+
let socket;
1041+
if (!fromEnd && (socket = msg.socket) && !socket.writableCorked) {
1042+
socket.cork();
1043+
process.nextTick(connectionCorkNT, socket);
10031044
}
10041045

10051046
let ret;
@@ -1028,6 +1069,12 @@ function connectionCorkNT(conn) {
10281069
conn.uncork();
10291070
}
10301071

1072+
function runChunkCallbacks(callbacks, err) {
1073+
for (let i = 0; i < callbacks.length; i++) {
1074+
callbacks[i](err);
1075+
}
1076+
}
1077+
10311078
OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
10321079
if (this.finished) {
10331080
throw new ERR_HTTP_HEADERS_SENT('set trailing');
@@ -1036,6 +1083,7 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
10361083
this._trailer = '';
10371084
const keys = ObjectKeys(headers);
10381085
const isArray = ArrayIsArray(headers);
1086+
const lenient = this._isLenientHeaderValidation();
10391087
// Retain for(;;) loop for performance reasons
10401088
// Refs: https://github.com/nodejs/node/pull/30958
10411089
for (let i = 0, l = keys.length; i < l; i++) {
@@ -1052,7 +1100,6 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
10521100

10531101
// Check if the field must be sent several times
10541102
const isArrayValue = ArrayIsArray(value);
1055-
const lenient = this._isLenientHeaderValidation();
10561103
if (
10571104
isArrayValue && value.length > 1 &&
10581105
(!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase()))
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const http = require('http');
6+
7+
// The outgoing-header known-field matcher short-circuits on
8+
// (length, first character) before lower-casing the name. Verify that
9+
// known connection-relevant headers are still recognized in any casing,
10+
// and that unknown headers sharing a known field's length and first
11+
// letter are still sent through untouched.
12+
13+
const server = http.createServer(common.mustCall((req, res) => {
14+
switch (req.url) {
15+
case '/upper':
16+
// Must be recognized: response uses identity framing, no chunking.
17+
res.writeHead(200, {
18+
'CONTENT-LENGTH': '2',
19+
'CoNnEcTiOn': 'close',
20+
});
21+
res.end('ok');
22+
break;
23+
case '/nearmiss':
24+
// Same length/first letter as known fields ('date', 'connection',
25+
// 'content-length', 'transfer-encoding') but NOT known: they must
26+
// be emitted verbatim and must not affect framing decisions.
27+
res.writeHead(200, {
28+
'Dote': 'x', // Len 4, 'd' (like date)
29+
'Xonnection': 'y', // Len 10, wrong first char
30+
'Continues-Len': 'z', // Len 13, no known field
31+
'Content-Lengthy': 'w', // Len 15, no known field
32+
'Transfer-Encoders': 'v', // Len 17 minus... 18: unknown
33+
});
34+
res.end('hi');
35+
break;
36+
default:
37+
res.writeHead(404);
38+
res.end();
39+
}
40+
}, 2));
41+
42+
server.listen(0, common.mustCall(() => {
43+
const port = server.address().port;
44+
45+
http.get({ port, path: '/upper' }, common.mustCall((res) => {
46+
// Content-Length was recognized despite the casing: no chunking.
47+
assert.strictEqual(res.headers['content-length'], '2');
48+
assert.strictEqual(res.headers['transfer-encoding'], undefined);
49+
assert.strictEqual(res.headers.connection, 'close');
50+
res.resume();
51+
res.on('end', common.mustCall(() => {
52+
http.get({ port, path: '/nearmiss' }, common.mustCall((res2) => {
53+
assert.strictEqual(res2.headers.dote, 'x');
54+
assert.strictEqual(res2.headers.xonnection, 'y');
55+
assert.strictEqual(res2.headers['continues-len'], 'z');
56+
assert.strictEqual(res2.headers['content-lengthy'], 'w');
57+
assert.strictEqual(res2.headers['transfer-encoders'], 'v');
58+
// None of them are Content-Length/TE: chunked framing applies.
59+
assert.strictEqual(res2.headers['transfer-encoding'], 'chunked');
60+
res2.resume();
61+
res2.on('end', common.mustCall(() => server.close()));
62+
}));
63+
}));
64+
}));
65+
}));

0 commit comments

Comments
 (0)