Commit bf246cf4b5b for nodejs
commit bf246cf4b5b157c81a093f1feb9c9c814d030b2f
Author: Robert Nagy <ronagy@icloud.com>
Date: Sun Nov 1 09:22:55 2026 +0100
buffer: limit UTF-8 fast calls to safe one-byte strings
Replace the allocating UTF-8 fast call with a fast binding for
sequential one-byte strings. Its slow fallback only returns a Smi
sentinel. JavaScript sends rejected strings to a separate regular
binding that can flatten strings and handle lazy deoptimization.
Both native callbacks used by the fast API avoid JS-heap allocation.
Add GC regression and string-representation coverage, including
optimized fast acceptance and rejection checks.
Fixes: https://github.com/nodejs/node/issues/66870
Refs: https://github.com/nodejs/node/pull/67134
Assisted-by: Codex
Signed-off-by: Robert Nagy <ronagy@icloud.com>
PR-URL: https://github.com/nodejs/node/pull/67136
Refs: https://github.com/nodejs/node/issues/66870
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Anna Henningsen <anna@addaleax.net>
Reviewed-By: Ulises Gascón <ulisesgascongonzalez@gmail.com>
diff --git a/lib/buffer.js b/lib/buffer.js
index d9c64f27cf3..ab494b9988a 100644
--- a/lib/buffer.js
+++ b/lib/buffer.js
@@ -56,7 +56,8 @@ const {
} = primordials;
const {
- byteLengthUtf8,
+ byteLengthUtf8: byteLengthUtf8Slow,
+ byteLengthUtf8Fast,
compare: _compare,
compareOffset,
copy: _copy,
@@ -907,6 +908,11 @@ function getEncodingOps(encoding) {
}
}
+function byteLengthUtf8(string) {
+ const length = byteLengthUtf8Fast(string);
+ return length === -1 ? byteLengthUtf8Slow(string) : length;
+}
+
function byteLength(string, encoding) {
if (typeof string !== 'string') {
if (isArrayBufferView(string) || isAnyArrayBuffer(string)) {
diff --git a/src/node_buffer.cc b/src/node_buffer.cc
index 1d16813b380..448ef5b6049 100644
--- a/src/node_buffer.cc
+++ b/src/node_buffer.cc
@@ -826,39 +826,27 @@ void SlowByteLengthUtf8(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(static_cast<uint64_t>(str->Utf8LengthV2(isolate)));
}
-uint32_t FastByteLengthUtf8(
- Local<Value> receiver,
- Local<Value> sourceValue,
- FastApiCallbackOptions& options) { // NOLINT(runtime/references)
+// The fast API's slow fallback must not flatten strings either: it uses the
+// same unsupported lazy-deoptimization continuation as the fast callback.
+// Return a Smi sentinel so JavaScript can make a separate ordinary API call.
+void ByteLengthUtf8FastFallback(const FunctionCallbackInfo<Value>& args) {
+ args.GetReturnValue().Set(-1);
+}
+
+int32_t FastByteLengthUtf8(Local<Value> receiver,
+ const FastOneByteString& source) {
TRACK_V8_FAST_API_CALL("Buffer::FastByteLengthUtf8");
- auto isolate = options.isolate;
- HandleScope handleScope(isolate);
- CHECK(sourceValue->IsString());
- Local<String> sourceStr = sourceValue.As<String>();
-
- if (!sourceStr->IsExternalOneByte()) {
- // Below ~512 units, or for one-byte, V8's Utf8LengthV2 is faster.
- if (sourceStr->Length() >= 512 && !sourceStr->IsOneByte()) {
- String::ValueView view(isolate, sourceStr);
- if (!view.is_one_byte()) {
- // with_replacement matches Buffer.from's U+FFFD for lone surrogates.
- return simdutf::utf8_length_from_utf16_with_replacement(
- reinterpret_cast<const char16_t*>(view.data16()),
- view.length())
- .count;
- }
- }
- return sourceStr->Utf8LengthV2(isolate);
- }
- auto source = sourceStr->GetExternalOneByteStringResource();
+ // Even two UTF-8 bytes per Latin-1 code unit fit in a 31-bit Smi.
+ static_assert(String::kMaxLength <= INT32_MAX / 4);
// For short inputs, the function call overhead to simdutf is maybe
// not worth it, reserve simdutf for long strings.
- if (source->length() > 128) {
- return simdutf::utf8_length_from_latin1(source->data(), source->length());
+ if (source.length > 128) {
+ return static_cast<int32_t>(
+ simdutf::utf8_length_from_latin1(source.data, source.length));
}
- uint32_t length = source->length();
- const auto input = reinterpret_cast<const uint8_t*>(source->data());
+ uint32_t length = source.length;
+ const auto input = reinterpret_cast<const uint8_t*>(source.data);
uint32_t answer = length;
uint32_t i = 0;
@@ -889,7 +877,7 @@ uint32_t FastByteLengthUtf8(
answer += input[i] >> 7;
}
- return answer;
+ return static_cast<int32_t>(answer);
}
static CFunction fast_byte_length_utf8(CFunction::Make(FastByteLengthUtf8));
@@ -2029,10 +2017,11 @@ void Initialize(Local<Object> target,
SetMethod(context, target, "setBufferPrototype", SetBufferPrototype);
+ SetMethodNoSideEffect(context, target, "byteLengthUtf8", SlowByteLengthUtf8);
SetFastMethodNoSideEffect(context,
target,
- "byteLengthUtf8",
- SlowByteLengthUtf8,
+ "byteLengthUtf8Fast",
+ ByteLengthUtf8FastFallback,
&fast_byte_length_utf8);
SetFastMethod(context, target, "copy", SlowCopy, &fast_copy);
SetFastMethodNoSideEffect(context, target, "compare", Compare, &fast_compare);
@@ -2123,6 +2112,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(SetBufferPrototype);
registry->Register(SlowByteLengthUtf8);
+ registry->Register(ByteLengthUtf8FastFallback);
registry->Register(fast_byte_length_utf8);
registry->Register(SlowCopy);
registry->Register(fast_copy);
diff --git a/test/parallel/test-buffer-bytelength-gc.js b/test/parallel/test-buffer-bytelength-gc.js
new file mode 100644
index 00000000000..cad54212d3f
--- /dev/null
+++ b/test/parallel/test-buffer-bytelength-gc.js
@@ -0,0 +1,35 @@
+// Flags: --allow-natives-syntax --gc-global --gc-interval=500
+'use strict';
+
+require('../common');
+const assert = require('assert');
+
+function byteLength(object, string) {
+ return object.x + Buffer.byteLength(string);
+}
+
+function makeDying() {
+ const object = { x: 1 };
+ object.dying = 1;
+ return object;
+}
+
+const keep = { x: 2, keep: 1 };
+
+// The optimized caller holds the dying objects' map weakly. A full GC while
+// flattening a cons string can invalidate that map and lazily deoptimize it.
+// A value-returning fast API call, including its slow fallback, cannot handle
+// that deoptimization and aborts instead.
+eval('%PrepareFunctionForOptimization(byteLength)');
+for (let i = 0; i < 10; i++) {
+ byteLength(makeDying(), 'ab');
+ byteLength(keep, 'ab');
+}
+eval('%OptimizeFunctionOnNextCall(byteLength)');
+assert.strictEqual(byteLength(keep, 'ab'), 4);
+
+const big = 'a'.repeat(1 << 20);
+for (let i = 0; i < 50; i++) {
+ const cons = big + String(i) + big;
+ assert.strictEqual(byteLength(keep, cons), 2 + cons.length);
+}
diff --git a/test/parallel/test-buffer-bytelength-safe-fast.js b/test/parallel/test-buffer-bytelength-safe-fast.js
new file mode 100644
index 00000000000..1665a9e99d2
--- /dev/null
+++ b/test/parallel/test-buffer-bytelength-safe-fast.js
@@ -0,0 +1,77 @@
+// Flags: --expose-internals --no-warnings --allow-natives-syntax --expose_externalize_string
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const { internalBinding } = require('internal/test/binding');
+const { byteLengthUtf8Fast } = internalBinding('buffer');
+
+const {
+ createExternalizableString,
+ createExternalizableTwoByteString,
+ externalizeString,
+ isOneByteString,
+} = globalThis;
+common.allowGlobals(
+ createExternalizableString,
+ createExternalizableTwoByteString,
+ externalizeString,
+ isOneByteString,
+ globalThis.x,
+);
+
+const flat = ['', 'hello', '\xe9', '\x00\x01\x7f\x80\xff'];
+for (const length of [1, 7, 8, 9, 127, 128, 129, 1024]) {
+ flat.push(Buffer.alloc(length, 0xe9).toString('latin1'));
+}
+const latin1 = flat[flat.length - 1];
+const externalOneByte = createExternalizableString('external é'.repeat(20));
+const externalTwoByte = createExternalizableTwoByteString('external €'.repeat(20));
+externalizeString(externalOneByte);
+externalizeString(externalTwoByte);
+const fallback = [
+ 'x'.repeat(1000) + latin1,
+ latin1.slice(1, 1000),
+ '€',
+ '\ud83d',
+ 'a€ðŸ˜€'.repeat(200),
+ externalOneByte,
+ externalTwoByte,
+];
+
+function fastLength(string) {
+ return byteLengthUtf8Fast(string);
+}
+
+eval('%PrepareFunctionForOptimization(fastLength)');
+for (const string of [...flat, ...fallback]) {
+ // Before optimization the callback only returns the rejection sentinel.
+ assert.strictEqual(fastLength(string), -1);
+}
+eval('%OptimizeFunctionOnNextCall(fastLength)');
+for (const string of flat) {
+ assert.strictEqual(fastLength(string), Buffer.from(string).length);
+}
+for (const string of fallback) {
+ assert.strictEqual(fastLength(string), -1);
+}
+assert.strictEqual(fastLength('hello'), 5);
+
+if (common.isDebug) {
+ const { getV8FastApiCallCount } = internalBinding('debug');
+ assert.strictEqual(
+ getV8FastApiCallCount('Buffer::FastByteLengthUtf8'), flat.length + 1);
+}
+
+const { byteLength } = Buffer;
+function test() {
+ for (const string of [...flat, ...fallback]) {
+ assert.strictEqual(byteLength(string), Buffer.from(string).length);
+ assert.strictEqual(byteLength(string, 'UTF-8'), Buffer.from(string).length);
+ assert.strictEqual(byteLength(string, 'unknown'), Buffer.from(string).length);
+ }
+}
+eval('%PrepareFunctionForOptimization(byteLength)');
+test();
+eval('%OptimizeFunctionOnNextCall(byteLength)');
+test();
diff --git a/test/parallel/test-buffer-bytelength-strings.js b/test/parallel/test-buffer-bytelength-strings.js
new file mode 100644
index 00000000000..99e4d5b9e01
--- /dev/null
+++ b/test/parallel/test-buffer-bytelength-strings.js
@@ -0,0 +1,51 @@
+// Flags: --allow-natives-syntax --expose_externalize_string
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+
+const {
+ createExternalizableString,
+ createExternalizableTwoByteString,
+ externalizeString,
+ isOneByteString,
+} = globalThis;
+
+common.allowGlobals(
+ createExternalizableString,
+ createExternalizableTwoByteString,
+ externalizeString,
+ isOneByteString,
+ globalThis.x,
+);
+
+const { byteLength } = Buffer;
+const latin1 = Buffer.from('aé'.repeat(100), 'latin1').toString('latin1');
+const external = createExternalizableString('external é'.repeat(20));
+externalizeString(external);
+assert.strictEqual(isOneByteString(external), true);
+
+// Check flat, cons, sliced, two-byte and external strings, including lone
+// surrogates, before and after optimizing the caller.
+const cases = [
+ 'hello',
+ 'é',
+ latin1,
+ 'x'.repeat(1000) + latin1,
+ latin1.slice(1, 150),
+ '€',
+ '\ud83d',
+ 'a€ðŸ˜€'.repeat(200),
+ external,
+];
+
+function test() {
+ for (const string of cases) {
+ assert.strictEqual(byteLength(string), Buffer.from(string).length);
+ }
+}
+
+eval('%PrepareFunctionForOptimization(byteLength)');
+test();
+eval('%OptimizeFunctionOnNextCall(byteLength)');
+test();
diff --git a/typings/internalBinding/buffer.d.ts b/typings/internalBinding/buffer.d.ts
index 4d5e1f5cde8..9e486c661bd 100644
--- a/typings/internalBinding/buffer.d.ts
+++ b/typings/internalBinding/buffer.d.ts
@@ -5,6 +5,7 @@ export interface BufferBinding {
setBufferPrototype(proto: object): void;
byteLengthUtf8(str: string): number;
+ byteLengthUtf8Fast(str: string): number;
copy(source: ArrayBufferView, target: ArrayBufferView, targetStart: number, sourceStart: number, toCopy: number): number;
compare(a: ArrayBufferView, b: ArrayBufferView): number;
compareOffset(source: ArrayBufferView, target: ArrayBufferView, targetStart?: number, sourceStart?: number, targetEnd?: number, sourceEnd?: number): number;