Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
e4b7ee280b fix: allow worker onerror / preventDefault() to prevent worker termination
Per the HTML spec, when an error event inside a worker is canceled via
`preventDefault()` (or the legacy `onerror` handler), the error is
considered handled and the worker should continue running. Previously,
Bun unconditionally terminated the worker on any uncaught exception
regardless of whether the error event was handled.

Three changes:
1. Make the worker's ErrorEvent cancelable (`init.cancelable = true`)
2. Check `event->defaultPrevented()` after dispatch and return whether
   the error was handled
3. Only terminate the worker and propagate the error to the parent if
   the error was NOT handled; undo `unhandled_error_counter` increment
   so the event loop stays alive

Closes #16424

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 02:40:35 +00:00
5 changed files with 101 additions and 12 deletions

View File

@@ -504,26 +504,36 @@ extern "C" void WebWorker__fireEarlyMessages(Worker* worker, Zig::GlobalObject*
worker->fireEarlyMessages(globalObject);
}
extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker* worker, BunString message, JSC::EncodedJSValue errorValue)
extern "C" bool WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker* worker, BunString message, JSC::EncodedJSValue errorValue)
{
JSValue error = JSC::JSValue::decode(errorValue);
ErrorEvent::Init init;
init.message = message.toWTFString(BunString::ZeroCopy).isolatedCopy();
init.error = error;
init.cancelable = false;
init.cancelable = true;
init.bubbles = false;
globalObject->globalEventScope->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes));
auto event = ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes);
globalObject->globalEventScope->dispatchEvent(event);
// Per the HTML spec, if the error event was canceled (preventDefault() called,
// or onerror returned true), the error is considered handled.
if (event->defaultPrevented())
return true;
// Error was not handled inside the worker — propagate to the parent.
switch (worker->options().kind) {
case WorkerOptions::Kind::Web:
return worker->dispatchErrorWithMessage(message.toWTFString(BunString::ZeroCopy));
worker->dispatchErrorWithMessage(message.toWTFString(BunString::ZeroCopy));
return false;
case WorkerOptions::Kind::Node:
if (!worker->dispatchErrorWithValue(globalObject, error)) {
// If serialization threw an error, use the string instead
worker->dispatchErrorWithMessage(message.toWTFString(BunString::ZeroCopy));
}
return;
return false;
}
return false;
}
extern "C" WebCore::Worker* WebWorker__getParentWorker(void* bunVM);

View File

@@ -50,7 +50,7 @@ pub const Status = enum(u8) {
extern fn WebWorker__dispatchExit(?*jsc.JSGlobalObject, *anyopaque, i32) void;
extern fn WebWorker__dispatchOnline(cpp_worker: *anyopaque, *jsc.JSGlobalObject) void;
extern fn WebWorker__fireEarlyMessages(cpp_worker: *anyopaque, *jsc.JSGlobalObject) void;
extern fn WebWorker__dispatchError(*jsc.JSGlobalObject, *anyopaque, bun.String, JSValue) void;
extern fn WebWorker__dispatchError(*jsc.JSGlobalObject, *anyopaque, bun.String, JSValue) bool;
export fn WebWorker__getParentWorker(vm: *jsc.VirtualMachine) ?*anyopaque {
const worker = vm.worker orelse return null;
@@ -392,8 +392,9 @@ fn flushLogs(this: *WebWorker) void {
error.JSTerminated => @panic("unhandled exception"),
};
defer str.deref();
bun.jsc.fromJSHostCallGeneric(vm.global, @src(), WebWorker__dispatchError, .{ vm.global, this.cpp_worker, str, err }) catch |e| {
_ = bun.jsc.fromJSHostCallGeneric(vm.global, @src(), WebWorker__dispatchError, .{ vm.global, this.cpp_worker, str, err }) catch |e| {
_ = vm.global.reportUncaughtException(vm.global.takeException(e).asException(vm.global.vm()).?);
return;
};
}
@@ -435,11 +436,22 @@ fn onUnhandledRejection(vm: *jsc.VirtualMachine, globalObject: *jsc.JSGlobalObje
bun.outOfMemory();
};
jsc.markBinding(@src());
WebWorker__dispatchError(globalObject, worker.cpp_worker, bun.String.cloneUTF8(array.written()), error_instance);
if (vm.worker) |worker_| {
_ = worker.setRequestedTerminate();
worker.parent_poll_ref.unrefConcurrently(worker.parent);
worker_.exitAndDeinit();
const handled = WebWorker__dispatchError(globalObject, worker.cpp_worker, bun.String.cloneUTF8(array.written()), error_instance);
if (!handled) {
if (vm.worker) |worker_| {
_ = worker.setRequestedTerminate();
worker.parent_poll_ref.unrefConcurrently(worker.parent);
worker_.exitAndDeinit();
}
} else {
// Error was handled by self.onerror / preventDefault() — undo the
// unhandled_error_counter increment and exit_code set by `uncaughtException`
// so the event loop stays alive and the worker keeps running.
if (vm.unhandled_error_counter > 0)
vm.unhandled_error_counter -= 1;
vm.exit_handler.exit_code = 0;
// Reset the rejection handler so future errors are also dispatched.
vm.onUnhandledRejection = &onUnhandledRejection;
}
}

View File

@@ -0,0 +1,4 @@
// Worker that does NOT handle errors — should terminate
setTimeout(() => {
throw new Error("unhandled test error");
}, 50);

View File

@@ -0,0 +1,14 @@
// Worker that handles errors via addEventListener + preventDefault()
self.addEventListener("error", e => {
e.preventDefault();
});
postMessage("before-error");
setTimeout(() => {
throw new Error("test error");
}, 50);
setTimeout(() => {
postMessage("after-error");
}, 200);

View File

@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
// https://github.com/oven-sh/bun/issues/16424
// Worker should continue running after an error is handled by self.onerror / preventDefault()
test("worker continues running when error event calls preventDefault()", async () => {
const worker = new Worker(new URL("./16424-worker-prevent-default.ts", import.meta.url).href);
try {
const messages: string[] = [];
const done = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Timed out waiting for worker messages")), 5000);
worker.onmessage = e => {
messages.push(e.data);
if (e.data === "after-error") {
clearTimeout(timeout);
resolve();
}
};
worker.onerror = () => {
clearTimeout(timeout);
reject(new Error("Error propagated to parent (should have been handled inside worker)"));
};
});
await done;
expect(messages).toContain("before-error");
expect(messages).toContain("after-error");
} finally {
worker.terminate();
}
});
test("worker terminates when error event is NOT handled", async () => {
const worker = new Worker(new URL("./16424-worker-no-handler.ts", import.meta.url).href);
const closed = await new Promise<boolean>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Timed out")), 5000);
worker.addEventListener("error", () => {
// Error propagated to parent — expected
});
worker.addEventListener("close", () => {
clearTimeout(timeout);
resolve(true);
});
});
expect(closed).toBe(true);
});