Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 36 additions & 14 deletions src/jsc/bindings/webcore/SerializedScriptValue.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4202,6 +4202,14 @@ SerializedScriptValue::SerializedScriptValue(WTF::FixedVector<SimpleCloneableVal
m_memoryCost = computeMemoryCost();
}

SerializedScriptValue::SerializedScriptValue(JSC::JSValue fastPathPrimitive)
: m_fastPathPrimitive(fastPathPrimitive)
, m_fastPath(FastPath::Primitive)
{
ASSERT(!fastPathPrimitive.isCell());
m_memoryCost = computeMemoryCost();
}

SerializedScriptValue::SerializedScriptValue(const String& fastPathString)
: m_fastPathString(fastPathString)
, m_fastPath(FastPath::String)
Expand Down Expand Up @@ -4254,6 +4262,8 @@ size_t SerializedScriptValue::computeMemoryCost() const

// Account for fast path string memory usage
switch (m_fastPath) {
case FastPath::Primitive:
break;
case FastPath::String:
ASSERT(m_simpleInMemoryPropertyTable.isEmpty());
cost += m_fastPathString.sizeInBytes();
Expand Down Expand Up @@ -4446,26 +4456,29 @@ ExceptionOr<Ref<SerializedScriptValue>> SerializedScriptValue::create(JSGlobalOb
&& messagePorts.isEmpty();

if (canUseFastPath) {
// Not isPrimitive(): on JSVALUE64 the empty JSValue has isCell()==true and must keep
// falling through to the full serializer below.
Comment thread
robobun marked this conversation as resolved.
if (!value.isCell())
return SerializedScriptValue::createPrimitiveFastPath(value);
Comment thread
robobun marked this conversation as resolved.

bool canUseStringFastPath = false;
bool canUseObjectFastPath = false;
bool canUseArrayFastPath = false;
JSObject* object = nullptr;
JSArray* array = nullptr;
Structure* structure = nullptr;
if (value.isCell()) {
auto* cell = value.asCell();
if (cell->isString()) {
canUseStringFastPath = true;
} else if (cell->isObject()) {
object = cell->getObject();
structure = object->structure();

if (auto* jsArray = dynamicDowncast<JSArray>(object)) {
canUseArrayFastPath = true;
array = jsArray;
} else if (isObjectFastPathCandidate(structure)) {
canUseObjectFastPath = true;
}
auto* cell = value.asCell();
if (cell->isString()) {
canUseStringFastPath = true;
} else if (cell->isObject()) {
object = cell->getObject();
structure = object->structure();

if (auto* jsArray = dynamicDowncast<JSArray>(object)) {
canUseArrayFastPath = true;
array = jsArray;
} else if (isObjectFastPathCandidate(structure)) {
canUseObjectFastPath = true;
}
}

Expand Down Expand Up @@ -4760,6 +4773,11 @@ ExceptionOr<Ref<SerializedScriptValue>> SerializedScriptValue::create(JSGlobalOb
return result;
}

Ref<SerializedScriptValue> SerializedScriptValue::createPrimitiveFastPath(JSC::JSValue value)
{
return adoptRef(*new SerializedScriptValue(value));
}

Ref<SerializedScriptValue> SerializedScriptValue::createStringFastPath(const String& string)
{
return adoptRef(*new SerializedScriptValue(Bun::toCrossThreadShareable(string)));
Expand Down Expand Up @@ -4870,6 +4888,10 @@ JSValue SerializedScriptValue::deserialize(JSGlobalObject& lexicalGlobalObject,
VM& vm = lexicalGlobalObject.vm();
auto scope = DECLARE_THROW_SCOPE(vm);
switch (m_fastPath) {
case FastPath::Primitive:
if (didFail)
*didFail = false;
return m_fastPathPrimitive;
case FastPath::String:
if (didFail)
*didFail = false;
Expand Down
8 changes: 8 additions & 0 deletions src/jsc/bindings/webcore/SerializedScriptValue.h
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ using DenseArrayElement = std::variant<JSC::JSValue, WTF::String, SimpleCloneabl

enum class FastPath : uint8_t {
None,
Primitive,
String,
SimpleObject,
SimpleArray,
Expand Down Expand Up @@ -114,6 +115,9 @@ class SerializedScriptValue : public ThreadSafeRefCounted<SerializedScriptValue>

static RefPtr<SerializedScriptValue> convert(JSC::JSGlobalObject& globalObject, JSC::JSValue value) { return create(globalObject, value, SerializationForStorage::Yes); }

// Fast path for postMessage with a bare non-cell primitive (int32/double/bool/null/undefined)
static Ref<SerializedScriptValue> createPrimitiveFastPath(JSC::JSValue);

// Fast path for postMessage with pure strings
static Ref<SerializedScriptValue> createStringFastPath(const String& string);

Expand Down Expand Up @@ -159,6 +163,8 @@ class SerializedScriptValue : public ThreadSafeRefCounted<SerializedScriptValue>
#endif
);

// Constructor for primitive fast path (non-cell JSValue)
explicit SerializedScriptValue(JSC::JSValue fastPathPrimitive);
// Constructor for string fast path
explicit SerializedScriptValue(const String& fastPathString);
explicit SerializedScriptValue(WTF::FixedVector<SimpleInMemoryPropertyTableEntry>&& object);
Expand All @@ -183,6 +189,8 @@ class SerializedScriptValue : public ThreadSafeRefCounted<SerializedScriptValue>

// Fast path for postMessage with pure strings - avoids serialization overhead
String m_fastPathString;
// Fast path for postMessage with a bare non-cell primitive. Only valid when m_fastPath == Primitive.
JSC::JSValue m_fastPathPrimitive {};
FastPath m_fastPath { FastPath::None };
size_t m_memoryCost { 0 };

Expand Down
4 changes: 4 additions & 0 deletions src/jsc/bindings/webcore/StructuredClone.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionStructuredClone, (JSC::JSGlobalObject * globa
auto serializeOptions = convertDictionary<StructuredSerializeOptions>(*globalObject, options);
RETURN_IF_EXCEPTION(throwScope, {});

// Structured clone is the identity function on a non-cell JSValue.
if (serializeOptions.transfer.isEmpty() && !value.isCell())
return JSValue::encode(value);

Vector<RefPtr<MessagePort>> ports;
ExceptionOr<Ref<SerializedScriptValue>> serialized = SerializedScriptValue::create(*globalObject, value, WTF::move(serializeOptions.transfer), ports);
if (serialized.hasException()) {
Expand Down
138 changes: 138 additions & 0 deletions test/js/web/structured-clone-fastpath.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,145 @@
import { deserialize, serialize } from "bun:jsc";
import { describe, expect, test } from "bun:test";
import { isASAN, rss } from "harness";

describe("Structured Clone Fast Path", () => {
// === Primitive fast path tests ===
Comment thread
robobun marked this conversation as resolved.

describe("bare primitive values", () => {
const cases: Array<[string, unknown]> = [
["int32", 42],
["int32 negative", -17],
["int32 zero", 0],
["int32 max", 2147483647],
["int32 min", -2147483648],
["double", 3.14159],
["double -0", -0],
["double NaN", NaN],
["double Infinity", Infinity],
["double -Infinity", -Infinity],
["double max safe int", Number.MAX_SAFE_INTEGER],
["double int32 max + 1", 2147483648],
["true", true],
["false", false],
["null", null],
["undefined", undefined],
["BigInt 0n", 0n],
["BigInt 1n", 1n],
["BigInt int32 max", 2147483647n],
["BigInt int32 min", -2147483648n],
["BigInt heap", 2n ** 80n],
["BigInt heap negative", -(2n ** 80n)],
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test.each(cases)("structuredClone(%s) round-trips", (_, value) => {
const cloned = structuredClone(value);
if (typeof value === "number" && Number.isNaN(value)) {
expect(cloned).toBeNaN();
} else if (Object.is(value, -0)) {
expect(Object.is(cloned, -0)).toBe(true);
} else {
expect(cloned).toBe(value);
}
});

test.each(cases)("postMessage(%s) round-trips via MessageChannel", async (_, value) => {
const { port1, port2 } = new MessageChannel();
try {
const { promise, resolve, reject } = Promise.withResolvers();
port2.onmessage = (e: MessageEvent) => resolve(e.data);
port2.onmessageerror = reject;
port1.postMessage(value);
const result = await promise;
if (typeof value === "number" && Number.isNaN(value)) {
expect(result).toBeNaN();
} else if (Object.is(value, -0)) {
expect(Object.is(result, -0)).toBe(true);
} else {
expect(result).toBe(value);
}
} finally {
port1.close();
port2.close();
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("bun:jsc serialize() still produces real bytes for primitives", () => {
// SerializationForStorage::Yes must bypass every fast path and emit wire bytes.
for (const [, value] of cases) {
const buf = serialize(value);
expect(buf.byteLength).toBeGreaterThan(0);
const back = deserialize(buf);
if (typeof value === "number" && Number.isNaN(value)) {
expect(back).toBeNaN();
} else if (Object.is(value, -0)) {
expect(Object.is(back, -0)).toBe(true);
} else {
expect(back).toBe(value);
}
}
});

test("postMessage of primitives round-trips across a Worker thread", async () => {
const url = URL.createObjectURL(
new Blob([`self.onmessage = e => self.postMessage(e.data);`], { type: "application/javascript" }),
);
const worker = new Worker(url);
try {
for (const [, value] of cases) {
const { promise, resolve, reject } = Promise.withResolvers();
worker.onmessage = (e: MessageEvent) => resolve(e.data);
worker.onmessageerror = reject;
worker.onerror = reject;
worker.postMessage(value);
const result = await promise;
if (typeof value === "number" && Number.isNaN(value)) {
expect(result).toBeNaN();
} else if (Object.is(value, -0)) {
expect(Object.is(result, -0)).toBe(true);
} else {
expect(result).toBe(value);
}
}
} finally {
worker.terminate();
URL.revokeObjectURL(url);
}
});

test("structuredClone(primitive, {transfer: [buffer]}) still detaches the buffer", () => {
const buf = new ArrayBuffer(8);
const cloned = structuredClone(42, { transfer: [buf] });
expect(cloned).toBe(42);
expect(buf.byteLength).toBe(0);
});

test("structuredClone of bare primitive skips the CloneSerializer", () => {
// An empty Map has no fast path and always pays the full CloneSerializer cost.
// A fast-pathed primitive must cost an order of magnitude less than that; off
// the fast path it runs at roughly half the Map cost.
const N = 2_000;
for (let i = 0; i < 500; i++) {
structuredClone(42);
structuredClone(new Map());
}

// Take the best of three runs for each to reduce scheduler/GC noise.
let primTime = Infinity;
let mapTime = Infinity;
for (let trial = 0; trial < 3; trial++) {
let t0 = performance.now();
for (let i = 0; i < N; i++) structuredClone(42);
primTime = Math.min(primTime, performance.now() - t0);

t0 = performance.now();
for (let i = 0; i < N; i++) structuredClone(new Map());
mapTime = Math.min(mapTime, performance.now() - t0);
}

expect(primTime).toBeLessThan(mapTime * 0.25);
});
});

test("structuredClone should work with empty object", () => {
const object = {};
const cloned = structuredClone(object);
Expand Down
Loading