Commit e18f10645c3 for nodejs
commit e18f10645c3c02814ca04a9d798dd15c2f21ef6c
Author: Guilherme Araújo <arauujogui@gmail.com>
Date: Sat Nov 14 21:39:09 2026 -0300
src: write strings to streams without flattening
StreamBase::WriteString() created a String::ValueView twice, the first
time in StringBytes::StorageSize() only to read the length. A ValueView
flattens the string, so writing a string built by concatenation, such
as the header of an HTTP response, allocated a flat copy on the V8 heap
before copying it again into the write buffer.
Read the length from the string in StorageSize(), and copy one-byte
strings that fit the stack buffer with String::WriteOneByte(), which
does not flatten. For UTF-8 the characters go to the last third of the
buffer and are converted into the rest of it: a Latin-1 character takes
at most two bytes.
Assisted-by: Claude
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/67435
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Philipp Dunkel <pip@pipobscure.com>
diff --git a/src/stream_base.cc b/src/stream_base.cc
index f36a4b7056b..8592ca269a0 100644
--- a/src/stream_base.cc
+++ b/src/stream_base.cc
@@ -8,6 +8,7 @@
#include "node_buffer.h"
#include "node_errors.h"
#include "node_external_reference.h"
+#include "simdutf.h"
#include "string_bytes.h"
#include "util-inl.h"
#include "v8.h"
@@ -196,6 +197,38 @@ int StreamBase::FinishWrite(const v8::FunctionCallbackInfo<v8::Value>& args,
return res.err;
}
+namespace {
+
+// Same as StringBytes::Write(), but copies one-byte strings out with
+// String::WriteOneByte(), which does not flatten them: strings written to a
+// stream are often built by concatenation (e.g. HTTP headers) and not used
+// again.
+size_t WriteStringWithoutFlattening(Isolate* isolate,
+ char* buf,
+ size_t buflen,
+ Local<String> string,
+ enum encoding enc) {
+ const size_t length = string->Length();
+ if (length == 0) return 0;
+ if (string->IsOneByte()) {
+ if ((enc == ASCII || enc == LATIN1) && buflen >= length) {
+ string->WriteOneByte(isolate, 0, length, reinterpret_cast<uint8_t*>(buf));
+ return length;
+ }
+ if (enc == UTF8 && buflen >= 3 * length) {
+ // Latin-1 takes at most 2 UTF-8 bytes per char: convert from the last
+ // third into the first two.
+ char* latin1 = buf + 2 * length;
+ string->WriteOneByte(
+ isolate, 0, length, reinterpret_cast<uint8_t*>(latin1));
+ return simdutf::convert_latin1_to_utf8(latin1, length, buf);
+ }
+ }
+ return StringBytes::Write(isolate, buf, buflen, string, enc);
+}
+
+} // namespace
+
int StreamBase::Writev(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();
@@ -297,11 +330,8 @@ int StreamBase::Writev(const FunctionCallbackInfo<Value>& args) {
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);
+ str_size = WriteStringWithoutFlattening(
+ isolate, str_storage, str_size, string, encoding);
bufs[i].base = str_storage;
bufs[i].len = str_size;
offset += str_size;
@@ -372,7 +402,7 @@ int StreamBase::WriteString(const FunctionCallbackInfo<Value>& args) {
if (args[2]->IsObject())
send_handle_obj = args[2].As<Object>();
- // Compute the size of the storage that the string will be flattened into.
+ // Compute the size of the storage that the string will be written into.
// For UTF8 strings that are very long, go ahead and take the hit for
// computing their actual size, rather than tripling the storage.
size_t storage_size;
@@ -395,11 +425,8 @@ int StreamBase::WriteString(const FunctionCallbackInfo<Value>& args) {
bool try_write = HasDoTryWrite() && storage_size <= sizeof(stack_storage) &&
(!IsIPCPipe() || send_handle_obj.IsEmpty());
if (try_write) {
- data_size = StringBytes::Write(isolate,
- stack_storage,
- storage_size,
- string,
- enc);
+ data_size = WriteStringWithoutFlattening(
+ isolate, stack_storage, storage_size, string, enc);
buf = uv_buf_init(stack_storage, data_size);
uv_buf_t* bufs = &buf;
@@ -433,11 +460,8 @@ int StreamBase::WriteString(const FunctionCallbackInfo<Value>& args) {
// Write it
bs = ArrayBuffer::NewBackingStore(
isolate, storage_size, BackingStoreInitializationMode::kUninitialized);
- data_size = StringBytes::Write(isolate,
- static_cast<char*>(bs->Data()),
- storage_size,
- string,
- enc);
+ data_size = WriteStringWithoutFlattening(
+ isolate, static_cast<char*>(bs->Data()), storage_size, string, enc);
}
CHECK_LE(data_size, storage_size);
diff --git a/src/string_bytes.cc b/src/string_bytes.cc
index 94b579b6984..9aec2abc41a 100644
--- a/src/string_bytes.cc
+++ b/src/string_bytes.cc
@@ -468,13 +468,13 @@ Maybe<size_t> StringBytes::StorageSize(Isolate* isolate,
Local<String> str;
if (!val->ToString(isolate->GetCurrentContext()).ToLocal(&str))
return Nothing<size_t>();
- String::ValueView view(isolate, str);
+ const size_t length = str->Length();
size_t data_size = 0;
switch (encoding) {
case ASCII:
case LATIN1:
- data_size = view.length();
+ data_size = length;
break;
case BUFFER:
@@ -482,24 +482,24 @@ Maybe<size_t> StringBytes::StorageSize(Isolate* isolate,
// A single UCS2 codepoint never takes up more than 3 utf8 bytes.
// It is an exercise for the caller to decide when a string is
// long enough to justify calling Size() instead of StorageSize()
- data_size = 3 * view.length();
+ data_size = 3 * length;
break;
case UCS2:
- data_size = view.length() * sizeof(uint16_t);
+ data_size = length * sizeof(uint16_t);
break;
case BASE64URL:
- data_size = simdutf::base64_length_from_binary(view.length(),
- simdutf::base64_url);
+ data_size =
+ simdutf::base64_length_from_binary(length, simdutf::base64_url);
break;
case BASE64:
- data_size = simdutf::base64_length_from_binary(view.length());
+ data_size = simdutf::base64_length_from_binary(length);
break;
case HEX:
- data_size = view.length() / 2;
+ data_size = length / 2;
break;
default:
diff --git a/test/parallel/test-net-write-concatenated-string.js b/test/parallel/test-net-write-concatenated-string.js
new file mode 100644
index 00000000000..8bfc7a4dd48
--- /dev/null
+++ b/test/parallel/test-net-write-concatenated-string.js
@@ -0,0 +1,60 @@
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+const net = require('net');
+
+// Writes concatenated strings around the size limits of the stack buffer used
+// by StreamBase::WriteString() and checks the bytes on the wire.
+
+function concat(length, base) {
+ let str = '';
+ for (let i = 0; i < length; i++)
+ str += String.fromCharCode(base + i % 16);
+ return str;
+}
+
+const cases = [];
+for (const length of [0, 13, 5461, 5462, 16384, 16385]) {
+ // ASCII, non-ASCII Latin-1, two-byte.
+ for (const base of [0x61, 0xe0, 0x2000]) {
+ for (const encoding of ['utf8', 'latin1', 'ascii']) {
+ cases.push({ length, base, encoding });
+ }
+ }
+}
+
+const expected = [];
+
+const server = net.createServer(common.mustCall((socket) => {
+ const chunks = [];
+ socket.on('data', (chunk) => chunks.push(chunk));
+ socket.on('end', common.mustCall(() => {
+ const actual = Buffer.concat(chunks);
+ let offset = 0;
+ for (const [i, buf] of expected.entries()) {
+ const { length, base, encoding } = cases[i];
+ assert.deepStrictEqual(
+ actual.subarray(offset, offset + buf.length),
+ buf,
+ `length ${length}, base 0x${base.toString(16)}, ${encoding}`);
+ offset += buf.length;
+ }
+ assert.strictEqual(offset, actual.length);
+ server.close();
+ }));
+}));
+
+server.listen(0, common.mustCall(() => {
+ const socket = net.connect(server.address().port);
+ let i = 0;
+ function next() {
+ if (i === cases.length) return socket.end();
+ const { length, base, encoding } = cases[i++];
+ const str = concat(length, base);
+ // Each write waits for the previous one so that it goes through
+ // WriteString() and not Writev().
+ socket.write(str, encoding, common.mustSucceed(next));
+ expected.push(Buffer.from(str, encoding));
+ }
+ socket.on('connect', common.mustCall(next));
+}));
diff --git a/test/parallel/test-net-writev-concatenated-string.js b/test/parallel/test-net-writev-concatenated-string.js
new file mode 100644
index 00000000000..975630fe1ac
--- /dev/null
+++ b/test/parallel/test-net-writev-concatenated-string.js
@@ -0,0 +1,62 @@
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+const net = require('net');
+
+// Writes concatenated strings with a Buffer after each of them in a single
+// StreamBase::Writev() call, like an HTTP response header followed by a Buffer
+// body, and checks the bytes on the wire.
+
+function concat(length, base) {
+ let str = '';
+ for (let i = 0; i < length; i++)
+ str += String.fromCharCode(base + i % 16);
+ return str;
+}
+
+const separator = Buffer.from('|');
+const writes = [];
+// Writev() reserves 3 bytes per character for UTF-8 strings of up to 65535
+// characters and the exact size for longer ones.
+for (const length of [0, 13, 5461, 16385, 65535, 65536]) {
+ // ASCII, non-ASCII Latin-1, two-byte.
+ for (const base of [0x61, 0xe0, 0x2000]) {
+ for (const encoding of ['utf8', 'latin1', 'ascii']) {
+ writes.push({
+ label: `length ${length}, base 0x${base.toString(16)}, ${encoding}`,
+ str: concat(length, base),
+ encoding,
+ });
+ }
+ }
+}
+
+const server = net.createServer(common.mustCall((socket) => {
+ const chunks = [];
+ socket.on('data', (chunk) => chunks.push(chunk));
+ socket.on('end', common.mustCall(() => {
+ const actual = Buffer.concat(chunks);
+ let offset = 0;
+ for (const { label, str, encoding } of writes) {
+ const expected = Buffer.concat([Buffer.from(str, encoding), separator]);
+ assert.deepStrictEqual(
+ actual.subarray(offset, offset + expected.length), expected, label);
+ offset += expected.length;
+ }
+ assert.strictEqual(offset, actual.length);
+ server.close();
+ }));
+}));
+
+server.listen(0, common.mustCall(() => {
+ const socket = net.connect(server.address().port, common.mustCall(() => {
+ // Corked writes are flushed together with Writev().
+ socket.cork();
+ for (const { str, encoding } of writes) {
+ socket.write(str, encoding);
+ socket.write(separator);
+ }
+ socket.uncork();
+ socket.end();
+ }));
+}));