Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 28 additions & 0 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,6 +4456,15 @@ ExceptionOr<Ref<SerializedScriptValue>> SerializedScriptValue::create(JSGlobalOb
&& messagePorts.isEmpty();

if (canUseFastPath) {
// A bare non-cell JSValue (int32/double/bool/null/undefined, plus BigInt32 when
// USE(BIGINT32)) has no heap identity and is safe to carry across threads as-is.
// Ordering is load-bearing: on JSVALUE64 the empty JSValue satisfies isCell()==true,
// so it continues to the cell branch below; this check must not be rewritten as
// isPrimitive() or hoisted above canUseFastPath (SerializationForStorage callers
// like bun:jsc serialize() depend on getting real wire bytes).
Comment thread
robobun marked this conversation as resolved.
Outdated
if (!value.isCell())
return SerializedScriptValue::createPrimitiveFastPath(value);
Comment thread
robobun marked this conversation as resolved.

bool canUseStringFastPath = false;
bool canUseObjectFastPath = false;
bool canUseArrayFastPath = false;
Expand Down Expand Up @@ -4760,6 +4779,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 +4894,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
5 changes: 5 additions & 0 deletions src/jsc/bindings/webcore/StructuredClone.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionStructuredClone, (JSC::JSGlobalObject * globa
auto serializeOptions = convertDictionary<StructuredSerializeOptions>(*globalObject, options);
RETURN_IF_EXCEPTION(throwScope, {});

// A non-cell JSValue (int32/double/bool/null/undefined) has no heap identity and structured
// clone is the identity function on it. With nothing to transfer, skip the round-trip.
Comment thread
robobun marked this conversation as resolved.
Outdated
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
129 changes: 129 additions & 0 deletions test/js/web/structured-clone-fastpath.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,136 @@
import { describe, expect, test } from "bun:test";
import { isASAN, rss } from "harness";
import { serialize, deserialize } from "bun:jsc";

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],
["double", 3.14159],
["double -0", -0],
["double NaN", NaN],
["double Infinity", Infinity],
["double -Infinity", -Infinity],
["double max safe int", Number.MAX_SAFE_INTEGER],
["true", true],
["false", false],
["null", null],
["undefined", undefined],
["small BigInt", 1n],
["large BigInt", 2n ** 80n],
["negative BigInt", -(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();
const { promise, resolve } = Promise.withResolvers();
port2.onmessage = (e: MessageEvent) => resolve(e.data);
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);
}
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.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", () => {
// Reference: an empty Map has no structured-clone fast path and always goes
// through the full CloneSerializer. Before the primitive fast path existed,
// structuredClone(42) took the same serializer path and ran at roughly half
// the cost of the Map (observed ratio 0.5-0.8). On the fast path it returns
// the value immediately and is an order of magnitude cheaper.
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