Repository navigation
Rust JSG serialization: options, transfer lists, SharedArrayBuffers, shared native-error format - #8025
Rust JSG serialization: options, transfer lists, SharedArrayBuffers, shared native-error format#8025bartlomieju wants to merge 11 commits into
Conversation
80a17c0 to
0a269f1
Compare
guybedford
left a comment
There was a problem hiding this comment.
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.
| 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()) { |
There was a problem hiding this comment.
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.
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.
0a269f1 to
a5bb3e6
Compare
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.
|
💬 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 infoNo defects found; only the changed test file was re-checked for code quality Recommendation: 💬 Optional Nits
View full reviewThe only change since the last review is the new test in 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: ✅ LGTMThe only change since the last review is a test file. Re-reading the C++ serializer patches turned up nothing new:
📖 AGENTS.md: ✅ LGTMNo issues found. ⚡ Performance: ✅ LGTMNo 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 📝 Documentation: ✅ LGTMNo issues found. Reviewed commit: |
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
|
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)
Reviewed commit: 62c289a9 · github run |
| if (!error->Get(context, key).ToLocal(&value) || | ||
| properties->CreateDataProperty(context, key.As<v8::Name>(), value).IsNothing()) { |
There was a problem hiding this comment.
[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.
| 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
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.
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:
enhanced_error_serialization(on by default since 2026-04-21), its own properties are now copied withCreateDataPropertyinstead ofSet. A setter onObject.prototypefor one of the error's own propertynames no longer runs, and that property now reaches the clone, as does an own
__proto__. This reversesthe serialize half of 2e11930, which kept
Setthere. We accept the risk without a compatibility flag;the commit message gives the reasoning.
JavaScript still gets
internal error; reference = <id>. But it used to be aKJ_ASSERTfailureconverted by
exceptionToJs(), which called the isolate observer'sreportInternalException()and loggedthe exception, with its source location, as a
jsgInternalError. It is now thrown withthrowInternalError(isolate, message), as Rust JSG throws it. That logs onlydeserialization failure, possible corruptionat ERROR with the reference id, and does not tell the observer. Only corrupt datareaches this path;
jsg::Serializernever writes it. Other corrupt data is reported as before.Commits
code into
workerd/jsg/ser.{h,c++}aswriteNativeError()/readNativeError(). Both serializers nowcall 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-testcase checks whatjsg::Deserializerthrows for malformed native errors, and passes both before and after. Suggested in review of Add structured serialization to Rust JSG #8000.
serialize_with()/deserialize_with()with
version,omit_header/read_header,preserve_stack_in_errorsanddiagnostic_context,matching
jsg::Serializer::Options/jsg::Deserializer::Options.ClassInstances::Reject, the equivalent oftreatClassInstancesAsPlainObjects = false, which RPC and Frankenvalue use.serialize_with()returnsSerialized, the counterpart ofReleased, which holds the bytes plus the shared and transferredbacking stores.
SerializeOptions::transfertakes a transfer list.Serialized's fields are private, andSerialized::deserialize()is the only way its buffers reach a reader. This matchesjsg::Deserializer(js, released): no caller can pass a reader another serialization's buffers, or thewrong kind of buffer.
Serializedin the isolate that wrote it.Serializedis neitherSendnorSync,so memory that isn't shared can't be reached from two threads.
jsg::Deserializer(changes C++).GetSharedArrayBufferFromId()usedKJ_ASSERTon the id, which threw a KJ exception through V8's frames.In
ser-testthat is a fatal uncaughtkj::Exception. It now throws the internal error that othercorrupt data throws. Callers always pass the same
Released's buffers, so only corrupt data reaches this.jsg::Serializer::transfer()skipped duplicates by comparing values, not buffers. So[view, view.buffer]registered one buffer with V8 twice, which trips a V8 debugDCHECK. Releasebuilds 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.
Workers; see above).
writeNativeError()usesCreateDataProperty, asreadNativeError()already does.error-deser-prototype-setter-testnow 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 throwswithout them.
Testing
src/rust/jsg-test/tests/serialization.rscheck that Rust JSG and C++ JSG write the samebytes and transfer the same number of buffers. They also check that each side reads the other's output with
its backing stores.
ser-testcases cover malformed native errors, an out-of-range SharedArrayBuffer id, and transferringeach buffer once.
//src/workerd/jsg/...,//src/workerd/api/...and//src/rust/...pass locally, and Rust clippy andrustfmt are clean.