Skip to content

webcore: preserve Error cause across structuredClone/postMessage/v8 serialization - #31525

Open
alii wants to merge 8 commits into
mainfrom
ali/ssv-error-cause
Open

alii wants to merge 8 commits into
mainfrom
ali/ssv-error-cause

Conversation

@alii

@alii alii commented May 28, 2026

Copy link
Copy Markdown
Member

structuredClone(err), Worker.postMessage(err), and node:v8.serialize/deserialize(err) silently dropped an Error's cause — the cloned error had cause === undefined. Node preserves it. The SerializedScriptValue ErrorInstance codec serialized only {type, message, line, column, sourceURL, stack}, never cause.

Serialize cause as a full value through the existing clone state machine (the same path Array/Object/Map children use), so it keeps its type — an Error cause stays an Error, an object stays an object, primitives by value, nested chains preserved — matching Node/V8 (not upstream WebKit's stringify-the-cause). The error is recorded in the object pool before its cause, so cycles (e.cause = e) and shared identity resolve correctly, and 5000-deep cause chains serialize via the work-stack with no native recursion.

Bumps CurrentVersion 13→14 and gates the cause read on the version, so already-persisted v13 v8.serialize blobs still deserialize (verified with a captured v13 blob). An error with no own cause round-trips unchanged; cause is reconstructed non-enumerable, matching new Error(msg, { cause }).

Matches Node exactly (string / number / object / nested-Error / cyclic / undefined / no-cause) across all three entry points; v14 also gains Error identity preservation. Adds tests across structuredClone, node:v8, and Worker.postMessage. Not a port regression — the C++ codec dropped cause in 1.3.14 too; Node is the reference.

@alii

alii commented May 28, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented May 28, 2026 •

Copy link
Copy Markdown
Collaborator

@robobun

robobun commented May 28, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Adopted & validated — ready for a maintainer.

Built debug (ASAN) and ran the full suites — structured-clone.test.ts + worker.test.ts pass end-to-end (0 fail):

  • Error-cause round-trips across all four entry points (structuredClone, bun:jsc, node:v8, cross-process): string / number / object / nested-Error / chain / cyclic / shared-identity / nested-in-containers, plus the captured v13-blob back-compat case.
  • Worker.postMessage cause round-trip passes.
  • error-cause-node-parity.test.mts (node:test) passes 20/20 under Node v24 and under this branch's build; structured-clone.test.ts spawns node --test on it so Node parity is enforced in CI.

Confirmed fail-before on a build without the fix (cause comes back undefined; parity file fails 18/20), pass-after on this branch. Release build verified locally as well.

CI (#59701): every build lane green (incl. ASAN, musl, baseline, android, freebsd, windows-cross) and every linux/windows/alpine test lane green (incl. debian-13-x64-asan-test-bun). The 9 failures are all on the 🍎 macOS lanes (which expired repeatedly before late agents picked them up) and none touches this diff: test-docker-build-{debian,alpine,distroless,debian-slim}, autobahn.test.ts, websocket-proxy.test.ts, sql.test.ts, sql-prepare-false.test.ts, regression/issue/21311.test.ts — docker/postgres-service-dependent or unrelated suites. The structured-clone/worker/Error-cause-parity files ran on those same darwin jobs and passed.

Commits on top of the original: 852ba195b2 (root the pending cause in m_gcBuffer, mirroring the map-value path) · 881b0974f5 (deflake two worker event-loop tests) · 90d254ebdd (node:test parity file run under node --test in CI) · 6d504cfaa9 (document ErrorInstanceTag in the wire-format grammar) · 37279fb8cf (sync main's noUnify entry for the xxhash3 Highway TU).

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

@coderabbitai

coderabbitai Bot commented May 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 17 minutes and 32 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a95ab579-4153-4489-a559-26561be70a10

📥 Commits

Reviewing files that changed from the base of the PR and between df75e22 and 37279fb.

📒 Files selected for processing (4)
  • scripts/build/unified.ts
  • src/jsc/bindings/webcore/SerializedScriptValue.cpp
  • test/js/web/workers/error-cause-node-parity.test.mts
  • test/js/web/workers/structured-clone.test.ts

Walkthrough

This PR extends structured-clone support for Error instances by adding serialization and deserialization of the cause property. The serialization format version is bumped to 14, and both the serializer and deserializer use iterative state-machine logic to emit and attach causes. Tests validate preservation across serializers, cross-process roundtrips, and worker boundaries, plus v13 compatibility.

Changes

Error cause structured-clone support

Layer / File(s) Summary
Serialization format version and state machine infrastructure
src/jsc/bindings/webcore/SerializedScriptValue.cpp
Version bumped to 14; new ErrorEndVisitCause state added to WalkerState enum; member variables m_pendingErrorCause and m_pendingErrorWithCause introduced to track pending error-cause values during iterative processing.
Error cause serialization and queueing
src/jsc/bindings/webcore/SerializedScriptValue.cpp
ErrorInstance serialization reads the cause data property, writes a hasCause flag to the payload, and stashes the cause in m_pendingErrorCause for state-machine processing; dumpIfTerminal detects pending causes and suppresses re-entrant output passes.
Serialization state machine cause processing
src/jsc/bindings/webcore/SerializedScriptValue.cpp
State-machine handlers for errorVisitCause and ErrorEndVisitCause append the pending cause value and clear the pending slot; StateUnknown dispatch diverts to cause-visit flow when a pending cause is present, enabling iterative serialization.
Error cause deserialization and queueing
src/jsc/bindings/webcore/SerializedScriptValue.cpp
ErrorInstanceTag deserialization reads hasCause flag when format version ≥ 14; if set, the constructed error is stashed in m_pendingErrorWithCause and returns non-terminal; readTerminal avoids consuming bytes when a pending error is re-entered.
Deserialization state machine cause attachment
src/jsc/bindings/webcore/SerializedScriptValue.cpp
ErrorEndVisitCause handler attaches the deserialized cause value to the error object; StateUnknown dispatch detects pending errors, pushes them onto the output stack, and transitions to cause-attachment to complete error construction.
Test helpers and matrix extension
test/js/web/workers/structured-clone.test.ts
Added v8SerializeRoundtrip helper for roundtripping via node:v8; test matrix now exercises all variants: structuredClone, jscSerializeRoundtrip, v8SerializeRoundtrip, and jscSerializeRoundtripCrossProcess.
Error cause semantics and backwards compatibility tests
test/js/web/workers/structured-clone.test.ts, test/js/web/workers/worker.test.ts, test/js/web/workers/error-cause-node-parity.test.ts
Comprehensive test coverage for cause preservation across primitive/object causes, nested error chains, self-referential causes, and shared instances; regression test ensures version-13 payloads without cause field still deserialize correctly; worker and Node parity tests validate preservation across worker boundaries and runtime parity.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The pull request description provides thorough detail on the problem, solution, implementation, and testing, but is missing the structured template sections. Restructure the description to follow the template with explicit 'What does this PR do?' and 'How did you verify your code works?' sections.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: preserving Error cause across multiple serialization/cloning mechanisms.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

…erialization

The cause stashed in m_pendingErrorCause is descended into by the state
machine one iteration later. Append it to m_gcBuffer when stashing it,
mirroring the map-value path (MapDataStartVisitEntry), so the codec roots
its own pending value rather than relying on the cause staying reachable
as an own property of the error being serialized.
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

The two "event listeners doesn't close event loop" tests killed the
spawned subprocess after a fixed setTimeout(1000) and reported a timeout.
Under the ASAN debug build the spawn + ~50-message worker exchange exceeds
1s, so the watchdog fired before the child could print "done" and exit 0,
failing the tests even though the behavior under test was correct. Drop the
timer and just await x.stdout + x.exited (await using handles cleanup),
matching the repo's no-setTimeout-in-tests guidance.
@robobun
robobun force-pushed the ali/ssv-error-cause branch from 9b31b63 to 881b097 Compare May 28, 2026 18:39

@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.

I didn't find any bugs and the test coverage is thorough, but this threads new control flow through the structured-clone state machine (with a wire-format version bump and new Error identity-pooling), so it's worth a human pass on the C++ side.

Extended reasoning...

Overview

This PR extends SerializedScriptValue.cpp to preserve Error.prototype.cause across structuredClone, postMessage, and node:v8 serialize/deserialize. It bumps CurrentVersion 13→14, adds a new ErrorEndVisitCause walker state, introduces m_pendingErrorCause / m_pendingErrorWithCause member slots, and weaves cause serialization into the existing iterative state machine rather than recursing natively. It also newly calls startObjectInternal() on ErrorInstances and appends them to m_gcBuffer on the deserializer side, which adds identity preservation (the same Error appearing twice now round-trips as a single object via ObjectReferenceTag). Two test files gain ~130 lines of coverage for primitive/object/nested/cyclic/shared causes plus a captured v13 blob for back-compat, and two flaky worker tests are rewritten to await subprocess exit instead of racing a 1s timeout.

Security risks

The deserializer reads one extra byte (hasCause) gated on m_version >= 14, and the cause value is read through the existing recursive-value path with the same maximumFilterRecursion depth check applied via outputObjectStack.size(). I don't see new untrusted-input parsing surface beyond what the existing object/array/map paths already expose, and the v13 back-compat gate means older persisted blobs don't hit the new read. No auth/crypto/permissions code is touched.

Level of scrutiny

This warrants careful human review. The change is well-reasoned and the tests are excellent (cycles, shared identity, deep chains, version back-compat, cross-process, cross-thread), but the implementation relies on subtle invariants: dumpIfTerminal is re-entered from StateUnknown after the array/object member visitors already called it once, and the new top-of-function guard on m_pendingErrorCause is what prevents double-emission on that second call. Likewise the deserializer's m_pendingErrorWithCause guard suppresses byte consumption on re-entry. These are correct as far as I traced, but the goto-driven state machine has several dumpIfTerminal / readTerminal call sites and the interaction with the new object-pool registration for errors (which changes both serialization output — ObjectReferenceTag for repeated errors — and m_gcBuffer indexing on deserialize) is the kind of thing a maintainer familiar with this file should sanity-check.

Other factors

robobun built debug+ASAN and confirmed both test suites pass with fail-before/pass-after on the cause round-trip; the only CI failures are unrelated musl LTO link errors. The wire-format bump is one-way (v14 blobs won't deserialize on older Bun), which is expected but worth a maintainer ack. No outstanding reviewer comments.

@alii

alii commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

Let's use node:test so we can verify the behaviour is the same in Node.js, too. Do we expect the serialization to be byte-exact between Bun and Node? @robobun

@alii

alii commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

Added test/js/web/workers/error-cause-node-parity.test.ts — a node:test file that runs identically under node --test and bun test, asserting structuredClone/v8 preserve an Error's cause (string / object / nested / cyclic-as-same-ref / absent). Passes 6/6 in both Node v26 and this build.

On byte-exact: no, and it shouldn't be. Bun's v8.serialize uses WebKit's SerializedScriptValue format; Node's uses the V8 serializer (ff0f… magic vs Bun's SSV header) — different wire formats, and v8 blobs are runtime-internal/not portable across engines. The contract that matters is behavioral parity (clone/round-trip yields an equivalent object graph), which the node:test verifies in both runtimes — not byte equality.

Comment thread src/jsc/bindings/webcore/SerializedScriptValue.cpp
Extend error-cause-node-parity so every case runs through both shared entry
points (structuredClone and the node:v8 round-trip): descriptor shape
(writable, non-enumerable, configurable), no-cause => no own property,
explicit-undefined cause, number/object causes, nested Error type, chains,
cyclic self-reference identity, shared identity, and identity inside
containers. Rename to .test.mts so Node parses it as ESM without the
MODULE_TYPELESS_PACKAGE_JSON warning; bun test still auto-discovers it.

structured-clone.test.ts now spawns `node --test` on the file, so the
Node.js half of the parity claim is enforced in CI rather than only being
runnable by hand. Serialization is intentionally not byte-compatible across
runtimes (JSC SerializedScriptValue vs V8 serializer); behavior is what must
match.
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Do we expect the serialization to be byte-exact between Bun and Node?

No — and it can't be. Bun's v8.serialize emits JSC's SerializedScriptValue format (little-endian uint32 version, then JSC tags — after this PR: 0e 00 00 00 37 … = v14 + ErrorInstanceTag), while Node emits V8's serializer format (ff 0f … = 0xFF header + format version). Same error serializes to 104 bytes in Bun vs 410 bytes in Node v24; blobs are not interchangeable in either direction, and that predates this PR. The 13→14 version gate here is for Bun↔Bun blob compat only. What must match Node is the round-trip behavior.

Done in 90d254e, building on your error-cause-node-parity file:

  • every case now runs through both shared entry points (structuredClone + node:v8 round-trip), and I added the rest of the matrix: descriptor shape ({writable: true, enumerable: false, configurable: true} — Node produces exactly this, even when the input's cause was an enumerable assigned property), explicit-undefined cause, chains, cyclic identity, shared identity, identity inside containers;
  • renamed .test.ts → .test.mts so Node parses it as ESM (kills the MODULE_TYPELESS_PACKAGE_JSON warning); bun test still auto-discovers it;
  • structured-clone.test.ts now spawns node --test on the file, so the Node half of the parity claim is enforced in CI instead of only being runnable by hand. Verified locally: 20/20 under Node v24 and 20/20 under this branch's build.

robobun added 2 commits June 2, 2026 00:08
The Terminal production listed every tag except ErrorInstanceTag (a gap
since v13 introduced it). Document the full layout including the v14
hasCause byte and optional recursive cause value.
Mirrors main's noUnify entry from 63d5cd4: foreach_target.h has a
TU-wide include guard, so when xxhash3.cpp shares a release-sized (32-file)
bundle with highway_strings.cpp it only expands the baseline ISA namespace
and HWY_EXPORT(HashLong) fails to resolve the N_SSE4/N_AVX2/N_AVX3
variants. Carrying the exclusion here keeps this branch building cleanly
against trees that contain the xxhash3 TU; the hunk is verbatim from main
so it merges as a no-op.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants