diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index bebe18a93545..f05fbed2eb21 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) { + // 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); + 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(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; } } @@ -4760,6 +4773,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 +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; 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..b3371b275eb9 100644 --- a/src/jsc/bindings/webcore/StructuredClone.cpp +++ b/src/jsc/bindings/webcore/StructuredClone.cpp @@ -56,6 +56,10 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionStructuredClone, (JSC::JSGlobalObject * globa auto serializeOptions = convertDictionary(*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> 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..5dcaeb4bb6fd 100644 --- a/test/js/web/structured-clone-fastpath.test.ts +++ b/test/js/web/structured-clone-fastpath.test.ts @@ -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 === + + 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)], + ]; + + 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(); + } + }); + + 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);