Skip to content

Commit 7cca6e9

Browse files
fix(worker): hand worker_threads 'error' listeners an Error rebuilt from the thrown value
A worker_threads 'error' listener received the runtime's ErrorEvent, not an Error, and the message it carried was V8's "Uncaught Error: ..." text. The worker now reads the thrown value's name and message and forwards them with the error payload. The parent rebuilds an Error from them with the worker's stack, using the built-in constructor the name belongs to, and the parent's global error event carries that same Error when nothing handled it. The Worker shim cancels an error its listeners took with preventDefault() rather than through the truthy-return contract of onerror.
1 parent 1d9337c commit 7cca6e9

16 files changed

Lines changed: 231 additions & 76 deletions

‎docs/ns-builtin-modules.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -732,12 +732,13 @@ resolvers read the same table differently:
732732
Seven rows are public today: `ns:module`, `ns:runtime`, `ns:util`,
733733
`node:module`, `node:url`, `node:util`, `node:worker_threads`.
734734
- The **internal require** builtins receive (previous section) is the only
735-
thing that can name an internal-only row. Five rows are marked that way:
735+
thing that can name an internal-only row. Six rows are marked that way:
736736
`internal/broadcast-channel`, `internal/dom-exception`, `internal/events`,
737-
`internal/message-channel`, `internal/message-event`. Their exports carry
738-
capabilities app code must not hold — listener-accounting hook keys, the
739-
error-reporter setter, base classes that must be the runtime's own rather
740-
than whatever a global currently names.
737+
`internal/message-channel`, `internal/message-event`,
738+
`internal/worker-events`. Their exports carry capabilities app code must not
739+
hold — listener-accounting hook keys, the error-reporter setter, the key a
740+
worker's rebuilt error travels under, base classes that must be the
741+
runtime's own rather than whatever a global currently names.
741742
- Builtins with **no row at all** (the intrinsics snapshot, the require
742743
factory, the console formatter) are invoked straight from their native call
743744
sites. There is no specifier that could reach them and nothing to mark.

‎docs/worker-threads.md‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ means deliberately unsupported.
5454
| `threadName` | shim | Always `undefined`. |
5555
| `workerData` | shim | Always `null` — see below. |
5656
| `parentPort` | shim | `null` on the main isolate. Inside a worker, a `MessagePort`-shaped `EventTarget` over the worker's existing parent channel: `postMessage` forwards to the global `postMessage`, `message`/`messageerror` are re-dispatched from the worker global scope, `start()` and `close()` are no-ops. It is **not** a real port: not transferable, no queue of its own. |
57-
| `Worker` | shim | A class over the runtime's global `Worker` with a small Node-style emitter (`on`/`once`/`off`/`removeListener`) for `message`, `messageerror`, `error`, `online` and `exit`. `postMessage(value, transfer)` and `terminate()` forward. `online` is emitted off a microtask after construction, not from the thread. Unsupported options throw a `TypeError` naming the option: `workerData`, `env`, `eval`, `transferList`, and `stdin`/`stdout`/`stderr` when explicitly truthy. The runtime's own `Worker` options (`androidPriority`) ride along untouched — the native constructor ignores keys it does not know. |
57+
| `Worker` | shim | A class over the runtime's global `Worker` with a small Node-style emitter (`on`/`once`/`off`/`removeListener`) for `message`, `messageerror`, `error`, `online` and `exit`. An `error` listener receives the worker's error rebuilt as an `Error` (see below). `postMessage(value, transfer)` and `terminate()` forward. `online` is emitted off a microtask after construction, not from the thread. Unsupported options throw a `TypeError` naming the option: `workerData`, `env`, `eval`, `transferList`, and `stdin`/`stdout`/`stderr` when explicitly truthy. The runtime's own `Worker` options (`androidPriority`) ride along untouched — the native constructor ignores keys it does not know. |
5858
| `postMessageToThread` | throws | `Error: postMessageToThread is not supported in this runtime`. |
5959
| `moveMessagePortToContext` | throws | `Error: moveMessagePortToContext is not supported in this runtime`. |
6060
| `locks` | absent | Web Locks are not implemented; the property does not exist. |
@@ -79,6 +79,22 @@ finished. `terminate()` therefore resolves with `0` and emits `exit` with code
7979
`0` on the way, and that is the only path that emits it. A worker that ends by
8080
its own `close()` produces no `exit`.
8181

82+
### An `error` listener receives a rebuilt `Error`
83+
84+
Node hands an `error` listener the worker's error deserialized on the parent.
85+
Only strings cross the isolate boundary here, so the parent rebuilds it from
86+
the thrown value's `name` and `message`, with the worker's stack as its
87+
`stack`. A built-in name (`TypeError`, `RangeError`, …) rebuilds with that
88+
constructor, so `instanceof` holds; any other name, a subclass's or a
89+
`DOMException`'s, is an own `name` on an `Error`. Other properties, such as a
90+
`code` or a `cause`, are not carried. A thrown value with no string `message`,
91+
such as a string or a number, arrives as an `Error` whose message is its string
92+
form, where Node hands over the value itself.
93+
94+
Once an `error` listener has received it, the error counts as handled and is
95+
not reported on the parent's global scope. With no `error` listener, the
96+
parent's global `error` event carries the same rebuilt `Error`.
97+
8298
### A worker error carries no `error` object, and the worker scope's `onerror` is not an event
8399

84100
An error the worker scope leaves unhandled reaches the parent as a real

‎eslint.config.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const capturedStatics = [
3232

3333
// Captured constructors. A destructure from `primordials` shadows the global,
3434
// so these only fire on the unguarded reference.
35-
const restrictedGlobals = ['Date', 'FinalizationRegistry', 'Map', 'Number', 'Promise', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError', 'Uint8Array', 'Uint32Array', 'WeakRef', 'WeakSet'].map((name) => ({
35+
const restrictedGlobals = ['Date', 'EvalError', 'FinalizationRegistry', 'Map', 'Number', 'Promise', 'Proxy', 'RangeError', 'ReferenceError', 'Set', 'String', 'SyntaxError', 'TypeError', 'Uint8Array', 'Uint32Array', 'URIError', 'WeakRef', 'WeakSet'].map((name) => ({
3636
name,
3737
message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`,
3838
}));
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
throw new DOMException("aborted in a worker", "AbortError");
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
var parentPort = require("node:worker_threads").parentPort;
22
parentPort.on("message", function () {
3-
throw new Error("thrown by a parentPort listener");
3+
throw new TypeError("thrown by a parentPort listener");
44
});

‎test-app/app/src/main/assets/app/tests/testMessaging.js‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ describe("Messaging runtime edges", function () {
232232
expect(seen.length).toBe(1);
233233
expect(seen[0].message).toContain("boom from worker");
234234
expect(seen[0].error instanceof Error).toBe(true);
235+
expect(seen[0].error.message).toBe("boom from worker");
235236
worker.terminate();
236237
done();
237238
};
@@ -268,14 +269,29 @@ describe("Messaging runtime edges", function () {
268269
worker.on("error", function (error) {
269270
setTimeout(function () {
270271
removeEventListener("error", listener);
271-
expect(error.message).toContain("boom from worker");
272+
expect(error instanceof Error).toBe(true);
273+
expect(error.name).toBe("Error");
274+
expect(error.message).toBe("boom from worker");
275+
expect(error.stack).toContain("boom from worker");
272276
expect(globalErrors).toEqual([]);
273277
worker.terminate();
274278
done();
275279
}, SETTLE);
276280
});
277281
});
278282

283+
it("hands a node:worker_threads 'error' listener a thrown DOMException's name and message", function (done) {
284+
var wt = require("node:worker_threads");
285+
var worker = new wt.Worker("~/tests/messaging/domExceptionThrowingWorker.js");
286+
worker.on("error", function (error) {
287+
expect(error instanceof Error).toBe(true);
288+
expect(error.name).toBe("AbortError");
289+
expect(error.message).toBe("aborted in a worker");
290+
worker.terminate();
291+
done();
292+
});
293+
});
294+
279295
it("calls a node:worker_threads once listener once when an earlier listener emits again", function () {
280296
var wt = require("node:worker_threads");
281297
var worker = new wt.Worker("~/tests/eventLoopEchoWorker.js");
@@ -296,18 +312,20 @@ describe("Messaging runtime edges", function () {
296312
it("routes a throw from a parentPort listener to the parent's 'error' listeners", function (done) {
297313
var wt = require("node:worker_threads");
298314
var worker = new wt.Worker("~/tests/messaging/parentPortThrowingWorker.js");
299-
var messages = [];
315+
var errors = [];
300316
var finish = function () {
301-
expect(messages.length).toBe(1);
302-
expect(messages[0]).toContain("thrown by a parentPort listener");
317+
expect(errors.length).toBe(1);
318+
expect(errors[0] instanceof TypeError).toBe(true);
319+
expect(errors[0].name).toBe("TypeError");
320+
expect(errors[0].message).toBe("thrown by a parentPort listener");
303321
worker.terminate();
304322
done();
305323
};
306324
// Nothing else settles the spec when the error never arrives.
307325
var guard = setTimeout(finish, 10000);
308326
worker.on("error", function (error) {
309-
messages.push(error.message);
310-
if (messages.length === 1) {
327+
errors.push(error);
328+
if (errors.length === 1) {
311329
clearTimeout(guard);
312330
setTimeout(finish, SETTLE);
313331
}

‎test-app/runtime/src/main/cpp/CallbackHandlers.cpp‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1902,14 +1902,16 @@ void CallbackHandlers::CallWorkerScopeOnErrorHandle(Isolate *isolate, TryCatch &
19021902
// parent sees the handler's own error, and only that one.
19031903
if (innerTc.HasCaught()) {
19041904
ExtractTryCatchInfo(isolate, context, innerTc, message, source, stackTrace, lineno);
1905-
wrapper->PassUncaughtExceptionFromWorkerToParent(message, source, stackTrace, lineno);
1905+
wrapper->PassUncaughtExceptionFromWorkerToParent(message, source, stackTrace, lineno,
1906+
innerTc.Exception());
19061907
return;
19071908
}
19081909

19091910
// Unhandled at the worker scope - including when there is no scope
19101911
// handler at all - so it becomes the parent's error event.
19111912
ExtractTryCatchInfo(isolate, context, tc, message, source, stackTrace, lineno);
1912-
wrapper->PassUncaughtExceptionFromWorkerToParent(message, source, stackTrace, lineno);
1913+
wrapper->PassUncaughtExceptionFromWorkerToParent(message, source, stackTrace, lineno,
1914+
tc.Exception());
19131915
} catch (NativeScriptException &ex) {
19141916
ex.ReThrowToV8();
19151917
} catch (std::exception e) {

‎test-app/runtime/src/main/cpp/NativeScriptException.cpp‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -918,7 +918,9 @@ void PromiseRejectionTracker::Drain() {
918918
!workerWrapper->IsTerminating() && !workerWrapper->IsDisposed()) {
919919
string forwarded = message;
920920
string forwardedStack = stackTrace;
921+
Local<Value> forwardedValue = reason;
921922
if (!thrown.IsEmpty()) {
923+
forwardedValue = thrown;
922924
forwarded = ToDetailString(isolate, thrown);
923925
// The handler's own stack replaces the reason's; an accessor
924926
// that throws costs the stack, never the forward.
@@ -930,7 +932,7 @@ void PromiseRejectionTracker::Drain() {
930932
}
931933
}
932934
workerWrapper->PassUncaughtExceptionFromWorkerToParent(
933-
forwarded, "", forwardedStack, 0);
935+
forwarded, "", forwardedStack, 0, forwardedValue);
934936
}
935937
}
936938
} else {

‎test-app/runtime/src/main/cpp/NsBuiltinModules.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ constexpr Registration kRegistry[] = {
6868
{"internal/events", BuiltinId::kEvents, nullptr, true},
6969
{"internal/message-channel", BuiltinId::kMessageChannel, messaging::CreateBinding, true},
7070
{"internal/message-event", BuiltinId::kMessageEvent, nullptr, true},
71+
{"internal/worker-events", BuiltinId::kWorkerEvents, nullptr, true},
7172
};
7273

7374
constexpr const char* kDebugKey = "debug";

‎test-app/runtime/src/main/cpp/WorkerEvents.cpp‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,28 +98,28 @@ void WorkerEvents::EmitMessage(Isolate* isolate, Local<Object> receiver,
9898
(void)state->emitMessage.Get(isolate)->Call(context, receiver, 3, args).ToLocal(&result);
9999
}
100100

101-
bool WorkerEvents::EmitError(Isolate* isolate, Local<Object> receiver,
102-
const std::string& message, const std::string& source,
103-
const std::string& stackTrace, int lineNumber) {
101+
MaybeLocal<Value> WorkerEvents::EmitError(Isolate* isolate, Local<Object> receiver,
102+
const std::string& message, const std::string& source,
103+
const std::string& stackTrace, int lineNumber,
104+
const std::string& errorName,
105+
const std::string& errorMessage) {
104106
auto* state = RuntimeState::For<WorkerEventsState>(isolate);
105107
if (state == nullptr || state->emitError.IsEmpty()) {
106-
return false;
108+
return MaybeLocal<Value>();
107109
}
108110
Runtime* runtime = Runtime::TryGetRuntime(isolate);
109111
if (runtime == nullptr) {
110-
return false;
112+
return MaybeLocal<Value>();
111113
}
112114
Local<Context> context = runtime->GetContext();
113115

114-
Local<Value> args[4]{ArgConverter::ConvertToV8String(isolate, message),
116+
Local<Value> args[6]{ArgConverter::ConvertToV8String(isolate, message),
115117
ArgConverter::ConvertToV8String(isolate, source),
116118
Number::New(isolate, lineNumber),
117-
ArgConverter::ConvertToV8String(isolate, stackTrace)};
118-
Local<Value> result;
119-
if (!state->emitError.Get(isolate)->Call(context, receiver, 4, args).ToLocal(&result)) {
120-
return false;
121-
}
122-
return result->BooleanValue(isolate);
119+
ArgConverter::ConvertToV8String(isolate, stackTrace),
120+
ArgConverter::ConvertToV8String(isolate, errorName),
121+
ArgConverter::ConvertToV8String(isolate, errorMessage)};
122+
return state->emitError.Get(isolate)->Call(context, receiver, 6, args);
123123
}
124124

125125
} // namespace tns

0 commit comments

Comments
 (0)