node/src/node_ipc_serdes.cc
Yagiz Nizipli 04c04c8b5c
child_process: serialize advanced IPC messages natively
The `advanced` IPC serialization codec was implemented in JavaScript
(ChildProcessSerializer / ChildProcessDeserializer in
lib/internal/child_process/serialization.js). It allocated a wrapper
serializer/deserializer per message and crossed the JS/C++ boundary
several times for every message (writeHeader, writeValue, releaseBuffer,
readHeader, readValue and friends).

Move the codec into a native `ipc_serdes` binding that drives the V8
ValueSerializer/ValueDeserializer with a C++ delegate. The wire format
is preserved byte-for-byte: a big-endian uint32 length prefix followed
by the V8 payload, with ArrayBufferViews tagged as host objects so that
Node Buffers round-trip as Buffers rather than plain Uint8Arrays. The
JSON codec is left unchanged.

A cctest (test/cctest/test_node_ipc_serdes.cc) exercises the binding
directly, covering round-trips of primitives, objects, typed arrays and
Buffers (including the Buffer-vs-Uint8Array distinction) and asserting
the big-endian length-prefix framing.

Round-trip throughput
(benchmark/child_process/child-process-ipc-roundtrip):

  payload   before     after      change
  64 B      ~300k/s    ~800k/s    +166%
  1 KiB     ~272k/s    ~616k/s    +126%
  16 KiB    ~91k/s     ~120k/s    +32%
  64 KiB    ~30k/s     ~35k/s     +16%

The gain is largest for small messages, where per-message JavaScript
overhead dominated, and tapers for large messages, where the actual
serialization (already native) dominates.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
PR-URL: https://github.com/nodejs/node/pull/63933
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
2026-06-20 16:58:11 +02:00

385 lines
13 KiB
C++

#include "env-inl.h"
#include "node_buffer.h"
#include "node_errors.h"
#include "node_external_reference.h"
#include "node_internals.h"
#include "util-inl.h"
#include <cstring>
#include <utility>
// Native implementation of the `advanced` child_process IPC serialization
// codec previously implemented in lib/internal/child_process/serialization.js
// (the ChildProcessSerializer / ChildProcessDeserializer classes). The wire
// format is preserved byte-for-byte:
// [4-byte big-endian payload length][V8 ValueSerializer payload]
// ArrayBufferViews are treated as host objects (matching v8.DefaultSerializer)
// and tagged so that Node Buffers round-trip as Buffers rather than plain
// Uint8Arrays.
namespace node {
using v8::ArrayBuffer;
using v8::ArrayBufferView;
using v8::BackingStore;
using v8::BigInt64Array;
using v8::BigUint64Array;
using v8::Context;
using v8::DataView;
using v8::Exception;
using v8::Float16Array;
using v8::Float32Array;
using v8::Float64Array;
using v8::FunctionCallbackInfo;
using v8::Int16Array;
using v8::Int32Array;
using v8::Int8Array;
using v8::Isolate;
using v8::Just;
using v8::Local;
using v8::Maybe;
using v8::MaybeLocal;
using v8::Name;
using v8::Nothing;
using v8::Object;
using v8::String;
using v8::Uint16Array;
using v8::Uint32Array;
using v8::Uint8Array;
using v8::Uint8ClampedArray;
using v8::Value;
using v8::ValueDeserializer;
using v8::ValueSerializer;
namespace ipc_serdes {
// Tags written before each host object, matching serialization.js.
static constexpr uint32_t kArrayBufferViewTag = 0;
static constexpr uint32_t kNotArrayBufferViewTag = 1;
// ArrayBufferView type indices, matching arrayBufferViewTypeToIndex() in
// lib/v8.js. Index 10 is reserved for Node's Buffer (FastBuffer).
static constexpr uint32_t kInvalidViewIndex = 0xFFFFFFFF;
static constexpr uint32_t kBufferIndex = 10;
static uint32_t GetViewTypeIndex(Local<Object> view) {
// Mirrors arrayBufferViewTypeToIndex() in lib/v8.js, classifying the view by
// its class tag. Node Buffers are detected separately by the caller via the
// `value.constructor === Buffer` check, matching DefaultSerializer.
if (view->IsInt8Array()) return 0;
if (view->IsUint8Array()) return 1;
if (view->IsUint8ClampedArray()) return 2;
if (view->IsInt16Array()) return 3;
if (view->IsUint16Array()) return 4;
if (view->IsInt32Array()) return 5;
if (view->IsUint32Array()) return 6;
if (view->IsFloat32Array()) return 7;
if (view->IsFloat64Array()) return 8;
if (view->IsDataView()) return 9;
if (view->IsBigInt64Array()) return 11;
if (view->IsBigUint64Array()) return 12;
if (view->IsFloat16Array()) return 13;
return kInvalidViewIndex;
}
static size_t BytesPerElement(uint32_t type_index) {
switch (type_index) {
case 3: // Int16Array
case 4: // Uint16Array
case 13: // Float16Array
return 2;
case 5: // Int32Array
case 6: // Uint32Array
case 7: // Float32Array
return 4;
case 8: // Float64Array
case 11: // BigInt64Array
case 12: // BigUint64Array
return 8;
default: // Int8/Uint8/Uint8Clamped/DataView/Buffer
return 1;
}
}
static MaybeLocal<Object> MakeView(Environment* env,
uint32_t type_index,
Local<ArrayBuffer> ab,
size_t byte_offset,
size_t byte_length) {
const size_t length = byte_length / BytesPerElement(type_index);
Local<Object> result;
switch (type_index) {
case 0:
result = Int8Array::New(ab, byte_offset, length);
break;
case 1:
result = Uint8Array::New(ab, byte_offset, length);
break;
case 2:
result = Uint8ClampedArray::New(ab, byte_offset, length);
break;
case 3:
result = Int16Array::New(ab, byte_offset, length);
break;
case 4:
result = Uint16Array::New(ab, byte_offset, length);
break;
case 5:
result = Int32Array::New(ab, byte_offset, length);
break;
case 6:
result = Uint32Array::New(ab, byte_offset, length);
break;
case 7:
result = Float32Array::New(ab, byte_offset, length);
break;
case 8:
result = Float64Array::New(ab, byte_offset, length);
break;
case 9:
result = DataView::New(ab, byte_offset, byte_length);
break;
case kBufferIndex: {
Local<Uint8Array> buf;
if (!Buffer::New(env, ab, byte_offset, byte_length).ToLocal(&buf)) {
return {};
}
result = buf;
break;
}
case 11:
result = BigInt64Array::New(ab, byte_offset, length);
break;
case 12:
result = BigUint64Array::New(ab, byte_offset, length);
break;
case 13:
result = Float16Array::New(ab, byte_offset, length);
break;
default:
THROW_ERR_INVALID_STATE(env, "Invalid host object type index");
return {};
}
return result;
}
class IPCSerializerDelegate : public ValueSerializer::Delegate {
public:
IPCSerializerDelegate(Environment* env, Local<Value> buffer_constructor)
: env_(env), buffer_constructor_(buffer_constructor) {}
void set_serializer(ValueSerializer* serializer) { serializer_ = serializer; }
void ThrowDataCloneError(Local<String> message) override {
env_->isolate()->ThrowException(Exception::Error(message));
}
Maybe<bool> WriteHostObject(Isolate* isolate, Local<Object> object) override {
if (object->IsArrayBufferView()) {
serializer_->WriteUint32(kArrayBufferViewTag);
// Matches v8.js DefaultSerializer._writeHostObject: a Node Buffer is
// identified by `value.constructor === Buffer`. The comparison is made
// against the stable Buffer reference captured by serialization.js
// (`require('buffer').Buffer`), not a value read back from
// Buffer.prototype, which is itself tamperable.
Local<Context> context = env_->context();
Local<Value> view_constructor;
if (!object->Get(context, FIXED_ONE_BYTE_STRING(isolate, "constructor"))
.ToLocal(&view_constructor)) {
return Nothing<bool>();
}
uint32_t type_index;
if (view_constructor->StrictEquals(buffer_constructor_)) {
type_index = kBufferIndex;
} else {
type_index = GetViewTypeIndex(object);
if (type_index == kInvalidViewIndex) {
THROW_ERR_INVALID_STATE(env_, "Unserializable host object");
return Nothing<bool>();
}
}
ArrayBufferViewContents<char> contents(object);
serializer_->WriteUint32(type_index);
serializer_->WriteUint32(static_cast<uint32_t>(contents.length()));
serializer_->WriteRawBytes(contents.data(), contents.length());
return Just(true);
}
// Non-view host object: serialize a shallow copy of its own enumerable
// properties, matching `writeValue({ ...object })` in serialization.js.
// Use CreateDataProperty (not Set) so inherited setters are not invoked,
// exactly as the object-spread did.
serializer_->WriteUint32(kNotArrayBufferViewTag);
Local<Context> context = env_->context();
Local<v8::Array> names;
if (!object->GetOwnPropertyNames(context).ToLocal(&names)) {
return Nothing<bool>();
}
Local<Object> copy = Object::New(isolate);
const uint32_t len = names->Length();
for (uint32_t i = 0; i < len; i++) {
Local<Value> key;
if (!names->Get(context, i).ToLocal(&key)) return Nothing<bool>();
Local<Value> val;
if (!object->Get(context, key).ToLocal(&val)) return Nothing<bool>();
if (copy->CreateDataProperty(context, key.As<Name>(), val).IsNothing())
return Nothing<bool>();
}
return serializer_->WriteValue(context, copy);
}
private:
Environment* env_;
// Stable `require('buffer').Buffer` reference passed from serialization.js,
// used to classify Node Buffers without reading Buffer.prototype.constructor
// (which can be tampered with).
Local<Value> buffer_constructor_;
ValueSerializer* serializer_ = nullptr;
};
class IPCDeserializerDelegate : public ValueDeserializer::Delegate {
public:
IPCDeserializerDelegate(Environment* env, Local<ArrayBuffer> ab)
: env_(env), ab_(ab) {}
void set_deserializer(ValueDeserializer* deserializer) {
deserializer_ = deserializer;
}
MaybeLocal<Object> ReadHostObject(Isolate* isolate) override {
uint32_t tag;
if (!deserializer_->ReadUint32(&tag)) return {};
if (tag == kNotArrayBufferViewTag) {
Local<Value> value;
if (!deserializer_->ReadValue(env_->context()).ToLocal(&value)) {
return {};
}
if (!value->IsObject()) {
THROW_ERR_INVALID_STATE(env_, "Host object must be an object");
return {};
}
return value.As<Object>();
}
// Only the two tags written by WriteHostObject are valid. Reject anything
// else, matching `assert(tag === kNotArrayBufferViewTag)` in the JS codec.
if (tag != kArrayBufferViewTag) {
THROW_ERR_INVALID_STATE(env_, "Invalid host object tag");
return {};
}
uint32_t type_index;
uint32_t byte_length;
if (!deserializer_->ReadUint32(&type_index) ||
!deserializer_->ReadUint32(&byte_length)) {
return {};
}
const void* data;
if (!deserializer_->ReadRawBytes(byte_length, &data)) return {};
const size_t bytes_per_element = BytesPerElement(type_index);
const size_t offset_in_ab = static_cast<const uint8_t*>(data) -
static_cast<const uint8_t*>(ab_->Data());
// Reuse the backing ArrayBuffer when the data is suitably aligned,
// otherwise copy into a fresh aligned buffer. Mirrors _readHostObject()
// in lib/v8.js.
if (offset_in_ab % bytes_per_element == 0) {
return MakeView(env_, type_index, ab_, offset_in_ab, byte_length);
}
std::shared_ptr<BackingStore> store =
ArrayBuffer::NewBackingStore(isolate, byte_length);
memcpy(store->Data(), data, byte_length);
Local<ArrayBuffer> copy = ArrayBuffer::New(isolate, std::move(store));
return MakeView(env_, type_index, copy, 0, byte_length);
}
private:
Environment* env_;
Local<ArrayBuffer> ab_;
ValueDeserializer* deserializer_ = nullptr;
};
static void Serialize(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();
Local<Context> context = env->context();
IPCSerializerDelegate delegate(env, args[1]);
ValueSerializer serializer(isolate, &delegate);
delegate.set_serializer(&serializer);
serializer.SetTreatArrayBufferViewsAsHostObjects(true);
// Reserve 4 bytes for the big-endian payload length, then write the
// standard V8 header and the value, matching serialization.js.
const uint8_t length_placeholder[4] = {0, 0, 0, 0};
serializer.WriteRawBytes(length_placeholder, sizeof(length_placeholder));
serializer.WriteHeader();
bool wrote;
if (!serializer.WriteValue(context, args[0]).To(&wrote) || !wrote) {
// A pending exception was set by the delegate or V8.
return;
}
std::pair<uint8_t*, size_t> result = serializer.Release();
uint8_t* buf = result.first;
const size_t size = result.second;
const uint32_t payload_length = static_cast<uint32_t>(size - 4);
buf[0] = (payload_length >> 24) & 0xFF;
buf[1] = (payload_length >> 16) & 0xFF;
buf[2] = (payload_length >> 8) & 0xFF;
buf[3] = payload_length & 0xFF;
Local<Object> out;
if (Buffer::New(env, reinterpret_cast<char*>(buf), size).ToLocal(&out)) {
args.GetReturnValue().Set(out);
}
}
static void Deserialize(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();
Local<Context> context = env->context();
CHECK(args[0]->IsArrayBufferView());
Local<ArrayBufferView> view = args[0].As<ArrayBufferView>();
Local<ArrayBuffer> ab = view->Buffer();
const size_t byte_offset = view->ByteOffset();
const size_t byte_length = view->ByteLength();
const uint8_t* data = static_cast<const uint8_t*>(ab->Data()) + byte_offset;
IPCDeserializerDelegate delegate(env, ab);
ValueDeserializer deserializer(isolate, data, byte_length, &delegate);
delegate.set_deserializer(&deserializer);
bool read_header;
if (!deserializer.ReadHeader(context).To(&read_header)) return;
Local<Value> value;
if (deserializer.ReadValue(context).ToLocal(&value)) {
args.GetReturnValue().Set(value);
}
}
static void Initialize(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
SetMethod(context, target, "serialize", Serialize);
SetMethod(context, target, "deserialize", Deserialize);
}
static void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(Serialize);
registry->Register(Deserialize);
}
} // namespace ipc_serdes
} // namespace node
NODE_BINDING_CONTEXT_AWARE_INTERNAL(ipc_serdes, node::ipc_serdes::Initialize)
NODE_BINDING_EXTERNAL_REFERENCE(ipc_serdes,
node::ipc_serdes::RegisterExternalReferences)