Repository navigation
Conversation
|
Updated 11:46 AM PT - May 1st, 2026
❌ @robobun, your commit 9412725 has 1 failures in
DetailsDetails🧪 To try this PR locally: bunx bun-pr 29060That installs a local version of the PR into your bun-29060 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughWorker error dispatch now returns whether the dispatched Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/regression/issue/29043.test.ts`:
- Around line 10-70: Add a second test case that mirrors "Worker error event
preventDefault() stops propagation and keeps worker running" but triggers the
error during initial worker evaluation (top-level throw) instead of inside
queueMicrotask; inside the new workerCode install the
globalThis.addEventListener('error', (e) => { e.preventDefault(); ... })
listener, schedule a macrotask (e.g., setImmediate or setTimeout) to keep the
worker alive and then throw at top-level so the spin / error_event_prevented
path is exercised, spawn the worker the same way (Worker with eval: true),
collect stdout/stderr and exit code, and assert that the worker's handler saw
the error, the worker printed "alive", the parent observed exit:0, and no parent
'error' events were emitted (same expectations as the original test).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 409c7cc4-dfaa-408a-a97f-f17a7370e7ad
📥 Commits
Reviewing files that changed from the base of the PR and between d63aa71 and 077497be152155d7c02cfb25879938de28dff768.
📒 Files selected for processing (3)
src/bun.js/bindings/webcore/Worker.cppsrc/bun.js/web_worker.zigtest/regression/issue/29043.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/bun.js/web_worker.zig`:
- Around line 462-473: The rollback currently force-sets
vm.exit_handler.exit_code = 0 which wipes any prior exit status; instead restore
the pre-dispatch exit code. Capture the previous exit code into a local (e.g.,
prev_exit_code) at the same place you increment unhandled_error_counter / set
exit_code = 1, then here set vm.exit_handler.exit_code = prev_exit_code (or, if
prev_exit_code is absent, avoid overwriting vm.exit_handler.exit_code). Update
references around worker.error_event_prevented, vm.unhandled_error_counter,
vm.exit_handler.exit_code, vm.onUnhandledRejection and ensure prev_handler and
the new prev_exit_code are threaded through the same scope so the original exit
code is preserved when rolling back.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0b3cd9ff-ae86-4413-9912-97a1d911d11f
📥 Commits
Reviewing files that changed from the base of the PR and between 077497be152155d7c02cfb25879938de28dff768 and f507f3fafbc0432d004c2ec97197811cab6e8601.
📒 Files selected for processing (2)
src/bun.js/web_worker.zigtest/regression/issue/29043.test.ts
beece85 to
f4a6b5f
Compare
|
✅ No merge conflicts detected when merging into Your branch is good to go! |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/bun.js/web_worker.zig`:
- Around line 438-445: The flushLogs() path currently sets
this.error_event_prevented = true when WebWorker__dispatchError returns
prevented, but callers (e.g., code that calls flushLogs(); exitAndDeinit();)
don’t observe that and still tear down the worker; change flushLogs() to return
the prevented boolean (or a tri-state indicating handled) instead of only
mutating this.error_event_prevented, propagate that return value to callers and
update callers that currently call flushLogs(); exitAndDeinit(); to check the
returned prevented flag and skip exitAndDeinit() when true; keep setting
this.error_event_prevented for backward compatibility but ensure the
immediate-returning callers honor the returned prevented value so cancelled
error events prevent shutdown.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0a07f9dc-1e0f-40fb-bd36-19567fc99573
📥 Commits
Reviewing files that changed from the base of the PR and between f507f3fafbc0432d004c2ec97197811cab6e8601 and f4a6b5f466c7d8581dd846f2eaafd86dbc5b051b.
📒 Files selected for processing (3)
src/bun.js/VirtualMachine.zigsrc/bun.js/bindings/webcore/Worker.cppsrc/bun.js/web_worker.zig
f4a6b5f to
24df2a5
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (2)
src/bun.js/web_worker.zig (2)
641-642:⚠️ Potential issue | 🟠 MajorHonor canceled
flushLogs()errors before final teardown.This tail path still calls
exitAndDeinit()unconditionally. If the dispatched workererrorevent is canceled here and its handler schedules more work, the worker is terminated anyway, which defeats the newflushLogs(): boolcontract.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/bun.js/web_worker.zig` around lines 641 - 642, The tail path currently ignores the flushLogs() outcome and always calls exitAndDeinit(); change it to honor flushLogs()'s boolean contract by capturing its return value (e.g. let ok = this.flushLogs()) and only call this.exitAndDeinit() when flushLogs() returns true; if flushLogs() indicates cancellation/false, skip/return early so the worker isn't torn down while a canceled error handler intends to schedule more work.
570-581:⚠️ Potential issue | 🟠 MajorRestore the pre-error exit code here, not
0.Lines 579-581 still overwrite any earlier
process.exitCode = Nset by preloads. If the load error is canceled, this path should restore the prior exit code rather than hard-resetting to success.Suggested fix
- var promise = vm.loadEntryPointForWebWorker(path) catch { - // If we called process.exit(), don't override the exit code - if (!this.exit_called) vm.exit_handler.exit_code = 1; + var promise = vm.loadEntryPointForWebWorker(path) catch { + const prev_exit_code = vm.exit_handler.exit_code; + // If we called process.exit(), don't override the exit code + if (!this.exit_called) vm.exit_handler.exit_code = 1; // A preload may have run and registered an `error` event // listener before the main module failed to load. If that // listener cancels the event, skip termination and let the // worker's event loop decide when to exit. There is no module // to finish running, but a preload may have scheduled work // (timers, microtasks) that should keep the worker alive. if (this.flushLogs()) { - vm.exit_handler.exit_code = 0; + vm.exit_handler.exit_code = prev_exit_code; break :evaluate_entry_point; } this.exitAndDeinit(); return; };Based on learnings, when a prevented worker error fires, the implementation should preserve any pre-error
process.exitCode = Nrather than forcing0.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/bun.js/web_worker.zig` around lines 570 - 581, The error-handling branch after vm.loadEntryPointForWebWorker currently forces vm.exit_handler.exit_code = 0 when this.flushLogs() returns true, which overwrites any prior exit code set by preloads; change this to restore the pre-error exit code instead of hard-resetting to 0 by using the previously saved exit code (e.g., a savedExitCode/this.saved_exit_code captured before the load attempt) or by avoiding assignment if a prior exit code exists; update the block around vm.loadEntryPointForWebWorker, this.exit_called, this.flushLogs(), and vm.exit_handler.exit_code in the evaluate_entry_point flow to restore that saved value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@src/bun.js/web_worker.zig`:
- Around line 641-642: The tail path currently ignores the flushLogs() outcome
and always calls exitAndDeinit(); change it to honor flushLogs()'s boolean
contract by capturing its return value (e.g. let ok = this.flushLogs()) and only
call this.exitAndDeinit() when flushLogs() returns true; if flushLogs()
indicates cancellation/false, skip/return early so the worker isn't torn down
while a canceled error handler intends to schedule more work.
- Around line 570-581: The error-handling branch after
vm.loadEntryPointForWebWorker currently forces vm.exit_handler.exit_code = 0
when this.flushLogs() returns true, which overwrites any prior exit code set by
preloads; change this to restore the pre-error exit code instead of
hard-resetting to 0 by using the previously saved exit code (e.g., a
savedExitCode/this.saved_exit_code captured before the load attempt) or by
avoiding assignment if a prior exit code exists; update the block around
vm.loadEntryPointForWebWorker, this.exit_called, this.flushLogs(), and
vm.exit_handler.exit_code in the evaluate_entry_point flow to restore that saved
value.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 86ebc53c-eb9a-4c5d-bbff-46d85053b5b5
📥 Commits
Reviewing files that changed from the base of the PR and between f4a6b5f466c7d8581dd846f2eaafd86dbc5b051b and 24df2a5ca9dd1a3928e9ace55593812a357a31a1.
📒 Files selected for processing (4)
src/bun.js/VirtualMachine.zigsrc/bun.js/bindings/webcore/Worker.cppsrc/bun.js/web_worker.zigtest/regression/issue/29043.test.ts
24df2a5 to
b4d54f7
Compare
b4d54f7 to
2e91bb3
Compare
e7bbdde to
9f3df93
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/bun.js/VirtualMachine.zig:685-706— When--unhandled-rejections=strictor=throwis used in a worker (viaexecArgv), apreventDefault()-cancelled error event still triggers a spuriousUnhandledRejectionWarning(strict mode) or a second dispatch of the error event (throw mode), becauseuncaughtExceptionreturnshandled=falseeven whenworker.error_event_preventedis set. The fix is to returntruefromuncaughtExceptionwhenworker.error_event_preventedis set, and to guard theemitUnhandledRejectionWarningcall in the.strictbranch with aworker.error_event_preventedcheck.Extended reasoning...
Root cause: In
VirtualMachine.zig,uncaughtException()(lines 685–706) correctly saves/restoresexit_handler.exit_codewhenworker.error_event_preventedis set, but still returnshandled = Bun__handleUncaughtException(...) > 0— which isfalsewhen the C++ handler returns 0 (the normal case). The flagerror_event_preventeddoes not influence thereturn handledat line 705.How it manifests — .strict mode (line 621):
The call is_ = this.uncaughtException(globalObject, wrapped_reason, true)— the return value is discarded. InsideuncaughtException,onUnhandledRejectionis called, the worker error listener detectspreventDefault(), setsworker.error_event_prevented = true, restores the handler, and returns early (the new path this PR introduces).uncaughtExceptionrestoresprev_exit_codeand returnsfalse. Back in.strict, execution continues unconditionally toBun__handleUnhandledRejection(returns 0, no JS rejection handler) and then toBun__promises__emitUnhandledRejectionWarningat line 623 — emitting a spuriousUnhandledRejectionWarningeven though the error was properly cancelled.How it manifests — .throw mode (lines 636–649):
The call isif (this.uncaughtException(globalObject, wrapped_reason, true))— when the event is prevented,uncaughtExceptionreturnsfalse, so the early-return guard at lines 637–641 is NOT taken. AfterdrainMicrotasks(), execution falls through to line 648 (unhandled_error_counter += 1) and line 649 (this.onUnhandledRejection(this, globalObject, reason)) — a second dispatch of the error event with the original unwrapped reason. TheonUnhandledRejectionfunction resetserror_event_prevented = falseat its entry, so a stateful one-shot listener that set a handled-flag on the first dispatch will NOT callpreventDefault()the second time, causing the worker to terminate despite the first prevention succeeding.Why these paths were unreachable before this PR: Before this PR,
web_worker.onUnhandledRejectionwas effectivelynoreturn— it always ended withexitAndDeinit(). This PR's newif (prevented) { ... return; }path is what makes lines 623 and 648–649 reachable for the first time from these code paths.Step-by-step proof for .throw mode:
- Worker spawned with
execArgv: ['--unhandled-rejections=throw']. Listener:let once = false; globalThis.addEventListener('error', e => { if (\!once) { once = true; e.preventDefault(); } }). - Unhandled rejection fires.
unhandledRejection()enters.throwcase;Bun__handleUnhandledRejectionreturns 0. uncaughtException(wrapped_reason)is called — first dispatch viaonUnhandledRejection. Listener callspreventDefault(), setsworker.error_event_prevented = true, returns early.uncaughtExceptionrestores exit code and returnshandled = false..throwbranch:uncaughtExceptionreturnedfalse, early return NOT taken. Falls through to line 649: second call toonUnhandledRejectionwith unwrappedreason.onUnhandledRejectionresetserror_event_prevented = false. Listener hasonce = true, does NOT callpreventDefault(). Worker callssetRequestedTerminate()andexitAndDeinit()— terminates despite first dispatch being properly prevented.
Fix: Return
truefromuncaughtExceptionwhenworker.error_event_preventedis set (fixes.throwdouble-dispatch). Additionally, guard theemitUnhandledRejectionWarningcall in the.strictcase with aworker.error_event_preventedcheck (since the return value is discarded there, the fix touncaughtExceptionalone does not help for strict mode). - Worker spawned with
9f3df93 to
c9ab08f
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/bun.js/VirtualMachine.zig:679-700— In--unhandled-rejections=strictmode, the.strictcase ofunhandledRejection()calls_ = this.uncaughtException(...)and discards the return value; before this PRuncaughtException()in a worker was effectively noreturn, so the subsequentBun__handleUnhandledRejectionandemitUnhandledRejectionWarningcalls were unreachable from a worker context. After this PR, when the worker's error listener callspreventDefault(),uncaughtException()returns instead of terminating, but because the.strictcase discards the return value it continues executing and emits a spuriousUnhandledPromiseRejectionWarningeven though the error was properly cancelled. Add a guard after the_ = this.uncaughtException(...)call in the.strictcase that checksworker.error_event_preventedand returns early if set, mirroring the fix already applied to the.throwcase.Extended reasoning...
What the bug is and how it manifests
In
VirtualMachine.zig, theunhandledRejection()function has a.strictcase (for--unhandled-rejections=strictmode) that callsuncaughtException()and explicitly discards the return value with_ = this.uncaughtException(globalObject, wrapped_reason, true). It then unconditionally proceeds to callBun__handleUnhandledRejectionand (if that returns 0)emitUnhandledRejectionWarning. Before this PR,uncaughtException()in a worker context always ended inexitAndDeinit()(noreturn) becauseonUnhandledRejectionalways calledexitAndDeinit. This made the post-call lines unreachable from a worker in.strictmode. After this PR, when the worker's error listener callspreventDefault(),onUnhandledRejectionreturns early instead of callingexitAndDeinit(), which meansuncaughtException()now returns — but the.strictcase cannot observe this because it discards the return value.The specific code path that triggers it
Worker spawned with
execArgv: ['--unhandled-rejections=strict']registers an error listener callinge.preventDefault(). An unhandled rejection fires. The.strictcase: (1) calls_ = this.uncaughtException(globalObject, wrapped_reason, true)— inside this,onUnhandledRejectiondispatches the error event, the listener callspreventDefault(), setsworker.error_event_prevented = true, and returns early.uncaughtExceptionrestores the exit code and returnsfalse. (2) Execution continues in.strict— there is no guard, so it reachesBun__handleUnhandledRejection(line 616), which returns 0 since there is no JS-levelprocess.on('unhandledRejection')handler. (3)emitUnhandledRejectionWarningfires — printing a spuriousUnhandledPromiseRejectionWarningto stderr even though the error was properly cancelled.Why existing code does not prevent it
The
.throwcase (lines 622–636) correctly checks the return value ofuncaughtException():if (this.uncaughtException(...))and returns early if true. The.strictcase uses_ =to discard the same return value, so it has no way to know the error was prevented. The new early-return path introduced by this PR (whenerror_event_preventedis true) makes lines 616–617 reachable for the first time in a worker context, turning previously dead code into a live bug path.What the impact would be
The worker stays alive correctly and exits with code 0 — the primary fix from this PR works. However, a spurious
UnhandledPromiseRejectionWarningis printed to the worker's stderr. This is incorrect per HTML spec intent (a prevented error should be fully suppressed) and confuses users who see a warning for an error they explicitly handled. The impact is limited to--unhandled-rejections=strict, which is a non-default, deprecated Node.js mode.How to fix it
After the
_ = this.uncaughtException(...)call in the.strictcase, add a guard that checksworker.error_event_preventedand returns early if set — mirroring the behavior of the.throwcase:// In the .strict case, after _ = this.uncaughtException(...) if (this.worker) |worker| { if (worker.error_event_prevented) { worker.error_event_prevented = false; return; } }
Step-by-step proof
- Worker is spawned with
execArgv: ['--unhandled-rejections=strict']and an error listener:globalThis.addEventListener('error', e => e.preventDefault()). - An unhandled rejection fires;
unhandledRejection()enters the.strictcase. Bun__handleUnhandledRejectionreturns 0 (no JS-registered unhandledRejection handler).wrapped_reasonis created;_ = this.uncaughtException(globalObject, wrapped_reason, true)is called.- Inside
uncaughtException,onUnhandledRejectiondispatches the cancelable ErrorEvent; the listener callse.preventDefault().worker.error_event_preventedis set totrue;onUnhandledRejectionreturns early.uncaughtExceptionrestores the exit code and returnsfalse. - Back in
.strict: the return value (false) is discarded. No guard checkserror_event_prevented. Bun__handleUnhandledRejectionis called again (line 616) and returns 0.emitUnhandledRejectionWarningfires — a spurious warning is printed to stderr even thoughpreventDefault()was called.
- Worker is spawned with
f59fcd6 to
18afeeb
Compare
18afeeb to
637fe90
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
test/regression/issue/29043.test.ts:115-121— The comment in Test 3 incorrectly claims to exercise the stale-flag reset at onUnhandledRejection entry (web_worker.zig:574), but the test only throws one error — making that reset a no-op (false→false). The test still provides real value as a guard against double-dispatch of a single error, but removing the reset at line 574 entirely would not cause this test to fail. To actually exercise the stale-flag scenario described in the comment, the test would need two consecutive errors: the first prevented (leaving error_event_prevented=true), then the second dispatching normally to verify the reset cleared the stale flag before dispatch.Extended reasoning...
What the comment claims vs. what the test actually does
The comment on Test 3 (lines 115-121) reads: "This exercises onUnhandledRejection's vm.worker.error_event_prevented = false reset at entry (which guards against a stale prevented-flag from an earlier cancelled error being observed by a new dispatch)." However, the test worker only throws a single error via queueMicrotask(() => { throw new Error("first"); }). With a single error, error_event_prevented begins at its default value of false, so the reset at web_worker.zig line 574 executes false→false — a complete no-op. There is no "earlier cancelled error" to leave a stale true in place.
The specific code path and why the reset is never meaningful here
When onUnhandledRejection is entered, it unconditionally runs if (vm.worker) |w| w.error_event_prevented = false; before dispatching the event. For the stale-flag reset to matter, a prior invocation must have set error_event_prevented = true (i.e., a previous error was cancelled via preventDefault()). With only one error, that condition never occurs. The flag goes false→false on entry, then false→true inside the prevented branch when the listener calls preventDefault(). No stale state is ever present.
What the test actually guards against
Despite the inaccurate comment, Test 3 provides genuine regression value. The dispatch:1 / not dispatch:2 / total:1 assertions would catch any bug that caused a single error to be dispatched to the worker's error listener twice — for example, a regression in the vm.log.reset() fix in flushLogs() added in this PR. That double-dispatch protection is real and useful, but it is a different mechanism from the stale-flag reset described in the comment.
Why removing the reset would not break this test
If the reset at line 574 were deleted entirely, this test would still pass with identical output. The only way to detect a regression in that reset would be to throw two consecutive errors: first one with preventDefault() called (leaving error_event_prevented = true), then a second error dispatched normally — and assert that the second dispatch is not skipped due to a stale true. Without the second error, the reset is simply never tested against a non-default state.
Addressing the refutation
The refuter correctly notes this is a comment-accuracy issue, not a runtime bug. The test behavior is correct and the test name accurately describes what it verifies. This is filed as a nit because the comment misleads future maintainers into believing the stale-flag reset is covered by regression tests when it is not, creating a false sense of coverage for that specific code path. The fix is either to update the comment to accurately describe the double-dispatch guard, or to add a second error throw to genuinely cover the stale-flag scenario.
-
🟡
src/bun.js/VirtualMachine.zig:638-640— The.strictunhandled-rejection guard atVirtualMachine.zig:639-641returns early without resettingworker.error_event_preventedtofalse, leaving a staletruethat would cause a subsequent error'sBun__handleUnhandledRejectiondispatch to be incorrectly skipped — even if no listener calledpreventDefault()for that second error. The fix is a one-liner: addworker.error_event_prevented = false;before thereturn. This path is currently unreachable since.strictmode is not yet wired for worker threads via execArgv (TODO atweb_worker.zig:427), but the logic flaw lives in newly-added code and will activate when that TODO is resolved.Extended reasoning...
What the bug is and how it manifests
In
VirtualMachine.zig's.strictcase ofunhandledRejection(), the PR added a guard (lines 639-641):if (this.worker) |worker| { if (worker.error_event_prevented) return; }
This guard correctly prevents
Bun__handleUnhandledRejectionfrom firing when a worker error listener calledpreventDefault(). However, it returns without first resettingworker.error_event_prevented = false. The flag staystrueafter the early return.The specific code path that triggers it
The only place
error_event_preventedis reset tofalseis at the entry ofonUnhandledRejection(web_worker.zig, inside theif (vm.worker) |w| w.error_event_prevented = false;line). That reset only fires whenonUnhandledRejectionis invoked. InsideuncaughtException,onUnhandledRejectionis only called whenBun__handleUncaughtExceptionreturns 0 (theif (\!handled)block). IfBun__handleUncaughtExceptionreturns > 0 for a subsequent error — meaning aprocess.on('uncaughtException')handler handled it — theif (\!handled)block is skipped entirely,onUnhandledRejectionis never called, and the reset never fires.Concrete trigger sequence
- Worker runs in
.strictmode (via the unreachable-until-TODO-resolvedexecArgvpath) with bothglobalThis.addEventListener('error', e => e.preventDefault())ANDprocess.on('uncaughtException', handler). - Error 1:
Bun__handleUncaughtExceptionreturns 0 →onUnhandledRejectioncalled → listener prevents →error_event_prevented = true. The.strictguard seestrueand returns — without resetting the flag. - Error 2:
Bun__handleUncaughtExceptionreturns > 0 (theprocess.on('uncaughtException')handler fires) →onUnhandledRejectionis NOT called → the entry-level reset never fires →error_event_preventedremainstrue(stale). - The
.strictguard sees the staletrueand returns early, skippingBun__handleUnhandledRejectionfor Error 2 — even though no listener actually calledpreventDefault()for it.
Why existing code doesn't prevent it
The
.throwmode counterpart avoids this because it checks the return value ofuncaughtException()directly (if (this.uncaughtException(...)) { ... return; }), anduncaughtExceptionitself setshandled = truewhenerror_event_preventedis observed (line 723). The.strictmode discards the return value with_ =and relies solely on the field-based guard — which is the guard that fails to clear the flag.Addressing the refutation
The refuter correctly notes this path is currently unreachable:
web_worker.zig:427has a TODO confirming that--unhandled-rejections=strict/throwfromexecArgvis not yet routed to the worker's transform options. This is accurate and means no user can currently trigger the bug. However, the guard was added precisely as defense-in-depth for when that TODO is addressed, and the missing reset is a correctness flaw in the newly-introduced logic. A one-line fix now avoids a silent regression later.How to fix it
Add
worker.error_event_prevented = false;before thereturnat line 640:if (this.worker) |worker| { if (worker.error_event_prevented) { worker.error_event_prevented = false; return; } }
- Worker runs in
6191d93 to
eb5ff80
Compare
There was a problem hiding this comment.
LGTM — all prior review concerns addressed in the final commit.
Extended reasoning...
Overview
This PR fixes two independent bugs that caused on a Worker's event to be silently ignored: (1) was created with , making a no-op; (2) the return value of was discarded, so the worker always propagated the error to the parent and terminated. The fix spans (make the event cancelable, return a ), (consume the bool, roll back / / handler, add bool return, reset log after dispatch), and (save/restore around , guard the case before ).
Security risks
No security-sensitive code paths touched. The change is entirely within worker error-event dispatch semantics.
Level of scrutiny
This PR went through extensive multi-round review. Multiple real bugs were identified and fixed iteratively: BunString leak in the prevented path, exit-code clobbering, log double-dispatch after the new fallthrough path, mode firing despite the event being cancelled, and a misleading test that claimed to exercise but didn't. All were resolved in the final commit.
Other factors
- Three regression tests covering async-throw, top-level-throw, and single-dispatch guard paths.
- The mode guard is now correctly placed before .
- after a successful dispatch prevents double-dispatch on subsequent calls.
- No new bugs found by the automated bug hunting system.
There was a problem hiding this comment.
LGTM — all prior issues have been addressed. The mode guard, double-dispatch fix, memory leak, log re-dispatch, and exit-code preservation are all correct in the final state.
Extended reasoning...
Overview
This PR fixes two root-cause bugs in WebWorker__dispatchError: cancelable = false on the ErrorEvent prevented preventDefault() from having any effect, and the dispatch return value was never checked, so the worker always terminated. The fix spans Worker.cpp, web_worker.zig, and VirtualMachine.zig.
Review of issues raised in prior runs
All issues I raised across multiple prior reviews have been addressed in subsequent commits:
- Memory leak of
bun.StringinonUnhandledRejection(prevented path) → fixed withdefer message_str.deref() flushLogs()re-dispatching the same error (Log.toJS does not clear msgs) → fixed withvm.log.reset()after dispatchflushLogs()return value not consumed by early-startup callers inspin()→ fixed by returningbooland checking inevaluate_entry_point- Exit-code clobbering when listener sets
process.exitCode = Nduring dispatch → fixed withprev_exit_codesnapshot + conditional restore .strictmode callingBun__handleUnhandledRejectionbefore theerror_event_preventedguard → fixed (guard now precedes the call)- Test 3 using
queueMicrotaskinstead of a Promise rejection to exercise the.throwbranch → fixed (test removed; author notedexecArgvflags are not yet routed to worker transform options, so the branch is unreachable in tests)
Security risks
No security-sensitive code paths are involved. The change is scoped to worker lifecycle management within the same origin/process.
Level of scrutiny
The logic spans non-trivial async state (error flags across function-call boundaries, exit-code bookkeeping, C++/Zig FFI return values). Multiple rounds of review were necessary to get it right. The current state is correct: the takeWorkerErrorEventPrevented() read-and-clear helper in the .strict path is a good safety measure against stale flags.
Other factors
The regression test covers three cases (async throw, top-level throw, double-dispatch guard). The --unhandled-rejections=throw/strict worker paths are currently unreachable (TODO in web_worker.zig:427) and are handled as defense-in-depth. Existing worker error tests pass.
9305919 to
1b160e3
Compare
468dacd to
bf75679
Compare
bf75679 to
e5629af
Compare
d264574 to
20732e6
Compare
519bf04 to
82d43f3
Compare
There was a problem hiding this comment.
No issues found on the current revision — all prior feedback has been addressed. That said, this threads a new keep-alive path through worker error handling, exit-code restoration, and the .bun/.strict/.throw rejection modes with several snapshot/restore interactions, so it's worth a maintainer pass before merge.
Extended reasoning...
Overview
This PR makes preventDefault() on a worker's global error event actually suppress propagation to the parent and keep the worker alive, per the HTML spec. It touches:
src/bun.js/bindings/webcore/Worker.cpp—WebWorker__dispatchErrornow creates a cancelableErrorEvent, checksdefaultPrevented(), and returnsbool.src/bun.js/web_worker.zig— addserror_event_preventedflag;flushLogs()returnsbooland resetsvm.log;onUnhandledRejectionsnapshots/restoresunhandled_error_counter+exit_handler.exit_codeand early-returns on prevent;spin()wraps entry-point evaluation in a labeled block so a cancelled load-failure error falls through to the event loop.src/bun.js/VirtualMachine.zig—uncaughtExceptionclears the flag at entry, snapshots/restoresexit_code, markshandled = trueon prevent;.strictmode inunhandledRejectionconsumes the flag viatakeWorkerErrorEventPrevented()beforeBun__handleUnhandledRejection; two new helper functions.- New regression test with 5 cases covering async throw, top-level throw, no-state-leak across dispatches, listener-that-throws, and inherited
--unhandled-rejections=strict.
Security risks
None identified. This is error-propagation/lifecycle logic with no auth, crypto, network, or untrusted-input parsing implications. The FFI signature change (void → bool) is matched on both sides.
Level of scrutiny
High. This is core runtime error-handling on a hot path (worker uncaught exceptions / unhandled rejections), with state spread across three files and at least three distinct entry routes (uncaughtException direct, .bun-mode unhandledRejection fallthrough, spin() entry-point rejection) plus the flushLogs load-failure path. The PR went through ~12 rounds of bot review uncovering subtle bugs — memory leak, exit-code clobbering, double-dispatch, stale-flag leak, snapshot placement — all of which were fixed, but the density of edge cases is itself a signal that the design has many interacting invariants.
Other factors
- All inline comments (mine and CodeRabbit's) are resolved; the current revision is clean per the bug hunter.
- The author explicitly scoped out two preexisting issues (listener-throws → re-entrancy panic via
is_handling_uncaught_exception; release-build worker-teardown race) as follow-ups — reasonable, but a maintainer should confirm that scoping. - The tests deliberately don't assert on
stderror the outer process exit code due to unrelated flakiness, and stay sequential becausetest.concurrenttickles a teardown race; both are documented in comments. - No human reviewer has engaged yet — only bots.
Given the complexity and the number of subtle interactions surfaced during review, this should get a maintainer's eyes rather than a bot approval.
ef22007 to
9412725
Compare
There was a problem hiding this comment.
No new issues found and all prior feedback is addressed, but this threads subtle state (error_event_prevented, exit-code/counter snapshot-restore) through uncaughtException/unhandledRejection/spin() across the Zig↔C++ boundary and was just rebased onto the #29957 worker-lifecycle rewrite — worth a human pass before merge.
Extended reasoning...
Overview
The PR makes the Worker global error event cancelable and honors preventDefault(): Worker.cpp now creates the ErrorEvent with cancelable = true and returns defaultPrevented() to Zig; web_worker.zig adds an error_event_prevented flag plus snapshot/restore of unhandled_error_counter and exit_handler.exit_code in onUnhandledRejection/flushLogs, and reworks spin()'s entry-point evaluation into a labeled block so a cancelled error falls through to the event loop instead of shutdown(); VirtualMachine.zig gains entry-reset of the flag in uncaughtException, a .strict-mode guard, and workerErrorEventPrevented/takeWorkerErrorEventPrevented helpers. A 5-case regression test is added.
Security risks
None apparent — this is error-propagation/lifecycle control flow inside the worker runtime; no auth, parsing, or untrusted-input surface is touched.
Level of scrutiny
High. This is core runtime error handling that went through ~12 iterative bug-fix rounds in review (memory leak, double-dispatch, stale-flag leakage across .strict mode, exit-code clobbering, counter over/under-count on listener re-entry), and was just semantically reapplied on top of the #29957 worker lifetime/lifecycle rewrite. The state machine is subtle: the error_event_prevented flag is set in one place, read/consumed in four, and reset in three, with ordering constraints documented in comments. The rebase note is thorough and tests (including the #29957 lifetime tests) reportedly pass, but the interaction surface is large enough that a maintainer familiar with the new shutdown()-noreturn structure should confirm the reapplied semantics.
Other factors
All inline comments from prior review rounds are resolved; the bug-hunting system found no new issues on the rebased HEAD. Two items were explicitly scoped out as preexisting/follow-ups (the is_handling_uncaught_exception re-entry panic when a listener itself throws on the direct-uncaughtException path, and the release-build worker-teardown race). Those are reasonable deferrals but worth a maintainer's awareness.
Per the HTML spec's "runtime script errors" algorithm for
WorkerGlobalScope, the ErrorEvent dispatched to the worker's global scope
must be cancelable. If the worker's own `error` listener cancels the
event via preventDefault() (or an `onerror` attribute returns true),
the error must not propagate to the parent Worker and the worker must
keep running.
Before this PR, WebWorker__dispatchError created the ErrorEvent with
cancelable=false and ignored the return value, so preventDefault() was
silently dropped and every worker error terminated the worker with
exit code 1, with the error propagated to the parent.
Fix:
Worker.cpp — WebWorker__dispatchError now creates a cancelable
ErrorEvent, checks defaultPrevented() after dispatch, and returns a
bool to the caller.
web_worker.zig — new `error_event_prevented` flag on WebWorker.
flushLogs() and onUnhandledRejection() snapshot `unhandled_error_counter`
and `exit_handler.exit_code` before dispatch; on prevent they restore
the counter to `snapshot - 1` (undoes the caller's `+= 1` plus any
re-entrant increments from a listener that both preventDefaults and
throws) and restore the exit_code only if it's still our sentinel 1.
spin() wraps entry-point evaluation in a labeled block so a cancelled
load-failure / rejected-promise falls through to the event loop instead
of terminating. flushLogs() now resets vm.log in both success and catch
branches so a keep-alive path doesn't re-dispatch the same error on a
later call.
VirtualMachine.zig — uncaughtException() clears the flag at entry
(defends against a stale `true` from a prior dispatch whose consumer
never read-and-cleared it), snapshots + restores exit_code, and marks
handled=true on prevent so the .throw path's guard early-returns.
unhandledRejection()'s .strict case uses takeWorkerErrorEventPrevented()
to read-and-clear the flag BEFORE Bun__handleUnhandledRejection, which
suppresses the process.on('unhandledRejection') dispatch + warning
when the error was already presented to (and cancelled by) the
globalThis listener.
Tests (5 cases):
1. Async throw (queueMicrotask -> uncaughtException path)
2. Top-level throw (spin()'s rejected-promise branch)
3. No prevented state leak across sequential dispatches
4. Listener that both preventDefaults and throws
5. Inherited --unhandled-rejections=strict: uncaughtException + unhandledRejection
handlers both fire for a subsequent unrelated rejection
Closes #29043
9412725 to
2192edc
Compare
Fixes #29043
Repro
Before:
After (matches Deno / HTML spec):
Cause
Two independent bugs in
WebWorker__dispatchErrorcombined to ignorepreventDefault():src/bun.js/bindings/webcore/Worker.cpp, theErrorEventwas created withcancelable = false, sopreventDefault()on it was a no-op.dispatchEventwas discarded, and the caller always dispatched the error to the parentWorkerand terminated the worker.Per the HTML spec's "runtime script errors" algorithm for
WorkerGlobalScope: the ErrorEvent must be cancelable, and if the event is cancelled (preventDefault()called oronerrorreturnedtrue) the error must not be reported to the parentWorker.Fix
Worker.cpp: create the ErrorEvent withcancelable = true, checkevent.defaultPrevented()after dispatching to the worker's global scope, and return aboolto the Zig side so the caller can react.web_worker.zigonUnhandledRejection: when the event was prevented, roll back the caller's pre-dispatch bookkeeping (theunhandled_error_counterincrement and theexit_code = 1set byuncaughtException), restore the handler, clear any pending exception, and return instead of terminating.spin's synchronous-rejected entry-point path via anerror_event_preventedflag onWebWorker.flushLogsto forward the same flag.Verification
test/regression/issue/29043.test.ts:bun bd test⇒ passgit stash && bun bd test⇒ fails on "Expected to contain: 'alive'" (worker terminated before the follow-up microtask ran) → confirming the test actually exercises the fix.Existing worker error tests (
worker.test.ts > error event > is fired with a string of the error,worker_threads.test.ts > error event > *) still pass unchanged.Rebase note
Rebased onto main after #29957 (worker: rewrite lifetime/lifecycle). Conflicts
in
src/bun.js/web_worker.zigwere due to the upstream rewrite moving allworker-thread functions (
create,threadMain,startVM,spin,shutdown,flushLogs,onUnhandledRejection) into a new file layout andswitching terminate paths from
exitAndDeinit→shutdown()(noreturn).Reapplied semantically on top of the new structure:
error_event_prevented: boolfield onWebWorker(under "Worker-thread only" block, next toexit_called).flushLogs(this, vm)— now returnsbool; resetsvm.login both success and catch branches.onUnhandledRejection— snapshot/restoreunhandled_error_counter+exit_handler.exit_code; on prevent, restore state, setworker.error_event_prevented = true, andreturn(caller keeps the worker alive) instead of the main-pathsetRequestedTerminate → shutdown.spin()— wraps entry-point evaluation in anevaluate_entry_point:labeled block so the load-failurecatchand the rejected-promise branch canbreakout on prevent and fall through to the event loop (instead ofshutdown()ing).VirtualMachine.zigandWorker.cppauto-merged cleanly. All 5 tests(
test/regression/issue/29043.test.ts) still pass on the rebased HEAD, andtest/js/web/workers/worker-terminate-lifetime.test.ts(3 cases from #29957)remains green.