From 790260a488e565ef24337ac066bc04aa2b344abe Mon Sep 17 00:00:00 2001 From: GetThatCookie Date: Mon, 3 Aug 2026 01:09:51 +0200 Subject: [PATCH 1/3] http,net,stream: optimize write and parser paths Signed-off-by: GetThatCookie --- benchmark/common.js | 11 +- benchmark/http/bench-parser.js | 2 + benchmark/http/cork.js | 157 +++++ lib/_http_client.js | 12 +- lib/_http_outgoing.js | 566 +++++++++++++++--- lib/_http_server.js | 19 +- lib/internal/stream_base_commons.js | 154 ++--- lib/internal/streams/utils.js | 8 + lib/internal/streams/writable.js | 205 ++++++- lib/net.js | 67 ++- src/env_properties.h | 3 +- src/node_http_parser.cc | 30 +- src/stream_base.cc | 292 ++++++--- src/stream_base.h | 8 + src/stream_wrap.cc | 11 + test/parallel/test-http-1.0.js | 6 +- test/parallel/test-http-automatic-headers.js | 28 + test/parallel/test-http-outgoing-auto-cork.js | 352 +++++++++++ .../test-http-outgoing-buffered-destroy.js | 181 ++++++ .../test-http-outgoing-buffered-drain.js | 278 +++++++++ .../parallel/test-http-outgoing-corked-end.js | 236 ++++++++ .../test-http-outgoing-flush-output.js | 64 ++ .../test-http-outgoing-message-inheritance.js | 11 +- ...test-http-parser-max-header-pairs-cache.js | 79 +++ test/parallel/test-http-response-cork.js | 11 +- .../test-http-server-response-standalone.js | 11 +- .../test-net-internal-writev-coalesce.js | 110 ++++ .../test-net-internal-writev-encoding.js | 86 +++ .../test-net-internal-writev-partial.js | 123 ++++ test/parallel/test-stream-pipeline.js | 6 +- test/parallel/test-webstreams-pipeline.js | 2 +- 31 files changed, 2805 insertions(+), 324 deletions(-) create mode 100644 benchmark/http/cork.js create mode 100644 test/parallel/test-http-outgoing-auto-cork.js create mode 100644 test/parallel/test-http-outgoing-buffered-destroy.js create mode 100644 test/parallel/test-http-outgoing-buffered-drain.js create mode 100644 test/parallel/test-http-outgoing-corked-end.js create mode 100644 test/parallel/test-http-outgoing-flush-output.js create mode 100644 test/parallel/test-http-parser-max-header-pairs-cache.js create mode 100644 test/parallel/test-net-internal-writev-coalesce.js create mode 100644 test/parallel/test-net-internal-writev-encoding.js create mode 100644 test/parallel/test-net-internal-writev-partial.js diff --git a/benchmark/common.js b/benchmark/common.js index 8443da40d79e..197bd5b93526 100644 --- a/benchmark/common.js +++ b/benchmark/common.js @@ -26,7 +26,7 @@ class Benchmark { // Parse job-specific configuration from the command line arguments const argv = process.argv.slice(2); - const parsed_args = this._parseArgs(argv, configs, options); + const parsed_args = this._parseArgs([...argv], configs, options); this.originalOptions = options; this.options = parsed_args.cli; @@ -38,8 +38,10 @@ class Benchmark { const groupNames = process.env.NODE_RUN_BENCHMARK_GROUPS?.split(',') ?? Object.keys(configs); for (const groupName of groupNames) { - const config = { ...configs[groupName][0], group: groupName }; - const parsed_args = this._parseArgs(argv, config, options); + const groupConfig = Array.isArray(configs[groupName]) ? + configs[groupName][0] : configs[groupName]; + const config = { ...groupConfig, group: groupName }; + const parsed_args = this._parseArgs([...argv], config, options); this.options = parsed_args.cli; this.extra_options = parsed_args.extra; @@ -221,6 +223,9 @@ class Benchmark { // function. const childEnv = { ...process.env }; childEnv.NODE_RUN_BENCHMARK_FN = ''; + if (this.originalOptions.byGroups) { + childEnv.NODE_RUN_BENCHMARK_GROUPS = config.group; + } // Create configuration arguments const childArgs = []; diff --git a/benchmark/http/bench-parser.js b/benchmark/http/bench-parser.js index 0a1e8f7b5e8a..72cb2b6feb18 100644 --- a/benchmark/http/bench-parser.js +++ b/benchmark/http/bench-parser.js @@ -31,6 +31,8 @@ function main({ len, n }) { function newParser(type) { const parser = new HTTPParser(); parser.initialize(type, {}); + // Direct parsers bypass cleanParser(); use its production default. + parser.maxHeaderPairs = 2000; parser.headers = []; diff --git a/benchmark/http/cork.js b/benchmark/http/cork.js new file mode 100644 index 000000000000..7e5b3d946571 --- /dev/null +++ b/benchmark/http/cork.js @@ -0,0 +1,157 @@ +'use strict'; + +const common = require('../common.js'); +const protocols = process.versions.openssl ? ['http', 'https'] : ['http']; + +const configs = { + sameTurn: [{ + type: ['bytes', 'buffer', 'uint8array'], + len: [64, 1024], + chunks: [1, 2, 4, 16], + mode: ['auto', 'explicit'], + transfer: ['chunked', 'length'], + protocol: protocols, + producer: ['sync'], + callback: [0], + c: [50], + duration: 5, + }], + streaming: [{ + type: ['bytes', 'buffer', 'uint8array'], + len: [64, 1024], + chunks: [4], + mode: ['auto'], + transfer: ['chunked'], + protocol: protocols, + producer: ['nextTick', 'microtask', 'immediate'], + callback: [0], + c: [50], + duration: 5, + }], + callbacks: [{ + type: ['bytes', 'buffer', 'uint8array'], + len: [64], + chunks: [4, 16], + mode: ['auto', 'explicit'], + transfer: ['chunked'], + protocol: protocols, + producer: ['sync'], + callback: [1], + c: [50], + duration: 5, + }], + fixedBody: [{ + type: ['bytes', 'buffer', 'uint8array'], + total: [64 * 1024], + chunks: [1, 4, 16, 128], + mode: ['auto', 'explicit'], + transfer: ['chunked'], + protocol: protocols, + producer: ['sync'], + callback: [0], + c: [50], + duration: 5, + }], + largeChunks: [{ + type: ['bytes', 'buffer', 'uint8array'], + len: [4 * 1024, 8 * 1024, 16 * 1024, 64 * 1024], + chunks: [1, 4], + mode: ['auto'], + transfer: ['chunked'], + protocol: protocols, + producer: ['sync'], + callback: [0], + c: [50], + duration: 5, + }], + concurrency: [{ + type: ['bytes'], + len: [64], + chunks: [4], + mode: ['auto'], + transfer: ['chunked'], + protocol: protocols, + producer: ['sync'], + callback: [0], + c: [1, 50, 500], + duration: 5, + }], +}; + +const bench = common.createBenchmark(main, configs, { byGroups: true }); + +function main({ + type, + len, + chunks, + mode, + transfer, + protocol, + producer, + callback, + c, + duration, + total, +}) { + const transport = require(protocol); + len ??= total / chunks; + const chunk = type === 'bytes' ? 'a'.repeat(len) : + type === 'buffer' ? Buffer.alloc(len, 'a') : + new Uint8Array(len).fill(0x61); + const writeCallback = callback ? (err) => { + if (err) throw err; + } : undefined; + + const schedule = producer === 'nextTick' ? process.nextTick : + producer === 'microtask' ? queueMicrotask : setImmediate; + + const onRequest = (req, res) => { + if (transfer === 'length') { + res.setHeader('Content-Length', len * chunks); + } + if (mode === 'explicit') { + res.cork(); + } + + if (producer === 'sync') { + for (let i = 0; i < chunks; i++) { + res.write(chunk, writeCallback); + } + res.end(); + return; + } + + let written = 0; + function writeNext() { + if (written++ === chunks) { + res.end(); + return; + } + res.write(chunk, writeCallback); + schedule(writeNext); + } + writeNext(); + }; + + let server; + if (protocol === 'https') { + const fixtures = require('../../test/common/fixtures'); + server = transport.createServer({ + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt'), + }, onRequest); + } else { + server = transport.createServer(onRequest); + } + + server.listen(0, () => { + bench.http({ + connections: c, + duration, + port: server.address().port, + scheme: protocol, + }, () => { + server.close(); + }); + }); +} diff --git a/lib/_http_client.js b/lib/_http_client.js index adcacb752e6e..1fef49a82a40 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -58,6 +58,7 @@ const { parseUniqueHeadersOption, OutgoingMessage, } = require('_http_outgoing'); +const { kDestroyMessageBuffer } = require('internal/streams/utils'); const Agent = require('_http_agent'); const { Buffer } = require('buffer'); const { defaultTriggerAsyncIdScope } = require('internal/async_hooks'); @@ -699,7 +700,11 @@ ClientRequest.prototype.destroy = function destroy(err) { } this[kError] = err; - this.socket?.destroy(err); + try { + this[kDestroyMessageBuffer](err); + } finally { + this.socket?.destroy(err); + } return this; }; @@ -710,7 +715,7 @@ function emitAbortNT(req) { function ondrain() { const msg = this._httpMessage; - if (msg && !msg.finished && msg[kNeedDrain]) { + if (msg && !msg.finished && msg[kNeedDrain] && msg.writableLength === 0) { msg[kNeedDrain] = false; msg.emit('drain'); } @@ -726,6 +731,9 @@ function socketCloseListener() { const parser = socket.parser; const res = req.res; + req[kDestroyMessageBuffer]( + req[kError] ?? socket._writableState.errored, + ); req.destroyed = true; if (res) { // Socket closed before we emitted 'end' below. diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 6bf7a1f9f68d..838641050795 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -35,6 +35,12 @@ const { } = primordials; const { getDefaultHighWaterMark } = require('internal/streams/state'); +const { + kDestroyMessageBuffer, + kInternalWritev, + kPendingMessageBytes, + kRawWritev, +} = require('internal/streams/utils'); const assert = require('internal/assert'); const EE = require('events'); const Stream = require('stream'); @@ -67,6 +73,7 @@ const { ERR_STREAM_DESTROYED, ERR_STREAM_NULL_VALUES, ERR_STREAM_WRITE_AFTER_END, + ERR_UNKNOWN_ENCODING, }, hideStackFrames, } = require('internal/errors'); @@ -82,14 +89,18 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => { }); const kCorked = Symbol('corked'); +const kAutoCorked = Symbol('autoCorked'); const kSocket = Symbol('kSocket'); -const kChunkedBuffer = Symbol('kChunkedBuffer'); -const kChunkedLength = Symbol('kChunkedLength'); +const kWriteBuffer = Symbol('kWriteBuffer'); +const kWriteCallbacks = Symbol('kWriteCallbacks'); +const kBufferedLength = Symbol('kBufferedLength'); +const kBufferedOutputSize = Symbol('kBufferedOutputSize'); const kUniqueHeaders = Symbol('kUniqueHeaders'); const kBytesWritten = Symbol('kBytesWritten'); const kErrored = Symbol('errored'); const kHighWaterMark = Symbol('kHighWaterMark'); const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites'); +const kMaxChunkedFramingFoldLength = 1024; const nop = () => {}; @@ -147,8 +158,11 @@ function OutgoingMessage(options) { this.finished = false; this._headerSent = false; this[kCorked] = 0; - this[kChunkedBuffer] = []; - this[kChunkedLength] = 0; + this[kAutoCorked] = false; + this[kWriteBuffer] = null; + this[kWriteCallbacks] = null; + this[kBufferedLength] = 0; + this[kBufferedOutputSize] = 0; this._closed = false; this[kSocket] = null; @@ -228,10 +242,40 @@ ObjectDefineProperty(OutgoingMessage.prototype, 'writableObjectMode', { ObjectDefineProperty(OutgoingMessage.prototype, 'writableLength', { __proto__: null, get() { - return this.outputSize + this[kChunkedLength] + (this[kSocket] ? this[kSocket].writableLength : 0); + return this.outputSize + this[kPendingMessageBytes]() + + (this[kSocket] ? this[kSocket].writableLength : 0); }, }); +OutgoingMessage.prototype[kPendingMessageBytes] = function() { + const buf = this[kWriteBuffer]; + if (buf === null || buf.length === 0) { + return 0; + } + + const len = this[kBufferedLength]; + let pending = len; + if (this.chunkedEncoding && len !== 0) { + let hexLength; + if (len < 0x100) { + hexLength = len < 0x10 ? 1 : 2; + } else if (len < 0x10000) { + hexLength = len < 0x1000 ? 3 : 4; + } else if (len < 0x1000000) { + hexLength = len < 0x100000 ? 5 : 6; + } else if (len < 0x100000000) { + hexLength = len < 0x10000000 ? 7 : 8; + } else { + hexLength = len.toString(16).length; + } + pending += hexLength + 4; + } + if (!this._headerSent && this._header !== null) { + pending += this._header.length; + } + return pending; +}; + ObjectDefineProperty(OutgoingMessage.prototype, 'writableHighWaterMark', { __proto__: null, get() { @@ -297,45 +341,259 @@ OutgoingMessage.prototype.cork = function cork() { } }; -OutgoingMessage.prototype.uncork = function uncork() { - this[kCorked]--; - if (this[kSocket]) { - this[kSocket].uncork(); +function canCombineAscii(data, encoding) { + return typeof data === 'string' && + (encoding === 'utf8' || encoding === 'latin1' || !encoding); +} + +// Above 1 KiB, preserving separate vectors is faster than flattening the +// V8 cons string created by adjoining HTTP chunk framing. +function canFoldChunkedFraming(data, encoding, byteLength) { + return byteLength <= kMaxChunkedFramingFoldLength && + canCombineAscii(data, encoding); +} + +function canFoldChunkedPrefix(msg, encoding) { + return msg._headerSent || encoding === 'latin1' || + Buffer.byteLength(msg._header) === msg._header.length; +} + +function sendWriteVector(msg, chunks, callback) { + const conn = msg[kSocket]; + if (conn && conn._httpMessage === msg && conn.writable && + typeof conn[kInternalWritev] === 'function' && + typeof conn[kRawWritev] === 'function') { + if (msg.outputData.length !== 0) { + msg._flushOutput(conn); + } + + if (!msg._headerSent && msg._header !== null) { + if (canCombineAscii(chunks[0], chunks[1])) { + chunks[0] = msg._header + chunks[0]; + } else { + chunks.unshift(msg._header, 'latin1'); + } + msg._headerSent = true; + } + return conn[kInternalWritev](chunks, callback); } - if (this[kCorked] || this[kChunkedBuffer].length === 0) { - return; + for (let i = 0; i < chunks.length - 2; i += 2) { + msg._send(chunks[i], chunks[i + 1], null); } + const ret = msg._send( + chunks[chunks.length - 2], + chunks[chunks.length - 1], + callback, + ); + return ret; +} - const len = this[kChunkedLength]; - const buf = this[kChunkedBuffer]; +function writeChunkedVector(msg, chunk, encoding, callback, len) { + if (canFoldChunkedFraming(chunk, encoding, len)) { + const framedChunk = chunk + '\r\n'; + if (!canFoldChunkedPrefix(msg, encoding)) { + return sendWriteVector( + msg, + [len.toString(16) + '\r\n', 'latin1', framedChunk, encoding], + callback, + ); + } + return sendWriteVector( + msg, + [len.toString(16) + '\r\n' + framedChunk, encoding], + callback, + ); + } + return sendWriteVector(msg, [ + len.toString(16) + '\r\n', 'latin1', + chunk, encoding, + crlf_buf, null, + ], callback); +} - assert(this.chunkedEncoding); +function bufferWriteCallback(msg, callback) { + const callbacks = msg[kWriteCallbacks]; + if (callbacks === null) { + msg[kWriteCallbacks] = callback; + } else if (typeof callbacks === 'function') { + msg[kWriteCallbacks] = [callbacks, callback]; + } else { + callbacks.push(callback); + } +} - let callbacks; - this._send(len.toString(16), 'latin1', null); - this._send(crlf_buf, null, null); - for (let n = 0; n < buf.length; n += 3) { - this._send(buf[n + 0], buf[n + 1], null); - if (buf[n + 2]) { - callbacks ??= []; - callbacks.push(buf[n + 2]); +function callWriteCallbacks(callbacks, error) { + if (typeof callbacks === 'function') { + callbacks(error); + } else { + for (let n = 0; n < callbacks.length; n++) { + callbacks[n](error); } } - this._send(crlf_buf, null, callbacks.length ? (err) => { - for (const callback of callbacks) { - callback(err); +} + +function updateBufferedOutputSize(msg, size) { + const delta = size - msg[kBufferedOutputSize]; + if (delta !== 0) { + msg[kBufferedOutputSize] = size; + msg._onPendingData(delta); + } +} + +function releaseBufferedOutputSize(msg) { + updateBufferedOutputSize(msg, 0); +} + +function flushWriteBuffer(msg, ending = false, finalCallback = null) { + if (msg.destroyed || msg[kSocket]?.destroyed) { + destroyWriteBuffer( + msg, + msg[kErrored] ?? msg[kSocket]?._writableState?.errored, + ); + return false; + } + + const buf = msg[kWriteBuffer]; + const len = msg[kBufferedLength]; + const chunked = msg.chunkedEncoding; + const callbacks = msg[kWriteCallbacks]; + + // The vector may be retained by the stream until an asynchronous write + // completes. Transfer ownership instead of copying it into another array. + msg[kWriteBuffer] = null; + msg[kWriteCallbacks] = null; + msg[kBufferedLength] = 0; + + let callback = finalCallback; + if (callbacks !== null) { + if (typeof callbacks === 'function' && finalCallback === null) { + callback = callbacks; + } else { + callback = (err) => { + callWriteCallbacks(callbacks, err); + if (finalCallback !== null) { + finalCallback(err); + } + }; } - } : null); + } - this[kChunkedBuffer].length = 0; - this[kChunkedLength] = 0; + // A message can cross its byte-based high-water mark even when Writable's + // string-length accounting stays below the socket high-water mark. Recheck + // message-level drain when an asynchronous vector completes; the socket's + // own drain event is not guaranteed in that case. + if (msg[kNeedDrain]) { + const writeCallback = callback; + callback = (error) => { + if (error === null || error === undefined) { + emitDrainIfNeeded(msg); + } + if (writeCallback !== null) { + writeCallback(error); + } + }; + } + + if (chunked) { + const prefix = len.toString(16) + '\r\n'; + const last = buf.length - 2; + let foldSuffix = false; + if ((!ending || msg._trailer.length === 0) && + canCombineAscii(buf[last], buf[last + 1])) { + const singlePayload = buf.length === 2 || + (buf[0] === null && buf.length === 4); + const lastLength = singlePayload ? len : + Buffer.byteLength(buf[last], buf[last + 1]); + foldSuffix = lastLength <= kMaxChunkedFramingFoldLength; + } + if (buf[0] === null) { + buf[0] = prefix; + } else { + buf[0] = prefix + buf[0]; + } - // If we had a pending drain and flushed all data, emit the drain event. - if (this[kNeedDrain] && this.writableLength === 0) { - this[kNeedDrain] = false; - this.emit('drain'); + const suffix = ending ? + '\r\n0\r\n' + msg._trailer + '\r\n' : '\r\n'; + if (foldSuffix) { + buf[last] += suffix; + } else { + buf.push( + ending ? suffix : crlf_buf, + ending ? 'latin1' : null, + ); + } } + + try { + sendWriteVector(msg, buf, callback); + } finally { + // Inactive messages contribute their message-level buffer to the + // connection-wide pending-data counter. Release that ownership only + // after the vector has either reached the socket or outputData. + releaseBufferedOutputSize(msg); + } + return true; +} + +function destroyWriteBuffer(msg, error) { + msg[kAutoCorked] = false; + const buf = msg[kWriteBuffer]; + const callbacks = msg[kWriteCallbacks]; + if ((buf === null || buf.length === 0) && callbacks === null) { + return; + } + + const callbackError = error || new ERR_STREAM_DESTROYED('write'); + msg[kWriteBuffer] = null; + msg[kWriteCallbacks] = null; + msg[kBufferedLength] = 0; + releaseBufferedOutputSize(msg); + if (callbacks !== null) { + if (typeof callbacks === 'function') { + process.nextTick(callbacks, callbackError); + } else { + process.nextTick(callWriteCallbacks, callbacks, callbackError); + } + } +} + +OutgoingMessage.prototype[kDestroyMessageBuffer] = function(error) { + destroyWriteBuffer(this, error); +}; + +function emitDrainIfNeeded(msg) { + if (msg[kNeedDrain] && msg.writableLength === 0) { + msg[kNeedDrain] = false; + msg.emit('drain'); + } +} + +OutgoingMessage.prototype.uncork = function uncork() { + if (!this[kCorked]) { + return; + } + this[kCorked]--; + + const hasBufferedWrites = !this[kCorked] && this[kWriteBuffer] !== null && + this[kWriteBuffer].length !== 0; + let flushed = false; + try { + if (hasBufferedWrites) { + flushed = flushWriteBuffer(this); + } + } finally { + if (this[kSocket]) { + this[kSocket].uncork(); + } + } + + if (!flushed) { + return; + } + + // If we had a pending drain and flushed all data, emit the drain event. + emitDrainIfNeeded(this); }; OutgoingMessage.prototype.setTimeout = function setTimeout(msecs, callback) { @@ -367,8 +625,14 @@ OutgoingMessage.prototype.destroy = function destroy(error) { this[kErrored] = error; if (this[kSocket]) { - this[kSocket].destroy(error); + // Settle writes that have not reached Writable before closing the message. + try { + this[kDestroyMessageBuffer](error); + } finally { + this[kSocket].destroy(error); + } } else { + this[kDestroyMessageBuffer](error); process.nextTick(emitDestroyNT, this); } @@ -391,8 +655,7 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL if (!this._headerSent && 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)) { + if (canCombineAscii(data, encoding)) { data = this._header + data; } else { const header = this._header; @@ -619,37 +882,54 @@ function storeHeader(self, state, key, value, validate, lenient) { matchHeader(self, state, key, value); } +function isHeaderField(field, lowerCase, canonicalCase) { + return field === lowerCase || field === canonicalCase || + field.toLowerCase() === lowerCase; +} + function matchHeader(self, state, field, value) { - if (field.length < 4 || field.length > 17) - return; - field = field.toLowerCase(); - switch (field) { - case 'connection': - state.connection = true; - self._removedConnection = false; - if (RE_CONN_CLOSE.test(value)) - self._last = true; - else - self.shouldKeepAlive = true; + const len = field.length; + switch (field.charCodeAt(0) | 0x20) { + case 0x63: // c + if (len === 10 && + isHeaderField(field, 'connection', 'Connection')) { + state.connection = true; + self._removedConnection = false; + if (RE_CONN_CLOSE.test(value)) + self._last = true; + else + self.shouldKeepAlive = true; + } else if (len === 14 && + isHeaderField(field, 'content-length', 'Content-Length')) { + state.contLen = true; + self._contentLength = +value; + self._removedContLen = false; + } break; - case 'transfer-encoding': - state.te = true; - self._removedTE = false; - if (RE_TE_CHUNKED.test(value)) - self.chunkedEncoding = true; + case 0x64: // d + if (len === 4 && isHeaderField(field, 'date', 'Date')) + state.date = true; break; - case 'content-length': - state.contLen = true; - self._contentLength = +value; - self._removedContLen = false; + case 0x65: // e + if (len === 6 && isHeaderField(field, 'expect', 'Expect')) + state.expect = true; break; - case 'date': - case 'expect': - case 'trailer': - state[field] = true; + case 0x6b: // k + if (len === 10 && isHeaderField(field, 'keep-alive', 'Keep-Alive')) + self._defaultKeepAlive = false; break; - case 'keep-alive': - self._defaultKeepAlive = false; + case 0x74: // t + if (len === 7 && isHeaderField(field, 'trailer', 'Trailer')) { + state.trailer = true; + } else if (len === 17 && + isHeaderField(field, + 'transfer-encoding', + 'Transfer-Encoding')) { + state.te = true; + self._removedTE = false; + if (RE_TE_CHUNKED.test(value)) + self.chunkedEncoding = true; + } break; } } @@ -1004,23 +1284,72 @@ function write_(msg, chunk, encoding, callback, fromEnd) { } } - if (!fromEnd && msg.socket && !msg.socket.writableCorked) { - msg.socket.cork(); - process.nextTick(connectionCorkNT, msg.socket); + const socket = msg.socket; + const activeSocket = socket?._httpMessage === msg && socket.writable; + if (!fromEnd && socket && !socket.writableCorked) { + socket.cork(); + msg[kAutoCorked] = true; + process.nextTick(connectionCorkNT, msg, socket); } let ret; - if (msg.chunkedEncoding && chunk.length !== 0) { + const chunked = msg.chunkedEncoding; + const bufferable = chunked || msg._contentLength !== null; + const buf = msg[kWriteBuffer]; + const buffering = bufferable && + ((chunk.length !== 0 && + (msg[kAutoCorked] || + (fromEnd && socket?._httpMessage === msg) || + msg[kCorked])) || + (chunk.length === 0 && buf !== null && buf.length !== 0)); + + if (buffering && encoding && + (encoding === 'buffer' ? typeof chunk === 'string' : + !Buffer.isEncoding(encoding))) { + throw new ERR_UNKNOWN_ENCODING(encoding); + } + + if (buffering) { + const trackPendingData = !activeSocket; + if (chunk.length === 0) { + // Preserve callback ordering without adding an empty socket write. + if (callback !== nop) { + bufferWriteCallback(msg, callback); + } + } else { + len ??= typeof chunk === 'string' ? + Buffer.byteLength(chunk, encoding) : chunk.byteLength; + // Writable normalizes Uint8Array views synchronously. Preserve that + // timing when the message-level buffer replaces an immediate + // Socket.write(), including its detached-ArrayBuffer behavior. + if (activeSocket && typeof chunk !== 'string' && + !(chunk instanceof Buffer)) { + chunk = Stream._uint8ArrayToBuffer(chunk); + } + let writeBuffer = buf; + if (writeBuffer === null) { + writeBuffer = chunked && + (!canFoldChunkedFraming(chunk, encoding, len) || + !canFoldChunkedPrefix(msg, encoding)) ? + [null, 'latin1'] : []; + msg[kWriteBuffer] = writeBuffer; + } + writeBuffer.push(chunk, encoding); + if (callback !== nop) { + bufferWriteCallback(msg, callback); + } + msg[kBufferedLength] += len; + } + if (trackPendingData) { + updateBufferedOutputSize(msg, msg[kPendingMessageBytes]()); + } + ret = msg.writableLength < msg.writableHighWaterMark; + } else if (bufferable && chunk.length !== 0) { len ??= typeof chunk === 'string' ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; - if (msg[kCorked] && msg._headerSent) { - msg[kChunkedBuffer].push(chunk, encoding, callback); - msg[kChunkedLength] += len; - ret = msg[kChunkedLength] < msg[kHighWaterMark]; + if (chunked) { + ret = writeChunkedVector(msg, chunk, encoding, callback, len); } else { - msg._send(len.toString(16), 'latin1', null); - msg._send(crlf_buf, null, null); - msg._send(chunk, encoding, null, len); - ret = msg._send(crlf_buf, null, callback); + ret = msg._send(chunk, encoding, callback, len); } } else { ret = msg._send(chunk, encoding, callback, len); @@ -1031,8 +1360,25 @@ function write_(msg, chunk, encoding, callback, fromEnd) { } -function connectionCorkNT(conn) { - conn.uncork(); +function connectionCorkNT(msg, conn) { + let flushed = false; + try { + if (msg[kAutoCorked]) { + msg[kAutoCorked] = false; + const hasBufferedWrites = !msg[kCorked] && + msg[kWriteBuffer] !== null && + msg[kWriteBuffer].length !== 0; + if (hasBufferedWrites) { + flushed = flushWriteBuffer(msg); + } + } + } finally { + conn.uncork(); + } + + if (flushed) { + emitDrainIfNeeded(msg); + } } OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { @@ -1140,24 +1486,43 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { const finish = onFinish.bind(undefined, this); - if (this._hasBody && this.chunkedEncoding) { - this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish); + // Flush message-level corked data together with the terminating chunk. + // Keep the socket corked so all HTTP framing is one logical write. + const hasBufferedWrites = this[kWriteBuffer] !== null && + this[kWriteBuffer].length !== 0; + let flushed = false; + + if (hasBufferedWrites) { + flushed = flushWriteBuffer(this, true, finish); + } else if (this._hasBody && this.chunkedEncoding) { + sendWriteVector( + this, + ['0\r\n' + this._trailer + '\r\n', 'latin1'], + finish, + ); } else if (!this._headerSent || this.writableLength || chunk) { - this._send('', 'latin1', finish); + sendWriteVector(this, ['', 'latin1'], finish); } else { process.nextTick(finish); } if (this[kSocket]) { // Fully uncork connection on end(). + this[kAutoCorked] = false; this[kSocket]._writableState.corked = 1; this[kSocket].uncork(); } this[kCorked] = 1; this.uncork(); + // Mark the message as ended before emitting drain. A synchronous drain + // listener must not be able to write after the terminating chunk. this.finished = true; + if (flushed) { + emitDrainIfNeeded(this); + } + // There is the first message on the outgoing queue, and we've sent // everything to the socket. debug('outgoing message end.'); @@ -1221,6 +1586,39 @@ OutgoingMessage.prototype._flushOutput = function _flushOutput(socket) { return undefined; const outputData = this.outputData; + if (socket._httpMessage === this && socket.writable && + typeof socket[kInternalWritev] === 'function' && + typeof socket[kRawWritev] === 'function') { + const vector = new Array(outputLength << 1); + let callbacks = null; + for (let i = 0; i < outputLength; i++) { + const entry = outputData[i]; + vector[i * 2] = entry.data; + vector[i * 2 + 1] = entry.encoding; + if (entry.callback !== null && entry.callback !== undefined && + entry.callback !== nop) { + if (callbacks === null) { + callbacks = entry.callback; + } else if (typeof callbacks === 'function') { + callbacks = [callbacks, entry.callback]; + } else { + callbacks.push(entry.callback); + } + } + } + + let callback = callbacks; + if (callbacks !== null && typeof callbacks !== 'function') { + callback = (error) => callWriteCallbacks(callbacks, error); + } + + const ret = socket[kInternalWritev](vector, callback); + this.outputData = []; + this._onPendingData(-this.outputSize); + this.outputSize = 0; + return ret; + } + socket.cork(); let ret; // Retain for(;;) loop for performance reasons @@ -1246,8 +1644,16 @@ OutgoingMessage.prototype.flushHeaders = function flushHeaders() { this._implicitHeader(); } - // Force-flush the headers. - this._send(''); + // Force-flush the headers. If an inactive message already owns a buffered + // contribution, move the header bytes from that contribution to outputData + // instead of counting the same bytes in both places. + try { + this._send(''); + } finally { + if (this[kBufferedOutputSize] !== 0) { + updateBufferedOutputSize(this, this[kPendingMessageBytes]()); + } + } }; OutgoingMessage.prototype.pipe = function pipe() { diff --git a/lib/_http_server.js b/lib/_http_server.js index 6cede195b879..6e2ce30f0266 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -58,6 +58,7 @@ const { validateHeaderName, validateHeaderValue, } = require('_http_outgoing'); +const { kDestroyMessageBuffer } = require('internal/streams/utils'); const { kOutHeaders, kNeedDrain, @@ -297,7 +298,7 @@ function onServerResponseClose() { // where the ServerResponse object has already been deconstructed. // Fortunately, that requires only a single if check. :-) if (this._httpMessage) { - emitCloseNT(this._httpMessage); + emitCloseNT(this._httpMessage, this._writableState.errored); } } @@ -881,8 +882,15 @@ function updateOutgoingData(socket, state, delta) { socketOnDrain(socket, state); } +function isOutgoingBackpressured(socket) { + const msg = socket._httpMessage; + return socket._writableState.needDrain || + (msg && !msg.finished && msg[kNeedDrain] && msg.writableLength !== 0); +} + function socketOnDrain(socket, state) { - const needPause = state.outgoingData > socket.writableHighWaterMark; + const needPause = isOutgoingBackpressured(socket) || + state.outgoingData > socket.writableHighWaterMark; // If we previously paused, then start reading again. if (socket._paused && !needPause) { @@ -1256,8 +1264,9 @@ function resOnFinish(req, res, socket, state, server) { } } -function emitCloseNT(self) { +function emitCloseNT(self, error) { if (!self._closed) { + self[kDestroyMessageBuffer](error); self.destroyed = true; self._closed = true; self.emit('close'); @@ -1288,8 +1297,8 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { // so that we don't become overwhelmed by a flood of // pipelined requests that may never be resolved. if (!socket._paused) { - const ws = socket._writableState; - if (ws.needDrain || state.outgoingData >= socket.writableHighWaterMark) { + if (isOutgoingBackpressured(socket) || + state.outgoingData >= socket.writableHighWaterMark) { socket._paused = true; // We also need to pause the parser, but don't do that until after // the call to execute, because we may still be processing the last diff --git a/lib/internal/stream_base_commons.js b/lib/internal/stream_base_commons.js index 6d144f8a0fa6..040b1fc5c956 100644 --- a/lib/internal/stream_base_commons.js +++ b/lib/internal/stream_base_commons.js @@ -8,11 +8,10 @@ const { const { Buffer } = require('buffer'); const { FastBuffer } = require('internal/buffer'); const { - WriteWrap, kReadBytesOrError, kArrayBufferOffset, kBytesWritten, - kLastWriteWasAsync, + kLastWriteErr, streamBaseState, } = internalBinding('stream_wrap'); const { UV_EOF } = internalBinding('uv'); @@ -43,41 +42,6 @@ const kBuffer = Symbol('kBuffer'); const kBufferGen = Symbol('kBufferGen'); const kBufferCb = Symbol('kBufferCb'); -function handleWriteReq(req, data, encoding) { - const { handle } = req; - - switch (encoding) { - case 'buffer': - { - const ret = handle.writeBuffer(req, data); - if (streamBaseState[kLastWriteWasAsync]) - req.buffer = data; - return ret; - } - case 'latin1': - case 'binary': - return handle.writeLatin1String(req, data); - case 'utf8': - case 'utf-8': - return handle.writeUtf8String(req, data); - case 'ascii': - return handle.writeAsciiString(req, data); - case 'ucs2': - case 'ucs-2': - case 'utf16le': - case 'utf-16le': - return handle.writeUcs2String(req, data); - default: - { - const buffer = Buffer.from(data, encoding); - const ret = handle.writeBuffer(req, buffer); - if (streamBaseState[kLastWriteWasAsync]) - req.buffer = buffer; - return ret; - } - } -} - function onWriteComplete(status) { debug('onWriteComplete', status, this.error); @@ -105,62 +69,117 @@ function onWriteComplete(status) { this.callback(null); } -function createWriteWrap(handle, callback) { - const req = new WriteWrap(); - - req.handle = handle; - req.oncomplete = onWriteComplete; - req.async = false; - req.bytes = 0; - req.buffer = null; - req.callback = callback; - - return req; -} - function writevGeneric(self, data, cb) { - const req = createWriteWrap(self[kHandle], cb); + const handle = self[kHandle]; const allBuffers = data.allBuffers; + let buffer = null; let chunks; if (allBuffers) { chunks = data; for (let i = 0; i < data.length; i++) data[i] = data[i].chunk; + buffer = chunks; } else { chunks = new Array(data.length << 1); for (let i = 0; i < data.length; i++) { const entry = data[i]; chunks[i * 2] = entry.chunk; chunks[i * 2 + 1] = entry.encoding; + if (entry.chunk instanceof Buffer) + buffer = chunks; } } - const err = req.handle.writev(req, chunks, allBuffers); + const ret = handle.writev(null, chunks, allBuffers); - // Retain chunks - if (err === 0) req._chunks = chunks; + return afterWriteDispatched(handle, ret, buffer, cb); +} - afterWriteDispatched(req, err, cb); - return req; +function writevGenericRaw(self, chunks, cb) { + const handle = self[kHandle]; + let buffer = null; + for (let i = 0; i < chunks.length; i += 2) { + if (chunks[i] instanceof Buffer) { + buffer = chunks; + break; + } + } + const ret = handle.writev(null, chunks, false); + const req = afterWriteDispatched(handle, ret, buffer, cb); + return req.async ? req.bytes : 0; } function writeGeneric(self, data, encoding, cb) { - const req = createWriteWrap(self[kHandle], cb); - const err = handleWriteReq(req, data, encoding); + const handle = self[kHandle]; + let ret; + let buffer = null; - afterWriteDispatched(req, err, cb); - return req; + switch (encoding) { + case 'buffer': + buffer = data; + ret = handle.writeBuffer(null, data); + break; + case 'latin1': + case 'binary': + ret = handle.writeLatin1String(null, data); + break; + case 'utf8': + case 'utf-8': + ret = handle.writeUtf8String(null, data); + break; + case 'ascii': + ret = handle.writeAsciiString(null, data); + break; + case 'ucs2': + case 'ucs-2': + case 'utf16le': + case 'utf-16le': + ret = handle.writeUcs2String(null, data); + break; + default: + buffer = Buffer.from(data, encoding); + ret = handle.writeBuffer(null, buffer); + break; + } + + return afterWriteDispatched(handle, ret, buffer, cb); } -function afterWriteDispatched(req, err, cb) { - req.bytes = streamBaseState[kBytesWritten]; - req.async = !!streamBaseState[kLastWriteWasAsync]; +// `ret` is either a numeric error code for a synchronous completion or the +// WriteWrap object created by C++ only when the write needs a request. +function afterWriteDispatched(handle, ret, buffer, cb) { + const bytes = streamBaseState[kBytesWritten]; + + if (typeof ret === 'number') { + if (ret !== 0) + cb(new ErrnoException(ret, 'write')); + else if (typeof cb === 'function') + cb(); + return { async: false, bytes }; + } - if (err !== 0) - return cb(new ErrnoException(err, 'write', req.error)); + const req = ret; + const err = streamBaseState[kLastWriteErr]; + if (err !== 0) { + cb(new ErrnoException(err, 'write', req.error)); + return { async: false, bytes }; + } - if (!req.async && typeof req.callback === 'function') { - req.callback(); + req.handle = handle; + req.oncomplete = onWriteComplete; + req.callback = cb; + req.async = true; + req.bytes = bytes; + req.buffer = buffer; + + // Some StreamBase implementations can finish while still inside the + // binding call. Replay that completion after the JS callback is attached. + const status = req.writeStatus; + if (status !== null) { + req.writeStatus = null; + req.oncomplete(status); } + + return req; } function onStreamRead(arrayBuffer) { @@ -273,6 +292,7 @@ function setStreamTimeout(msecs, callback) { module.exports = { writevGeneric, + writevGenericRaw, writeGeneric, onStreamRead, kAfterAsyncWrite, diff --git a/lib/internal/streams/utils.js b/lib/internal/streams/utils.js index 45f55316104f..4680ea35fc18 100644 --- a/lib/internal/streams/utils.js +++ b/lib/internal/streams/utils.js @@ -18,6 +18,10 @@ const kIsWritable = SymbolFor('nodejs.stream.writable'); const kIsDisturbed = SymbolFor('nodejs.stream.disturbed'); const kOnConstructed = Symbol('kOnConstructed'); +const kDestroyMessageBuffer = Symbol('kDestroyMessageBuffer'); +const kInternalWritev = Symbol('kInternalWritev'); +const kPendingMessageBytes = Symbol('kPendingMessageBytes'); +const kRawWritev = Symbol('kRawWritev'); const kIsClosedPromise = SymbolFor('nodejs.webstream.isClosedPromise'); const kControllerErrorFunction = SymbolFor('nodejs.webstream.controllerErrorFunction'); @@ -316,7 +320,11 @@ function isErrored(stream) { } module.exports = { + kDestroyMessageBuffer, + kInternalWritev, kOnConstructed, + kPendingMessageBytes, + kRawWritev, isDestroyed, kIsDestroyed, isDisturbed, diff --git a/lib/internal/streams/writable.js b/lib/internal/streams/writable.js index 47e003ea3a8a..59bf6204eda8 100644 --- a/lib/internal/streams/writable.js +++ b/lib/internal/streams/writable.js @@ -26,6 +26,7 @@ 'use strict'; const { + Array, ArrayPrototypeSlice, Error, FunctionPrototypeSymbolHasInstance, @@ -70,6 +71,9 @@ const { }, } = require('internal/errors'); const { + kDestroyMessageBuffer, + kInternalWritev, + kRawWritev, kState, // bitfields kObjectMode, @@ -84,7 +88,11 @@ const { kOnConstructed, } = require('internal/streams/utils'); -const { assignFunctionName } = require('internal/util'); +const { + assignFunctionName, + encodingsMap, + normalizeEncoding, +} = require('internal/util'); const { errorOrDestroy } = destroyImpl; @@ -99,6 +107,10 @@ const kDefaultEncodingValue = Symbol('kDefaultEncodingValue'); const kWriteCbValue = Symbol('kWriteCbValue'); const kAfterWriteTickInfoValue = Symbol('kAfterWriteTickInfoValue'); const kBufferedValue = Symbol('kBufferedValue'); +const kBufferedContainsWritev = Symbol('kBufferedContainsWritev'); +// Encoding IDs avoid repeated native string parsing once their JS +// normalization cost is amortized by a sufficiently large vector. +const kMinEncodingIdVectors = 20; const kSync = 1 << 9; const kFinalCalled = 1 << 10; @@ -365,6 +377,7 @@ function resetBuffer(state) { state.bufferedIndex = 0; state[kState] |= kAllBuffers | kAllNoop; state[kState] &= ~kBuffered; + state[kBufferedContainsWritev] &&= false; } WritableState.prototype.getBuffer = function getBuffer() { @@ -512,6 +525,82 @@ Writable.prototype.write = function(chunk, encoding, cb) { return _write(this, chunk, encoding, cb) === true; }; +function normalizeWriteEncoding(state, encoding) { + if (!encoding) { + return (state[kState] & kDefaultUTF8Encoding) !== 0 ? + 'utf8' : state.defaultEncoding; + } + if (encoding !== 'buffer' && !Buffer.isEncoding(encoding)) { + throw new ERR_UNKNOWN_ENCODING(encoding); + } + return encoding; +} + +function normalizeWriteChunk(state, chunk, encoding) { + if (typeof chunk === 'string') { + if (encoding === 'buffer') { + throw new ERR_UNKNOWN_ENCODING(encoding); + } + return (state[kState] & kDecodeStrings) !== 0 ? + Buffer.from(chunk, encoding) : chunk; + } + if (chunk instanceof Buffer) return chunk; + if (Stream._isArrayBufferView(chunk)) { + return Stream._uint8ArrayToBuffer(chunk); + } + throw new ERR_INVALID_ARG_TYPE( + 'chunk', ['string', 'Buffer', 'TypedArray', 'DataView'], chunk); +} + +// Internal entry point for callers that already have one logical write split +// across multiple chunks. It deliberately shares Writable's state machine +// instead of invoking _writev() directly. +function internalWritev(chunks, cb) { + const state = this._writableState; + + if (cb == null || typeof cb !== 'function') { + cb = nop; + } + + let len = 0; + const useEncodingIds = chunks.length >= kMinEncodingIdVectors * 2; + + for (let i = 0; i < chunks.length; i += 2) { + let chunk = chunks[i]; + let encoding = chunks[i + 1]; + + encoding = normalizeWriteEncoding(state, encoding); + chunk = normalizeWriteChunk(state, chunk, encoding); + if (typeof chunk !== 'string') { + encoding = 'buffer'; + } else if (useEncodingIds) { + encoding = encodingsMap[normalizeEncoding(encoding)]; + } + + len += chunk.length; + chunks[i] = chunk; + chunks[i + 1] = encoding; + } + + let err; + if ((state[kState] & kEnding) !== 0) { + err = new ERR_STREAM_WRITE_AFTER_END(); + } else if ((state[kState] & kDestroyed) !== 0) { + err = new ERR_STREAM_DESTROYED('write'); + } + + if (err) { + process.nextTick(cb, err); + errorOrDestroy(this, err, true); + return false; + } + + state.pendingcb++; + return writevOrBuffer(this, state, chunks, cb, len); +} + +Writable[kInternalWritev] = internalWritev; + Writable.prototype.cork = function() { const state = this._writableState; @@ -586,6 +675,39 @@ function writeOrBuffer(stream, state, chunk, encoding, callback) { return ret && (state[kState] & (kDestroyed | kErrored)) === 0; } +function writevOrBuffer(stream, state, chunks, callback, len) { + state.length += len; + + if ((state[kState] & (kWriting | kErrored | kCorked | kConstructed)) !== kConstructed) { + if ((state[kState] & kBuffered) === 0) { + state[kState] |= kBuffered; + state[kBufferedValue] = []; + } + + state[kBufferedValue].push({ + chunk: chunks, + encoding: kRawWritev, + callback, + length: len, + }); + state[kBufferedContainsWritev] = true; + state[kState] &= ~kAllBuffers; + if ((state[kState] & kAllNoop) !== 0 && callback !== nop) { + state[kState] &= ~kAllNoop; + } + } else { + doWritevRaw(stream, state, len, chunks, callback); + } + + const ret = state.length < state.highWaterMark || state.length === 0; + + if (!ret) { + state[kState] |= kNeedDrain; + } + + return ret && (state[kState] & (kDestroyed | kErrored)) === 0; +} + function doWrite(stream, state, writev, len, chunk, encoding, cb) { state.writelen = len; if (cb !== nop) { @@ -601,6 +723,19 @@ function doWrite(stream, state, writev, len, chunk, encoding, cb) { state[kState] &= ~kSync; } +function doWritevRaw(stream, state, len, chunks, cb) { + state.writelen = len; + if (cb !== nop) { + state.writecb = cb; + } + state[kState] |= kWriting | kSync | kExpectWriteCb; + if ((state[kState] & kDestroyed) !== 0) + state.onwrite(new ERR_STREAM_DESTROYED('write')); + else + stream[kRawWritev](chunks, state.onwrite); + state[kState] &= ~kSync; +} + function onwriteError(stream, state, er, cb) { --state.pendingcb; @@ -729,8 +864,10 @@ function errorBuffer(state) { if ((state[kState] & kBuffered) !== 0) { for (let n = state.bufferedIndex; n < state.buffered.length; ++n) { - const { chunk, callback } = state[kBufferedValue][n]; - const len = (state[kState] & kObjectMode) !== 0 ? 1 : chunk.length; + const entry = state[kBufferedValue][n]; + const { callback } = entry; + const len = entry.encoding === kRawWritev ? entry.length : + ((state[kState] & kObjectMode) !== 0 ? 1 : entry.chunk.length); state.length -= len; callback(state.errored ?? new ERR_STREAM_DESTROYED('write')); } @@ -768,21 +905,55 @@ function clearBuffer(stream, state) { buffered[n].callback(err); } }; - // Make a copy of `buffered` if it's going to be used by `callback` above, - // since `doWrite` will mutate the array. - const chunks = (state[kState] & kAllNoop) !== 0 && i === 0 ? - buffered : ArrayPrototypeSlice(buffered, i); - chunks.allBuffers = (state[kState] & kAllBuffers) !== 0; + const rawWritev = state[kBufferedContainsWritev] === true; + let chunks; + if (!rawWritev) { + // Make a copy of `buffered` if it's going to be used by `callback` + // above, since `doWrite` will mutate the array. + chunks = (state[kState] & kAllNoop) !== 0 && i === 0 ? + buffered : ArrayPrototypeSlice(buffered, i); + chunks.allBuffers = (state[kState] & kAllBuffers) !== 0; + } else { + let chunkCount = 0; + for (let n = i; n < buffered.length; n++) { + const entry = buffered[n]; + chunkCount += entry.encoding === kRawWritev ? + entry.chunk.length : 2; + } + + chunks = new Array(chunkCount); + let chunkIndex = 0; + for (let n = i; n < buffered.length; n++) { + const entry = buffered[n]; + if (entry.encoding !== kRawWritev) { + chunks[chunkIndex++] = entry.chunk; + chunks[chunkIndex++] = entry.encoding; + } else { + for (let m = 0; m < entry.chunk.length; m++) { + chunks[chunkIndex++] = entry.chunk[m]; + } + } + } + } - doWrite(stream, state, true, state.length, chunks, '', callback); + if (rawWritev) { + doWritevRaw(stream, state, state.length, chunks, callback); + } else { + doWrite(stream, state, true, state.length, chunks, '', callback); + } resetBuffer(state); } else { do { - const { chunk, encoding, callback } = buffered[i]; + const entry = buffered[i]; buffered[i++] = null; - const len = objectMode ? 1 : chunk.length; - doWrite(stream, state, false, len, chunk, encoding, callback); + if (entry.encoding !== kRawWritev) { + const { chunk, encoding, callback } = entry; + const len = objectMode ? 1 : chunk.length; + doWrite(stream, state, false, len, chunk, encoding, callback); + } else { + doWritevRaw(stream, state, entry.length, entry.chunk, entry.callback); + } } while (i < buffered.length && (state[kState] & kWriting) === 0); if (i === buffered.length) { @@ -1116,9 +1287,13 @@ const destroy = destroyImpl.destroy; Writable.prototype.destroy = function(err, cb) { const state = this._writableState; - // Invoke pending callbacks. - if ((state[kState] & (kBuffered | kOnFinished)) !== 0 && (state[kState] & kDestroyed) === 0) { - process.nextTick(errorBuffer, state); + if ((state[kState] & kDestroyed) === 0) { + this._httpMessage?.[kDestroyMessageBuffer]?.(err); + + // Invoke pending callbacks. + if ((state[kState] & (kBuffered | kOnFinished)) !== 0) { + process.nextTick(errorBuffer, state); + } } destroy.call(this, err, cb); diff --git a/lib/net.js b/lib/net.js index 445a7d59f8cb..96c3540513b8 100644 --- a/lib/net.js +++ b/lib/net.js @@ -73,6 +73,8 @@ const { convertIpv6StringToBuffer } = internalBinding('cares_wrap'); const { Buffer } = require('buffer'); const { ShutdownWrap } = internalBinding('stream_wrap'); +const { encodings: stringDecoderEncodings } = + internalBinding('string_decoder'); const { TCP, TCPConnectWrap, @@ -90,6 +92,7 @@ const { } = require('internal/async_hooks'); const { writevGeneric, + writevGenericRaw, writeGeneric, onStreamRead, kAfterAsyncWrite, @@ -100,6 +103,11 @@ const { kBufferCb, kBufferGen, } = require('internal/stream_base_commons'); +const { + kInternalWritev, + kPendingMessageBytes, + kRawWritev, +} = require('internal/streams/utils'); const { ErrnoException, ExceptionWithHostPort, @@ -1234,7 +1242,7 @@ Socket.prototype._writeGeneric = function(writev, data, encoding, cb) { // waiting for this one to be done. if (this.connecting) { this._pendingData = data; - this._pendingEncoding = encoding; + this._pendingEncoding = writev === kRawWritev ? kRawWritev : encoding; this.once('connect', function connect() { this.off('close', onClose); this._writeGeneric(writev, data, encoding, cb); @@ -1255,6 +1263,11 @@ Socket.prototype._writeGeneric = function(writev, data, encoding, cb) { this._unrefTimer(); + if (writev === kRawWritev) { + this[kLastWriteQueueSize] = writevGenericRaw(this, data, cb); + return; + } + let req; if (writev) req = writevGeneric(this, data, cb); @@ -1269,6 +1282,13 @@ Socket.prototype._writev = function(chunks, cb) { this._writeGeneric(true, chunks, '', cb); }; +Socket.prototype[kInternalWritev] = + stream.Writable[kInternalWritev]; + +Socket.prototype[kRawWritev] = function(chunks, cb) { + this._writeGeneric(kRawWritev, chunks, '', cb); +}; + Socket.prototype._write = function(data, encoding, cb) { this._writeGeneric(false, data, encoding, cb); @@ -1281,8 +1301,27 @@ protoGetter('_bytesDispatched', function _bytesDispatched() { return this._handle ? this._handle.bytesWritten : this[kBytesWritten]; }); +function rawWritevSize(chunks) { + let bytes = 0; + for (let i = 0; i < chunks.length; i += 2) { + const chunk = chunks[i]; + const encoding = chunks[i + 1]; + bytes += chunk instanceof Buffer ? + chunk.length : Buffer.byteLength( + chunk, + typeof encoding === 'number' ? + stringDecoderEncodings[encoding] : encoding, + ); + } + return bytes; +} + protoGetter('bytesWritten', function bytesWritten() { let bytes = this._bytesDispatched; + const message = this._httpMessage; + if (typeof message?.[kPendingMessageBytes] === 'function') { + bytes += message[kPendingMessageBytes](); + } const data = this._pendingData; const encoding = this._pendingEncoding; const writableBuffer = this.writableBuffer; @@ -1291,20 +1330,28 @@ protoGetter('bytesWritten', function bytesWritten() { return undefined; for (const el of writableBuffer) { - bytes += el.chunk instanceof Buffer ? - el.chunk.length : - Buffer.byteLength(el.chunk, el.encoding); + if (el.encoding !== kRawWritev) { + bytes += el.chunk instanceof Buffer ? + el.chunk.length : + Buffer.byteLength(el.chunk, el.encoding); + } else { + bytes += rawWritevSize(el.chunk); + } } if (ArrayIsArray(data)) { // Was a writev, iterate over chunks to get total length - for (let i = 0; i < data.length; i++) { - const chunk = data[i]; + if (encoding === kRawWritev) { + bytes += rawWritevSize(data); + } else { + for (let i = 0; i < data.length; i++) { + const chunk = data[i]; - if (data.allBuffers || chunk instanceof Buffer) - bytes += chunk.length; - else - bytes += Buffer.byteLength(chunk.chunk, chunk.encoding); + if (data.allBuffers || chunk instanceof Buffer) + bytes += chunk.length; + else + bytes += Buffer.byteLength(chunk.chunk, chunk.encoding); + } } } else if (data) { // Writes are either a string or a Buffer. diff --git a/src/env_properties.h b/src/env_properties.h index e3179287dce4..91e47cd36a6b 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -388,7 +388,8 @@ V(wrap_string, "wrap") \ V(writable_string, "writable") \ V(write_host_object_string, "_writeHostObject") \ - V(write_queue_size_string, "writeQueueSize") + V(write_queue_size_string, "writeQueueSize") \ + V(write_status_string, "writeStatus") #define PER_ISOLATE_TEMPLATE_PROPERTIES(V) \ V(a_record_template, v8::DictionaryTemplate) \ diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc index 62e83074bf88..c7b4eeac88f9 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -323,6 +323,7 @@ class Parser : public AsyncWrap, public StreamListener { allocator_.Reset(); url_.Reset(); status_message_.Reset(); + max_header_pairs_ = -1; if (connectionsList_ != nullptr) { connectionsList_->Push(this); @@ -465,6 +466,7 @@ class Parser : public AsyncWrap, public StreamListener { num_fields_ = 0; num_values_ = 0; header_pairs_ = 0; + max_header_pairs_ = -1; // METHOD if (parser_.type == HTTP_REQUEST) { @@ -1034,6 +1036,7 @@ class Parser : public AsyncWrap, public StreamListener { headers_completed_ = false; max_http_header_size_ = max_http_header_size; header_pairs_ = 0; + max_header_pairs_ = -1; } @@ -1053,21 +1056,23 @@ class Parser : public AsyncWrap, public StreamListener { header_pairs_ += 2; - Local max_header_pairs_v; - if (!object() - ->Get(env()->context(), - FIXED_ONE_BYTE_STRING(env()->isolate(), "maxHeaderPairs")) - .ToLocal(&max_header_pairs_v)) { - got_exception_ = true; - return -1; - } + if (max_header_pairs_ < 0) { + Local max_header_pairs_v; + if (!object() + ->Get(env()->context(), + FIXED_ONE_BYTE_STRING(env()->isolate(), "maxHeaderPairs")) + .ToLocal(&max_header_pairs_v)) { + got_exception_ = true; + return -1; + } - if (!max_header_pairs_v->IsNumber()) { - return 0; + const double value = max_header_pairs_v->IsNumber() + ? max_header_pairs_v.As()->Value() + : 0; + max_header_pairs_ = value > 0 ? value : 0; } - const double max_header_pairs = max_header_pairs_v.As()->Value(); - if (max_header_pairs > 0 && header_pairs_ > max_header_pairs) { + if (max_header_pairs_ > 0 && header_pairs_ > max_header_pairs_) { llhttp_set_error_reason(&parser_, "HPE_HEADER_OVERFLOW:Header overflow"); return HPE_USER; } @@ -1109,6 +1114,7 @@ class Parser : public AsyncWrap, public StreamListener { const char* current_buffer_data_; bool headers_completed_ = false; size_t header_pairs_ = 0; + double max_header_pairs_ = -1; bool pending_pause_ = false; bool received_data_ = false; uint64_t header_nread_ = 0; diff --git a/src/stream_base.cc b/src/stream_base.cc index 370b8f682ead..1277376510fa 100644 --- a/src/stream_base.cc +++ b/src/stream_base.cc @@ -13,6 +13,7 @@ #include "v8.h" #include // INT_MAX +#include namespace node { @@ -41,6 +42,33 @@ using v8::Signature; using v8::String; using v8::Value; +struct PreparedWritevChunk { + Local value; + enum encoding encoding; + bool is_buffer; +}; + +static void AppendWriteBuffer(uv_buf_t* bufs, + size_t* count, + char* base, + size_t len, + bool coalesce) { + if (coalesce && len > 0 && *count > 0) { + uv_buf_t& previous = bufs[*count - 1]; + const size_t max_len = + std::numeric_limits::max(); + if (previous.len > 0 && previous.base + previous.len == base && + len <= max_len && previous.len <= max_len - len) { + previous.len += static_cast(len); + return; + } + } + + bufs[*count].base = base; + bufs[*count].len = len; + (*count)++; +} + int StreamBase::Shutdown(v8::Local req_wrap_obj) { Environment* env = stream_env(); @@ -175,6 +203,18 @@ int StreamBase::Shutdown(const FunctionCallbackInfo& args) { void StreamBase::SetWriteResult(const StreamWriteResult& res) { env_->stream_base_state()[kBytesWritten] = res.bytes; env_->stream_base_state()[kLastWriteWasAsync] = res.async; + env_->stream_base_state()[kLastWriteErr] = res.err; +} + +int StreamBase::FinishWrite(const FunctionCallbackInfo& args, + const StreamWriteResult& res, + bool lazy_req) { + SetWriteResult(res); + if (lazy_req && res.wrap_obj) { + args.GetReturnValue().Set(res.wrap_obj->object()); + return kReturnValueSet; + } + return res.err; } int StreamBase::Writev(const FunctionCallbackInfo& args) { @@ -182,125 +222,131 @@ int StreamBase::Writev(const FunctionCallbackInfo& args) { Isolate* isolate = env->isolate(); Local context = env->context(); - CHECK(args[0]->IsObject()); + const bool lazy_write_wrap = !args[0]->IsObject(); CHECK(args[1]->IsArray()); - Local req_wrap_obj = args[0].As(); + Local req_wrap_obj; + if (!lazy_write_wrap) + req_wrap_obj = args[0].As(); Local chunks = args[1].As(); bool all_buffers = args[2]->IsTrue(); - size_t count; - if (all_buffers) - count = chunks->Length(); - else - count = chunks->Length() >> 1; - - MaybeStackBuffer bufs(count); - - size_t storage_size = 0; - size_t offset; - - if (!all_buffers) { - // Determine storage size first + if (all_buffers) { + const size_t count = chunks->Length(); + MaybeStackBuffer bufs(count); for (size_t i = 0; i < count; i++) { Local chunk; - if (!chunks->Get(context, i * 2).ToLocal(&chunk)) + if (!chunks->Get(context, i).ToLocal(&chunk)) return -1; + bufs[i].base = Buffer::Data(chunk); + bufs[i].len = Buffer::Length(chunk); + } - if (Buffer::HasInstance(chunk)) - continue; - // Buffer chunk, no additional storage required + StreamWriteResult res = Write(*bufs, count, nullptr, req_wrap_obj); + return FinishWrite(args, res, lazy_write_wrap); + } - // String chunk - Local string; - if (!chunk->ToString(context).ToLocal(&string)) - return -1; - Local next_chunk; - if (!chunks->Get(context, i * 2 + 1).ToLocal(&next_chunk)) - return -1; - enum encoding encoding = ParseEncoding(isolate, next_chunk); - size_t chunk_size; - if ((encoding == UTF8 && - string->Length() > 65535 && - !StringBytes::Size(isolate, string, encoding).To(&chunk_size)) || - !StringBytes::StorageSize(isolate, string, encoding) - .To(&chunk_size)) { - return -1; - } - storage_size += chunk_size; + const size_t count = chunks->Length() >> 1; + + MaybeStackBuffer bufs(count); + MaybeStackBuffer prepared_chunks(count); + size_t storage_size = 0; + + // Determine storage size and retain metadata for materialization. + for (size_t i = 0; i < count; i++) { + Local chunk; + if (!chunks->Get(context, i * 2).ToLocal(&chunk)) + return -1; + + if (Buffer::HasInstance(chunk)) { + prepared_chunks[i].value = chunk; + prepared_chunks[i].is_buffer = true; + continue; } - if (storage_size > INT_MAX) - return UV_ENOBUFS; - } else { - for (size_t i = 0; i < count; i++) { - Local chunk; - if (!chunks->Get(context, i).ToLocal(&chunk)) - return -1; - bufs[i].base = Buffer::Data(chunk); - bufs[i].len = Buffer::Length(chunk); + Local string; + if (!chunk->ToString(context).ToLocal(&string)) + return -1; + Local next_chunk; + if (!chunks->Get(context, i * 2 + 1).ToLocal(&next_chunk)) + return -1; + const enum encoding encoding = ParseEncoding( + isolate, next_chunk, next_chunk, LATIN1); + prepared_chunks[i].value = string; + prepared_chunks[i].encoding = encoding; + prepared_chunks[i].is_buffer = false; + size_t chunk_size; + if ((encoding == UTF8 && + string->Length() > 65535 && + !StringBytes::Size(isolate, string, encoding).To(&chunk_size)) || + !StringBytes::StorageSize(isolate, string, encoding) + .To(&chunk_size)) { + return -1; } + storage_size += chunk_size; } + if (storage_size > INT_MAX) + return UV_ENOBUFS; + std::unique_ptr bs; if (storage_size > 0) { bs = ArrayBuffer::NewBackingStore( isolate, storage_size, BackingStoreInitializationMode::kUninitialized); } - offset = 0; - if (!all_buffers) { - for (size_t i = 0; i < count; i++) { - Local chunk; - if (!chunks->Get(context, i * 2).ToLocal(&chunk)) - return -1; - - // Write buffer - if (Buffer::HasInstance(chunk)) { - bufs[i].base = Buffer::Data(chunk); - bufs[i].len = Buffer::Length(chunk); - continue; - } - - // Write string - CHECK_LE(offset, storage_size); - char* str_storage = - static_cast(bs ? bs->Data() : nullptr) + offset; - size_t str_size = (bs ? bs->ByteLength() : 0) - offset; - - Local string; - if (!chunk->ToString(context).ToLocal(&string)) - return -1; - Local next_chunk; - if (!chunks->Get(context, i * 2 + 1).ToLocal(&next_chunk)) - return -1; - enum encoding encoding = ParseEncoding(isolate, next_chunk); - str_size = StringBytes::Write(isolate, - str_storage, - str_size, - string, - encoding); - bufs[i].base = str_storage; - bufs[i].len = str_size; - offset += str_size; + size_t offset = 0; + size_t write_count = 0; + bool previous_was_string = false; + for (size_t i = 0; i < count; i++) { + const Local chunk = prepared_chunks[i].value; + + if (prepared_chunks[i].is_buffer) { + AppendWriteBuffer(*bufs, + &write_count, + Buffer::Data(chunk), + Buffer::Length(chunk), + false); + previous_was_string = false; + continue; } + + CHECK_LE(offset, storage_size); + char* str_storage = bs == nullptr ? + nullptr : static_cast(bs->Data()) + offset; + size_t str_size = storage_size - offset; + str_size = StringBytes::Write( + isolate, + str_storage, + str_size, + chunk.As(), + prepared_chunks[i].encoding); + AppendWriteBuffer( + *bufs, &write_count, str_storage, str_size, previous_was_string); + previous_was_string = str_size > 0; + offset += str_size; } - StreamWriteResult res = Write(*bufs, count, nullptr, req_wrap_obj); - SetWriteResult(res); - if (res.wrap != nullptr && storage_size > 0) + StreamWriteResult res = Write(*bufs, + write_count, + nullptr, + req_wrap_obj); + if (res.wrap != nullptr && storage_size > 0) { res.wrap->SetBackingStore(std::move(bs)); - return res.err; + } + return FinishWrite(args, res, lazy_write_wrap); } int StreamBase::WriteBuffer(const FunctionCallbackInfo& args) { - CHECK(args[0]->IsObject()); CHECK(args[1]->IsUint8Array()); Environment* env = Environment::GetCurrent(args); - Local req_wrap_obj = args[0].As(); + const bool lazy_req = !args[0]->IsObject(); + Local req_wrap_obj; + if (!lazy_req) + req_wrap_obj = args[0].As(); uv_buf_t buf; buf.base = Buffer::Data(args[1]); buf.len = Buffer::Length(args[1]); @@ -310,6 +356,15 @@ int StreamBase::WriteBuffer(const FunctionCallbackInfo& args) { if (args[2]->IsObject() && IsIPCPipe()) { Local send_handle_obj = args[2].As(); + if (lazy_req) { + if (!env->write_wrap_template() + ->NewInstance(env->context()) + .ToLocal(&req_wrap_obj)) { + return UV_EBUSY; + } + StreamReq::ResetObject(req_wrap_obj); + } + HandleWrap* wrap; ASSIGN_OR_RETURN_UNWRAP(&wrap, send_handle_obj, UV_EINVAL); send_handle = reinterpret_cast(wrap->GetHandle()); @@ -323,9 +378,7 @@ int StreamBase::WriteBuffer(const FunctionCallbackInfo& args) { } StreamWriteResult res = Write(&buf, 1, send_handle, req_wrap_obj); - SetWriteResult(res); - - return res.err; + return FinishWrite(args, res, lazy_req); } @@ -333,10 +386,12 @@ template int StreamBase::WriteString(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); Isolate* isolate = env->isolate(); - CHECK(args[0]->IsObject()); CHECK(args[1]->IsString()); - Local req_wrap_obj = args[0].As(); + const bool lazy_req = !args[0]->IsObject(); + Local req_wrap_obj; + if (!lazy_req) + req_wrap_obj = args[0].As(); Local string = args[1].As(); Local send_handle_obj; if (args[2]->IsObject()) @@ -383,8 +438,10 @@ int StreamBase::WriteString(const FunctionCallbackInfo& args) { // Immediate failure or success if (err != 0 || count == 0) { - SetWriteResult(StreamWriteResult { false, err, nullptr, data_size, {} }); - return err; + return FinishWrite( + args, + StreamWriteResult{false, err, nullptr, data_size, {}}, + lazy_req); } // Partial write @@ -417,6 +474,15 @@ int StreamBase::WriteString(const FunctionCallbackInfo& args) { uv_stream_t* send_handle = nullptr; if (IsIPCPipe() && !send_handle_obj.IsEmpty()) { + if (lazy_req && req_wrap_obj.IsEmpty()) { + if (!env->write_wrap_template() + ->NewInstance(env->context()) + .ToLocal(&req_wrap_obj)) { + return UV_EBUSY; + } + StreamReq::ResetObject(req_wrap_obj); + } + HandleWrap* wrap; ASSIGN_OR_RETURN_UNWRAP(&wrap, send_handle_obj, UV_EINVAL); send_handle = reinterpret_cast(wrap->GetHandle()); @@ -432,11 +498,10 @@ int StreamBase::WriteString(const FunctionCallbackInfo& args) { StreamWriteResult res = Write(&buf, 1, send_handle, req_wrap_obj, try_write); res.bytes += synchronously_written; - SetWriteResult(res); if (res.wrap != nullptr) res.wrap->SetBackingStore(std::move(bs)); - return res.err; + return FinishWrite(args, res, lazy_req); } @@ -662,7 +727,9 @@ void StreamBase::JSMethod(const FunctionCallbackInfo& args) { if (!wrap->IsAlive()) return args.GetReturnValue().Set(UV_EINVAL); AsyncHooks::DefaultTriggerAsyncIdScope trigger_scope(wrap->GetAsyncWrap()); - args.GetReturnValue().Set((wrap->*Method)(args)); + const int ret = (wrap->*Method)(args); + if (ret != kReturnValueSet) + args.GetReturnValue().Set(ret); } int StreamResource::DoTryWrite(uv_buf_t** bufs, size_t* count) { @@ -760,20 +827,47 @@ void ReportWritesToJSStreamListener::OnStreamAfterReqFinished( CHECK(!async_wrap->persistent().IsEmpty()); Local req_wrap_obj = async_wrap->object(); + Local oncomplete; + if (!req_wrap_obj->Get(env->context(), env->oncomplete_string()) + .ToLocal(&oncomplete)) { + return; + } + + const char* msg = stream->Error(); + + if (!oncomplete->IsFunction()) { + if (req_wrap_obj + ->Set(env->context(), + env->write_status_string(), + Integer::New(env->isolate(), status)) + .IsNothing()) { + return; + } + if (msg != nullptr) { + if (req_wrap_obj + ->Set(env->context(), + env->error_string(), + OneByteString(env->isolate(), msg)) + .IsNothing()) { + return; + } + stream->ClearError(); + } + return; + } + Local argv[] = { Integer::New(env->isolate(), status), stream->GetObject(), Undefined(env->isolate()) }; - const char* msg = stream->Error(); if (msg != nullptr) { argv[2] = OneByteString(env->isolate(), msg); stream->ClearError(); } - if (req_wrap_obj->Has(env->context(), env->oncomplete_string()).FromJust()) - async_wrap->MakeCallback(env->oncomplete_string(), arraysize(argv), argv); + async_wrap->MakeCallback(oncomplete.As(), arraysize(argv), argv); } void ReportWritesToJSStreamListener::OnStreamAfterWrite( diff --git a/src/stream_base.h b/src/stream_base.h index be00134eb1fc..6ce9056f8fc6 100644 --- a/src/stream_base.h +++ b/src/stream_base.h @@ -10,6 +10,8 @@ #include "v8.h" +#include // INT_MIN + namespace node { // Forward declarations @@ -405,14 +407,20 @@ class StreamBase : public StreamResource { kArrayBufferOffset, kBytesWritten, kLastWriteWasAsync, + kLastWriteErr, kNumStreamBaseStateFields }; private: + static constexpr int kReturnValueSet = INT_MIN; + Environment* env_; EmitToJSStreamListener default_listener_; void SetWriteResult(const StreamWriteResult& res); + int FinishWrite(const v8::FunctionCallbackInfo& args, + const StreamWriteResult& res, + bool lazy_req); static void AddAccessor(v8::Isolate* isolate, v8::Local sig, enum v8::PropertyAttribute attributes, diff --git a/src/stream_wrap.cc b/src/stream_wrap.cc index b41f6ac74947..d7ce7de0bbda 100644 --- a/src/stream_wrap.cc +++ b/src/stream_wrap.cc @@ -95,6 +95,16 @@ void LibuvStreamWrap::Initialize(Local target, Local ww = FunctionTemplate::New(isolate, IsConstructCallCallback); ww->InstanceTemplate()->SetInternalFieldCount(WriteWrap::kInternalFieldCount); + ww->InstanceTemplate()->Set(env->oncomplete_string(), v8::Null(isolate)); + ww->InstanceTemplate()->Set(FIXED_ONE_BYTE_STRING(isolate, "callback"), + v8::Null(isolate)); + ww->InstanceTemplate()->Set(env->handle_string(), v8::Null(isolate)); + ww->InstanceTemplate()->Set(FIXED_ONE_BYTE_STRING(isolate, "async"), + v8::False(isolate)); + ww->InstanceTemplate()->Set(FIXED_ONE_BYTE_STRING(isolate, "bytes"), + v8::Integer::New(isolate, 0)); + ww->InstanceTemplate()->Set(env->buffer_string(), v8::Null(isolate)); + ww->InstanceTemplate()->Set(env->write_status_string(), v8::Null(isolate)); ww->Inherit(AsyncWrap::GetConstructorTemplate(env)); SetConstructorFunction(context, target, "WriteWrap", ww); env->set_write_wrap_template(ww->InstanceTemplate()); @@ -103,6 +113,7 @@ void LibuvStreamWrap::Initialize(Local target, NODE_DEFINE_CONSTANT(target, kArrayBufferOffset); NODE_DEFINE_CONSTANT(target, kBytesWritten); NODE_DEFINE_CONSTANT(target, kLastWriteWasAsync); + NODE_DEFINE_CONSTANT(target, kLastWriteErr); target ->Set(context, FIXED_ONE_BYTE_STRING(isolate, "streamBaseState"), diff --git a/test/parallel/test-http-1.0.js b/test/parallel/test-http-1.0.js index 639bd228df00..fb35fad26cd2 100644 --- a/test/parallel/test-http-1.0.js +++ b/test/parallel/test-http-1.0.js @@ -148,10 +148,8 @@ function test(handler, request_generator, response_validator) { 'Connection: close\r\n' + 'Transfer-Encoding: chunked\r\n' + '\r\n' + - '7\r\n' + - 'Hello, \r\n' + - '6\r\n' + - 'world!\r\n' + + 'd\r\n' + + 'Hello, world!\r\n' + '0\r\n' + '\r\n'; diff --git a/test/parallel/test-http-automatic-headers.js b/test/parallel/test-http-automatic-headers.js index 5e99f1ee39dd..91d26ffa5be5 100644 --- a/test/parallel/test-http-automatic-headers.js +++ b/test/parallel/test-http-automatic-headers.js @@ -3,6 +3,34 @@ const common = require('../common'); const assert = require('assert'); const http = require('http'); +{ + const msg = new http.OutgoingMessage(); + msg.sendDate = true; + msg._storeHeader('', [ + ['cOnNeCtIoN', 'close'], + ['cOnTeNt-LeNgTh', '0'], + ['dAtE', 'custom'], + ['eXpEcT', '100-continue'], + ['kEeP-aLiVe', 'timeout=1'], + ]); + + assert.strictEqual(msg._last, true); + assert.strictEqual(msg._contentLength, 0); + assert.strictEqual(msg._defaultKeepAlive, false); + assert.strictEqual(msg._header.includes('\r\nDate: '), false); + assert.strictEqual(msg.outputData.length, 1); +} + +{ + const msg = new http.OutgoingMessage(); + msg._storeHeader('', [ + ['tRaIlEr', 'x-test'], + ['tRaNsFeR-EnCoDiNg', 'chunked'], + ]); + + assert.strictEqual(msg.chunkedEncoding, true); +} + const server = http.createServer(common.mustCall((req, res) => { res.setHeader('X-Date', 'foo'); res.setHeader('X-Connection', 'bar'); diff --git a/test/parallel/test-http-outgoing-auto-cork.js b/test/parallel/test-http-outgoing-auto-cork.js new file mode 100644 index 000000000000..cc9769f2714c --- /dev/null +++ b/test/parallel/test-http-outgoing-auto-cork.js @@ -0,0 +1,352 @@ +// Flags: --expose-internals + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); +const { kRawWritev } = require('internal/streams/utils'); + +function runRawResponse(mode, expectedBody, expectedWritevParts, + contentLength = false, payload = 'ABC') { + return new Promise((resolve, reject) => { + const writevParts = []; + const server = http.createServer(common.mustCall((req, res) => { + const originalWritev = res.socket[kRawWritev]; + res.socket[kRawWritev] = function(chunks, callback) { + writevParts.push(chunks.length >> 1); + return originalWritev.call(this, chunks, callback); + }; + + if (contentLength) { + res.setHeader('Content-Length', 3); + } + + if (mode === 'auto') { + res.write('A'); + res.write('B'); + res.end('C'); + } else if (mode === 'explicit') { + res.cork(); + res.write('A'); + res.write('B'); + res.end('C'); + } else if (mode === 'socket') { + res.socket.cork(); + res.write('A'); + res.write('B'); + res.end('C'); + } else if (mode === 'nextTick') { + res.write('A'); + process.nextTick(() => { + res.write('B'); + process.nextTick(() => res.end('C')); + }); + } else if (mode === 'separate') { + res.write('A'); + setImmediate(() => { + res.write('B'); + setImmediate(() => res.end('C')); + }); + } else if (mode === 'chunkedEnd') { + res.write(payload); + res.end(); + } else { + res.end(payload); + } + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const socket = net.createConnection({ + host: common.localhostIPv4, + port: server.address().port, + }); + let response = ''; + + socket.setEncoding('latin1'); + socket.on('error', reject); + socket.on('data', (chunk) => response += chunk); + socket.on('end', common.mustCall(() => { + const body = response.slice(response.indexOf('\r\n\r\n') + 4); + assert.strictEqual(body, expectedBody); + assert.deepStrictEqual(writevParts, expectedWritevParts); + server.close(common.mustCall(resolve)); + })); + socket.on('connect', common.mustCall(() => { + socket.write( + 'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n', + ); + })); + })); + }); +} + +function runDetachedUint8Array() { + return new Promise((resolve, reject) => { + const server = http.createServer(common.mustCall((req, res) => { + const chunk = new Uint8Array([0x41, 0x42, 0x43]); + res.write(chunk, common.mustSucceed(() => { + req.socket.destroy(); + })); + structuredClone(chunk.buffer, { transfer: [chunk.buffer] }); + // The buffered view must already be normalized before detachment. + res.end(); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.get({ + host: common.localhostIPv4, + port: server.address().port, + }); + request.on('error', () => {}); + request.on('close', common.mustCall(() => { + server.close(common.mustCall(resolve)); + })); + })); + }); +} + +function runContentLengthCallbacks() { + return new Promise((resolve, reject) => { + const callbacks = []; + const server = http.createServer(common.mustCall((req, res) => { + res.setHeader('Content-Length', 3); + res.write('A', common.mustCall(() => callbacks.push('A'))); + res.write('B', common.mustCall(() => callbacks.push('B'))); + res.write('', common.mustCall(() => callbacks.push('empty'))); + res.end('C', common.mustCall(() => { + callbacks.push('end'); + assert.deepStrictEqual(callbacks, ['A', 'B', 'empty', 'end']); + })); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.get({ + host: common.localhostIPv4, + port: server.address().port, + }, common.mustCall((response) => { + response.resume(); + response.on('end', common.mustCall(() => { + server.close(common.mustCall(resolve)); + })); + })); + request.on('error', reject); + })); + }); +} + +function runContentLengthBody(chunks, expectedBody, expectedWritevParts) { + return new Promise((resolve, reject) => { + const writevParts = []; + const server = http.createServer(common.mustCall((req, res) => { + const originalWritev = res.socket[kRawWritev]; + res.socket[kRawWritev] = function(vector, callback) { + writevParts.push(vector.length >> 1); + return originalWritev.call(this, vector, callback); + }; + + res.setHeader('Content-Length', expectedBody.length); + for (let n = 0; n < chunks.length - 1; n++) { + res.write(chunks[n][0], chunks[n][1]); + } + const last = chunks[chunks.length - 1]; + res.end(last[0], last[1]); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.get({ + host: common.localhostIPv4, + port: server.address().port, + }, common.mustCall((response) => { + const body = []; + response.on('data', (chunk) => body.push(chunk)); + response.on('end', common.mustCall(() => { + assert.deepStrictEqual(Buffer.concat(body), expectedBody); + assert.deepStrictEqual(writevParts, expectedWritevParts); + server.close(common.mustCall(resolve)); + })); + })); + request.on('error', reject); + })); + }); +} + +function runClientBeforeConnect(contentLength = false) { + return new Promise((resolve, reject) => { + const server = http.createServer(common.mustCall((request, response) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => body += chunk); + request.on('end', common.mustCall(() => { + assert.strictEqual(body, 'ABC'); + response.end(); + })); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.request({ + host: common.localhostIPv4, + port: server.address().port, + method: 'POST', + headers: contentLength ? { 'Content-Length': 3 } : undefined, + }, common.mustCall((response) => { + response.resume(); + response.on('end', common.mustCall(() => { + server.close(common.mustCall(resolve)); + })); + })); + request.on('error', reject); + request.on('socket', common.mustCall((socket) => { + request.write('A'); + request.write('B'); + process.nextTick(common.mustCall(() => { + assert.strictEqual(typeof socket.bytesWritten, 'number'); + request.end('C'); + })); + })); + })); + }); +} + +function runClientContentLengthBody() { + return new Promise((resolve, reject) => { + const expected = Buffer.from([0xff, 0x00, 0x80]); + const writevParts = []; + const server = http.createServer(common.mustCall((request, response) => { + const body = []; + request.on('data', (chunk) => body.push(chunk)); + request.on('end', common.mustCall(() => { + assert.deepStrictEqual(Buffer.concat(body), expected); + response.end(); + })); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.request({ + host: common.localhostIPv4, + port: server.address().port, + method: 'POST', + headers: { 'Content-Length': expected.length }, + }, common.mustCall((response) => { + response.resume(); + response.on('end', common.mustCall(() => { + assert.deepStrictEqual(writevParts, [3]); + server.close(common.mustCall(resolve)); + })); + })); + request.on('error', reject); + request.on('socket', common.mustCall((socket) => { + const originalWritev = socket[kRawWritev]; + socket[kRawWritev] = function(vector, callback) { + writevParts.push(vector.length >> 1); + return originalWritev.call(this, vector, callback); + }; + request.write(Buffer.from([0xff, 0x00])); + request.end(new Uint8Array([0x80])); + })); + })); + }); +} + +function runInvalidEncoding(explicit, chunk, encoding, prefix = null) { + return new Promise((resolve, reject) => { + const server = http.createServer(common.mustCall((req, res) => { + if (explicit) { + res.cork(); + } + if (prefix !== null) { + res.write(prefix); + } + assert.throws( + () => res.write(chunk, encoding), + { code: 'ERR_UNKNOWN_ENCODING' }, + ); + res.end(); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.get({ + host: common.localhostIPv4, + port: server.address().port, + }, common.mustCall((response) => { + response.resume(); + response.on('end', common.mustCall(() => { + server.close(common.mustCall(resolve)); + })); + })); + request.on('error', reject); + })); + }); +} + +async function main() { + await runRawResponse('auto', '3\r\nABC\r\n0\r\n\r\n', [3]); + await runRawResponse('explicit', '3\r\nABC\r\n0\r\n\r\n', [3]); + await runRawResponse( + 'socket', + '1\r\nA\r\n1\r\nB\r\n1\r\nC\r\n0\r\n\r\n', + [3], + ); + await runRawResponse( + 'nextTick', + '1\r\nA\r\n1\r\nB\r\n1\r\nC\r\n0\r\n\r\n', + [1, 1, 1], + ); + await runRawResponse( + 'separate', + '1\r\nA\r\n1\r\nB\r\n1\r\nC\r\n0\r\n\r\n', + [1, 1, 1], + ); + await runRawResponse('auto', 'ABC', [3], true); + await runRawResponse('explicit', 'ABC', [3], true); + await runRawResponse('separate', 'ABC', [1, 1, 1], true); + await runRawResponse('end', 'ABC', [1], true); + for (const [payload, expectedWritevParts] of [ + ['A'.repeat(1024), [1]], + ['A'.repeat(1025), [3]], + ['\u00e9'.repeat(512), [1]], + ['\u00e9'.repeat(513), [3]], + ]) { + const length = Buffer.byteLength(payload); + const wirePayload = Buffer.from(payload).toString('latin1'); + await runRawResponse( + 'chunkedEnd', + `${length.toString(16)}\r\n${wirePayload}\r\n0\r\n\r\n`, + expectedWritevParts, + false, + payload, + ); + } + await runDetachedUint8Array(); + await runContentLengthCallbacks(); + await runContentLengthBody( + [ + [Buffer.from([0xff, 0x00]), null], + [new Uint8Array([0x80]), null], + ], + Buffer.from([0xff, 0x00, 0x80]), + [3], + ); + await runContentLengthBody( + [['A', 'utf16le'], ['B', 'utf16le']], + Buffer.from([0x41, 0x00, 0x42, 0x00]), + [3], + ); + await runClientBeforeConnect(); + await runClientBeforeConnect(true); + await runClientContentLengthBody(); + await runInvalidEncoding(false, 'A', 'invalid'); + await runInvalidEncoding(true, 'A', 'buffer'); + await runInvalidEncoding(false, Buffer.from('A'), 'invalid'); + await runInvalidEncoding(false, '', 'invalid', 'A'); +} + +main().then(common.mustCall()); diff --git a/test/parallel/test-http-outgoing-buffered-destroy.js b/test/parallel/test-http-outgoing-buffered-destroy.js new file mode 100644 index 000000000000..7090f03c5e06 --- /dev/null +++ b/test/parallel/test-http-outgoing-buffered-destroy.js @@ -0,0 +1,181 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +function runDestroy(explicit, contentLength = false) { + return new Promise((resolve, reject) => { + const expected = new Error('intentional'); + let callbacks = 0; + const timer = setTimeout( + common.mustNotCall('timed out waiting for buffered write callbacks'), + common.platformTimeout(1000), + ); + const server = http.createServer(common.mustCall((req, res) => { + if (explicit) { + res.cork(); + } + if (contentLength) { + res.setHeader('Content-Length', 2); + } + + function onWrite(error) { + assert.strictEqual(error, expected); + if (++callbacks === 2) { + clearTimeout(timer); + server.close(common.mustCall(resolve)); + } + } + + res.write('A', common.mustCall(onWrite)); + res.write('B', common.mustCall(onWrite)); + res.destroy(expected); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.get({ + host: common.localhostIPv4, + port: server.address().port, + agent: false, + }); + request.on('error', () => {}); + })); + }); +} + +function runClientDestroy(waitForSocket) { + return new Promise((resolve, reject) => { + const expected = new Error('intentional client destroy'); + const server = http.createServer((request) => request.destroy()); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.request({ + host: common.localhostIPv4, + port: server.address().port, + method: 'POST', + agent: false, + }); + request.on('error', () => {}); + + function destroy() { + if (!waitForSocket) { + assert.strictEqual(request.socket, null); + request.cork(); + request.write('A'); + } + request.write('B', common.mustCall((error) => { + assert.strictEqual(error, expected); + server.close(common.mustCall(resolve)); + })); + request.destroy(expected); + } + + if (waitForSocket) { + request.on('socket', common.mustCall(destroy)); + } else { + destroy(); + } + })); + }); +} + +function runServerSocketDestroy(explicit, expected) { + return new Promise((resolve, reject) => { + const server = http.createServer(common.mustCall((req, res) => { + let callbackCalled = false; + if (explicit) { + res.cork(); + } + if (expected) { + res.socket.prependOnceListener('error', common.mustCall((error) => { + assert.strictEqual(error, expected); + assert.strictEqual(callbackCalled, true); + })); + } + res.write('A', common.mustCall((error) => { + callbackCalled = true; + if (!expected) { + assert.strictEqual(error?.code, 'ERR_STREAM_DESTROYED'); + } else { + assert.strictEqual(error, expected); + } + server.close(common.mustCall(resolve)); + })); + res.socket.destroy(expected); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.get({ + host: common.localhostIPv4, + port: server.address().port, + agent: false, + }); + request.on('error', () => {}); + })); + }); +} + +function runClientSocketDestroy(explicit, expected) { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => socket.resume()); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.request({ + host: common.localhostIPv4, + port: server.address().port, + method: 'POST', + agent: false, + }); + request.on('error', () => {}); + request.on('socket', common.mustCall((socket) => { + socket.once('connect', common.mustCall(() => { + let callbackCalled = false; + if (explicit) { + request.cork(); + } + if (expected) { + socket.prependOnceListener('error', common.mustCall((error) => { + assert.strictEqual(error, expected); + assert.strictEqual(callbackCalled, true); + })); + } + request.write('A', common.mustCall((error) => { + callbackCalled = true; + if (!expected) { + assert.strictEqual(error?.code, 'ERR_STREAM_DESTROYED'); + } else { + assert.strictEqual(error, expected); + } + server.close(common.mustCall(resolve)); + })); + socket.destroy(expected); + })); + })); + })); + }); +} + +async function main() { + await runDestroy(false); + await runDestroy(true); + await runDestroy(false, true); + await runDestroy(true, true); + await runClientDestroy(false); + await runClientDestroy(true); + await runServerSocketDestroy(false); + await runServerSocketDestroy(true); + await runServerSocketDestroy(false, new Error('server socket destroy')); + await runServerSocketDestroy(true, new Error('server socket destroy')); + await runClientSocketDestroy(false); + await runClientSocketDestroy(true); + await runClientSocketDestroy(false, new Error('client socket destroy')); + await runClientSocketDestroy(true, new Error('client socket destroy')); +} + +main().then(common.mustCall()); diff --git a/test/parallel/test-http-outgoing-buffered-drain.js b/test/parallel/test-http-outgoing-buffered-drain.js new file mode 100644 index 000000000000..959d74956e8b --- /dev/null +++ b/test/parallel/test-http-outgoing-buffered-drain.js @@ -0,0 +1,278 @@ +// Flags: --expose-internals + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); +const { kRawWritev } = require('internal/streams/utils'); + +function runBackpressure(contentLength = false) { + return new Promise((resolve, reject) => { + const server = http.createServer(common.mustCall((req, res) => { + if (contentLength) { + res.setHeader('Content-Length', 1); + } + assert.strictEqual(res.write('A'), false); + assert.strictEqual(res.writableNeedDrain, true); + res.once('drain', common.mustCall(() => { + assert.strictEqual(res.writableNeedDrain, false); + res.end(); + })); + })); + + server.on('connection', common.mustCall((socket) => { + socket._writableState.highWaterMark = 100; + })); + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.get({ + host: common.localhostIPv4, + port: server.address().port, + }, common.mustCall((response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => body += chunk); + response.on('end', common.mustCall(() => { + assert.strictEqual(body, 'A'); + server.close(common.mustCall(resolve)); + })); + })); + request.on('error', reject); + })); + }); +} + +function runClientBufferedDrain() { + return new Promise((resolve, reject) => { + let accepted = false; + const server = net.createServer(common.mustCall((socket) => { + accepted = true; + socket.on('error', reject); + socket.resume(); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const request = http.request({ + host: common.localhostIPv4, + port: server.address().port, + method: 'POST', + headers: { 'Content-Length': 200 }, + }); + request.on('error', () => {}); + request.on('socket', common.mustCall((socket) => { + socket._writableState.highWaterMark = 64; + const originalWritev = socket[kRawWritev]; + let completeFirstWrite; + + socket[kRawWritev] = common.mustCall((chunks, callback) => { + completeFirstWrite = callback; + }); + socket.once('connect', common.mustCall(() => { + const start = common.mustCall(() => { + assert.strictEqual(request.write('A'.repeat(100)), false); + setImmediate(common.mustCall(() => { + assert.strictEqual(typeof completeFirstWrite, 'function'); + request.cork(); + assert.strictEqual(request.write('B'.repeat(100)), false); + + let drained = false; + request.once('drain', common.mustCall(() => { + drained = true; + assert.strictEqual(request.writableLength, 0); + socket.destroy(); + server.close(common.mustCall(resolve)); + })); + + completeFirstWrite(); + assert.strictEqual(drained, false); + assert.strictEqual(request.writableLength, 100); + + socket[kRawWritev] = originalWritev; + request.uncork(); + })); + }); + if (accepted) start(); + else server.once('connection', start); + })); + })); + })); + }); +} + +function runAsyncEncodedDrain() { + return new Promise((resolve, reject) => { + const server = http.createServer(common.mustCall((req, res) => { + const body = '\ud83d\ude00'.repeat(32); + res._implicitHeader(); + const length = Buffer.byteLength(body, 'utf8'); + const pending = res._header.length + length + + length.toString(16).length + 4; + res.socket._writableState.highWaterMark = pending; + + const originalWritev = res.socket[kRawWritev]; + let completeWrite; + res.socket[kRawWritev] = common.mustCall((chunks, callback) => { + completeWrite = callback; + }); + + assert.strictEqual(res.write(body, 'utf8'), false); + assert.strictEqual(res.writableLength, pending); + let drained = false; + res.once('drain', common.mustCall(() => { + drained = true; + assert.strictEqual(res.writableLength, 0); + })); + + setImmediate(common.mustCall(() => { + assert.strictEqual(typeof completeWrite, 'function'); + completeWrite(); + setImmediate(common.mustCall(() => { + assert.strictEqual(drained, true); + res.socket[kRawWritev] = originalWritev; + res.socket.destroy(); + server.close(common.mustCall(resolve)); + })); + })); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const socket = net.createConnection({ + host: common.localhostIPv4, + port: server.address().port, + }); + socket.on('error', () => {}); + socket.on('connect', common.mustCall(() => { + socket.write('GET / HTTP/1.1\r\nHost: localhost\r\n\r\n'); + })); + socket.resume(); + })); + }); +} + +function runPipelinedBackpressure() { + return new Promise((resolve, reject) => { + let requests = 0; + const server = http.createServer(common.mustCall((req, res) => { + requests++; + if (requests === 1) { + assert.strictEqual(res.write('A'.repeat(1000)), false); + return; + } + + // The active response has exceeded its high-water mark in the + // message-level auto-cork buffer. Parsing must pause before the next + // network read, and queuing an inactive response must not resume it. + assert.strictEqual(req.socket._paused, true); + res.end('B'); + assert.strictEqual(req.socket._paused, true); + req.socket.destroy(); + }, 2)); + + server.on('connection', common.mustCall((socket) => { + socket._writableState.highWaterMark = 100; + })); + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const socket = net.createConnection({ + host: common.localhostIPv4, + port: server.address().port, + }); + socket.on('error', reject); + socket.on('close', common.mustCall(() => { + server.close(common.mustCall(resolve)); + })); + socket.on('connect', common.mustCall(() => { + socket.write( + 'GET /first HTTP/1.1\r\nHost: localhost\r\n\r\n' + + 'GET /second HTTP/1.1\r\nHost: localhost\r\n\r\n', + ); + })); + })); + }); +} + +function runQueuedBackpressure(flushHeaders = false) { + return new Promise((resolve, reject) => { + let requests = 0; + let socket; + const server = http.createServer(common.mustCall((req, res) => { + requests++; + if (requests === 1) { + return; + } + if (requests === 2) { + res.cork(); + if (flushHeaders) { + let trackedPendingData = 0; + const updatePendingData = res._onPendingData; + res._onPendingData = (delta) => { + trackedPendingData += delta; + updatePendingData(delta); + }; + res.write('A'); + const pending = res.writableLength; + req.socket._writableState.highWaterMark = pending + 1; + res.flushHeaders(); + assert.strictEqual(res.writableLength, pending); + assert.strictEqual(trackedPendingData, pending); + } else { + assert.strictEqual(res.write('A'.repeat(1000)), false); + } + setImmediate(() => { + socket.write('GET /third HTTP/1.1\r\nHost: localhost\r\n\r\n'); + }); + return; + } + + if (flushHeaders) { + // flushHeaders() transfers the header from the message-level buffer + // to outputData. It must not count the same bytes in both owners. + assert.strictEqual(req.socket._paused, false); + } else { + // The explicitly corked second response is not assigned to the socket, + // but its message-level buffer still belongs to this connection. + assert.strictEqual(req.socket._paused, true); + } + req.socket.destroy(); + }, 3)); + + server.on('connection', common.mustCall((connection) => { + if (!flushHeaders) { + connection._writableState.highWaterMark = 100; + } + })); + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + socket = net.createConnection({ + host: common.localhostIPv4, + port: server.address().port, + }); + socket.on('error', reject); + socket.on('close', common.mustCall(() => { + server.close(common.mustCall(resolve)); + })); + socket.on('connect', common.mustCall(() => { + socket.write( + 'GET /first HTTP/1.1\r\nHost: localhost\r\n\r\n' + + 'GET /second HTTP/1.1\r\nHost: localhost\r\n\r\n', + ); + })); + })); + }); +} + +async function main() { + await runBackpressure(); + await runBackpressure(true); + await runClientBufferedDrain(); + await runAsyncEncodedDrain(); + await runPipelinedBackpressure(); + await runQueuedBackpressure(); + await runQueuedBackpressure(true); +} + +main().then(common.mustCall()); diff --git a/test/parallel/test-http-outgoing-corked-end.js b/test/parallel/test-http-outgoing-corked-end.js new file mode 100644 index 000000000000..b800c815b8f8 --- /dev/null +++ b/test/parallel/test-http-outgoing-corked-end.js @@ -0,0 +1,236 @@ +/* eslint-disable node-core/crypto-check */ +// Flags: --expose-internals + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const asyncHooks = require('async_hooks'); +const http = require('http'); +const net = require('net'); +const { kInternalWritev } = require('internal/streams/utils'); + +const writeWraps = []; +const tlsHandles = new Set(); +const hook = asyncHooks.createHook({ + init(asyncId, type, triggerAsyncId, resource) { + if (type === 'WRITEWRAP') { + writeWraps.push(resource); + } + }, +}); +hook.enable(); + +function runRoundTrip(transport, serverOptions, requestOptions = {}) { + return new Promise((resolve, reject) => { + const server = serverOptions === undefined ? + transport.createServer(onRequest) : + transport.createServer(serverOptions, onRequest); + + function onRequest(req, res) { + if (res.socket.encrypted) { + tlsHandles.add(res.socket._handle); + } + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => body += chunk); + req.on('end', common.mustCall(() => { + assert.strictEqual(body, 'ABCD'); + + const callbacks = []; + res.setHeader('Trailer', 'x-test'); + res.flushHeaders(); + res.uncork(); + assert.strictEqual(res.writableCorked, 0); + + // Exercise an explicit flush while the socket remains corked. + res.cork(); + res.cork(); + res.write('E', common.mustCall(() => callbacks.push('E'))); + res.write('F', common.mustCall(() => callbacks.push('F'))); + + const originalWritev = res.socket[kInternalWritev]; + res.socket[kInternalWritev] = common.mustCall(function(...args) { + assert.notStrictEqual(this.writableCorked, 0); + return originalWritev.apply(this, args); + }); + try { + res.uncork(); + res.uncork(); + } finally { + res.socket[kInternalWritev] = originalWritev; + } + + // end() must flush this buffer before the terminating chunk. + res.cork(); + res.cork(); + res.write('G', common.mustCall(() => callbacks.push('G'))); + res.addTrailers({ 'x-test': 'é' }); + res.once('finish', common.mustCall(() => callbacks.push('finish'))); + res.end('H', common.mustCall(() => { + callbacks.push('end'); + assert.deepStrictEqual(callbacks, ['E', 'F', 'G', 'finish', 'end']); + })); + assert.strictEqual(res.writableCorked, 0); + })); + } + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const callbacks = []; + const req = transport.request({ + host: common.localhostIPv4, + port: server.address().port, + method: 'POST', + ...requestOptions, + }, common.mustCall((res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => body += chunk); + res.on('end', common.mustCall(() => { + assert.strictEqual(body, 'EFGH'); + assert.strictEqual(res.trailers['x-test'], 'é'); + server.close(common.mustCall(resolve)); + })); + })); + + req.on('error', reject); + req.uncork(); + assert.strictEqual(req.writableCorked, 0); + req.cork(); + req.cork(); + req.write('A', common.mustCall(() => callbacks.push('A'))); + req.write('B', common.mustCall(() => callbacks.push('B'))); + req.uncork(); + req.uncork(); + req.cork(); + req.cork(); + req.write('C', common.mustCall(() => callbacks.push('C'))); + req.once('finish', common.mustCall(() => callbacks.push('finish'))); + req.end('D', common.mustCall(() => { + callbacks.push('end'); + assert.deepStrictEqual(callbacks, ['A', 'B', 'C', 'finish', 'end']); + })); + assert.strictEqual(req.writableCorked, 0); + })); + }); +} + +function runPipelined() { + return new Promise((resolve, reject) => { + let firstResponse; + const server = http.createServer(common.mustCall((req, res) => { + if (req.url === '/first') { + firstResponse = res; + return; + } + + assert.strictEqual(req.url, '/second'); + assert.strictEqual(res.socket, null); + res.cork(); + res.cork(); + res.write('B'); + res.write('C'); + res.end(); + assert.strictEqual(res.writableCorked, 0); + firstResponse.end('A'); + }, 2)); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const socket = net.createConnection({ + host: common.localhostIPv4, + port: server.address().port, + }); + let response = ''; + + socket.setEncoding('latin1'); + socket.on('error', reject); + socket.on('data', (chunk) => response += chunk); + socket.on('end', common.mustCall(() => { + assert.match(response, /\r\n\r\nAHTTP\/1\.1 200 OK\r\n/); + assert.match(response, /\r\n\r\n2\r\nBC\r\n0\r\n\r\n$/); + server.close(common.mustCall(resolve)); + })); + socket.on('connect', common.mustCall(() => { + socket.end( + 'GET /first HTTP/1.1\r\nHost: localhost\r\n\r\n' + + 'GET /second HTTP/1.1\r\nHost: localhost\r\n' + + 'Connection: close\r\n\r\n', + ); + })); + })); + }); +} + +function runDrainOnEnd() { + return new Promise((resolve, reject) => { + const server = http.createServer(common.mustCall((req, res) => { + res.cork(); + assert.strictEqual(res.write('1'.repeat(10)), true); + assert.strictEqual(res.write('2'.repeat(1000)), false); + assert.strictEqual(res.writableNeedDrain, true); + + res.once('drain', common.mustCall(() => { + assert.strictEqual(res.finished, true); + assert.strictEqual(res.writableNeedDrain, false); + assert.strictEqual(res.writableLength, 0); + })); + res.end(); + })); + + server.on('connection', common.mustCall((socket) => { + socket._writableState.highWaterMark = 1000; + })); + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const req = http.get({ + host: common.localhostIPv4, + port: server.address().port, + }, common.mustCall((res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => body += chunk); + res.on('end', common.mustCall(() => { + assert.strictEqual(body, '1'.repeat(10) + '2'.repeat(1000)); + server.close(common.mustCall(resolve)); + })); + })); + req.on('error', reject); + })); + }); +} + +async function main() { + await runRoundTrip(http); + + if (common.hasCrypto) { + const fixtures = require('../common/fixtures'); + const https = require('https'); + await runRoundTrip(https, { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + }, { rejectUnauthorized: false }); + } + + await runPipelined(); + await runDrainOnEnd(); +} + +function verifyWriteWrapOwnership() { + hook.disable(); + let tlsWriteWraps = 0; + for (const writeWrap of writeWraps) { + if (tlsHandles.has(writeWrap.handle)) { + tlsWriteWraps++; + assert.strictEqual(Object.hasOwn(writeWrap, '_chunks'), false); + } + } + if (tlsHandles.size !== 0) { + assert.notStrictEqual(tlsWriteWraps, 0); + } +} + +main() + .then(verifyWriteWrapOwnership) + .then(common.mustCall()); diff --git a/test/parallel/test-http-outgoing-flush-output.js b/test/parallel/test-http-outgoing-flush-output.js new file mode 100644 index 000000000000..6153591d9296 --- /dev/null +++ b/test/parallel/test-http-outgoing-flush-output.js @@ -0,0 +1,64 @@ +// Flags: --expose-internals + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); +const { kInternalWritev } = require('internal/streams/utils'); + +function runPipelinedOutputVector() { + return new Promise((resolve, reject) => { + let firstResponse; + let requests = 0; + const callbacks = []; + const vectorLengths = []; + const server = http.createServer(common.mustCall((req, res) => { + requests++; + if (requests === 1) { + firstResponse = res; + return; + } + + assert.strictEqual(res.socket, null); + const socket = req.socket; + const originalWritev = socket[kInternalWritev]; + socket[kInternalWritev] = common.mustCall(function(vector, callback) { + vectorLengths.push(vector.length >> 1); + return originalWritev.call(this, vector, callback); + }, 2); + + res.write('A', common.mustCall(() => callbacks.push('A'))); + res.write(Buffer.from('B'), common.mustCall(() => callbacks.push('B'))); + res.end('C', common.mustCall(() => callbacks.push('end'))); + assert(res.outputData.length > 1); + firstResponse.end('first'); + }, 2)); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const socket = net.createConnection({ + host: common.localhostIPv4, + port: server.address().port, + }); + socket.on('error', reject); + socket.on('data', () => {}); + socket.on('end', common.mustCall(() => { + assert.deepStrictEqual(callbacks, ['A', 'B', 'end']); + assert.strictEqual(vectorLengths.length, 2); + assert(vectorLengths[1] > 1); + server.close(common.mustCall(resolve)); + })); + socket.on('connect', common.mustCall(() => { + socket.write( + 'GET /first HTTP/1.1\r\nHost: localhost\r\n\r\n' + + 'GET /second HTTP/1.1\r\nHost: localhost\r\n' + + 'Connection: close\r\n\r\n', + ); + })); + })); + }); +} + +runPipelinedOutputVector().then(common.mustCall()); diff --git a/test/parallel/test-http-outgoing-message-inheritance.js b/test/parallel/test-http-outgoing-message-inheritance.js index d0da4c68c311..6dabecc78685 100644 --- a/test/parallel/test-http-outgoing-message-inheritance.js +++ b/test/parallel/test-http-outgoing-message-inheritance.js @@ -15,18 +15,11 @@ class Response extends OutgoingMessage { const res = new Response(); -let firstChunk = true; - const ws = new Writable({ write: common.mustCall((chunk, encoding, callback) => { - if (firstChunk) { - assert(chunk.toString().endsWith('hello world')); - firstChunk = false; - } else { - assert.strictEqual(chunk.length, 0); - } + assert(chunk.toString().endsWith('hello world')); setImmediate(callback); - }, 2) + }) }); res.socket = ws; diff --git a/test/parallel/test-http-parser-max-header-pairs-cache.js b/test/parallel/test-http-parser-max-header-pairs-cache.js new file mode 100644 index 000000000000..a8d88b798c89 --- /dev/null +++ b/test/parallel/test-http-parser-max-header-pairs-cache.js @@ -0,0 +1,79 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { HTTPParser } = require('_http_common'); + +const { REQUEST } = HTTPParser; +const kOnHeaders = HTTPParser.kOnHeaders | 0; +const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0; +const kOnBody = HTTPParser.kOnBody | 0; +const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; + +function createParser() { + const parser = new HTTPParser(); + parser.initialize(REQUEST, {}); + parser[kOnHeaders] = () => {}; + parser[kOnHeadersComplete] = () => {}; + parser[kOnBody] = common.mustNotCall(); + parser[kOnMessageComplete] = () => {}; + return parser; +} + +// maxHeaderPairs is cached once for each independent header section. Main +// headers, trailers, the next message, and a reinitialized parser must each +// observe a fresh value. +{ + const parser = createParser(); + const limits = [2, 4, 2, 2]; + + Object.defineProperty(parser, 'maxHeaderPairs', { + configurable: true, + get: common.mustCall(() => limits.shift(), limits.length), + }); + + parser[kOnHeadersComplete] = common.mustCall(undefined, 3); + parser[kOnMessageComplete] = common.mustCall(undefined, 3); + + const pipelined = Buffer.from( + 'POST /first HTTP/1.1\r\n' + + 'Transfer-Encoding: chunked\r\n' + + '\r\n' + + '0\r\n' + + 'X-A: a\r\n' + + 'X-B: b\r\n' + + '\r\n' + + 'GET /second HTTP/1.1\r\n' + + 'X-C: c\r\n' + + '\r\n' + ); + assert.strictEqual(parser.execute(pipelined, 0, pipelined.length), + pipelined.length); + + parser.initialize(REQUEST, {}); + const reused = Buffer.from('GET /reused HTTP/1.1\r\nX-D: d\r\n\r\n'); + assert.strictEqual(parser.execute(reused, 0, reused.length), reused.length); + assert.deepStrictEqual(limits, []); +} + +// Preserve the existing exception behavior for the first property lookup. +{ + const parser = createParser(); + const expected = new Error('maxHeaderPairs getter'); + Object.defineProperty(parser, 'maxHeaderPairs', { + get: common.mustCall(() => { throw expected; }), + }); + const request = Buffer.from('GET / HTTP/1.1\r\nX-A: a\r\n\r\n'); + assert.throws(() => parser.execute(request, 0, request.length), expected); +} + +// Non-positive and non-number values continue to mean unlimited. +for (const maxHeaderPairs of [undefined, null, NaN, 0, -1, new Number(2)]) { + const parser = createParser(); + parser.maxHeaderPairs = maxHeaderPairs; + const request = Buffer.from( + 'GET / HTTP/1.1\r\nX-A: a\r\nX-B: b\r\nX-C: c\r\n\r\n' + ); + assert.strictEqual(parser.execute(request, 0, request.length), + request.length); +} diff --git a/test/parallel/test-http-response-cork.js b/test/parallel/test-http-response-cork.js index a587e2dfbf59..b128a7ed4071 100644 --- a/test/parallel/test-http-response-cork.js +++ b/test/parallel/test-http-response-cork.js @@ -1,15 +1,18 @@ +// Flags: --expose-internals + 'use strict'; const common = require('../common'); const http = require('http'); const assert = require('assert'); +const { kInternalWritev } = require('internal/streams/utils'); const server = http.createServer(common.mustCallAtLeast((req, res) => { let corked = false; - const originalWrite = res.socket.write; - res.socket.write = common.mustCall((...args) => { + const originalWritev = res.socket[kInternalWritev]; + res.socket[kInternalWritev] = common.mustCall(function(...args) { assert.strictEqual(corked, false); - return originalWrite.call(res.socket, ...args); - }, 5); + return originalWritev.apply(this, args); + }); corked = true; res.cork(); assert.strictEqual(res.writableCorked, res.socket.writableCorked); diff --git a/test/parallel/test-http-server-response-standalone.js b/test/parallel/test-http-server-response-standalone.js index bc7ca56f894b..5fd087915119 100644 --- a/test/parallel/test-http-server-response-standalone.js +++ b/test/parallel/test-http-server-response-standalone.js @@ -15,18 +15,11 @@ const res = new ServerResponse({ httpVersionMinor: 1 }); -let firstChunk = true; - const ws = new Writable({ write: common.mustCall((chunk, encoding, callback) => { - if (firstChunk) { - assert(chunk.toString().endsWith('hello world')); - firstChunk = false; - } else { - assert.strictEqual(chunk.length, 0); - } + assert(chunk.toString().endsWith('hello world')); setImmediate(callback); - }, 2) + }) }); res.assignSocket(ws); diff --git a/test/parallel/test-net-internal-writev-coalesce.js b/test/parallel/test-net-internal-writev-coalesce.js new file mode 100644 index 000000000000..a69bbec2edb8 --- /dev/null +++ b/test/parallel/test-net-internal-writev-coalesce.js @@ -0,0 +1,110 @@ +// Flags: --expose-gc --expose-internals --no-warnings + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const asyncHooks = require('async_hooks'); +const net = require('net'); +const { internalBinding } = require('internal/test/binding'); +const { kInternalWritev } = require('internal/streams/utils'); +const { + WriteWrap, + kLastWriteWasAsync, + streamBaseState, +} = internalBinding('stream_wrap'); + +const direct = Buffer.alloc(32 * 1024 * 1024, 0x78); +let vector = []; +let expected = ''; +for (let i = 0; i < 32; i++) { + const value = String.fromCharCode(0x41 + (i % 26)).repeat(2048); + vector.push(value, 'latin1'); + expected += value; +} +expected = Buffer.from(expected); + +let accepted; +let client; +let started = false; +const writeWraps = []; + +const hook = asyncHooks.createHook({ + init(asyncId, type, triggerAsyncId, resource) { + if (type === 'WRITEWRAP') { + writeWraps.push(resource); + } + }, +}); +hook.enable(); + +let received = 0; +let tail = Buffer.alloc(0); +const timer = setTimeout( + common.mustNotCall('timed out waiting for the coalesced write'), + common.platformTimeout(10_000), +); + +const server = net.createServer(common.mustCall((socket) => { + accepted = socket; + socket.on('data', (chunk) => { + received += chunk.length; + if (chunk.length >= expected.length) { + tail = chunk.subarray(-expected.length); + } else { + tail = Buffer.concat([tail, chunk]).subarray(-expected.length); + } + }); + socket.on('end', common.mustCall(() => { + assert.strictEqual(received, direct.length + expected.length); + assert.deepStrictEqual(tail, expected); + clearTimeout(timer); + hook.disable(); + server.close(common.mustCall()); + })); + socket.pause(); + startWrites(); +})); + +function startWrites() { + if (started || accepted === undefined || client === undefined || + client.connecting) { + return; + } + started = true; + + // Keep a native write queued without marking Writable as busy. The + // following pure-string vector must then survive solely through the + // coalesced BackingStore owned by its native WriteWrap. + const req = new WriteWrap(); + req.handle = client._handle; + req.oncomplete = common.mustCall( + (status) => assert.strictEqual(status, 0), + ); + req.async = false; + req.bytes = 0; + req.buffer = direct; + assert.strictEqual(client._handle.writeBuffer(req, direct), 0); + req.async = !!streamBaseState[kLastWriteWasAsync]; + assert.strictEqual(req.async, true); + + client[kInternalWritev](vector, common.mustCall(() => client.end())); + const coalescedWrap = writeWraps.at(-1); + assert.notStrictEqual(coalescedWrap, req); + assert.notStrictEqual(coalescedWrap, undefined); + assert.strictEqual(coalescedWrap.async, true); + assert.strictEqual(coalescedWrap.bytes, expected.length); + assert.strictEqual(coalescedWrap.buffer, null); + + vector = null; + global.gc(); + setImmediate(() => accepted.resume()); +} + +server.listen(0, common.localhostIPv4, common.mustCall(() => { + client = net.createConnection({ + host: common.localhostIPv4, + port: server.address().port, + }, common.mustCall(startWrites)); + client.on('error', common.mustNotCall()); +})); diff --git a/test/parallel/test-net-internal-writev-encoding.js b/test/parallel/test-net-internal-writev-encoding.js new file mode 100644 index 000000000000..bf7510ea8fec --- /dev/null +++ b/test/parallel/test-net-internal-writev-encoding.js @@ -0,0 +1,86 @@ +// Flags: --expose-internals + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); +const { + kInternalWritev, + kRawWritev, +} = require('internal/streams/utils'); + +function runMixedSocketWrites() { + return new Promise((resolve, reject) => { + const vector = []; + const vectorBody = []; + const encodedStrings = [ + ['\u0100', 'utf8'], + ['\u0100', 'utf-8'], + ['\u0100', 'utf16le'], + ['\u0100', 'utf-16le'], + ['\u0100', 'ucs2'], + ['\u0100', 'ucs-2'], + ['\u0100', 'ascii'], + ['\u0100', 'latin1'], + ['\u0100', 'binary'], + ['QQ==', 'base64'], + ['QQ', 'base64url'], + ['41', 'hex'], + ]; + for (let i = 0; i < 32; i++) { + const [chunk, encoding] = encodedStrings[i % encodedStrings.length]; + vector.push(chunk, encoding); + vectorBody.push(Buffer.from(chunk, encoding)); + } + const expected = Buffer.concat([ + Buffer.from('A'), + ...vectorBody, + Buffer.from('DE'), + ]); + + const server = net.createServer(common.mustCall((socket) => { + const body = []; + socket.on('data', (chunk) => body.push(chunk)); + socket.on('end', common.mustCall(() => { + assert.deepStrictEqual(Buffer.concat(body), expected); + server.close(common.mustCall(resolve)); + })); + })); + + server.on('error', reject); + server.listen(0, common.localhostIPv4, common.mustCall(() => { + const socket = net.createConnection({ + host: common.localhostIPv4, + port: server.address().port, + }); + const callbacks = []; + socket.on('error', reject); + socket.on('connect', common.mustCall(() => { + const originalWritev = socket[kRawWritev]; + socket[kRawWritev] = common.mustCall(function(chunks, callback) { + assert.strictEqual(chunks.length, 68); + for (let i = 1; i <= 32; i++) { + assert.strictEqual(typeof chunks[i * 2 + 1], 'number'); + } + return originalWritev.call(this, chunks, callback); + }); + + socket.cork(); + socket.write('A', common.mustCall(() => callbacks.push('A'))); + socket[kInternalWritev](vector, + common.mustCall(() => callbacks.push('vector'))); + socket.write(new Uint8Array([0x44]), + common.mustCall(() => callbacks.push('D'))); + assert.strictEqual(socket.bytesWritten, expected.length - 1); + socket.uncork(); + socket.end('E', common.mustCall(() => { + callbacks.push('E'); + assert.deepStrictEqual(callbacks, ['A', 'vector', 'D', 'E']); + })); + })); + })); + }); +} + +runMixedSocketWrites().then(common.mustCall()); diff --git a/test/parallel/test-net-internal-writev-partial.js b/test/parallel/test-net-internal-writev-partial.js new file mode 100644 index 000000000000..01687e347683 --- /dev/null +++ b/test/parallel/test-net-internal-writev-partial.js @@ -0,0 +1,123 @@ +// Flags: --expose-gc --expose-internals + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const asyncHooks = require('async_hooks'); +const net = require('net'); +const { kInternalWritev } = require('internal/streams/utils'); + +const prefix = 'BEGIN'; +const suffix = 'END'; +const payloadLength = 32 * 1024 * 1024; +const expectedLength = prefix.length + payloadLength + suffix.length; + +let accepted; +let client; +let started = false; +let chunks; +let writeWrap; + +const onWriteWrapInit = common.mustCall((resource) => { + assert.strictEqual(writeWrap, undefined); + // The native request is observable at async-init before C++ returns it to + // JS. Its stable fields exist, but dispatch metadata is attached only after + // the binding call completes. + assert.strictEqual(resource.handle, null); + assert.strictEqual(resource.oncomplete, null); + assert.strictEqual(resource.callback, null); + assert.strictEqual(resource.async, false); + assert.strictEqual(resource.bytes, 0); + assert.strictEqual(resource.buffer, null); + assert.strictEqual(Object.hasOwn(resource, '_chunks'), false); + writeWrap = resource; +}); + +const hook = asyncHooks.createHook({ + init(asyncId, type, triggerAsyncId, resource) { + if (type !== 'WRITEWRAP') { + return; + } + onWriteWrapInit(resource); + }, +}); +hook.enable(); + +let received = 0; +let first = Buffer.alloc(0); +let last = Buffer.alloc(0); + +const timer = setTimeout( + common.mustNotCall('timed out waiting for the partial write'), + common.platformTimeout(10_000), +); + +const server = net.createServer(common.mustCall((socket) => { + accepted = socket; + socket.on('data', (chunk) => { + if (first.length < prefix.length) { + const needed = prefix.length - first.length; + first = Buffer.concat([first, chunk.subarray(0, needed)]); + } + if (chunk.length >= suffix.length) { + last = chunk.subarray(chunk.length - suffix.length); + } else { + last = Buffer.concat([last, chunk]); + if (last.length > suffix.length) { + last = last.subarray(last.length - suffix.length); + } + } + received += chunk.length; + }); + socket.on('end', common.mustCall(() => { + assert.strictEqual(received, expectedLength); + assert.strictEqual(first.toString(), prefix); + assert.strictEqual(last.toString(), suffix); + clearTimeout(timer); + hook.disable(); + server.close(common.mustCall()); + })); + socket.pause(); + startWrite(); +})); + +function startWrite() { + if (started || accepted === undefined || client === undefined || + client.connecting) { + return; + } + started = true; + + chunks = [ + prefix, 'latin1', + Buffer.alloc(payloadLength, 0x78), 'buffer', + suffix, 'latin1', + ]; + assert.strictEqual(client[kInternalWritev](chunks, common.mustCall(() => { + client.end(); + })), false); + + // The payload is larger than the kernel send buffer, so uv_try_write() + // must leave an asynchronous remainder and lazily create one WriteWrap. + assert.notStrictEqual(writeWrap, undefined); + assert.strictEqual(writeWrap.handle, client._handle); + assert.strictEqual(typeof writeWrap.oncomplete, 'function'); + assert.strictEqual(typeof writeWrap.callback, 'function'); + assert.strictEqual(writeWrap.async, true); + assert.strictEqual(writeWrap.bytes, expectedLength); + assert.strictEqual(writeWrap.buffer, chunks); + assert.strictEqual(Object.hasOwn(writeWrap, '_chunks'), false); + + chunks = null; + global.gc(); + setImmediate(() => accepted.resume()); +} + +server.listen(0, common.localhostIPv4, common.mustCall(() => { + client = net.createConnection({ + host: common.localhostIPv4, + port: server.address().port, + }, common.mustCall(startWrite)); + client.on('error', common.mustNotCall()); +})); diff --git a/test/parallel/test-stream-pipeline.js b/test/parallel/test-stream-pipeline.js index a1a02ea8ed19..66a03fdcdd0c 100644 --- a/test/parallel/test-stream-pipeline.js +++ b/test/parallel/test-stream-pipeline.js @@ -243,12 +243,12 @@ tmpdir.refresh(); pipeline(rs, res, () => {}); })); - let cnt = 10; + let received = 0; const badSink = new Writable({ write(data, enc, cb) { - cnt--; - if (cnt === 0) cb(new Error('kaboom')); + received += data.length; + if (received >= 50) cb(new Error('kaboom')); else cb(); } }); diff --git a/test/parallel/test-webstreams-pipeline.js b/test/parallel/test-webstreams-pipeline.js index a4bf579f5f11..5643524449ad 100644 --- a/test/parallel/test-webstreams-pipeline.js +++ b/test/parallel/test-webstreams-pipeline.js @@ -212,7 +212,7 @@ const http = require('http'); values.push(chunk?.toString()); }); res.on('end', common.mustCall(() => { - assert.deepStrictEqual(values, ['hello', 'world']); + assert.strictEqual(values.join(''), 'helloworld'); server.close(); })); })); From bb58fcd99042e106108e261ff2670ef442605b96 Mon Sep 17 00:00:00 2001 From: GetThatCookie Date: Mon, 3 Aug 2026 14:57:09 +0200 Subject: [PATCH 2/3] http,net,stream: reconcile write and parser paths Signed-off-by: GetThatCookie --- benchmark/common.js | 11 +- benchmark/http/cork.js | 174 ++++++++---------- benchmark/http2/compat.js | 9 +- lib/_http_outgoing.js | 121 ++++++++---- lib/internal/http2/compat.js | 36 +++- lib/internal/streams/utils.js | 2 + lib/net.js | 2 + test/parallel/test-http-outgoing-auto-cork.js | 33 +++- .../test-http-outgoing-buffered-drain.js | 15 +- .../parallel/test-http-outgoing-end-buffer.js | 72 ++++++++ .../test-http-outgoing-flush-output.js | 6 +- ...st-http-outgoing-message-write-callback.js | 62 +++++++ test/parallel/test-http-response-cork.js | 9 +- .../test-http2-compat-serverresponse-end.js | 8 + .../test-http2-options-server-response.js | 13 +- 15 files changed, 403 insertions(+), 170 deletions(-) create mode 100644 test/parallel/test-http-outgoing-end-buffer.js diff --git a/benchmark/common.js b/benchmark/common.js index 197bd5b93526..8443da40d79e 100644 --- a/benchmark/common.js +++ b/benchmark/common.js @@ -26,7 +26,7 @@ class Benchmark { // Parse job-specific configuration from the command line arguments const argv = process.argv.slice(2); - const parsed_args = this._parseArgs([...argv], configs, options); + const parsed_args = this._parseArgs(argv, configs, options); this.originalOptions = options; this.options = parsed_args.cli; @@ -38,10 +38,8 @@ class Benchmark { const groupNames = process.env.NODE_RUN_BENCHMARK_GROUPS?.split(',') ?? Object.keys(configs); for (const groupName of groupNames) { - const groupConfig = Array.isArray(configs[groupName]) ? - configs[groupName][0] : configs[groupName]; - const config = { ...groupConfig, group: groupName }; - const parsed_args = this._parseArgs([...argv], config, options); + const config = { ...configs[groupName][0], group: groupName }; + const parsed_args = this._parseArgs(argv, config, options); this.options = parsed_args.cli; this.extra_options = parsed_args.extra; @@ -223,9 +221,6 @@ class Benchmark { // function. const childEnv = { ...process.env }; childEnv.NODE_RUN_BENCHMARK_FN = ''; - if (this.originalOptions.byGroups) { - childEnv.NODE_RUN_BENCHMARK_GROUPS = config.group; - } // Create configuration arguments const childArgs = []; diff --git a/benchmark/http/cork.js b/benchmark/http/cork.js index 7e5b3d946571..605d38b9d346 100644 --- a/benchmark/http/cork.js +++ b/benchmark/http/cork.js @@ -3,117 +3,99 @@ const common = require('../common.js'); const protocols = process.versions.openssl ? ['http', 'https'] : ['http']; -const configs = { - sameTurn: [{ - type: ['bytes', 'buffer', 'uint8array'], - len: [64, 1024], - chunks: [1, 2, 4, 16], - mode: ['auto', 'explicit'], - transfer: ['chunked', 'length'], - protocol: protocols, - producer: ['sync'], - callback: [0], - c: [50], - duration: 5, - }], - streaming: [{ - type: ['bytes', 'buffer', 'uint8array'], - len: [64, 1024], - chunks: [4], - mode: ['auto'], - transfer: ['chunked'], - protocol: protocols, - producer: ['nextTick', 'microtask', 'immediate'], - callback: [0], - c: [50], - duration: 5, - }], - callbacks: [{ - type: ['bytes', 'buffer', 'uint8array'], - len: [64], - chunks: [4, 16], - mode: ['auto', 'explicit'], - transfer: ['chunked'], - protocol: protocols, - producer: ['sync'], - callback: [1], - c: [50], - duration: 5, - }], - fixedBody: [{ - type: ['bytes', 'buffer', 'uint8array'], - total: [64 * 1024], - chunks: [1, 4, 16, 128], - mode: ['auto', 'explicit'], - transfer: ['chunked'], - protocol: protocols, - producer: ['sync'], - callback: [0], - c: [50], - duration: 5, - }], - largeChunks: [{ - type: ['bytes', 'buffer', 'uint8array'], - len: [4 * 1024, 8 * 1024, 16 * 1024, 64 * 1024], - chunks: [1, 4], - mode: ['auto'], - transfer: ['chunked'], - protocol: protocols, - producer: ['sync'], - callback: [0], - c: [50], - duration: 5, - }], - concurrency: [{ - type: ['bytes'], - len: [64], - chunks: [4], - mode: ['auto'], - transfer: ['chunked'], - protocol: protocols, - producer: ['sync'], - callback: [0], - c: [1, 50, 500], - duration: 5, - }], +const scenarios = { + 'end-64': { + len: 64, + chunks: 1, + endChunk: true, + }, + 'end-1024': { + len: 1024, + chunks: 1, + endChunk: true, + }, + 'end-1025': { + len: 1025, + chunks: 1, + endChunk: true, + }, + 'auto-4': { + len: 64, + chunks: 4, + }, + 'auto-16': { + len: 64, + chunks: 16, + }, + 'explicit-16': { + len: 64, + chunks: 16, + explicit: true, + }, + 'content-length-16': { + len: 64, + chunks: 16, + contentLength: true, + }, + 'next-tick-4': { + len: 64, + chunks: 4, + schedule: process.nextTick, + }, + 'callbacks-16': { + len: 64, + chunks: 16, + callbacks: true, + }, + 'fixed-body-128': { + len: 512, + chunks: 128, + }, + 'large-16k': { + len: 16 * 1024, + chunks: 4, + }, }; -const bench = common.createBenchmark(main, configs, { byGroups: true }); +const bench = common.createBenchmark(main, { + type: ['string', 'buffer', 'uint8array'], + scenario: Object.keys(scenarios), + protocol: protocols, + c: [50], + duration: [5], +}); -function main({ - type, - len, - chunks, - mode, - transfer, - protocol, - producer, - callback, - c, - duration, - total, -}) { +function main({ type, scenario, protocol, c, duration }) { + const { + callbacks, + chunks, + contentLength, + endChunk, + explicit, + len, + schedule, + } = scenarios[scenario]; const transport = require(protocol); - len ??= total / chunks; - const chunk = type === 'bytes' ? 'a'.repeat(len) : + const chunk = type === 'string' ? 'a'.repeat(len) : type === 'buffer' ? Buffer.alloc(len, 'a') : new Uint8Array(len).fill(0x61); - const writeCallback = callback ? (err) => { + const writeCallback = callbacks ? (err) => { if (err) throw err; } : undefined; - const schedule = producer === 'nextTick' ? process.nextTick : - producer === 'microtask' ? queueMicrotask : setImmediate; - const onRequest = (req, res) => { - if (transfer === 'length') { + if (contentLength) { res.setHeader('Content-Length', len * chunks); } - if (mode === 'explicit') { + if (explicit) { res.cork(); } + if (endChunk) { + res.end(chunk, writeCallback); + return; + } - if (producer === 'sync') { + if (schedule === undefined) { for (let i = 0; i < chunks; i++) { res.write(chunk, writeCallback); } diff --git a/benchmark/http2/compat.js b/benchmark/http2/compat.js index d37bb20c5cdd..597bfb90dd96 100644 --- a/benchmark/http2/compat.js +++ b/benchmark/http2/compat.js @@ -6,6 +6,8 @@ const fs = require('fs'); const file = path.join(path.resolve(__dirname, '../fixtures'), 'alice.html'); const bench = common.createBenchmark(main, { + response: ['end', 'pipe'], + size: [64], requests: [100, 1000, 5000], streams: [1, 10, 20, 40, 100, 200], clients: [2], @@ -13,10 +15,15 @@ const bench = common.createBenchmark(main, { duration: 5, }, { flags: ['--no-warnings'] }); -function main({ requests, streams, clients, duration }) { +function main({ response, size, requests, streams, clients, duration }) { const http2 = require('http2'); + const body = 'a'.repeat(size); const server = http2.createServer(); server.on('request', (req, res) => { + if (response === 'end') { + res.end(body); + return; + } const out = fs.createReadStream(file); res.setHeader('content-type', 'text/html'); out.pipe(res); diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 838641050795..489a7272ebed 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -36,6 +36,7 @@ const { const { getDefaultHighWaterMark } = require('internal/streams/state'); const { + kDefaultWritev, kDestroyMessageBuffer, kInternalWritev, kPendingMessageBytes, @@ -44,6 +45,8 @@ const { const assert = require('internal/assert'); const EE = require('events'); const Stream = require('stream'); +// Internal vectors may only bypass Socket's canonical Writable methods. +const WritablePrototypeWrite = Stream.Writable.prototype.write; const { kOutHeaders, utcDate, kNeedDrain } = require('internal/http'); const { Buffer } = require('buffer'); const { @@ -100,7 +103,8 @@ const kBytesWritten = Symbol('kBytesWritten'); const kErrored = Symbol('errored'); const kHighWaterMark = Symbol('kHighWaterMark'); const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites'); -const kMaxChunkedFramingFoldLength = 1024; +const kDirectEndWrite = Symbol('kDirectEndWrite'); +const kMaxCoalescedWriteLength = 1024; const nop = () => {}; @@ -256,6 +260,8 @@ OutgoingMessage.prototype[kPendingMessageBytes] = function() { const len = this[kBufferedLength]; let pending = len; if (this.chunkedEncoding && len !== 0) { + // writableLength is queried for every buffered write. Avoid converting + // common 32-bit body lengths to a temporary hexadecimal string. let hexLength; if (len < 0x100) { hexLength = len < 0x10 ? 1 : 2; @@ -349,7 +355,7 @@ function canCombineAscii(data, encoding) { // Above 1 KiB, preserving separate vectors is faster than flattening the // V8 cons string created by adjoining HTTP chunk framing. function canFoldChunkedFraming(data, encoding, byteLength) { - return byteLength <= kMaxChunkedFramingFoldLength && + return byteLength <= kMaxCoalescedWriteLength && canCombineAscii(data, encoding); } @@ -358,11 +364,18 @@ function canFoldChunkedPrefix(msg, encoding) { Buffer.byteLength(msg._header) === msg._header.length; } +function canUseInternalWritev(msg, conn) { + return conn && conn._httpMessage === msg && conn.writable && + conn.write === WritablePrototypeWrite && + typeof conn[kDefaultWritev] === 'function' && + conn._writev === conn[kDefaultWritev] && + typeof conn[kInternalWritev] === 'function' && + typeof conn[kRawWritev] === 'function'; +} + function sendWriteVector(msg, chunks, callback) { const conn = msg[kSocket]; - if (conn && conn._httpMessage === msg && conn.writable && - typeof conn[kInternalWritev] === 'function' && - typeof conn[kRawWritev] === 'function') { + if (canUseInternalWritev(msg, conn)) { if (msg.outputData.length !== 0) { msg._flushOutput(conn); } @@ -375,17 +388,32 @@ function sendWriteVector(msg, chunks, callback) { } msg._headerSent = true; } + if (chunks.length === 2) { + return conn.write(chunks[0], chunks[1], callback); + } return conn[kInternalWritev](chunks, callback); } - for (let i = 0; i < chunks.length - 2; i += 2) { - msg._send(chunks[i], chunks[i + 1], null); + const shouldCork = conn && conn._httpMessage === msg && conn.writable && + !conn.writableCorked; + if (shouldCork) { + conn.cork(); + } + let ret; + try { + for (let i = 0; i < chunks.length - 2; i += 2) { + msg._send(chunks[i], chunks[i + 1], null); + } + ret = msg._send( + chunks[chunks.length - 2], + chunks[chunks.length - 1], + callback, + ); + } finally { + if (shouldCork) { + conn.uncork(); + } } - const ret = msg._send( - chunks[chunks.length - 2], - chunks[chunks.length - 1], - callback, - ); return ret; } @@ -505,7 +533,7 @@ function flushWriteBuffer(msg, ending = false, finalCallback = null) { (buf[0] === null && buf.length === 4); const lastLength = singlePayload ? len : Buffer.byteLength(buf[last], buf[last + 1]); - foldSuffix = lastLength <= kMaxChunkedFramingFoldLength; + foldSuffix = lastLength <= kMaxCoalescedWriteLength; } if (buf[0] === null) { buf[0] = prefix; @@ -657,6 +685,25 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL // See: /test/parallel/test-http-outgoing-message-inheritance.js if (canCombineAscii(data, encoding)) { data = this._header + data; + } else if (Buffer.isBuffer(data) && + data.byteLength <= kMaxCoalescedWriteLength) { + data = this._header + Buffer.prototype.toString.call(data, 'latin1'); + encoding = 'latin1'; + } else if (isUint8Array(data) && + data.byteLength <= kMaxCoalescedWriteLength) { + const header = this._header; + const headerLength = header.length; + const combined = Buffer.allocUnsafe(headerLength + data.byteLength); + Buffer.prototype.write.call( + combined, + header, + 0, + headerLength, + 'latin1', + ); + Buffer.prototype.set.call(combined, data, headerLength); + data = combined; + encoding = null; } else { const header = this._header; this.outputData.unshift({ @@ -882,6 +929,8 @@ function storeHeader(self, state, key, value, validate, lenient) { matchHeader(self, state, key, value); } +// Header serialization overwhelmingly sees lower- or canonical-case names. +// Avoid allocating a lower-case copy for those common forms. function isHeaderField(field, lowerCase, canonicalCase) { return field === lowerCase || field === canonicalCase || field.toLowerCase() === lowerCase; @@ -1223,7 +1272,7 @@ function strictContentLength(msg) { ); } -function write_(msg, chunk, encoding, callback, fromEnd) { +function write_(msg, chunk, encoding, callback, fromEnd, endCallback = null) { if (typeof callback !== 'function') callback = nop; @@ -1296,6 +1345,16 @@ function write_(msg, chunk, encoding, callback, fromEnd) { const chunked = msg.chunkedEncoding; const bufferable = chunked || msg._contentLength !== null; const buf = msg[kWriteBuffer]; + if (fromEnd && endCallback !== null && activeSocket && !chunked && + buf === null && !msg[kAutoCorked] && !msg[kCorked] && + msg.outputData.length === 0 && chunk.length !== 0 && + (canCombineAscii(chunk, encoding) || + (isUint8Array(chunk) && + chunk.byteLength <= kMaxCoalescedWriteLength)) && + socket.write === WritablePrototypeWrite) { + msg._send(chunk, encoding, endCallback, len); + return kDirectEndWrite; + } const buffering = bufferable && ((chunk.length !== 0 && (msg[kAutoCorked] || @@ -1446,6 +1505,8 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { encoding = null; } + let finish; + let directEndWrite = false; if (chunk) { if (this.finished) { onError(this, @@ -1454,11 +1515,10 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { return this; } - if (this[kSocket]) { - this[kSocket].cork(); - } - - write_(this, chunk, encoding, null, true); + finish = onFinish.bind(undefined, this); + directEndWrite = write_( + this, chunk, encoding, null, true, finish, + ) === kDirectEndWrite; } else if (this.finished) { if (typeof callback === 'function') { if (!this.writableFinished) { @@ -1469,10 +1529,6 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { } return this; } else if (!this._header) { - if (this[kSocket]) { - this[kSocket].cork(); - } - this._contentLength = 0; this._implicitHeader(); } @@ -1484,7 +1540,7 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { throw new ERR_HTTP_CONTENT_LENGTH_MISMATCH(this[kBytesWritten], this._contentLength); } - const finish = onFinish.bind(undefined, this); + finish ??= onFinish.bind(undefined, this); // Flush message-level corked data together with the terminating chunk. // Keep the socket corked so all HTTP framing is one logical write. @@ -1492,7 +1548,9 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { this[kWriteBuffer].length !== 0; let flushed = false; - if (hasBufferedWrites) { + if (directEndWrite) { + // The single body write already owns the finish callback. + } else if (hasBufferedWrites) { flushed = flushWriteBuffer(this, true, finish); } else if (this._hasBody && this.chunkedEncoding) { sendWriteVector( @@ -1506,11 +1564,12 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { process.nextTick(finish); } - if (this[kSocket]) { + const socket = this[kSocket]; + this[kAutoCorked] = false; + if (socket?.writableCorked) { // Fully uncork connection on end(). - this[kAutoCorked] = false; - this[kSocket]._writableState.corked = 1; - this[kSocket].uncork(); + socket._writableState.corked = 1; + socket.uncork(); } this[kCorked] = 1; this.uncork(); @@ -1586,9 +1645,7 @@ OutgoingMessage.prototype._flushOutput = function _flushOutput(socket) { return undefined; const outputData = this.outputData; - if (socket._httpMessage === this && socket.writable && - typeof socket[kInternalWritev] === 'function' && - typeof socket[kRawWritev] === 'function') { + if (canUseInternalWritev(this, socket)) { const vector = new Array(outputLength << 1); let callbacks = null; for (let i = 0; i < outputLength; i++) { diff --git a/lib/internal/http2/compat.js b/lib/internal/http2/compat.js index d9c123ee846b..1e74ea6a3119 100644 --- a/lib/internal/http2/compat.js +++ b/lib/internal/http2/compat.js @@ -864,17 +864,25 @@ class Http2ServerResponse extends Stream { // skipped entirely when none have been registered. state.finishing = true; - if (chunk !== null && chunk !== undefined) + const hasChunk = chunk !== null && chunk !== undefined; + const endWithChunk = hasChunk && + this.write === Http2ServerResponsePrototypeWrite; + if (endWithChunk) { + if (!stream.headersSent) + this.writeHead(state.statusCode); + } else if (hasChunk) { this.write(chunk, encoding); + } + const previousHeadRequest = state.headRequest; + const previousEnding = state.ending; state.headRequest = stream.headRequest; state.ending = true; + let finishTarget; if (typeof cb === 'function') { - if (stream.writableEnded) - this.once('finish', cb); - else - stream.once('finish', cb); + finishTarget = stream.writableEnded ? this : stream; + finishTarget.once('finish', cb); } if (!stream.headersSent) @@ -882,8 +890,19 @@ class Http2ServerResponse extends Stream { if (this[kState].closed || stream.destroyed) onStreamCloseResponse.call(stream); - else - stream.end(); + else { + try { + if (endWithChunk) + stream.end(chunk, encoding); + else + stream.end(); + } catch (err) { + state.ending = previousEnding; + state.headRequest = previousHeadRequest; + finishTarget?.removeListener('finish', cb); + throw err; + } + } return this; } @@ -998,6 +1017,9 @@ class Http2ServerResponse extends Stream { } } +// Preserve user-defined write behavior when end() receives a final chunk. +const Http2ServerResponsePrototypeWrite = Http2ServerResponse.prototype.write; + function onServerStream(ServerRequest, ServerResponse, stream, headers, flags, rawHeaders) { const server = this; diff --git a/lib/internal/streams/utils.js b/lib/internal/streams/utils.js index 4680ea35fc18..604b30ce44a7 100644 --- a/lib/internal/streams/utils.js +++ b/lib/internal/streams/utils.js @@ -18,6 +18,7 @@ const kIsWritable = SymbolFor('nodejs.stream.writable'); const kIsDisturbed = SymbolFor('nodejs.stream.disturbed'); const kOnConstructed = Symbol('kOnConstructed'); +const kDefaultWritev = Symbol('kDefaultWritev'); const kDestroyMessageBuffer = Symbol('kDestroyMessageBuffer'); const kInternalWritev = Symbol('kInternalWritev'); const kPendingMessageBytes = Symbol('kPendingMessageBytes'); @@ -320,6 +321,7 @@ function isErrored(stream) { } module.exports = { + kDefaultWritev, kDestroyMessageBuffer, kInternalWritev, kOnConstructed, diff --git a/lib/net.js b/lib/net.js index 96c3540513b8..a1752d240923 100644 --- a/lib/net.js +++ b/lib/net.js @@ -104,6 +104,7 @@ const { kBufferGen, } = require('internal/stream_base_commons'); const { + kDefaultWritev, kInternalWritev, kPendingMessageBytes, kRawWritev, @@ -1282,6 +1283,7 @@ Socket.prototype._writev = function(chunks, cb) { this._writeGeneric(true, chunks, '', cb); }; +Socket.prototype[kDefaultWritev] = Socket.prototype._writev; Socket.prototype[kInternalWritev] = stream.Writable[kInternalWritev]; diff --git a/test/parallel/test-http-outgoing-auto-cork.js b/test/parallel/test-http-outgoing-auto-cork.js index cc9769f2714c..ac4b58272d35 100644 --- a/test/parallel/test-http-outgoing-auto-cork.js +++ b/test/parallel/test-http-outgoing-auto-cork.js @@ -8,14 +8,28 @@ const http = require('http'); const net = require('net'); const { kRawWritev } = require('internal/streams/utils'); -function runRawResponse(mode, expectedBody, expectedWritevParts, +function runRawResponse(mode, expectedBody, expectedWriteParts, contentLength = false, payload = 'ABC') { return new Promise((resolve, reject) => { - const writevParts = []; + const writeParts = []; const server = http.createServer(common.mustCall((req, res) => { + const originalWrite = res.socket._write; + res.socket._write = function(chunk, encoding, callback) { + writeParts.push(1); + return originalWrite.call(this, chunk, encoding, callback); + }; + if (mode === 'socket' || mode === 'customWritev') { + const originalStreamWritev = res.socket._writev; + function writev(chunks, callback) { + writeParts.push(chunks.length); + return originalStreamWritev.call(this, chunks, callback); + } + res.socket._writev = mode === 'customWritev' ? + common.mustCall(writev) : writev; + } const originalWritev = res.socket[kRawWritev]; res.socket[kRawWritev] = function(chunks, callback) { - writevParts.push(chunks.length >> 1); + writeParts.push(chunks.length >> 1); return originalWritev.call(this, chunks, callback); }; @@ -23,7 +37,7 @@ function runRawResponse(mode, expectedBody, expectedWritevParts, res.setHeader('Content-Length', 3); } - if (mode === 'auto') { + if (mode === 'auto' || mode === 'customWritev') { res.write('A'); res.write('B'); res.end('C'); @@ -71,7 +85,11 @@ function runRawResponse(mode, expectedBody, expectedWritevParts, socket.on('end', common.mustCall(() => { const body = response.slice(response.indexOf('\r\n\r\n') + 4); assert.strictEqual(body, expectedBody); - assert.deepStrictEqual(writevParts, expectedWritevParts); + assert.deepStrictEqual( + writeParts, + expectedWriteParts, + `${mode}, contentLength=${contentLength}, payload=${payload.length}`, + ); server.close(common.mustCall(resolve)); })); socket.on('connect', common.mustCall(() => { @@ -289,6 +307,11 @@ function runInvalidEncoding(explicit, chunk, encoding, prefix = null) { async function main() { await runRawResponse('auto', '3\r\nABC\r\n0\r\n\r\n', [3]); + await runRawResponse( + 'customWritev', + '3\r\nABC\r\n0\r\n\r\n', + [3], + ); await runRawResponse('explicit', '3\r\nABC\r\n0\r\n\r\n', [3]); await runRawResponse( 'socket', diff --git a/test/parallel/test-http-outgoing-buffered-drain.js b/test/parallel/test-http-outgoing-buffered-drain.js index 959d74956e8b..c825ca400fa8 100644 --- a/test/parallel/test-http-outgoing-buffered-drain.js +++ b/test/parallel/test-http-outgoing-buffered-drain.js @@ -1,12 +1,9 @@ -// Flags: --expose-internals - 'use strict'; const common = require('../common'); const assert = require('assert'); const http = require('http'); const net = require('net'); -const { kRawWritev } = require('internal/streams/utils'); function runBackpressure(contentLength = false) { return new Promise((resolve, reject) => { @@ -64,10 +61,10 @@ function runClientBufferedDrain() { request.on('error', () => {}); request.on('socket', common.mustCall((socket) => { socket._writableState.highWaterMark = 64; - const originalWritev = socket[kRawWritev]; + const originalWrite = socket._write; let completeFirstWrite; - socket[kRawWritev] = common.mustCall((chunks, callback) => { + socket._write = common.mustCall((chunk, encoding, callback) => { completeFirstWrite = callback; }); socket.once('connect', common.mustCall(() => { @@ -90,7 +87,7 @@ function runClientBufferedDrain() { assert.strictEqual(drained, false); assert.strictEqual(request.writableLength, 100); - socket[kRawWritev] = originalWritev; + socket._write = originalWrite; request.uncork(); })); }); @@ -112,9 +109,9 @@ function runAsyncEncodedDrain() { length.toString(16).length + 4; res.socket._writableState.highWaterMark = pending; - const originalWritev = res.socket[kRawWritev]; + const originalWrite = res.socket._write; let completeWrite; - res.socket[kRawWritev] = common.mustCall((chunks, callback) => { + res.socket._write = common.mustCall((chunk, encoding, callback) => { completeWrite = callback; }); @@ -131,7 +128,7 @@ function runAsyncEncodedDrain() { completeWrite(); setImmediate(common.mustCall(() => { assert.strictEqual(drained, true); - res.socket[kRawWritev] = originalWritev; + res.socket._write = originalWrite; res.socket.destroy(); server.close(common.mustCall(resolve)); })); diff --git a/test/parallel/test-http-outgoing-end-buffer.js b/test/parallel/test-http-outgoing-end-buffer.js new file mode 100644 index 000000000000..ca88c0476004 --- /dev/null +++ b/test/parallel/test-http-outgoing-end-buffer.js @@ -0,0 +1,72 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +const mutable = Buffer.from('A'); +let mutableResponses = 0; + +function sequence(length) { + return Uint8Array.from({ length }, (_, index) => index & 0xff); +} + +const bodies = { + '/buffer-1024': Buffer.from(sequence(1024)), + '/buffer-1025': Buffer.from(sequence(1025)), + '/uint8array-1024': sequence(1024), +}; + +const server = http.createServer(common.mustCall((req, res) => { + let body; + if (req.url === '/mutable') { + mutable[0] = 0x41 + mutableResponses++; + body = mutable; + } else { + body = bodies[req.url]; + assert(body); + } + + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(body.byteLength), + }); + res.end(body); +}, 5)); + +async function main() { + await new Promise((resolve) => { + server.listen(0, common.localhostIPv4, common.mustCall(resolve)); + }); + + const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + async function request(path) { + return new Promise((resolve, reject) => { + const req = http.get({ + agent, + host: common.localhostIPv4, + path, + port: server.address().port, + }, common.mustCall((res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', common.mustCall(() => resolve(Buffer.concat(chunks)))); + })); + req.on('error', reject); + }); + } + + assert.deepStrictEqual(await request('/mutable'), Buffer.from('A')); + assert.deepStrictEqual(await request('/mutable'), Buffer.from('B')); + assert.deepStrictEqual(await request('/buffer-1024'), bodies['/buffer-1024']); + assert.deepStrictEqual(await request('/buffer-1025'), bodies['/buffer-1025']); + assert.deepStrictEqual( + await request('/uint8array-1024'), + Buffer.from(bodies['/uint8array-1024']), + ); + + agent.destroy(); + await new Promise((resolve) => server.close(common.mustCall(resolve))); +} + +main().then(common.mustCall()); diff --git a/test/parallel/test-http-outgoing-flush-output.js b/test/parallel/test-http-outgoing-flush-output.js index 6153591d9296..dc17c95eddf5 100644 --- a/test/parallel/test-http-outgoing-flush-output.js +++ b/test/parallel/test-http-outgoing-flush-output.js @@ -27,7 +27,7 @@ function runPipelinedOutputVector() { socket[kInternalWritev] = common.mustCall(function(vector, callback) { vectorLengths.push(vector.length >> 1); return originalWritev.call(this, vector, callback); - }, 2); + }); res.write('A', common.mustCall(() => callbacks.push('A'))); res.write(Buffer.from('B'), common.mustCall(() => callbacks.push('B'))); @@ -46,8 +46,8 @@ function runPipelinedOutputVector() { socket.on('data', () => {}); socket.on('end', common.mustCall(() => { assert.deepStrictEqual(callbacks, ['A', 'B', 'end']); - assert.strictEqual(vectorLengths.length, 2); - assert(vectorLengths[1] > 1); + assert.strictEqual(vectorLengths.length, 1); + assert(vectorLengths[0] > 1); server.close(common.mustCall(resolve)); })); socket.on('connect', common.mustCall(() => { diff --git a/test/parallel/test-http-outgoing-message-write-callback.js b/test/parallel/test-http-outgoing-message-write-callback.js index 3a32285faaff..b1944abef217 100644 --- a/test/parallel/test-http-outgoing-message-write-callback.js +++ b/test/parallel/test-http-outgoing-message-write-callback.js @@ -1,3 +1,5 @@ +// Flags: --expose-internals + 'use strict'; const common = require('../common'); @@ -8,6 +10,10 @@ const common = require('../common'); const assert = require('assert'); const http = require('http'); const stream = require('stream'); +const { + kInternalWritev, + kRawWritev, +} = require('internal/streams/utils'); for (const method of ['GET, HEAD']) { const expected = ['a', 'b', '', Buffer.alloc(0), 'c']; @@ -37,3 +43,59 @@ for (const method of ['GET, HEAD']) { assert.deepStrictEqual(results, expected); })); } + +// A custom socket write may invoke its callback synchronously. The end() +// callback must be registered before that custom path can emit 'finish'. +{ + const writable = new stream.Writable({ + write(chunk, encoding, callback) { + callback(); + } + }); + writable.write = function(chunk, encoding, callback) { + callback(); + return true; + }; + writable[kInternalWritev] = common.mustNotCall(); + writable[kRawWritev] = common.mustNotCall(); + + const res = new http.ServerResponse({ + method: 'GET', + httpVersionMajor: 1, + httpVersionMinor: 1 + }); + res.assignSocket(writable); + + let ended = false; + res.end('body', common.mustCall(() => ended = true)); + assert.strictEqual(ended, true); +} + +// Flushing output queued before socket assignment must preserve the same +// public write override instead of switching to an internal vector path. +{ + const writes = []; + const writable = new stream.Writable({ + write(chunk, encoding, callback) { + callback(); + } + }); + writable.write = function(chunk, encoding, callback) { + writes.push(chunk); + callback(); + return true; + }; + writable[kInternalWritev] = common.mustNotCall(); + writable[kRawWritev] = common.mustNotCall(); + + const res = new http.ServerResponse({ + method: 'GET', + httpVersionMajor: 1, + httpVersionMinor: 1 + }); + res.write('A'); + res.end('B', common.mustCall()); + assert(res.outputData.length > 1); + res.assignSocket(writable); + assert(writes.length > 1); +} diff --git a/test/parallel/test-http-response-cork.js b/test/parallel/test-http-response-cork.js index b128a7ed4071..6044fe773d23 100644 --- a/test/parallel/test-http-response-cork.js +++ b/test/parallel/test-http-response-cork.js @@ -1,17 +1,14 @@ -// Flags: --expose-internals - 'use strict'; const common = require('../common'); const http = require('http'); const assert = require('assert'); -const { kInternalWritev } = require('internal/streams/utils'); const server = http.createServer(common.mustCallAtLeast((req, res) => { let corked = false; - const originalWritev = res.socket[kInternalWritev]; - res.socket[kInternalWritev] = common.mustCall(function(...args) { + const originalWrite = res.socket._write; + res.socket._write = common.mustCall(function(...args) { assert.strictEqual(corked, false); - return originalWritev.apply(this, args); + return originalWrite.apply(this, args); }); corked = true; res.cork(); diff --git a/test/parallel/test-http2-compat-serverresponse-end.js b/test/parallel/test-http2-compat-serverresponse-end.js index 03c3db2c6e88..874c3ebf84f8 100644 --- a/test/parallel/test-http2-compat-serverresponse-end.js +++ b/test/parallel/test-http2-compat-serverresponse-end.js @@ -24,6 +24,14 @@ const { // It may be invoked repeatedly without throwing errors // but callback will only be called once const server = createServer(mustCall((request, response) => { + const stream = response.stream; + const streamEnd = stream.end; + stream.write = mustNotCall(); + stream.end = mustCall(function(chunk, encoding) { + assert.strictEqual(chunk, 'end'); + assert.strictEqual(encoding, 'utf8'); + return streamEnd.call(this, chunk, encoding); + }); response.end('end', 'utf8', mustCall(() => { response.end(mustCall()); process.nextTick(() => { diff --git a/test/parallel/test-http2-options-server-response.js b/test/parallel/test-http2-options-server-response.js index 6f1ae1881d22..3dc3175934a1 100644 --- a/test/parallel/test-http2-options-server-response.js +++ b/test/parallel/test-http2-options-server-response.js @@ -3,20 +3,27 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); +const assert = require('assert'); const h2 = require('http2'); class MyServerResponse extends h2.Http2ServerResponse { status(code) { return this.writeHead(code, { 'Content-Type': 'text/plain' }); } + + write(...args) { + this.writeCalled = true; + return super.write(...args); + } } const server = h2.createServer({ Http2ServerResponse: MyServerResponse -}, (req, res) => { +}, common.mustCall((req, res) => { res.status(200); - res.end(); -}); + res.end('body'); + assert.strictEqual(res.writeCalled, true); +})); server.listen(0); server.on('listening', common.mustCall(() => { From 39318a1d642699827470b35d89cb8cced8094181 Mon Sep 17 00:00:00 2001 From: GetThatCookie Date: Mon, 3 Aug 2026 17:02:40 +0200 Subject: [PATCH 3/3] stream: reuse unexposed managed read buffers Signed-off-by: GetThatCookie --- src/env.cc | 21 +++++++++++++++++---- src/env.h | 6 ++++++ src/stream_base.cc | 2 ++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/env.cc b/src/env.cc index 87340112fbeb..f9fd8f7951b0 100644 --- a/src/env.cc +++ b/src/env.cc @@ -77,6 +77,8 @@ using v8::Undefined; using v8::Value; using worker::Worker; +constexpr size_t kManagedBufferCacheSize = 64 * 1024; + int const ContextEmbedderTag::kNodeContextTag = 0x6e6f64; void* const ContextEmbedderTag::kNodeContextTagPtr = const_cast( static_cast(&ContextEmbedderTag::kNodeContextTag)); @@ -746,10 +748,16 @@ void Environment::add_refs(int64_t diff) { } uv_buf_t Environment::allocate_managed_buffer(const size_t suggested_size) { - std::unique_ptr bs = ArrayBuffer::NewBackingStore( - isolate(), - suggested_size, - BackingStoreInitializationMode::kUninitialized); + std::unique_ptr bs; + if (suggested_size == kManagedBufferCacheSize && + managed_buffer_cache_ != nullptr) { + bs = std::move(managed_buffer_cache_); + } else { + bs = ArrayBuffer::NewBackingStore( + isolate(), + suggested_size, + BackingStoreInitializationMode::kUninitialized); + } uv_buf_t buf = uv_buf_init(static_cast(bs->Data()), bs->ByteLength()); released_allocated_buffers_.emplace(buf.base, std::move(bs)); return buf; @@ -767,6 +775,11 @@ std::unique_ptr Environment::release_managed_buffer( return bs; } +void Environment::recycle_managed_buffer(std::unique_ptr bs) { + if (bs != nullptr && bs->ByteLength() == kManagedBufferCacheSize) + managed_buffer_cache_ = std::move(bs); +} + std::string Environment::GetExecPath(const std::vector& argv) { char exec_path_buf[2 * PATH_MAX]; size_t exec_path_len = sizeof(exec_path_buf); diff --git a/src/env.h b/src/env.h index c2caf9790238..ca96caffaca1 100644 --- a/src/env.h +++ b/src/env.h @@ -1041,6 +1041,9 @@ class Environment final : public MemoryRetainer { uv_buf_t allocate_managed_buffer(const size_t suggested_size); std::unique_ptr release_managed_buffer(const uv_buf_t& buf); + // Only buffers that were not exposed externally may be recycled. + void recycle_managed_buffer( + std::unique_ptr backing_store); void AddUnmanagedFd(int fd); void RemoveUnmanagedFd(int fd); @@ -1257,6 +1260,9 @@ class Environment final : public MemoryRetainer { std::unordered_map> released_allocated_buffers_; + // Retains at most one unexposed read buffer for reuse. + std::unique_ptr managed_buffer_cache_; + v8::CpuProfiler* cpu_profiler_ = nullptr; std::vector pending_profiles_; }; diff --git a/src/stream_base.cc b/src/stream_base.cc index 1277376510fa..fe4d60cccff6 100644 --- a/src/stream_base.cc +++ b/src/stream_base.cc @@ -764,6 +764,7 @@ void EmitToJSStreamListener::OnStreamRead(ssize_t nread, const uv_buf_t& buf_) { std::unique_ptr bs = env->release_managed_buffer(buf_); if (nread <= 0) { + env->recycle_managed_buffer(std::move(bs)); if (nread < 0) stream->CallJSOnreadMethod(nread, Local()); return; @@ -775,6 +776,7 @@ void EmitToJSStreamListener::OnStreamRead(ssize_t nread, const uv_buf_t& buf_) { bs = ArrayBuffer::NewBackingStore( isolate, nread, BackingStoreInitializationMode::kUninitialized); memcpy(bs->Data(), old_bs->Data(), nread); + env->recycle_managed_buffer(std::move(old_bs)); } stream->CallJSOnreadMethod(nread, ArrayBuffer::New(isolate, std::move(bs)));