From b5b2d533cc2a346a2c5cca361300a1c870aa2a67 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:02:22 +0000 Subject: [PATCH 1/4] structuredClone/postMessage: add fast path for bare primitive values A non-cell JSValue (int32, double, boolean, null, undefined, and BigInt32 where enabled) has no heap identity and is safe to carry across threads as-is, so structured clone is the identity function on it. The existing fast-path block in SerializedScriptValue::create was gated on value.isCell(), so these values fell through to the full CloneSerializer: transfer-list scan, ObjectPool/HashSet setup, Vector heap alloc, version header + tag writes, and a symmetric CloneDeserializer on receive. Add FastPath::Primitive, storing the JSValue directly on the SerializedScriptValue, and take it for any non-cell input inside the existing canUseFastPath guard (so SerializationForStorage callers like bun:jsc serialize() keep producing real wire bytes). structuredClone() itself additionally returns the value unchanged when there is nothing to transfer, skipping the SerializedScriptValue allocation entirely. --- .../webcore/SerializedScriptValue.cpp | 28 ++++ .../bindings/webcore/SerializedScriptValue.h | 8 ++ src/jsc/bindings/webcore/StructuredClone.cpp | 5 + test/js/web/structured-clone-fastpath.test.ts | 129 ++++++++++++++++++ 4 files changed, 170 insertions(+) diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index bebe18a93545..d4cd4795618b 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4202,6 +4202,14 @@ SerializedScriptValue::SerializedScriptValue(WTF::FixedVector> 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). + if (!value.isCell()) + return SerializedScriptValue::createPrimitiveFastPath(value); + bool canUseStringFastPath = false; bool canUseObjectFastPath = false; bool canUseArrayFastPath = false; @@ -4760,6 +4779,11 @@ ExceptionOr> SerializedScriptValue::create(JSGlobalOb return result; } +Ref SerializedScriptValue::createPrimitiveFastPath(JSC::JSValue value) +{ + return adoptRef(*new SerializedScriptValue(value)); +} + Ref SerializedScriptValue::createStringFastPath(const String& string) { return adoptRef(*new SerializedScriptValue(Bun::toCrossThreadShareable(string))); @@ -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; diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.h b/src/jsc/bindings/webcore/SerializedScriptValue.h index 78347c7980c3..d3b18310269d 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.h +++ b/src/jsc/bindings/webcore/SerializedScriptValue.h @@ -71,6 +71,7 @@ using DenseArrayElement = std::variant static RefPtr 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 createPrimitiveFastPath(JSC::JSValue); + // Fast path for postMessage with pure strings static Ref createStringFastPath(const String& string); @@ -159,6 +163,8 @@ class SerializedScriptValue : public ThreadSafeRefCounted #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&& object); @@ -183,6 +189,8 @@ class SerializedScriptValue : public ThreadSafeRefCounted // 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 }; diff --git a/src/jsc/bindings/webcore/StructuredClone.cpp b/src/jsc/bindings/webcore/StructuredClone.cpp index 56e0b0fd6f95..e0d1a91e21a0 100644 --- a/src/jsc/bindings/webcore/StructuredClone.cpp +++ b/src/jsc/bindings/webcore/StructuredClone.cpp @@ -56,6 +56,11 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionStructuredClone, (JSC::JSGlobalObject * globa auto serializeOptions = convertDictionary(*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. + if (serializeOptions.transfer.isEmpty() && !value.isCell()) + return JSValue::encode(value); + Vector> ports; ExceptionOr> serialized = SerializedScriptValue::create(*globalObject, value, WTF::move(serializeOptions.transfer), ports); if (serialized.hasException()) { diff --git a/test/js/web/structured-clone-fastpath.test.ts b/test/js/web/structured-clone-fastpath.test.ts index f6279c458fcb..a44edae723fa 100644 --- a/test/js/web/structured-clone-fastpath.test.ts +++ b/test/js/web/structured-clone-fastpath.test.ts @@ -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 === + + 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)], + ]; + + 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(); + }); + + 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); From e433c8848f2158516e1746666eff0b062ddeae5c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:04:44 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- test/js/web/structured-clone-fastpath.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/web/structured-clone-fastpath.test.ts b/test/js/web/structured-clone-fastpath.test.ts index a44edae723fa..678981f6243f 100644 --- a/test/js/web/structured-clone-fastpath.test.ts +++ b/test/js/web/structured-clone-fastpath.test.ts @@ -1,6 +1,6 @@ +import { deserialize, serialize } from "bun:jsc"; 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 === From 4b66cb64677a8b4f0ac13414ce806e1684b99822 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:08:33 +0000 Subject: [PATCH 3/4] review: trim fast-path comments, cover int32/BigInt32 boundaries, wire messageerror to rejection --- .../webcore/SerializedScriptValue.cpp | 8 +-- src/jsc/bindings/webcore/StructuredClone.cpp | 3 +- test/js/web/structured-clone-fastpath.test.ts | 49 +++++++++++-------- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index d4cd4795618b..79b201149071 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4456,12 +4456,8 @@ ExceptionOr> 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). + // Not isPrimitive(): on JSVALUE64 the empty JSValue has isCell()==true and must keep + // falling through to the full serializer below. if (!value.isCell()) return SerializedScriptValue::createPrimitiveFastPath(value); diff --git a/src/jsc/bindings/webcore/StructuredClone.cpp b/src/jsc/bindings/webcore/StructuredClone.cpp index e0d1a91e21a0..b3371b275eb9 100644 --- a/src/jsc/bindings/webcore/StructuredClone.cpp +++ b/src/jsc/bindings/webcore/StructuredClone.cpp @@ -56,8 +56,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionStructuredClone, (JSC::JSGlobalObject * globa auto serializeOptions = convertDictionary(*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. + // Structured clone is the identity function on a non-cell JSValue. if (serializeOptions.transfer.isEmpty() && !value.isCell()) return JSValue::encode(value); diff --git a/test/js/web/structured-clone-fastpath.test.ts b/test/js/web/structured-clone-fastpath.test.ts index 678981f6243f..5dcaeb4bb6fd 100644 --- a/test/js/web/structured-clone-fastpath.test.ts +++ b/test/js/web/structured-clone-fastpath.test.ts @@ -10,19 +10,25 @@ describe("Structured Clone Fast Path", () => { ["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], - ["small BigInt", 1n], - ["large BigInt", 2n ** 80n], - ["negative BigInt", -(2n ** 80n)], + ["BigInt 0n", 0n], + ["BigInt 1n", 1n], + ["BigInt int32 max", 2147483647n], + ["BigInt int32 min", -2147483648n], + ["BigInt heap", 2n ** 80n], + ["BigInt heap negative", -(2n ** 80n)], ]; test.each(cases)("structuredClone(%s) round-trips", (_, value) => { @@ -38,19 +44,23 @@ describe("Structured Clone Fast Path", () => { 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); + 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(); } - port1.close(); - port2.close(); }); test("bun:jsc serialize() still produces real bytes for primitives", () => { @@ -78,6 +88,7 @@ describe("Structured Clone Fast Path", () => { 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; @@ -103,11 +114,9 @@ describe("Structured Clone Fast Path", () => { }); 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. + // 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); From cf904695806fe833ebefe61822e22ee84cb25ea4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:24:51 +0000 Subject: [PATCH 4/4] SerializedScriptValue: drop now-redundant isCell() guard after the primitive early return --- .../webcore/SerializedScriptValue.cpp | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index 79b201149071..f05fbed2eb21 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4467,20 +4467,18 @@ ExceptionOr> SerializedScriptValue::create(JSGlobalOb 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(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(object)) { + canUseArrayFastPath = true; + array = jsArray; + } else if (isObjectFastPathCandidate(structure)) { + canUseObjectFastPath = true; } }