Skip to content

Worker: report close code 0 when the parent terminates a Web Worker - #41969

Open
robobun wants to merge 5 commits into
mainfrom
robobun/48e9b40f/worker-close-code
Open

robobun wants to merge 5 commits into
mainfrom
robobun/48e9b40f/worker-close-code

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The close event code after worker.terminate() depends on timing. A worker stopped while it still evaluates its entry module reports { code: 1, wasClean: false }. A worker stopped once its loop is idle reports { code: 0, wasClean: true }. A terminate() about 30 ms after new Worker() gives a mix of both.
  • docs/runtime/workers.mdx says the CloseEvent "contains the exit code passed to process.exit(), or 0 if it closed for another reason".
  • The 1 comes from the entry-load path in src/jsc/web_worker.rs: load_entry_point_for_web_worker returns Err(WorkerTerminated) once the parent asked for termination, and the Err arm set exit_code = 1 as if the load had failed. The idle loop breaks out without touching exit_code.

Fix

  • The Err arm no longer sets exit_code = 1 when the parent's terminate() is what cut the load short (termination_requested_by_parent()). It still does for a real load failure, and still leaves a process.exit() code alone.
  • termination_requested_by_parent() reads terminated_by_parent under the vm_handle lock. request_termination (parent thread) sets requested_terminate and then terminated_by_parent while it holds that lock, so a worker that already saw the request cannot read a stale false.
  • The proxy is unchanged: node:worker_threads still reports 1 for a worker its parent stopped, as Node does. A Web Worker now reports the untouched 0. A code the worker chose itself (an uncaught error's 1, an unsettled top-level await's 13, process.exit(n)) is never overwritten.
  • Verified: test/js/web/workers/worker.test.ts ("close code is 0 after terminate() ..."). The busy case fails on 1.4.3 with { code: 1, wasClean: false }, and 40 alternating busy/idle runs on this build all give 0. Also ran the other terminate tests in that file and the terminate/exit-code tests in test/js/node/worker_threads/worker_threads.test.ts (still expect 1).

Background

  • A worker thread reports its exit through WebWorker__workerGlobalScopeDestroyed(proxy, exit_code, stopped_by_parent). The proxy (WorkerMessagingProxy, parent thread) turns that into the close event for the global Worker and the exit event for node:worker_threads, and already maps "stopped by parent" to Node's 1 for Kind::Node.
  • requested_terminate is set by the parent's terminate(), by an exiting ancestor, or by the worker itself (process.exit(), an uncaught error). terminated_by_parent is the subset "the parent asked while the VM was live", which is the case that must not count as a failure.
Notes

The 'close' event code after worker.terminate() depended on timing: 1 if
terminate() landed while the worker was still evaluating its entry module
(the load path in web_worker.rs sets exit_code = 1 on WorkerTerminated),
0 once its loop was idle. docs/runtime/workers.mdx says the code is the
one passed to process.exit(), or 0.

The proxy already overrides the code for a worker stopped by its parent:
node:worker_threads reports 1 like Node. A Web Worker now reports 0 in
that case, as the comment at that site already claimed.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file.

Or wait 5 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 0fe9e1b5-6116-4906-ab73-f522a745d867

📥 Commits

Reviewing files that changed from the base of the PR and between d745f03 and eeed83e.

📒 Files selected for processing (2)
  • src/jsc/web_worker.rs
  • test/js/web/workers/worker.test.ts

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/jsc/bindings/webcore/WorkerMessagingProxy.cpp Outdated
Deciding in the proxy from stoppedByParent alone could also zero a code
the worker did choose (a resolve failure's 1 or an unsettled top-level
await's 13) when a terminate() raced the worker's own exit, because
stoppedByParent only excludes process.exit(). Instead, stop producing the
spurious 1: the entry-load Err arm no longer treats a load that the
parent's terminate() cut short as a load failure. terminated_by_parent is
read under the vm_handle lock that request_termination holds while it
sets requested_terminate and then terminated_by_parent, so a worker that
already saw the request cannot read a stale false. The proxy keeps its
Node-only override to 1.
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/jsc/web_worker.rs
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced and verified with bun bd test test/js/web/workers/worker.test.ts -t "close code is 0". On 1.4.3 the busy case gives { code: 1, wasClean: false }; with this branch 40 alternating busy/idle runs all give 0.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — moving the fix to the producer side addresses the earlier concern: only the entry-load Err arm is guarded now, so an uncaught-error 1 or unsettled-TLA 13 set elsewhere is no longer overwritten when a terminate() races in.

Checked: request_termination holds vm_handle.lock() across both set_requested_terminate() and the terminated_by_parent store, so the helper's Relaxed load under the same lock is sound; the worker thread already takes vm_handle.lock() under the API lock in shutdown(), so no new lock-ordering edge. The Err path is only reachable after vm_handle is published (earlier has_requested_terminate() checks at 793/836 short-circuit before), so terminated_by_parent is always set when the terminate came from the parent. Test awaits the worker's own message before terminating and asserts a combined {code, wasClean} object.

Extended reasoning...

Overview

The PR fixes the close event code reported when a parent calls worker.terminate() while the worker is still evaluating its entry module. Previously the Err arm of load_entry_point_for_web_worker set exit_code = 1 unconditionally (unless process.exit() had run), treating a parent-initiated TerminationException as a load failure. The fix adds !self.termination_requested_by_parent() to that guard and introduces a small helper that reads terminated_by_parent under vm_handle.lock(). A test.each in worker.test.ts covers both a busy-evaluating worker and an idle-loop worker.

The first revision fixed this at the consumer (WorkerMessagingProxy.cpp), which I flagged would overwrite error-driven exit codes (uncaught error's 1, unsettled TLA's 13) when a terminate() raced them. Commits 9fadaef and 67022da moved the fix to the worker thread's load path, so only the specific "TerminationException aborted the entry load" case skips the failure code; all other exit-code producers are untouched and the proxy is unchanged.

Security risks

None. This is exit-code reporting for Web Workers; no auth, crypto, untrusted-input parsing, or resource limits are involved.

Level of scrutiny

Moderate — threading correctness matters here. I verified request_termination (web_worker.rs:540-561) takes vm_handle.lock() before calling set_requested_terminate() and holds it through the terminated_by_parent.store(true, Relaxed), so the new helper's lock-then-Relaxed-load cannot observe a stale false once the worker has seen requested_terminate (the mutex provides the happens-before edge; the Relaxed ordering is fine per REVIEW.md's "comment any weakened ordering", and the comment does). Lock ordering matches existing code: the worker thread already takes vm_handle.lock() while holding the JSC API lock in shutdown(), and handle.request_termination() on the parent side does not need the API lock, so no new deadlock edge is introduced. The Err arm is only reachable after vm_handle has been published (start_vm publishes at line 732; earlier terminate checks at 793 and 836 return before entry load), so the if let Some(handle) branch in request_termination will have fired and set terminated_by_parent whenever the parent is the cause.

Other factors

The test follows harness conventions: test.each matrix, awaits once(w, "message") (an observable readiness signal from the worker itself, not a sleep), registers the close listener before calling terminate(), and asserts a combined object with .toEqual. The busy-evaluating case is the regression (fails on the base build with {code: 1, wasClean: false}); the idle case is matrix coverage confirming the existing correct path stays correct. The github-actions bot inline comments on lines 868/1100/1104 were self-resolved and appear to have been addressed by the "shorten comments" commit. No human CHANGES_REQUESTED reviews are outstanding.

…its neighbour has

It boots 48 workers in a child process. A debug+ASAN build on a loaded
machine takes about 7s for that, past the 5s default, which fails the whole
file for unrelated changes. Same isDebug ? 30_000 : 5_000 ceiling as the
preload test above it.
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:03 AM PT - Sep 8th, 2026

✅ @robobun, your commit eeed83e7a3df032abb4109e30ca29e560b6faac1 passed in Build #112922! 🎉


🧪   To try this PR locally:

bunx bun-pr 41969

That installs a local version of the PR into your bun-41969 executable, so you can run:

bun-41969 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — moving the guard to the producer's Err arm addresses the concern from the earlier revision (error-driven codes like 13 and the uncaught-exception 1 are set on separate paths this change doesn't touch, so they can no longer be zeroed).

Checked: termination_requested_by_parent() takes the same vm_handle lock that request_termination holds across set_requested_terminate() → terminated_by_parent.store(true), so a worker that bailed on WorkerTerminated cannot read a stale false; the worker thread does not hold that lock at the call site in spin, so no self-deadlock. The new tests await the worker's own message before terminating (no sleep) and cover both the busy-load and idle-loop timing.

Extended reasoning...

Overview

The PR narrows one branch in WebWorker::spin (src/jsc/web_worker.rs:863-876): when load_entry_point_for_web_worker returns Err, the worker no longer stamps exit_code = 1 if the parent's terminate() is what aborted the load. A new helper termination_requested_by_parent() reads terminated_by_parent under the vm_handle mutex. Two new test.each cases in worker.test.ts assert { code: 0, wasClean: true } for terminate() during entry evaluation and while idle. The final commit adds an isDebug ? 30_000 : 5_000 per-test timeout to the pre-existing 48-worker readFile-churn test.

Security risks

None. No user input parsing, no auth, no network. The change is a read of an internal atomic under an existing lock and a conditional skip of an exit-code assignment.

Level of scrutiny

Moderate — cross-thread state on the worker termination path warrants a synchronization check, which I did: request_termination (parent thread) acquires vm_handle.lock() at line 546, publishes requested_terminate, and only then stores terminated_by_parent at line 554 while still holding the lock. The new reader takes the same lock, so if the worker's load bailed because it observed requested_terminate, acquiring the lock here blocks until the parent has released it after writing terminated_by_parent = true. The Ordering::Relaxed is fine because the mutex provides the happens-before edge; the doc comment states this explicitly per REVIEW.md's "comment any weakened ordering" rule. The worker thread does not hold vm_handle at the spin call site (it's published-then-released earlier, re-taken only in shutdown()), so no self-deadlock.

Other factors

This is my fourth look at this PR. The first revision fixed it in the proxy (WorkerMessagingProxy.cpp), which I flagged as able to zero out a worker-chosen error code (uncaught exception's 1, unsettled-TLA's 13) when terminate() raced the error. The author moved the fix to the specific load-abort Err arm on the worker thread, which is the only path that was wrongly producing a 1 — the uncaught-exception and TLA paths write their codes elsewhere and are untouched. The latest push only adds a debug-build timeout to a neighbouring stress test (48 worker boots in a subprocess), which is the "rare outlier" case the root CLAUDE.md carves out. Tests await real signals (once(w, "message"), once(w, "close")) with no sleeps, use test.each, and assert a combined object with .toEqual. The bug hunter ran to dry_streak with no findings.

…of raising its timeout

REVIEW.md: shrink the workload rather than raise a per-test timeout. Four
rounds of four workers still put terminate() against in-flight readFile
completions at delays 0-6ms; a debug build gets through them in ~2.5s.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants