From 94a4ffd32ac834bf708aff461394209e46e73438 Mon Sep 17 00:00:00 2001 From: Hakaou Date: Thu, 22 Oct 2026 12:41:06 +0200 Subject: [PATCH] v8: keep immutable ArrayBuffer views immutable v8.serialize stores an ArrayBufferView as a type index, a byte length and the referenced bytes. A view over an immutable ArrayBuffer came back as a mutable view. structuredClone and MessagePort keep immutability because V8 serializes views natively there. For a view over a whole immutable ArrayBuffer, write the type index with its high bit set. Then write the buffer itself through writeValue. V8 tags the buffer as immutable and rebuilds it immutable on read. The reader wraps the buffer in the view class. The encoding of views over mutable ArrayBuffers does not change. A view over part of an immutable ArrayBuffer now throws an error. The error message points to sliceToImmutable. An immutable copy of the range needs that method. It only exists with --js-immutable-arraybuffer. It is not a primordial. User code can replace it before this module loads. V8 has no native way to do the copy yet. A new serdes binding detects immutability. The ArrayBuffer.prototype.immutable getter only exists with the flag. An immutable ArrayBuffer can still be deserialized without it. The reader now copies a mutable view that it reads from an immutable transport buffer. Before this change the view pointed into the transport and came back immutable. The reader also throws ERR_INVALID_STATE on an unknown type index. Before this change it threw a TypeError on undefined. This change leaves the advanced IPC codec in src/node_ipc_serdes.cc as is. Refs: https://github.com/nodejs/node/issues/66346 Assisted-by: Claude Code Signed-off-by: Hakaou --- doc/api/v8.md | 9 + lib/v8.js | 68 +++++++- src/node_serdes.cc | 11 ++ test/parallel/test-v8-serdes-immutable.js | 200 ++++++++++++++++++++++ 4 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 test/parallel/test-v8-serdes-immutable.js diff --git a/doc/api/v8.md b/doc/api/v8.md index 2517866581e5..49b81477313a 100644 --- a/doc/api/v8.md +++ b/doc/api/v8.md @@ -926,11 +926,20 @@ provided by subclasses. A subclass of [`Serializer`][] that serializes `TypedArray` (in particular [`Buffer`][]) and `DataView` objects as host objects, and only stores the part of their underlying `ArrayBuffer`s that they are referring to. +A view over a whole immutable `ArrayBuffer` is stored as that immutable +`ArrayBuffer` and is deserialized over an immutable `ArrayBuffer`. A view over +part of an immutable `ArrayBuffer` cannot be serialized: serialize a view over +`buffer.sliceToImmutable(start, end)` instead. ### Class: `v8.DefaultDeserializer` diff --git a/lib/v8.js b/lib/v8.js index ea3366bb9617..a2738016e0e6 100644 --- a/lib/v8.js +++ b/lib/v8.js @@ -16,6 +16,7 @@ const { Array, + ArrayBufferPrototypeGetByteLength, BigInt64Array, BigUint64Array, DataView, @@ -29,6 +30,7 @@ const { JSONParse, ObjectPrototypeToString, SymbolDispose, + TypedArrayPrototypeSet, Uint16Array, Uint32Array, Uint8Array, @@ -36,6 +38,12 @@ const { } = primordials; const { Buffer } = require('buffer'); +const { + codes: { + ERR_INVALID_STATE, + }, +} = require('internal/errors'); +const { isArrayBuffer } = require('internal/util/types'); const { validateString, validateOneOf, @@ -44,6 +52,7 @@ const { const { Serializer, Deserializer, + isImmutableArrayBuffer, } = internalBinding('serdes'); const { namespace: startupSnapshot, @@ -401,6 +410,22 @@ Deserializer.prototype.readRawBytes = function readRawBytes(length) { length); }; +// High bit: a V8-encoded ArrayBuffer replaces length + raw bytes. +const kNestedArrayBufferFlag = 0x80000000; +const kTypeIndexMask = 0x7fffffff; + +// sliceToImmutable() is not a primordial and can be replaced before this +// module loads, so only views over a whole buffer are nested. +function immutableRange(serializer, buffer, byteOffset, byteLength) { + if (byteOffset === 0 && + ArrayBufferPrototypeGetByteLength(buffer) === byteLength) { + return buffer; + } + throw new serializer._getDataCloneError( + 'Cannot serialize a view over part of an immutable ArrayBuffer. ' + + 'Serialize a view over buffer.sliceToImmutable(start, end) instead'); +} + function arrayBufferViewTypeToIndex(abView) { const type = ObjectPrototypeToString(abView); if (type === '[object Int8Array]') return 0; @@ -467,11 +492,15 @@ class DefaultSerializer extends Serializer { `Unserializable host object: ${inspect(abView)}`); } } + const { buffer, byteOffset, byteLength } = abView; + if (isImmutableArrayBuffer(buffer)) { + this.writeUint32(i | kNestedArrayBufferFlag); + this.writeValue(immutableRange(this, buffer, byteOffset, byteLength)); + return; + } this.writeUint32(i); - this.writeUint32(abView.byteLength); - this.writeRawBytes(new Uint8Array(abView.buffer, - abView.byteOffset, - abView.byteLength)); + this.writeUint32(byteLength); + this.writeRawBytes(new Uint8Array(buffer, byteOffset, byteLength)); } } @@ -483,20 +512,41 @@ class DefaultDeserializer extends Deserializer { */ _readHostObject() { const typeIndex = this.readUint32(); - const ctor = arrayBufferViewIndexToType(typeIndex); + const ctor = arrayBufferViewIndexToType(typeIndex & kTypeIndexMask); + if (ctor === undefined) { + throw new ERR_INVALID_STATE( + `unknown ArrayBufferView type index ${typeIndex}`); + } + const BYTES_PER_ELEMENT = ctor.BYTES_PER_ELEMENT || 1; + + if ((typeIndex & kNestedArrayBufferFlag) !== 0) { + const buffer = this.readValue(); + if (!isArrayBuffer(buffer)) { + throw new ERR_INVALID_STATE( + 'expected an ArrayBuffer value for an ArrayBufferView'); + } + const bufferLength = ArrayBufferPrototypeGetByteLength(buffer); + if (bufferLength % BYTES_PER_ELEMENT !== 0) { + throw new ERR_INVALID_STATE( + `${bufferLength} bytes do not fit ${ctor.name}`); + } + return new ctor(buffer, 0, bufferLength / BYTES_PER_ELEMENT); + } + const byteLength = this.readUint32(); const byteOffset = this._readRawBytes(byteLength); - const BYTES_PER_ELEMENT = ctor.BYTES_PER_ELEMENT || 1; const offset = this.buffer.byteOffset + byteOffset; - if (offset % BYTES_PER_ELEMENT === 0) { + if (offset % BYTES_PER_ELEMENT === 0 && + !isImmutableArrayBuffer(this.buffer.buffer)) { return new ctor(this.buffer.buffer, offset, byteLength / BYTES_PER_ELEMENT); } - // Copy to an aligned buffer first. + // Copy to an aligned, mutable buffer first. const buffer_copy = Buffer.allocUnsafe(byteLength); - buffer_copy.set(new Uint8Array(this.buffer.buffer, this.buffer.byteOffset + byteOffset, byteLength)); + TypedArrayPrototypeSet(buffer_copy, + new Uint8Array(this.buffer.buffer, offset, byteLength)); return new ctor(buffer_copy.buffer, buffer_copy.byteOffset, byteLength / BYTES_PER_ELEMENT); diff --git a/src/node_serdes.cc b/src/node_serdes.cc index 00fcd4b6afcc..6a2f11e067a2 100644 --- a/src/node_serdes.cc +++ b/src/node_serdes.cc @@ -461,6 +461,13 @@ void DeserializerContext::ReadRawBytes( args.GetReturnValue().Set(offset); } +// Deserialized buffers can be immutable even when the flag-gated JS getter +// is unavailable. The getter can also be replaced by user code. +void IsImmutableArrayBuffer(const FunctionCallbackInfo& args) { + args.GetReturnValue().Set(args[0]->IsArrayBuffer() && + args[0].As()->IsImmutable()); +} + void Initialize(Local target, Local unused, Local context, @@ -468,6 +475,8 @@ void Initialize(Local target, Environment* env = Environment::GetCurrent(context); Isolate* isolate = env->isolate(); + SetMethod(context, target, "isImmutableArrayBuffer", IsImmutableArrayBuffer); + Local ser = NewFunctionTemplate(isolate, SerializerContext::New); @@ -523,6 +532,8 @@ void Initialize(Local target, } void RegisterExternalReferences(ExternalReferenceRegistry* registry) { + registry->Register(IsImmutableArrayBuffer); + registry->Register(SerializerContext::New); registry->Register(SerializerContext::WriteHeader); diff --git a/test/parallel/test-v8-serdes-immutable.js b/test/parallel/test-v8-serdes-immutable.js new file mode 100644 index 000000000000..1092fe93de44 --- /dev/null +++ b/test/parallel/test-v8-serdes-immutable.js @@ -0,0 +1,200 @@ +// Flags: --js-immutable-arraybuffer +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const v8 = require('v8'); + +if (typeof ArrayBuffer.prototype.transferToImmutable !== 'function') + common.skip('ArrayBuffer.prototype.transferToImmutable is not available'); + +function immutable(bytes) { + const ab = new ArrayBuffer(bytes.length); + new Uint8Array(ab).set(bytes); + return ab.transferToImmutable(); +} + +function bytesOf(view) { + return [...new Uint8Array(view.buffer, view.byteOffset, view.byteLength)]; +} + +const source = immutable([9, 9, 65, 66, 67, 68, 9, 9]); + +// A whole immutable view keeps its type, its bytes and its immutability. +{ + const cases = [ + [new Uint8Array(source), 1], + [Buffer.from(source), 1], + [new Uint16Array(source), 2], + [new DataView(source), 1], + [new Float64Array(source), 8], + ]; + for (const [view, bytesPerElement] of cases) { + const result = v8.deserialize(v8.serialize(view)); + assert.strictEqual(result.constructor, view.constructor); + assert.strictEqual(result.buffer.immutable, true); + assert.strictEqual(result.byteOffset, 0); + assert.strictEqual(result.byteLength, 8); + assert.strictEqual(result.length ?? 8, 8 / bytesPerElement); + assert.deepStrictEqual(bytesOf(result), [9, 9, 65, 66, 67, 68, 9, 9]); + } +} + +// A partial view is refused, an explicit sliceToImmutable() copy serializes. +{ + const secret = immutable([...Buffer.from('PREFIX|PUBLIC|SUFFIX')]); + assert.throws(() => v8.serialize(new Uint8Array(secret, 7, 6)), { + name: 'Error', + message: /Serialize a view over buffer\.sliceToImmutable\(start, end\) instead/, + }); + const serialized = v8.serialize(new Uint8Array(secret.sliceToImmutable(7, 13))); + assert.strictEqual(Buffer.from(serialized).includes('PUBLIC'), true); + assert.strictEqual(Buffer.from(serialized).includes('PREFIX'), false); + assert.strictEqual(Buffer.from(serialized).includes('SUFFIX'), false); + const result = v8.deserialize(serialized); + assert.strictEqual(result.buffer.immutable, true); + assert.deepStrictEqual(bytesOf(result), [...Buffer.from('PUBLIC')]); +} + +// A masked byteLength does not make a partial view look whole. +{ + const buffer = immutable([...Buffer.from('PUBLIC|SECRET')]); + const view = new Uint8Array(buffer, 0, 6); + Object.defineProperty(buffer, 'byteLength', { value: 6 }); + assert.throws(() => v8.serialize(view), { + message: /view over part of an immutable ArrayBuffer/, + }); +} + +// Views sharing a whole buffer still share it after the round trip. +{ + const result = v8.deserialize(v8.serialize({ + first: new Uint8Array(source), + second: new DataView(source), + })); + assert.strictEqual(result.first.buffer, result.second.buffer); + assert.strictEqual(result.first.buffer.immutable, true); +} + +// Mutable and immutable views mix, with repeated references and a cycle. +{ + const mutable = new Uint8Array([1, 2, 3]); + const value = { mutable, immutable: new Uint8Array(source), again: mutable }; + value.self = value; + const result = v8.deserialize(v8.serialize(value)); + assert.strictEqual(result.mutable.buffer.immutable, false); + assert.strictEqual(result.immutable.buffer.immutable, true); + assert.strictEqual(result.mutable, result.again); + assert.strictEqual(result.self, result); +} + +// Only the mutable view keeps aliasing the transport bytes. +{ + const serialized = v8.serialize({ + mutable: new Uint8Array([5, 5]), + immutable: new Uint8Array(immutable([65, 66])), + }); + const result = v8.deserialize(serialized); + serialized.fill(0); + assert.deepStrictEqual(bytesOf(result.immutable), [65, 66]); + assert.deepStrictEqual(bytesOf(result.mutable), [0, 0]); +} + +// A mutable view read from an immutable transport comes back mutable. +{ + const serialized = v8.serialize(new Uint8Array([7, 8])); + const transport = new Uint8Array(immutable([...serialized])); + const result = v8.deserialize(transport); + assert.strictEqual(result.buffer.immutable, false); + assert.deepStrictEqual(bytesOf(result), [7, 8]); +} + +// SharedArrayBuffer views and transport keep working. +{ + const shared = new SharedArrayBuffer(4); + new Uint8Array(shared).set([1, 2, 3, 4]); + for (const view of [new Uint8Array(shared), new DataView(shared), Buffer.from(shared)]) { + const result = v8.deserialize(v8.serialize(view)); + assert.strictEqual(result.constructor, view.constructor); + assert.deepStrictEqual(bytesOf(result), [1, 2, 3, 4]); + } + const serialized = v8.serialize(new Uint8Array([6, 7])); + const transport = new Uint8Array(new SharedArrayBuffer(serialized.byteLength)); + transport.set(serialized); + assert.deepStrictEqual(bytesOf(v8.deserialize(transport)), [6, 7]); +} + +// Malformed records are rejected with ERR_INVALID_STATE. +{ + class BadSerializer extends v8.Serializer { + constructor(write) { + super(); + this._setTreatArrayBufferViewsAsHostObjects(true); + this._writeHostObject = write; + } + } + const serializeWith = (write) => { + const serializer = new BadSerializer(write); + serializer.writeHeader(); + serializer.writeValue(new Uint8Array(1)); + return serializer.releaseBuffer(); + }; + const unknownIndex = serializeWith(function() { + this.writeUint32(14); + this.writeUint32(0); + }); + assert.throws(() => v8.deserialize(unknownIndex), { + code: 'ERR_INVALID_STATE', + message: /unknown ArrayBufferView type index 14/, + }); + const notABuffer = serializeWith(function() { + this.writeUint32(1 | 0x80000000); + this.writeValue(4); + }); + assert.throws(() => v8.deserialize(notABuffer), { + code: 'ERR_INVALID_STATE', + message: /expected an ArrayBuffer value/, + }); + const wrongLength = serializeWith(function() { + this.writeUint32(4 | 0x80000000); + this.writeValue(immutable([1, 2, 3])); + }); + assert.throws(() => v8.deserialize(wrongLength), { + code: 'ERR_INVALID_STATE', + message: /3 bytes do not fit Uint16Array/, + }); +} + +// Symbol.species is not consulted, aligned or not. +{ + const serialized = v8.serialize(new Uint16Array([257, 514])); + Object.defineProperty(Uint8Array, Symbol.species, { + configurable: true, + get: common.mustNotCall('Symbol.species must not be consulted'), + }); + for (const shift of [0, 1]) { + const transport = new Uint8Array(serialized.byteLength + shift); + transport.set(serialized, shift); + const result = v8.deserialize( + new Uint8Array(transport.buffer, shift, serialized.byteLength)); + assert.deepStrictEqual([...result], [257, 514]); + } + delete Uint8Array[Symbol.species]; +} + +// Without the flag, an immutable view read from a record serializes again. +{ + const { spawnSync } = require('child_process'); + const record = v8.serialize(new Uint8Array(source)).toString('hex'); + const child = spawnSync(process.execPath, ['-e', ` + const v8 = require('v8'); + const whole = v8.deserialize(Buffer.from('${record}', 'hex')); + whole[0] = 0; + if (whole[0] === 0) throw new Error('expected an immutable buffer'); + const again = v8.deserialize(v8.serialize(whole)); + again[0] = 0; + if (again[0] === 0) throw new Error('expected an immutable buffer again'); + console.log(String.fromCharCode(...again.subarray(2, 6))); + `], { encoding: 'utf8' }); + assert.strictEqual(child.status, 0, child.stderr); + assert.strictEqual(child.stdout.trim(), 'ABCD'); +}