diff --git a/scripts/build/unified.ts b/scripts/build/unified.ts index 6cf64671b899..a631d606f504 100644 --- a/scripts/build/unified.ts +++ b/scripts/build/unified.ts @@ -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. diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index 6452cea51e5d..d85d16601e05 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -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" @@ -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; [[maybe_unused]] static constexpr unsigned TerminatorTag = 0xFFFFFFFF; [[maybe_unused]] static constexpr unsigned StringPoolTag = 0xFFFFFFFE; [[maybe_unused]] static constexpr unsigned NonIndexPropertiesTag = 0xFFFFFFFD; @@ -639,6 +641,7 @@ static constexpr unsigned StringDataIs8BitFlag = 0x80000000; * | WasmMemoryTag * | RTCDataChannelTransferTag * | DOMExceptionTag + * | ErrorInstanceTag ()? // added to the object pool; and exist since version 14 * | WebCodecsEncodedVideoChunkTag * * Inside certificate, data is serialized in this format as per spec: @@ -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; @@ -1685,6 +1694,9 @@ class CloneSerializer : public CloneBase { return true; } if (auto* errorInstance = dynamicDowncast(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); @@ -1731,6 +1743,16 @@ 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); @@ -1738,7 +1760,19 @@ class CloneSerializer : public CloneBase { write(column); writeNullableString(sourceURL); writeNullableString(stack); - return true; + write(static_cast(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()) { auto index = m_transferredMessagePorts.find(obj); @@ -2577,6 +2611,7 @@ class CloneSerializer : public CloneBase { } #endif // Vector& m_blobHandles; + JSValue m_pendingErrorCause; ObjectPool m_objectPool; ObjectPool m_transferredMessagePorts; ObjectPool m_transferredArrayBuffers; @@ -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; @@ -2848,6 +2898,8 @@ SerializationReturnCode CloneSerializer::serialize(JSValue in) break; } + if (!m_pendingErrorCause.isEmpty()) + goto errorVisitCause; if (isArray(inValue)) goto arrayStartState; if (isMap(inValue)) @@ -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(); @@ -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); @@ -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 m_constantPool; // Vector> m_imageDataPool; const Vector>& m_messagePorts; @@ -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(PropertyAttribute::DontEnum)); + outValue = error; + break; + } + stateUnknown: case StateUnknown: JSValue terminal = readTerminal(); @@ -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; diff --git a/test/js/web/workers/error-cause-node-parity.test.mts b/test/js/web/workers/error-cause-node-parity.test.mts new file mode 100644 index 000000000000..457a3654517b --- /dev/null +++ b/test/js/web/workers/error-cause-node-parity.test.mts @@ -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]); + }); +} diff --git a/test/js/web/workers/structured-clone.test.ts b/test/js/web/workers/structured-clone.test.ts index 90e0f729974d..249d95876506 100644 --- a/test/js/web/workers/structured-clone.test.ts +++ b/test/js/web/workers/structured-clone.test.ts @@ -1,7 +1,8 @@ import { deserialize, serialize } from "bun:jsc"; import { openSync } from "fs"; -import { bunEnv } from "harness"; +import { bunEnv, nodeExe } from "harness"; import { bunExe } from "js/bun/shell/test_builder"; +import v8 from "node:v8"; import { join } from "path"; function jscSerializeRoundtrip(value: any) { const serialized = serialize(value); @@ -9,6 +10,10 @@ function jscSerializeRoundtrip(value: any) { return cloned; } +function v8SerializeRoundtrip(value: any) { + return v8.deserialize(v8.serialize(value)); +} + function jscSerializeRoundtripCrossProcess(original: any) { const serialized = serialize(original); @@ -31,7 +36,12 @@ function jscSerializeRoundtripCrossProcess(original: any) { return deserialize(result.stdout); } -for (const structuredCloneFn of [structuredClone, jscSerializeRoundtrip, jscSerializeRoundtripCrossProcess]) { +for (const structuredCloneFn of [ + structuredClone, + jscSerializeRoundtrip, + v8SerializeRoundtrip, + jscSerializeRoundtripCrossProcess, +]) { describe(structuredCloneFn.name, () => { let primitives_tests = [ { description: "primitive undefined", value: undefined }, @@ -163,6 +173,93 @@ for (const structuredCloneFn of [structuredClone, jscSerializeRoundtrip, jscSeri } }); + describe("Error", () => { + test("preserves message, name and stack", () => { + const input = new TypeError("typed"); + const cloned = structuredCloneFn(input); + expect(cloned).toBeInstanceOf(TypeError); + expect(cloned).not.toBe(input); + expect(cloned.message).toBe("typed"); + expect(cloned.name).toBe("TypeError"); + expect(typeof cloned.stack).toBe("string"); + }); + + test("without cause has no own cause property", () => { + const cloned = structuredCloneFn(new Error("plain")); + expect(Object.hasOwn(cloned, "cause")).toBe(false); + expect(cloned.cause).toBeUndefined(); + }); + + test("preserves a string cause", () => { + const cloned = structuredCloneFn(new Error("x", { cause: "boom" })); + expect(Object.hasOwn(cloned, "cause")).toBe(true); + expect(cloned.cause).toBe("boom"); + expect(cloned.message).toBe("x"); + }); + + test("preserves a number cause", () => { + const cloned = structuredCloneFn(new Error("x", { cause: 42 })); + expect(cloned.cause).toBe(42); + }); + + test("preserves an explicit undefined cause", () => { + const cloned = structuredCloneFn(new Error("x", { cause: undefined })); + expect(Object.hasOwn(cloned, "cause")).toBe(true); + expect(cloned.cause).toBeUndefined(); + }); + + test("preserves an object cause structurally", () => { + const cloned = structuredCloneFn(new Error("x", { cause: { code: 42, nested: { ok: true } } })); + expect(cloned.cause).toEqual({ code: 42, nested: { ok: true } }); + expect(typeof cloned.cause).toBe("object"); + }); + + test("preserves a nested Error cause as an Error", () => { + const inner = new RangeError("inner"); + const cloned = structuredCloneFn(new Error("outer", { cause: inner })); + expect(cloned.cause).toBeInstanceOf(RangeError); + expect(cloned.cause.message).toBe("inner"); + expect(Object.hasOwn(cloned.cause, "cause")).toBe(false); + }); + + test("preserves a chain of Error causes", () => { + const a = new Error("a"); + const b = new Error("b", { cause: a }); + const c = new Error("c", { cause: b }); + const cloned = structuredCloneFn(c); + expect(cloned.message).toBe("c"); + expect(cloned.cause).toBeInstanceOf(Error); + expect(cloned.cause.message).toBe("b"); + expect(cloned.cause.cause).toBeInstanceOf(Error); + expect(cloned.cause.cause.message).toBe("a"); + expect(Object.hasOwn(cloned.cause.cause, "cause")).toBe(false); + }); + + test("preserves identity for a self-referential cause", () => { + const e = new Error("self"); + // @ts-expect-error + e.cause = e; + const cloned = structuredCloneFn(e); + expect(cloned.cause).toBe(cloned); + expect(cloned.message).toBe("self"); + }); + + test("preserves identity when the same Error appears twice", () => { + const e = new Error("shared", { cause: "x" }); + const cloned = structuredCloneFn([e, e]); + expect(cloned[0]).toBe(cloned[1]); + expect(cloned[0].cause).toBe("x"); + }); + + test("preserves cause when nested inside containers", () => { + const e = new Error("e", { cause: [1, 2, 3] }); + const cloned = structuredCloneFn({ arr: [e], map: new Map([["k", e]]) }); + expect(cloned.arr[0]).toBeInstanceOf(Error); + expect(cloned.arr[0].cause).toEqual([1, 2, 3]); + expect(cloned.map.get("k")).toBe(cloned.arr[0]); + }); + }); + describe("bun blobs work", () => { test("simple", async () => { const blob = new Blob(["hello"], { type: "application/octet-stream" }); @@ -319,6 +416,44 @@ function createBlob(arr: number[]): Blob { return new Blob([view]); } +test("Error blob serialized at version 13 (no cause field) still deserializes", () => { + // Captured from Bun 1.3.14: v8.serialize(e) where e = new Error("hi") with + // line/column/sourceURL/stack deleted. Ensures the version-gated cause read + // does not break previously-persisted blobs. + // prettier-ignore + const v13 = Buffer.from([ + 13, 0, 0, 0, // version 13 + 55, // ErrorInstanceTag + 0, // SerializableErrorType::Error + 0, 0, 0, 0, // message: isNull = false + 2, 0, 0, 128, 104, 105, // message: "hi" (8-bit, length 2) + 0, 0, 0, 0, // line + 0, 0, 0, 0, // column + 1, 0, 0, 0, // sourceURL: isNull = true + 1, 0, 0, 0, // stack: isNull = true + ]); + for (const fn of [(b: Buffer) => v8.deserialize(b), (b: Buffer) => deserialize(b)]) { + const out = fn(v13); + expect(out).toBeInstanceOf(Error); + expect(out.message).toBe("hi"); + expect(Object.hasOwn(out, "cause")).toBe(false); + } +}); + +test("Error cause parity tests also pass under node.js", async () => { + // error-cause-node-parity.test.mts is written with node:test, so bun test + // runs it natively; spawning `node --test` on the same file verifies Node + // agrees with every assertion. + await using proc = Bun.spawn({ + cmd: [nodeExe() ?? "node", "--test", join(import.meta.dir, "error-cause-node-parity.test.mts")], + stdout: "inherit", + stderr: "inherit", + stdin: "ignore", + env: bunEnv, + }); + expect(await proc.exited).toBe(0); +}); + describe("structuredClone with ArrayBuffer larger than serialization buffer capacity", () => { // The serialization buffer is a WTF::Vector capped at 2GiB. Cloning an // ArrayBuffer at or above that size must throw DataCloneError instead of aborting. diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index c8696c9f84bb..3dd482561d06 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -201,60 +201,30 @@ describe("web worker", () => { }); }); - test("worker with event listeners doesn't close event loop", done => { - const x = Bun.spawn({ + test("worker with event listeners doesn't close event loop", async () => { + await using x = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "many-messages-event-loop.js"), "worker-fixture-many-messages.js"], env: bunEnv, stdio: ["inherit", "pipe", "inherit"], }); - const timer = setTimeout(() => { - x.kill(); - done(new Error("timeout")); - }, 1000); - - x.exited.then(async code => { - clearTimeout(timer); - if (code !== 0) { - done(new Error("exited with non-zero code")); - } else { - const text = await new Response(x.stdout).text(); - if (!text.includes("done")) { - console.log({ text }); - done(new Error("event loop killed early")); - } else { - done(); - } - } - }); + const [text, code] = await Promise.all([x.stdout.text(), x.exited]); + // "done" is only printed once the worker has replied to all ~50 messages, + // which proves the event loop stayed alive for the whole exchange. + expect(text).toContain("done"); + expect(code).toBe(0); }); - test("worker with event listeners doesn't close event loop 2", done => { - const x = Bun.spawn({ + test("worker with event listeners doesn't close event loop 2", async () => { + await using x = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "many-messages-event-loop.js"), "worker-fixture-many-messages2.js"], env: bunEnv, stdio: ["inherit", "pipe", "inherit"], }); - const timer = setTimeout(() => { - x.kill(); - done(new Error("timeout")); - }, 1000); - - x.exited.then(async code => { - clearTimeout(timer); - if (code !== 0) { - done(new Error("exited with non-zero code")); - } else { - const text = await new Response(x.stdout).text(); - if (!text.includes("done")) { - console.log({ text }); - done(new Error("event loop killed early")); - } else { - done(); - } - } - }); + const [text, code] = await Promise.all([x.stdout.text(), x.exited]); + expect(text).toContain("done"); + expect(code).toBe(0); }); test("worker with process.exit", done => { @@ -413,4 +383,47 @@ describe("worker_threads", () => { await p; expect(message).toEqual("hello"); }); + + test("postMessage preserves Error cause across the worker boundary", async () => { + const worker = new wt.Worker( + 'const { parentPort } = require("worker_threads"); parentPort.on("message", m => parentPort.postMessage(m));', + { eval: true }, + ); + try { + const cyclic: any = new Error("self"); + cyclic.cause = cyclic; + const inputs = [ + new Error("plain"), + new Error("str", { cause: "boom" }), + new Error("obj", { cause: { code: 42 } }), + new Error("nested", { cause: new RangeError("inner") }), + cyclic, + ]; + const received: any[] = []; + const done = new Promise((resolve, reject) => { + worker.on("error", reject); + worker.on("message", m => { + received.push(m); + if (received.length === inputs.length) resolve(); + }); + }); + for (const e of inputs) worker.postMessage(e); + await done; + + expect(received[0]).toBeInstanceOf(Error); + expect(Object.hasOwn(received[0], "cause")).toBe(false); + + expect(received[1].cause).toBe("boom"); + + expect(received[2].cause).toEqual({ code: 42 }); + + expect(received[3].cause).toBeInstanceOf(RangeError); + expect(received[3].cause.message).toBe("inner"); + + expect(received[4].cause).toBe(received[4]); + expect(received[4].message).toBe("self"); + } finally { + await worker.terminate(); + } + }); });