Skip to content
4 changes: 4 additions & 0 deletions scripts/build/unified.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ const noUnify: readonly string[] = [
// baseline ISA namespace exists for image_resize → HWY_EXPORT can't find
// N_AVX2/N_AVX3/etc. variants.
"src/jsc/bindings/image_resize.cpp",
// Third highway TU — same foreach_target.h include-guard reason as
// image_resize.cpp: it must expand its own per-ISA namespaces so
// HWY_EXPORT(HashLong) resolves the N_AVX2/N_AVX3/etc. variants.
"src/jsc/bindings/xxhash3.cpp",
// Declares its own minimal CGRect/kCFStringEncodingUTF8/kCFNumberDoubleType
// so it doesn't pull a CoreGraphics load command; bundled with files that
// include the real CF headers those names become ambiguous.
Expand Down
98 changes: 94 additions & 4 deletions src/jsc/bindings/webcore/SerializedScriptValue.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ enum WalkerState { StateUnknown,
MapDataEndVisitKey,
MapDataEndVisitValue,
SetDataStartVisitEntry,
SetDataEndVisitKey };
SetDataEndVisitKey,
ErrorEndVisitCause };

// These can't be reordered, and any new types must be added to the end of the list
// When making changes to these lists please cover your new type(s) in the API test "IndexedDB.StructuredCloneBackwardCompatibility"
Expand Down Expand Up @@ -568,8 +569,9 @@ const uint8_t cryptoKeyOKPOpNameTagMaximumValue = 1;
* Version 11. added support for Blob's memory cost.
* Version 12. added support for agent cluster ID.
* Version 13. added support for ErrorInstance objects.
* Version 14. added support for ErrorInstance cause (recursively serialized) and identity preservation.
*/
[[maybe_unused]] static constexpr unsigned CurrentVersion = 13;
[[maybe_unused]] static constexpr unsigned CurrentVersion = 14;
Comment thread
robobun marked this conversation as resolved.
[[maybe_unused]] static constexpr unsigned TerminatorTag = 0xFFFFFFFF;
[[maybe_unused]] static constexpr unsigned StringPoolTag = 0xFFFFFFFE;
[[maybe_unused]] static constexpr unsigned NonIndexPropertiesTag = 0xFFFFFFFD;
Expand Down Expand Up @@ -639,6 +641,7 @@ static constexpr unsigned StringDataIs8BitFlag = 0x80000000;
* | WasmMemoryTag <value:uint32_t>
* | RTCDataChannelTransferTag <identifier:uint32_t>
* | DOMExceptionTag <message:String> <name:String>
* | ErrorInstanceTag <type:uint8_t> <message:NullableString> <line:uint32_t> <column:uint32_t> <sourceURL:NullableString> <stack:NullableString> <hasCause:uint8_t> (<cause:Value>)? // added to the object pool; <hasCause> and <cause> exist since version 14
* | WebCodecsEncodedVideoChunkTag <identifier:uint32_t>
*
* Inside certificate, data is serialized in this format as per spec:
Expand Down Expand Up @@ -1569,6 +1572,12 @@ class CloneSerializer : public CloneBase {

bool dumpIfTerminal(JSValue value, SerializationReturnCode& code)
{
// An ErrorInstance with a cause has already written its header and stashed
// the cause for the state machine to recurse into; the re-entry from
// StateUnknown must not write anything further for that value.
if (!m_pendingErrorCause.isEmpty())
return false;

if (!value.isCell()) {
dumpImmediate(value, code);
return true;
Expand Down Expand Up @@ -1685,6 +1694,9 @@ class CloneSerializer : public CloneBase {
return true;
}
if (auto* errorInstance = dynamicDowncast<ErrorInstance>(obj)) {
if (!startObjectInternal(errorInstance)) // handle duplicates
return true;

auto& vm = m_lexicalGlobalObject->vm();
auto errorTypeValue = errorInstance->get(m_lexicalGlobalObject, vm.propertyNames->name);
RETURN_IF_EXCEPTION(scope, false);
Expand Down Expand Up @@ -1731,14 +1743,36 @@ class CloneSerializer : public CloneBase {
}
RETURN_IF_EXCEPTION(scope, false);

bool hasCause = false;
JSValue cause;
PropertyDescriptor causeDescriptor;
if (errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->cause, causeDescriptor) && causeDescriptor.isDataDescriptor()) {
scope.assertNoException();
hasCause = true;
cause = causeDescriptor.value();
}
RETURN_IF_EXCEPTION(scope, false);

write(ErrorInstanceTag);
write(errorNameToSerializableErrorType(errorTypeString));
writeNullableString(message);
write(line);
write(column);
writeNullableString(sourceURL);
writeNullableString(stack);
return true;
write(static_cast<uint8_t>(hasCause ? 1 : 0));
if (!hasCause)
return true;
// The cause is serialized recursively via the state machine so that
// nested errors, arbitrary objects, and cycles are handled without
// native recursion. Returning false tells the caller to fall through
// to StateUnknown, which will dispatch on m_pendingErrorCause. Root
// the cause in m_gcBuffer while it is pending, mirroring the map
// value path, so it cannot be collected before it is visited.
if (cause.isCell())
m_gcBuffer.appendWithCrashOnOverflow(cause);
m_pendingErrorCause = cause;
return false;
}
if (obj->inherits<JSMessagePort>()) {
auto index = m_transferredMessagePorts.find(obj);
Expand Down Expand Up @@ -2577,6 +2611,7 @@ class CloneSerializer : public CloneBase {
}
#endif
// Vector<URLKeepingBlobAlive>& m_blobHandles;
JSValue m_pendingErrorCause;
ObjectPool m_objectPool;
ObjectPool m_transferredMessagePorts;
ObjectPool m_transferredArrayBuffers;
Expand Down Expand Up @@ -2837,6 +2872,21 @@ SerializationReturnCode CloneSerializer::serialize(JSValue in)
goto setDataStartVisitEntry;
}

errorVisitCause: {
ASSERT(inValue.isObject());
if (inputObjectStack.size() > maximumFilterRecursion)
return SerializationReturnCode::StackOverflowError;
inputObjectStack.append(asObject(inValue));
inValue = m_pendingErrorCause;
m_pendingErrorCause = JSValue();
stateStack.append(ErrorEndVisitCause);
goto stateUnknown;
}
case ErrorEndVisitCause: {
inputObjectStack.removeLast();
break;
}

stateUnknown:
case StateUnknown: {
auto terminalCode = SerializationReturnCode::SuccessfullyCompleted;
Expand All @@ -2848,6 +2898,8 @@ SerializationReturnCode CloneSerializer::serialize(JSValue in)
break;
}

if (!m_pendingErrorCause.isEmpty())
goto errorVisitCause;
if (isArray(inValue))
goto arrayStartState;
if (isMap(inValue))
Expand Down Expand Up @@ -4798,6 +4850,12 @@ class CloneDeserializer : public CloneBase {

JSValue readTerminal()
{
// An ErrorInstance with a cause has already been constructed and is
// waiting for the state machine to deserialize and attach its cause.
// The re-entry from StateUnknown must not consume any bytes here.
if (m_pendingErrorWithCause)
return JSValue();

SerializationTag tag = readTag();
// if (!isTypeExposedToGlobalObject(*m_globalObject, tag))
// return JSValue();
Expand Down Expand Up @@ -5036,7 +5094,22 @@ class CloneDeserializer : public CloneBase {
fail();
return JSValue();
}
return ErrorInstance::create(m_lexicalGlobalObject, WTF::move(message), toErrorType(serializedErrorType), { line, column }, WTF::move(sourceURL), WTF::move(stackString));
ErrorInstance* error = ErrorInstance::create(m_lexicalGlobalObject, WTF::move(message), toErrorType(serializedErrorType), { line, column }, WTF::move(sourceURL), WTF::move(stackString));
if (m_version < 14)
return error;
m_gcBuffer.appendWithCrashOnOverflow(error);
uint8_t hasCause;
if (!read(hasCause)) {
fail();
return JSValue();
}
if (!hasCause)
return error;
// Hand the partially-constructed error to the state machine, which
// will deserialize the cause value (recursing as needed) and attach
// it in ErrorEndVisitCause. The error is rooted via m_gcBuffer.
m_pendingErrorWithCause = error;
return JSValue();
}
case ObjectReferenceTag: {
auto index = readConstantPoolIndex(m_gcBuffer);
Expand Down Expand Up @@ -5291,6 +5364,7 @@ class CloneDeserializer : public CloneBase {
const uint8_t* m_ptr;
const uint8_t* const m_end;
unsigned m_version;
JSObject* m_pendingErrorWithCause { nullptr };
Vector<CachedString> m_constantPool;
// Vector<Ref<ImageData>> m_imageDataPool;
const Vector<RefPtr<MessagePort>>& m_messagePorts;
Expand Down Expand Up @@ -5507,6 +5581,14 @@ DeserializationResult CloneDeserializer::deserialize()
goto setDataStartVisitEntry;
}

case ErrorEndVisitCause: {
JSObject* error = outputObjectStack.last();
outputObjectStack.removeLast();
error->putDirect(vm, vm.propertyNames->cause, outValue, static_cast<unsigned>(PropertyAttribute::DontEnum));
outValue = error;
break;
}

stateUnknown:
case StateUnknown:
JSValue terminal = readTerminal();
Expand All @@ -5518,6 +5600,14 @@ DeserializationResult CloneDeserializer::deserialize()
outValue = terminal;
break;
}
if (m_pendingErrorWithCause) {
if (outputObjectStack.size() > maximumFilterRecursion)
return std::make_pair(JSValue(), SerializationReturnCode::StackOverflowError);
outputObjectStack.append(m_pendingErrorWithCause);
m_pendingErrorWithCause = nullptr;
stateStack.append(ErrorEndVisitCause);
goto stateUnknown;
}
SerializationTag tag = readTag();
if (tag == ArrayTag)
goto arrayStartState;
Expand Down
91 changes: 91 additions & 0 deletions test/js/web/workers/error-cause-node-parity.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Uses node:test (not bun:test) so the exact same file runs under both
// `node --test` and `bun test` — proving structuredClone/v8 preserve an Error's
// `cause` identically in both runtimes. Byte-exact serialization is NOT a goal
// (Bun uses WebKit's SerializedScriptValue format, Node uses V8's serializer);
// what must match is the observable result of cloning/round-tripping.
//
// structured-clone.test.ts spawns `node --test` on this file so the Node.js
// side of the parity claim is enforced in CI, not just runnable by hand.
import assert from "node:assert/strict";
import { test } from "node:test";
import v8 from "node:v8";

const clones: Array<[string, (value: any) => any]> = [
["structuredClone", structuredClone],
["v8 round-trip", value => v8.deserialize(v8.serialize(value))],
];

for (const [name, clone] of clones) {
test(`${name} preserves a string cause as a non-enumerable own data property`, () => {
const e = clone(new TypeError("typed", { cause: "boom" }));
assert.ok(e instanceof TypeError);
assert.equal(e.message, "typed");
assert.deepEqual(Object.getOwnPropertyDescriptor(e, "cause"), {
value: "boom",
writable: true,
enumerable: false,
configurable: true,
});
});

test(`${name} leaves an Error without a cause unchanged`, () => {
const e = clone(new Error("nc"));
assert.ok(!Object.hasOwn(e, "cause"));
assert.equal(e.cause, undefined);
});

test(`${name} preserves a number cause`, () => {
assert.equal(clone(new Error("x", { cause: 42 })).cause, 42);
});

test(`${name} preserves an explicit undefined cause as an own property`, () => {
const e = clone(new Error("x", { cause: undefined }));
assert.ok(Object.hasOwn(e, "cause"));
assert.equal(e.cause, undefined);
});

test(`${name} preserves an object cause structurally`, () => {
const e = clone(new Error("x", { cause: { code: 42, nested: { ok: true } } }));
assert.deepEqual(e.cause, { code: 42, nested: { ok: true } });
});

test(`${name} preserves a nested Error cause as an Error of the right type`, () => {
const e = clone(new Error("outer", { cause: new RangeError("inner") }));
assert.ok(e.cause instanceof RangeError);
assert.equal(e.cause.message, "inner");
assert.ok(!Object.hasOwn(e.cause, "cause"));
});

test(`${name} preserves a chain of Error causes`, () => {
const a = new Error("a");
const b = new Error("b", { cause: a });
const e = clone(new Error("c", { cause: b }));
assert.equal(e.message, "c");
assert.equal(e.cause.message, "b");
assert.equal(e.cause.cause.message, "a");
assert.ok(!Object.hasOwn(e.cause.cause, "cause"));
});

test(`${name} preserves a cyclic cause as the same reference, non-enumerable`, () => {
const o = new Error("self");
o.cause = o; // an enumerable own property on the input
const e = clone(o);
assert.equal(e.cause, e);
assert.equal(Object.getOwnPropertyDescriptor(e, "cause")!.enumerable, false);
});

test(`${name} keeps identity when the same Error appears twice`, () => {
const o = new Error("shared", { cause: "x" });
const e = clone([o, o]);
assert.equal(e[0], e[1]);
assert.equal(e[0].cause, "x");
});

test(`${name} preserves cause and identity inside containers`, () => {
const o = new Error("e", { cause: [1, 2, 3] });
const e = clone({ arr: [o], map: new Map([["k", o]]) });
assert.ok(e.arr[0] instanceof Error);
assert.deepEqual(e.arr[0].cause, [1, 2, 3]);
assert.equal(e.map.get("k"), e.arr[0]);
});
}
Loading
Loading