Skip to content

Rust JSG serialization: options, transfer lists, SharedArrayBuffers, shared native-error format - #8025

Open
bartlomieju wants to merge 11 commits into
mainfrom
biwanczuk/jsg-native-error-ser
Open

bartlomieju wants to merge 11 commits into
mainfrom
biwanczuk/jsg-native-error-ser

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-ups to #8000. These bring Rust JSG serialization up to C++ jsg::Serializer / jsg::Deserializer,
except for externals. Four commits change C++ JSG (1, 7, 8 and 9). Each commit builds and passes tests on its
own; the commit messages have the details.

Important

Two behavior changes in C++ JSG:

  • Commit 9 (visible to Workers): when a native error is serialized under
    enhanced_error_serialization (on by default since 2026-04-21), its own properties are now copied with
    CreateDataProperty instead of Set. A setter on Object.prototype for one of the error's own property
    names no longer runs, and that property now reaches the clone, as does an own __proto__. This reverses
    the serialize half of 2e11930, which kept Set there. We accept the risk without a compatibility flag;
    the commit message gives the reasoning.
  • Commit 1 (reporting only): a native error cut short before its type is reported differently.
    JavaScript still gets internal error; reference = <id>. But it used to be a KJ_ASSERT failure
    converted by exceptionToJs(), which called the isolate observer's reportInternalException() and logged
    the exception, with its source location, as a jsgInternalError. It is now thrown with
    throwInternalError(isolate, message), as Rust JSG throws it. That logs only deserialization failure, possible corruption at ERROR with the reference id, and does not tell the observer. Only corrupt data
    reaches this path; jsg::Serializer never writes it. Other corrupt data is reported as before.

Commits

  1. Give native errors one serialization format for C++ and Rust JSG. Move the Rust shims' native-error
    code into workerd/jsg/ser.{h,c++} as writeNativeError() / readNativeError(). Both serializers now
    call it, and the error types are one table. The bytes and what JavaScript sees are unchanged; the
    reporting change above is the one difference. A new ser-test case checks what jsg::Deserializer
    throws for malformed native errors, and passes both before and after. Suggested in review of Add structured serialization to Rust JSG #8000.
  2. Take serializer and deserializer options in Rust JSG. Adds serialize_with() / deserialize_with()
    with version, omit_header / read_header, preserve_stack_in_errors and diagnostic_context,
    matching jsg::Serializer::Options / jsg::Deserializer::Options.
  3. Let Rust JSG serialization reject class instances. Adds ClassInstances::Reject, the equivalent of
    treatClassInstancesAsPlainObjects = false, which RPC and Frankenvalue use.
  4. Pass SharedArrayBuffers and transfer lists through Rust JSG serialization. serialize_with() returns
    Serialized, the counterpart of Released, which holds the bytes plus the shared and transferred
    backing stores. SerializeOptions::transfer takes a transfer list.
  5. Read Rust JSG's serialized buffers only with their data. Serialized's fields are private, and
    Serialized::deserialize() is the only way its buffers reach a reader. This matches
    jsg::Deserializer(js, released): no caller can pass a reader another serialization's buffers, or the
    wrong kind of buffer.
  6. Keep Rust JSG's Serialized in the isolate that wrote it. Serialized is neither Send nor Sync,
    so memory that isn't shared can't be reached from two threads.
  7. Throw corrupt SharedArrayBuffer ids as JavaScript errors in jsg::Deserializer (changes C++).
    GetSharedArrayBufferFromId() used KJ_ASSERT on the id, which threw a KJ exception through V8's frames.
    In ser-test that is a fatal uncaught kj::Exception. It now throws the internal error that other
    corrupt data throws. Callers always pass the same Released's buffers, so only corrupt data reaches this.
  8. Transfer each ArrayBuffer once in C++ and Rust JSG serialization (changes C++).
    jsg::Serializer::transfer() skipped duplicates by comparing values, not buffers. So
    [view, view.buffer] registered one buffer with V8 twice, which trips a V8 debug DCHECK. Release
    builds clone correctly but list the same backing store twice. Both serializers now skip duplicates by
    buffer. The clone JavaScript sees is unchanged, and transferred buffers are never persisted.
  9. Copy native errors' own properties as data properties when serializing (changes C++, visible to
    Workers; see above). writeNativeError() uses CreateDataProperty, as readNativeError() already does.
    error-deser-prototype-setter-test now expects the setter not to run and the property to round-trip.
    Suggested in review.

Not included

Externals (ExternalHandler) are still unsupported in Rust JSG. The module docs and README say what throws
without them.

Testing

  • The Rust tests in src/rust/jsg-test/tests/serialization.rs check that Rust JSG and C++ JSG write the same
    bytes and transfer the same number of buffers. They also check that each side reads the other's output with
    its backing stores.
  • New ser-test cases cover malformed native errors, an out-of-range SharedArrayBuffer id, and transferring
    each buffer once.
  • //src/workerd/jsg/..., //src/workerd/api/... and //src/rust/... pass locally, and Rust clippy and
    rustfmt are clean.

@bartlomieju
bartlomieju force-pushed the biwanczuk/jsg-native-error-ser branch from 80a17c0 to 0a269f1 Compare October 27, 2026 16:29

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

Checked out and ran ser-test@ and jsg-test_test locally, and traced the parity points by hand (IsHostObject ordering, name stringification via ToDetailString, deferred detach and the WasDetached skip, the transfer error split, the corrupt-data paths). All match. One nit inline.

Comment thread src/workerd/jsg/ser.c++ Outdated
if (key->StrictEquals(nameKey) || key->StrictEquals(messageKey)) continue;
v8::Local<v8::Value> value;
if (!error->Get(context, key).ToLocal(&value) ||
properties->Set(context, key, value).IsNothing()) {

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.

Nit: now that both serializers share this, consider CreateDataProperty here to mirror the read side. Set on the fresh object would invoke a user-installed setter on Object.prototype for a matching key instead of landing the own property. Pre-existing, not blocking.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in ac36509, note that this reverts change from 2e11930, which kept Set on the serialize side.

The format in which a native error is written as a host object was
defined twice: by jsg::Serializer and jsg::Deserializer (ErrorTag,
getErrorTagFromName, toJsError), and by Rust JSG's serialization shims
(ERROR_TYPES, writeError, readError), kept equal only by byte-equality
tests.

Move the shims' version, which uses V8 alone, into workerd/jsg/ser.h as
writeNativeError() and readNativeError(), beside writeApiObject() and
readApiObject(), and have both serializers call it. The error types
become one table whose order is the format. readNativeError() takes
PreserveStack for jsg::Deserializer's preserveStackInErrors option; the
Rust shim passes YES, as before.

The bytes written are unchanged, and so is what JavaScript sees:
- The name is compared as UTF-8 up to the first NUL, as
  kj::str(v8::Local) compared it.
- A message that is not a string still throws a DataCloneError
  DOMException with the same message, now created directly rather than
  tunneled through a KJ exception. That exception was never internal,
  so it was neither logged nor reported, and still isn't.
- Truncated data still throws "internal error; reference = <id>".
- Reading still runs with JavaScript execution disallowed:
  jsg::Deserializer disallows it around the whole read, and the nested
  reads no longer disallow it again.

Behavior change: how jsg::Deserializer reports a native error cut short
before its type. It was a KJ_ASSERT failure, converted by
exceptionToJs(), which reported it to the isolate's observer
(reportInternalException) and logged the exception, with its source
location, as a "jsgInternalError". It is now thrown with
throwInternalError(isolate, message), as the Rust shims throw it: only
"deserialization failure, possible corruption" is logged, at ERROR,
with the reference id, and the observer is not told. Other corrupt data
that jsg::Deserializer reads (a truncated host-object tag, an API
object's fields) is reported as before. Such data comes only from a
bug or tampering, never from jsg::Serializer.

A new ser-test case checks what jsg::Deserializer throws for both
malformed native errors; it passes before and after this change.

Suggested in review of #8000.
jsg::ser::serialize() and deserialize() only did what jsg::Serializer
and jsg::Deserializer do with their default options. Callers that
share data with C++ code using other options could not match it:
RPC, queues, WebSockets and storage write version 15, storage reads
legacy data as version 13 without a header, and RPC and streams read
errors without their stack.

Add serialize_with() and deserialize_with(), taking SerializeOptions
and DeserializeOptions, which mirror jsg::Serializer::Options and
jsg::Deserializer::Options:

- version: the format version to write, or to read in. A version older
  than 13 is an internal error, as C++ asserts; a version newer than
  V8's is V8's DataCloneError, left pending.
- omit_header / read_header.
- preserve_stack_in_errors, passed to readNativeError().
- diagnostic_context, the label in the internal error for a missing or
  unsupported header.

The shims apply them in the order C++ does: a given read version
overrides the header's. serialize() and deserialize() are now
serialize_with() and deserialize_with() with the defaults.

The tests compare the bytes with jsg::Serializer's for each version,
with and without a header, and read them with both deserializers.
jsg::Serializer::Options::treatClassInstancesAsPlainObjects = false
makes jsg::Serializer throw a DataCloneError for a class instance
rather than write its own properties as a plain object, which would be
read back without its prototype or methods. RPC and Frankenvalue use
it.

Add SerializeOptions::class_instances: AsPlainObjects (the default and
the structured clone algorithm's behavior) or Reject. With Reject, the
shim decides what is a host object as jsg::Serializer::IsHostObject()
does: objects with internal fields, native errors when they are
written as host objects, and objects whose prototype is not
Object.prototype. writeApiObject() then throws the DataCloneError
jsg::Serializer throws for such an object, since the shim has no
ExternalHandler to offer it to. Plain objects, arrays, built-in types
(including instances of their subclasses, which V8 writes as the
built-in type), errors and API objects are written as before.

The tests compare the bytes with jsg::Serializer's with and without
enhanced_error_serialization.
…tion

jsg::Serializer keeps the backing store of each SharedArrayBuffer it
writes, and takes a transfer list whose ArrayBuffers it detaches,
handing their backing stores over in Released. jsg::Deserializer takes
both sets of backing stores back. Rust JSG's serializer numbered
SharedArrayBuffers but dropped their backing stores, so data holding
one could not be read back, and it had no transfer list.

serialize_with() now returns Serialized, the counterpart of Released:
the bytes and the two lists of backing stores, as jsg::v8::BackingStore.
SerializeOptions::transfer lists the values to transfer, and
DeserializeOptions takes transferred_array_buffers and
shared_array_buffers; Serialized::deserialize_options() fills them in,
as Deserializer(js, released) does. serialize() still returns only the
bytes.

The shims transfer as jsg::Serializer::transfer() does, so the bytes
are the same:
- A value that is not an ArrayBuffer or a view of one throws a
  TypeError, and a view of a SharedArrayBuffer a DataCloneError, before
  anything is detached.
- Values are numbered as given, a value given twice once. They are
  compared by value, not by buffer, as C++ compares them.
- Buffers are detached after the value is written, skipping any that
  JavaScript run by serialization already detached, so a getter sees
  the buffer intact and V8 still finds the buffer of a view written
  after it.

Reading, a backing store of the wrong kind (a shared one to transfer,
or one that is not shared for a SharedArrayBuffer) is an internal
error rather than a V8 check failure, and data that names a
SharedArrayBuffer beyond the given list is an internal error rather
than jsg::Deserializer's assertion thrown through V8. Without the list,
V8 throws for a SharedArrayBuffer, as before.

BackingStore gains hidden as_ffi()/from_ffi() for bridges in other
crates, which the jsg-test helpers use to hand backing stores to and
from C++ JSG, and a Debug impl.

Externals remain unsupported; the module docs and README say what
throws without them.

The tests compare the bytes and the number of transferred buffers with
jsg::Serializer's, check what is detached after each, and read each
serializer's output with the other's deserializer and backing stores.
DeserializeOptions took any backing stores, so a caller could hand a
reader another serialization's buffers, or shared and transferred ones
swapped, which the shim had to catch to avoid V8 check failures. C++
JSG can't get there: every caller that gives jsg::Deserializer backing
stores takes them from the same serializer's Released.

Make the Rust API equally unable to: Serialized's fields are private,
with data() and into_data() for the bytes, and its buffers reach a
reader only through Serialized::deserialize(), the counterpart of
jsg::Deserializer(js, released, options). deserialize_with() reads
bytes with no buffers. DeserializeOptions no longer holds backing
stores, and the shim no longer checks their kinds.

The shim keeps one check: data that names a SharedArrayBuffer beyond
the list it came with is read as corrupt, an internal error, rather
than indexing past the list. The hidden from_ffi() and
backing_store_addresses(), for the jsg-test helpers that compare with
C++ JSG, require only owned backing stores of the right kinds, so a
test reaches that check without breaking from_ffi()'s contract.
Serialized was Send, since BackingStore holds only an address, so safe
code could move it to another thread and read it into another isolate.
When serialization ran a getter that transferred a buffer the outer
value also transfers, the outer Serialized still holds that buffer's
backing store, which isn't shared, while the inner clone owns a live
buffer over it; reading the outer one on another thread would let two
threads reach that memory. jsg::Serializer's Released has the same
contents, but its callers read it in the isolate that wrote it.

Make Serialized neither Send nor Sync, so that it is read where it was
written, as Released is.
…rializer

jsg::Deserializer::GetSharedArrayBufferFromId() asserted, with
KJ_ASSERT, that the id was within the SharedArrayBuffers it was given.
V8 calls it from ValueDeserializer::ReadValue(), so a failed assertion
threw a KJ exception through V8's frames rather than to a catch in
jsg::Deserializer; in ser-test it ends the process with a fatal uncaught
kj::Exception.

Throw the internal error that other corrupt data throws, as Rust JSG's
shim does. Callers give jsg::Deserializer the SharedArrayBuffers of the
same serializer's Released, so only corrupt data reaches this; the new
ser-test case gives it an empty list.
jsg::Serializer::transfer() skipped a value already listed, but compared
the values, not their buffers, so a transfer list of a view and its
buffer, or of two views of one buffer, registered the buffer with V8
more than once. V8 debug builds DCHECK against that in
ValueSerializer::TransferArrayBuffer(); release builds let the last
registration win and deserialize correctly, with the extra entries in
Released::transferredArrayBuffers holding the same backing store. The
Rust JSG shim compared values too, to write the same bytes.

Compare buffers in both, so each buffer is registered, numbered and
released once. Nothing visible to JavaScript changes: the clone is the
same, and transferred buffers are only ever read by the deserializer
given the same Released, never persisted, so their numbering needs no
compatibility.
@bartlomieju
bartlomieju force-pushed the biwanczuk/jsg-native-error-ser branch from 0a269f1 to a5bb3e6 Compare October 28, 2026 09:22
writeNativeError() copies an error's own properties, other than name
and message, to a fresh object that it writes, and copied them with
Set(). That ran a setter that Object.prototype has for a key instead of
copying the property, which was then missing from the clone, and a
setter that threw made serialization throw. An own `__proto__` property
replaced the object's prototype instead and was lost.

Define them as data properties, as readNativeError() does when reading
them back, so that each property is copied as it is and no JavaScript
runs for it.

Compatibility: this changes what Workers can observe with
enhanced_error_serialization, which is on by default from 2026-04-21,
for structuredClone and everything else that serializes errors: a
setter on Object.prototype for one of an error's own property names no
longer runs, and the property, like an own `__proto__`, now reaches the
clone. 2e11930 kept Set() here when it moved deserialization to data
properties. We accept the risk without a compatibility flag: it reaches
only Workers that install such setters and clone errors with matching
own properties, and the new behavior is what the clone of any other
error already gets. The data stays readable by every version of
jsg::Deserializer, which reads the properties as an ordinary object.

error-deser-prototype-setter-test now expects the setter not to run and
the property to round-trip, and the Rust JSG tests expect the same of
both serializers.

Suggested in review.
@bartlomieju
bartlomieju marked this pull request as ready for review October 28, 2026 09:57
@bartlomieju
bartlomieju requested review from a team as code owners October 28, 2026 09:57
@clanky-the-clanker

clanky-the-clanker Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

💬 Approved with comments — No issues found. The new test for a buffer detached during serialization looks correct. Code quality was re-checked only for that test file; the other 10 files have not changed since the earlier review approved them.

Findings: 1 info

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.


📋 Code Quality: 💬 1 info

No defects found; only the changed test file was re-checked for code quality

Recommendation: 💬 Optional Nits

  • [INFO] Not re-checked for code quality in this pass (unchanged since ac36509, which found no issues there): src/rust/jsg-test/ffi.c++, src/rust/jsg-test/ffi.h, src/rust/jsg-test/lib.rs, src/rust/jsg/ffi.h, src/rust/jsg/ser.c++, src/rust/jsg/ser.rs, src/rust/jsg/v8.rs, src/workerd/jsg/ser-test.c++, src/workerd/jsg/ser.c++, src/workerd/jsg/ser.h.
View full review

The only change since the last review is the new test in src/rust/jsg-test/tests/serialization.rs. In it, a getter detaches a transferred buffer with b.transfer() during serialization. The test runs this case through both the Rust and C++ serializers, compares their bytes and transferred-buffer counts, and checks that each deserializer can read the other's output. It looks correct.

Code quality was re-checked only for this file. The other changed files have not changed since commit ac36509, where they were reviewed with no issues found.


✅ All other checks passed

🔒 Security: ✅ LGTM

The only change since the last review is a test file. Re-reading the C++ serializer patches turned up nothing new:

  • Native-error property copy: CreateDataProperty on the serialize side (src/workerd/jsg/ser.c++) means a setter on Object.prototype no longer runs, and an own __proto__ no longer replaces the clone's prototype.
  • Backing-store ownership: src/rust/jsg/ser.c++ passes backing-store addresses to Rust only when serialization succeeds.
  • SharedArrayBuffer ids: both sides check the id is in range and throw an internal error instead of asserting.

📖 AGENTS.md: ✅ LGTM

No issues found.


⚡ Performance: ✅ LGTM

No performance problems. The transfer dedup scans the transfer list once per entry, which is fine for lists this small. The shared native-error code does the same work as the code it replaces, and CreateDataProperty costs no more than Set.


📝 Documentation: ✅ LGTM

No issues found.


Reviewed commit: 62c289a9

@codecov-commenter

codecov-commenter commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 34.64203% with 283 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.46%. Comparing base (13b798a) to head (62c289a).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/rust/jsg/ser.c++ 0.00% 124 Missing ⚠️
src/rust/jsg/ser.rs 0.00% 103 Missing ⚠️
src/workerd/jsg/ser.c++ 69.29% 16 Missing and 23 partials ⚠️
src/rust/jsg/v8.rs 0.00% 13 Missing ⚠️
src/workerd/jsg/ser-test.c++ 93.93% 0 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8025      +/-   ##
==========================================
- Coverage   69.48%   69.46%   -0.02%     
==========================================
  Files         695      695              
  Lines      181587   181758     +171     
  Branches    29204    29203       -1     
==========================================
+ Hits       126170   126255      +85     
- Misses      38351    38434      +83     
- Partials    17066    17069       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ask-bonk

ask-bonk Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Since last review: 1 resolved, 1 still open, 0 new.

Adds a nested-transfer regression case for Rust JSG serialization.

Carried forward from the last review: api-compat, docs (no author changes in their files since then; earlier findings stand)

Not re-run: design-simplicity, kj-style (no author changes in their files since the last review)

Specialist findings not posted (1)
  • tests#1 [WARNING] src/rust/jsg-test/tests/serialization.rs:810: This regression case cannot detect the behavior its comment describes. The getter detaches b before release() runs, but the comment says V8 currently allows release() to detach that already-deta… — The new test invokes Rust release with the registered buffer already detached and checks parity with C++ JSG; a second detach is currently a documented V8 no-op, so it has no distinct external outcome…

Reviewed commit: 62c289a9 · github run

Comment thread src/workerd/jsg/ser.c++
Comment on lines +592 to +593
if (!error->Get(context, key).ToLocal(&value) ||
properties->CreateDataProperty(context, key.As<v8::Name>(), value).IsNothing()) {

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.

[WARNING] enhanced_error_serialization has been enabled by date since 2026-04-21, so existing Workers that use an Object.prototype setter (or an own __proto__) now observe a different clone result. Preserve the previous Set behavior behind a new compatibility flag and use CreateDataProperty only once that flag is enabled.

Suggested change
if (!error->Get(context, key).ToLocal(&value) ||
properties->CreateDataProperty(context, key.As<v8::Name>(), value).IsNothing()) {
if (!error->Get(context, key).ToLocal(&value) ||
(FeatureFlags::get(Lock::from(isolate)).getErrorSerializationOwnDataProperties()
? properties->CreateDataProperty(context, key.As<v8::Name>(), value)
: properties->Set(context, key, value))
.IsNothing()) {

Compatibility flags control behavioral changes in workerd. They allow breaking changes to be rolled out gradually using compatibility dates.

— docs/reference/adding-a-compatibility-flag.md

Comment thread src/rust/jsg-test/tests/serialization.rs
A getter that serialization runs can detach a buffer that is being
transferred, as a nested structuredClone() that transfers it does. Both
serializers' release() then skip it rather than detaching it again, and
the data still reads with either deserializer. Test that, with
ArrayBuffer.prototype.transfer() in the getter, since the test harness
has no structuredClone().

Removing the WasDetached() check in release() doesn't make this test
fail: V8's JSArrayBuffer::Detach() returns early for a detached buffer,
so detaching twice is a no-op today. The check guards against V8
changing that, as jsg::Serializer::release()'s does.

Suggested in review.
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.

4 participants