From 7254eaec568c1e0866dfcdb9859ea8d3393817db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 13 Sep 2026 05:53:31 -0700 Subject: [PATCH] fix(worker_threads): preserve error metadata --- .../bindings/webcore/WorkerMessagingProxy.cpp | 145 +++++++++++++++++- .../worker_threads/worker_threads.test.ts | 76 +++++++++ 2 files changed, 220 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index edf7f5e30b62..b456da948e1d 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -40,6 +40,9 @@ #include "Worker.h" #include "ZigGlobalObject.h" #include +#include +#include +#include #include namespace WebCore { @@ -510,6 +513,133 @@ static String errorCodeOf(JSC::JSGlobalObject& globalObject, JSC::JSValue value) return code; } +class SerializedWorkerErrorMetadata final : public ThreadSafeRefCounted { +public: + static Ref create() { return adoptRef(*new SerializedWorkerErrorMetadata); } + + RefPtr properties; + RefPtr cause; + RefPtr causeMetadata; + bool causeEnumerable { false }; + +private: + SerializedWorkerErrorMetadata() = default; +}; + +static constexpr unsigned maxWorkerErrorMetadataDepth = 32; + +static RefPtr serializeWorkerErrorMetadata(Zig::GlobalObject& globalObject, JSC::ErrorInstance& error, HashSet& seen, bool includeCode, unsigned depth) +{ + if (!seen.add(&error).isNewEntry) + return nullptr; + + auto& vm = JSC::getVM(&globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto metadata = SerializedWorkerErrorMetadata::create(); + + auto* propertiesObject = JSC::constructEmptyObject(&globalObject); + JSC::Strong protectedProperties(vm, propertiesObject); + JSC::PropertyNameArrayBuilder properties(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); + error.methodTable()->getOwnPropertyNames(&error, &globalObject, properties, JSC::DontEnumPropertiesMode::Exclude); + if (!scope.exception()) { + for (const auto& property : properties) { + if (property == vm.propertyNames->cause || (!includeCode && property == WebCore::builtinNames(vm).codePublicName())) + continue; + JSC::JSValue propertyValue = error.get(&globalObject, property); + if (scope.exception()) { + scope.clearException(); + continue; + } + if (!propertyValue.isCallable() && !propertyValue.isSymbol()) { + propertiesObject->putDirectMayBeIndex(&globalObject, property, propertyValue); + if (scope.exception()) + scope.clearException(); + } + } + metadata->properties = SerializedScriptValue::create(globalObject, propertiesObject, SerializationForStorage::No, SerializationErrorMode::NonThrowing); + } + CLEAR_IF_EXCEPTION(scope); + + JSC::PropertyDescriptor causeDescriptor; + bool hasCause = error.getOwnPropertyDescriptor(&globalObject, vm.propertyNames->cause, causeDescriptor); + if (scope.exception()) { + scope.clearException(); + return metadata; + } + if (!hasCause) + return metadata; + + JSC::JSValue cause = error.get(&globalObject, vm.propertyNames->cause); + if (scope.exception()) { + scope.clearException(); + return metadata; + } + if (cause.isCallable() || cause.isSymbol()) + return metadata; + + metadata->cause = SerializedScriptValue::create(globalObject, cause, SerializationForStorage::No, SerializationErrorMode::NonThrowing); + CLEAR_IF_EXCEPTION(scope); + if (!metadata->cause) + return metadata; + metadata->causeEnumerable = causeDescriptor.enumerable(); + if (depth + 1 < maxWorkerErrorMetadataDepth) { + if (auto* causeError = dynamicDowncast(cause)) + metadata->causeMetadata = serializeWorkerErrorMetadata(globalObject, *causeError, seen, true, depth + 1); + } + return metadata; +} + +static void applyWorkerErrorMetadata(JSC::JSGlobalObject& globalObject, JSC::JSObject& error, const SerializedWorkerErrorMetadata& metadata) +{ + auto& vm = JSC::getVM(&globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + + if (metadata.properties) { + JSC::JSValue propertiesValue = metadata.properties->deserialize(globalObject, &globalObject, SerializationErrorMode::NonThrowing); + if (scope.exception()) { + scope.clearException(); + } else if (auto* properties = propertiesValue.getObject()) { + JSC::PropertyNameArrayBuilder names(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); + properties->methodTable()->getOwnPropertyNames(properties, &globalObject, names, JSC::DontEnumPropertiesMode::Exclude); + if (!scope.exception()) { + for (const auto& name : names) { + JSC::JSValue propertyValue = properties->get(&globalObject, name); + if (scope.exception()) { + scope.clearException(); + continue; + } + error.putDirectMayBeIndex(&globalObject, name, propertyValue); + if (scope.exception()) + scope.clearException(); + } + } + CLEAR_IF_EXCEPTION(scope); + } + } + + if (!metadata.cause) + return; + JSC::JSValue cause = metadata.cause->deserialize(globalObject, &globalObject, SerializationErrorMode::NonThrowing); + if (scope.exception()) { + scope.clearException(); + return; + } + JSC::PropertyDescriptor descriptor; + descriptor.setValue(cause); + descriptor.setWritable(true); + descriptor.setEnumerable(metadata.causeEnumerable); + descriptor.setConfigurable(true); + error.methodTable()->defineOwnProperty(&error, &globalObject, vm.propertyNames->cause, descriptor, false); + if (scope.exception()) { + scope.clearException(); + return; + } + if (metadata.causeMetadata) { + if (auto* causeError = dynamicDowncast(cause)) + applyWorkerErrorMetadata(globalObject, *causeError, *metadata.causeMetadata); + } +} + bool WorkerMessagingProxy::postSerializedErrorToWorkerObject(Zig::GlobalObject& workerGlobalObject, JSC::JSValue value) { // Top of the worker's error-dispatch stack: neither the structured clone (which can run script @@ -537,7 +667,16 @@ bool WorkerMessagingProxy::postSerializedErrorToWorkerObject(Zig::GlobalObject& // preserves a string `error.code` (lib/internal/error_serdes.js). String errorCode = errorCodeOf(workerGlobalObject, value); - return ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, m_loaderLoopKind, [protectedThis = Ref { *this }, serialized = serialized.releaseNonNull(), errorCode = WTF::move(errorCode).isolatedCopy()](ScriptExecutionContext& context) { + // Node's worker error serializer preserves an Error's own enumerable metadata and recursively + // serializes `cause`. The ordinary structured-clone Error path intentionally keeps only + // standard Error fields, so carry these worker-only additions separately. + RefPtr serializedMetadata; + if (auto* errorInstance = dynamicDowncast(value)) { + HashSet seen; + serializedMetadata = serializeWorkerErrorMetadata(workerGlobalObject, *errorInstance, seen, false, 0); + } + + return ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, m_loaderLoopKind, [protectedThis = Ref { *this }, serialized = serialized.releaseNonNull(), serializedMetadata = WTF::move(serializedMetadata), errorCode = WTF::move(errorCode).isolatedCopy()](ScriptExecutionContext& context) { RefPtr workerObject = protectedThis->m_workerObject; if (!workerObject) return; @@ -550,6 +689,10 @@ bool WorkerMessagingProxy::postSerializedErrorToWorkerObject(Zig::GlobalObject& if (auto* errorObject = deserialized.getObject()) errorObject->putDirect(vm, WebCore::builtinNames(vm).codePublicName(), JSC::jsString(vm, errorCode)); } + if (serializedMetadata) { + if (auto* errorObject = deserialized.getObject()) + applyWorkerErrorMetadata(*globalObject, *errorObject, *serializedMetadata); + } ErrorEvent::Init init; init.error = deserialized; workerObject->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes)); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 52eb1515d413..aa52bbbba2ee 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -760,6 +760,82 @@ describe("error event", () => { expect(err.message).toBe("oh no"); }); + test("preserves Error cause and enumerable metadata", async () => { + const worker = new Worker( + `const cause = new RangeError("root cause", { cause: new URIError("deep cause") }); + cause.code = "E_CAUSE"; + cause.details = { stage: "inner" }; + const error = new TypeError("outer", { cause }); + error.code = "E_WORKER"; + error.details = { retryable: true, attempts: 2 }; + error.requestId = 42; + error[0] = "numeric metadata"; + throw error;`, + { eval: true }, + ); + const [err] = await once(worker, "error"); + expect(err).toBeInstanceOf(TypeError); + expect(err.message).toBe("outer"); + expect(err.code).toBe("E_WORKER"); + expect(err.cause).toBeInstanceOf(RangeError); + expect(err.cause.message).toBe("root cause"); + expect(err.cause.code).toBe("E_CAUSE"); + expect(err.cause.details).toEqual({ stage: "inner" }); + expect(err.cause.cause).toBeInstanceOf(URIError); + expect(err.cause.cause.message).toBe("deep cause"); + expect(err.details).toEqual({ retryable: true, attempts: 2 }); + expect(err.requestId).toBe(42); + expect(err[0]).toBe("numeric metadata"); + expect(Object.getOwnPropertyDescriptor(err, "cause")?.enumerable).toBe(false); + }); + + test("preserves an assigned cause as enumerable", async () => { + const worker = new Worker( + `const error = new Error("outer"); + error.cause = "assigned cause"; + throw error;`, + { eval: true }, + ); + const [err] = await once(worker, "error"); + expect(err.cause).toBe("assigned cause"); + expect(Object.getOwnPropertyDescriptor(err, "cause")?.enumerable).toBe(true); + expect(Object.keys(err)).toContain("cause"); + }); + + test("an uncloneable cause does not suppress cloneable metadata", async () => { + const worker = new Worker( + `const { MessageChannel } = require("node:worker_threads"); + const error = new Error("outer", { cause: new MessageChannel().port1 }); + error.details = { preserved: true }; + throw error;`, + { eval: true }, + ); + const [err] = await once(worker, "error"); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("outer"); + expect(err.details).toEqual({ preserved: true }); + }); + + test("bounds deeply nested cause metadata", async () => { + const worker = new Worker( + `let error = new Error("leaf"); + for (let i = 0; i < 10_000; i++) error = new Error("level " + i, { cause: error }); + throw error;`, + { eval: true }, + ); + const [err] = await once(worker, "error"); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("level 9999"); + let cause = err; + let depth = 0; + while (cause instanceof Error) { + cause = cause.cause; + depth++; + } + expect(depth).toBeGreaterThan(1); + expect(depth).toBeLessThan(100); + }); + test("falls back to string when the error cannot be serialized", async () => { const worker = new Worker( /* js */ `