Skip to content

structuredClone/postMessage: add fast path for bare primitive values - #36591

Open
robobun wants to merge 4 commits into
mainfrom
farm/fe729565/primitive-structured-clone-fastpath
Open

structuredClone/postMessage: add fast path for bare primitive values#36591
robobun wants to merge 4 commits into
mainfrom
farm/fe729565/primitive-structured-clone-fastpath

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

What

port.postMessage(1), structuredClone(42), and the like fell off the structured-clone fast path into the full CloneSerializer. This adds FastPath::Primitive so a bare non-cell JSValue (int32, double, boolean, null, undefined, and BigInt32 where enabled) is carried as-is.

Why

The canUseFastPath block in SerializedScriptValue::create only looked at value.isCell() candidates (strings, JSArray, flat objects). A non-cell value fell past the block into the full serializer: transfer-list scan, ObjectPool/HashSet setup, Vector<uint8_t> heap alloc, version header + tag writes, plus a symmetric CloneDeserializer on receive.

A non-cell JSValue has no heap identity and is safe to carry across threads as-is. The existing SimpleObject fast path already stores primitive property values as bare cross-thread JSValues, so this follows the same established pattern for the top-level value.

How

  • SerializedScriptValue.h: add FastPath::Primitive, m_fastPathPrimitive, createPrimitiveFastPath, and the matching private ctor.
  • SerializedScriptValue.cpp: inside if (canUseFastPath) and before the value.isCell() branch, if (!value.isCell()) return createPrimitiveFastPath(value);. Ordering is load-bearing: on JSVALUE64 the empty JSValue satisfies isCell() == true and keeps going down the existing branch; SerializationForStorage callers (bun:jsc serialize()) are already excluded by canUseFastPath and keep producing real bytes. Add the Primitive arm to both the computeMemoryCost and deserialize switches.
  • StructuredClone.cpp: when the transfer list is empty and the value is non-cell, return it directly without constructing a SerializedScriptValue.

Measurement

Release build, 1M iterations of structuredClone(42):

before after
structuredClone(42) ~532 ns/op identity return
ratio vs structuredClone(new Map()) (full serializer) 0.52-0.78 0.005

Testing

Added to test/js/web/structured-clone-fastpath.test.ts:

  • Round-trip of every non-cell primitive (int32, doubles incl. -0/NaN/Infinity, booleans, null, undefined, small/large/negative BigInt) via structuredClone, MessageChannel, and a cross-thread Worker echo.
  • bun:jsc serialize() still produces real bytes for every primitive (the SerializationForStorage::Yes path is unchanged).
  • structuredClone(42, { transfer: [buf] }) still detaches the buffer.
  • structuredClone(42) runs at <0.25x the cost of structuredClone(new Map()) (fails at ~0.5x on the previous build, passes at ~0.005x here).

Regression-checked against test/js/web/workers/{structured-clone,structuredClone-classes,message-channel,message-port-pipe,worker-postmessage-transfer}.test.ts, test/js/web/broadcastchannel/broadcast-channel.test.ts, and test/js/node/worker_threads/worker_threads.test.ts (553 tests, all pass).


[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/structured-clone-fastpath.test.ts
bun test v1.4.0 (cf9046958)

test/js/web/structured-clone-fastpath.test.ts:
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32) round-trips [5.71ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 negative) round-trips [0.81ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 zero) round-trips [0.46ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 max) round-trips [1.02ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 min) round-trips [0.44ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double) round-trips [0.46ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double -0) round-trips [0.54ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double NaN) round-trips [0.52ms]
(pass) Structured Clone Fast Path > bare primitive values > struc
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/web/structured-clone-fastpath.test.ts:
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32) round-trips [0.14ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 negative) round-trips [0.01ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 zero) round-trips
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 max) round-trips
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 min) round-trips
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double) round-trips
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double -0) round-trips [0.01ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double NaN) round-trips
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double Infinity) round-trips
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double -Infinity) round-trips
(pass) Structured Clone Fast Path > bare primitive values > structure
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/structured-clone-fastpath.test.ts
bun test v1.4.0 (cf9046958)

test/js/web/structured-clone-fastpath.test.ts:
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32) round-trips [5.25ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 negative) round-trips [0.58ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 zero) round-trips [0.34ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 max) round-trips [0.29ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 min) round-trips [0.32ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double) round-trips [0.30ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double -0) round-trips [0.37ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(double NaN) round-trips [0.35ms]
(pass) Structured Clone Fast Path > bare primitive values > struc
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 969ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/27] gen cpp.rs (cppbind)
[1/27] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 4m 40s
[23/27] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-0.cpp.o
[24/27] link bun-profile
[26/27] strip bun
[26/27] bun-profile --revision
1.4.0-canary.1+cf9046958
[build] done
bun test v1.4.0-canary.1 (cf9046958)

test/js/web/structured-clone-fastpath.test.ts:
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32) round-trips [0.10ms]
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 negative) round-trips
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int32 zero) round-trips
(pass) Structured Clone Fast Path > bare primitive values > structuredClone(int3
... (truncated)
diff hotspot
src/jsc/bindings/webcore/SerializedScriptValue.cpp |  50 +++++---
 src/jsc/bindings/webcore/SerializedScriptValue.h   |   8 ++
 src/jsc/bindings/webcore/StructuredClone.cpp       |   4 +
 test/js/web/structured-clone-fastpath.test.ts      | 138 +++++++++++++++++++++
 4 files changed, 186 insertions(+), 14 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
src/jsc/bindings/webcore/SerializedScriptValue.cpp      7      8      0
src/jsc/bindings/webcore/SerializedScriptValue.h        2      4      0
src/jsc/bindings/webcore/StructuredClone.cpp            4      2      0
test/js/web/structured-clone-fastpath.test.ts           2      9      0

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<uint8_t> 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.
Comment thread src/jsc/bindings/webcore/SerializedScriptValue.cpp Outdated
Comment thread src/jsc/bindings/webcore/StructuredClone.cpp Outdated
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Primitive structured-clone fast path

Layer / File(s) Summary
Primitive value storage and retrieval
src/jsc/bindings/webcore/SerializedScriptValue.h, src/jsc/bindings/webcore/SerializedScriptValue.cpp
SerializedScriptValue stores non-cell JSValue values in a new Primitive fast path and returns them directly during deserialization.
Structured clone integration
src/jsc/bindings/webcore/StructuredClone.cpp
jsFunctionStructuredClone returns non-cell values directly when no transfer list is provided.
Primitive fast-path validation
test/js/web/structured-clone-fastpath.test.ts
Tests cover primitive values, BigInts, special numbers, worker messaging, transfer-list detachment, wire-format serialization, and performance.

Possibly related PRs

  • oven-sh/bun#36474: Both changes modify SerializedScriptValue creation, deserialization, and memory accounting APIs.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding a fast path for primitive values in structuredClone and postMessage.
Description check ✅ Passed The description explains what changed, why it changed, how it works, and how it was verified with detailed tests and measurements.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/structured-clone-fastpath.test.ts`:
- Line 6: In the primitive fast-path tests, remove the section banner and
transient timing-history comments, including the related comments around the
additional referenced lines. Keep only one concise comment documenting the
durable invariant that primitive cloning must bypass the serializer path.
- Around line 9-26: Add boundary cases to the cases array used by the
structured-clone fast-path test: include INT32_MIN and INT32_MAX values, the
supported BigInt32 minimum and maximum limits, and retain an out-of-range BigInt
case to verify fallback behavior. Use the project’s existing limit symbols or
conventions where available.
- Around line 39-54: Update test/js/web/structured-clone-fastpath.test.ts lines
39-54 in the MessageChannel test to reject the awaiting promise through
port2.onmessageerror and close both ports in a finally block. Update lines 79-82
in the worker request test to route worker.onmessageerror to rejection of the
current request; ensure each failure path cannot leave a promise pending.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7e387f67-4433-41e6-8c86-4fa4cf108efb

📥 Commits

Reviewing files that changed from the base of the PR and between df49a6e and b5b2d53.

📒 Files selected for processing (4)
  • src/jsc/bindings/webcore/SerializedScriptValue.cpp
  • src/jsc/bindings/webcore/SerializedScriptValue.h
  • src/jsc/bindings/webcore/StructuredClone.cpp
  • test/js/web/structured-clone-fastpath.test.ts

Comment thread test/js/web/structured-clone-fastpath.test.ts
Comment thread test/js/web/structured-clone-fastpath.test.ts
Comment thread test/js/web/structured-clone-fastpath.test.ts
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. receiveMessageOnPort unexpectedly drops messages with falsy values including undefined #26501 - receiveMessageOnPort drops messages with falsy values (undefined, null, 0, false, '') — these are exactly the non-cell primitive types the new FastPath::Primitive path handles

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #26501

🤖 Generated with Claude Code

Comment thread src/jsc/bindings/webcore/SerializedScriptValue.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit, I checked: the !value.isCell() guard correctly excludes the empty JSValue on JSVALUE64 (encodes as 0, which satisfies isCell()), heap BigInts, and Symbols (all cells → fall through to the full serializer as before). canUseFastPath already gates on forStorage == No, so bun:jsc serialize() still emits wire bytes. The StructuredClone.cpp identity return runs after convertDictionary and only when transfer.isEmpty(), so transfer-list validation and detach semantics are preserved. Both m_fastPath switches (computeMemoryCost, deserialize) have the new Primitive arm.

Extended reasoning...

This is a small, well-contained perf optimization that mirrors the existing FastPath::String/SimpleObject pattern (which already stores bare non-cell JSValues in SimpleCloneableValue for cross-thread transfer). The only finding is a cosmetic redundant-guard nit. Deferring rather than approving because this is C++ JSC structured-clone code on a cross-thread path — worth a maintainer glance even though I found nothing wrong with the mechanism.

Comment thread src/jsc/bindings/webcore/SerializedScriptValue.cpp
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:27 PM PT - Jul 31st, 2026

@robobun, your commit cf904695806fe833ebefe61822e22ee84cb25ea4 passed in Build #86530! 🎉


🧪   To try this PR locally:

bunx bun-pr 36591

That installs a local version of the PR into your bun-36591 executable, so you can run:

bun-36591 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but this is a perf optimization in SerializedScriptValue (cross-thread JSValue carrying + a timing-ratio assertion in the new test), so worth a human look.

Checked: canUseFastPath already excludes SerializationForStorage::Yes and non-empty transfer lists, so bun:jsc serialize() and transfer-detach still hit the full serializer. Both m_fastPath switches (computeMemoryCost, deserialize) have the new Primitive arm. Storing a bare non-cell JSValue cross-thread matches the existing SimpleCloneableValue pattern used by SimpleObject/SimpleArray. The StructuredClone.cpp identity return runs after convertDictionary, so invalid transfer options still throw first.

Extended reasoning...

Overview

Adds FastPath::Primitive to SerializedScriptValue so bare non-cell JSValues (int32/double/bool/null/undefined/BigInt32) skip the full CloneSerializer. Touches SerializedScriptValue.{h,cpp} (new enum arm, ctor, member field, two switch arms, early return in create), StructuredClone.cpp (identity return for non-cell + empty transfer), and adds ~140 lines of tests to structured-clone-fastpath.test.ts. My earlier nit about the now-redundant if (value.isCell()) wrapper was addressed in cf90469.

Security risks

None identified. Non-cell JSValues are immediate 64-bit encodings with no heap identity, so carrying them across threads as-is is safe — the same pattern is already used for SimpleCloneableValue property values in the existing SimpleObject/SimpleArray fast paths. No user-controlled parsing, no allocation sizing from untrusted input.

Level of scrutiny

Medium-high. This is hot-path C++ in the JSC bindings that runs on every postMessage/structuredClone, and the SerializedScriptValue object crosses thread boundaries (Worker, BroadcastChannel). The change is small and follows an established in-file pattern, but perf claims and cross-thread value handling in JSC bindings are exactly the kind of thing REVIEW.md flags for maintainer sign-off. The new timing-ratio test (primTime < mapTime * 0.25) uses best-of-3 over 2k iterations with a ~50x margin between the observed ratio (~0.005) and the threshold, which looks robust, but timing-based assertions in CI deserve a second opinion.

Other factors

  • Verified both switch (m_fastPath) sites (memory cost, deserialize) cover the new arm; no other consumers of the enum.
  • canUseFastPath gates on forStorage == No, forTransfer == No, and empty transfer/port lists, so bun:jsc serialize() and transfer-detach are unaffected (both covered by new tests).
  • The empty-JSValue edge case (isCell()==true on JSVALUE64) is unchanged from pre-PR behavior — it fails the !isCell() test and reaches asCell() exactly as before.
  • One minor unaddressed CodeRabbit nit remains about the wording of the perf-test comment ("off the fast path it runs at roughly half the Map cost"); not blocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants