Skip to content

worker: make termination interrupt a worker blocked in Atomics.wait - #32802

Closed
robobun wants to merge 2 commits into
mainfrom
farm/26492eef/worker-terminate-atomics-wait
Closed

worker: make termination interrupt a worker blocked in Atomics.wait#32802
robobun wants to merge 2 commits into
mainfrom
farm/26492eef/worker-terminate-atomics-wait

Conversation

@robobun

@robobun robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes #29173

Problem

Two symptoms, one cause.

  1. worker.terminate() never completes when the worker thread is blocked in an infinite Atomics.wait(). The worker thread stays parked in the futex forever, the exit/close event never fires, and if the worker was keeping the event loop alive the whole process hangs. This is Bun process does not exit after Tinypool destroy() completes (worker threads remain alive) #29173 (Tinypool's pool.destroy() hangs; its idle workers block in Atomics.wait waiting for work).

  2. Since the worker lifetimes rework (Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075), a worker's own exit joins its child workers during teardown. If a worker calls process.exit() (or dies on an uncaught throw) while one of its own children is parked in Atomics.wait, the middle worker can never finish exiting, its parent never receives 'exit', and the process never exits. This is a regression relative to pre-rework main:

// main -> child -> grandchild. Grandchild parks in Atomics.wait; child calls process.exit(5).
import { Worker, parentPort, workerData } from "node:worker_threads";
const role = workerData?.role ?? "main";
if (role === "main") {
  const c = new Worker(new URL(import.meta.url), { workerData: { role: "child" } });
  c.on("exit", code => console.log("child exit-event", code));
} else if (role === "child") {
  const g = new Worker(new URL(import.meta.url), { workerData: { role: "grand" } });
  g.on("message", () => setTimeout(() => process.exit(5), 150));
} else {
  parentPort.postMessage("parked");
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);
}

Before the rework this printed child exit-event 5 and exited in about a second; on current main it hangs forever (the grandchild thread stays in futex_wait, the child blocks joining it, and await child.terminate() from the parent never resolves either). Node exits (V8's TerminateExecution interrupts Atomics.wait).

Cause

JSC's off-thread termination contract has two parts, and Bun's shared helper JSC__VM__notifyNeedTermination (src/jsc/bindings/bindings.cpp) only did one of them.

  1. vm.notifyNeedTermination() fires the NeedTermination VM trap bit. VMTraps::requestThreadStopIfNeeded then wakes the target VM's sync Atomics waiter:

    // VMTraps.cpp:419
    if (hasTrapBit(NeedTermination))
        vm.syncWaiter()->condition().notifyOne();
  2. The waiter that just woke up re-checks its loop condition, which is keyed on a different flag:

    // WaiterListManager.cpp:95
    while (syncWaiter->isOnList() && time.now() < time && !vm.hasTerminationRequest())
        syncWaiter->condition().waitUntil(list->lock, time.approximate<WallTime>());

m_hasTerminationRequest is normally set by VMTraps::handleTraps (case NeedTermination), but that only runs at a JS safepoint on the worker thread itself. A thread parked in a futex never reaches one. So the waiter wakes, sees hasTerminationRequest() still false, and goes right back to waitUntil(infinity). JSC's SignalSender even re-notifies it every 1ms, and it re-parks every time.

Bun already has both halves of the contract, just in two different helpers that no caller combined: JSGlobalObject__requestTermination sets the request (used by bun:test timeouts, JS-thread only), and JSC__VM__notifyNeedTermination fired the trap (used by every worker termination path).

Fix

Set hasTerminationRequest in JSC__VM__notifyNeedTermination before firing the trap. The order matters: requestThreadStopIfNeeded's notifyOne() fires on the thread-stop transition, so the flag has to already be visible when the woken waiter re-checks its loop condition. The waiter then returns WaitSyncResult::Terminated and atomicsWaitImpl throws the termination exception, unwinding the worker normally. A worker that calls Atomics.wait after termination was requested is caught by the same condition before it ever parks.

This is the one shared helper behind every worker termination path (Worker#terminate() for both Web and node:worker_threads workers, an exiting parent or exiting worker stopping its children, process.exit() inside a worker, an uncaught worker error), so the single change covers all of them. setHasTerminationRequest() is safe off-thread: it writes the flag and requests a ConcurrentEntryScopeService, whose request set is atomic.

Worker exit semantics are unchanged: on_exit clears the flag (Bun__GlobalObject__clearExceptionsForExit) before dispatching 'exit' handlers, so a worker exiting by its own process.exit() still runs them, and a parent-terminated worker still skips them via forbid_script (the existing "worker stop ordering as seen by the worker's own handlers" tests cover both and pass).

Verification

Debug+ASAN build, test/js/node/worker_threads/worker_threads.test.ts:

  • terminate() interrupts a worker blocked in Atomics.wait: times out (90s) without the fix, passes in ~1.9s with it.
  • process.exit() in a worker completes while its own child worker is parked in Atomics.wait: times out without the fix, passes in ~4.4s with it.

Both tests prove the worker is really parked before termination fires by spinning until Atomics.notify reports one woken agent. The standalone nested repro above prints child exit-event 5 and exits 0 in ~3.8s with the fix; without it, it hangs until killed (3/3 each way). Full file: 123 pass, 0 fail.

The first revision of this PR carried the Web Worker flavor of the terminate test in test/js/web/workers/worker-terminate-lifetime.test.ts; this revision keeps the coverage in worker_threads.test.ts instead, because that file currently fails under ASAN on main for an unrelated pre-existing leak (terminate() mid-dns leaks the node:fs binding box, tracked in #35159) which would mask results here. The code path under test (WebWorker__requestTermination) is shared by both Worker flavors.

Not covered by this change, for completeness:

  • Blocking syscalls (readFileSync on a pipe that never delivers, execSync, spawnSync) still block termination until they return, matching Node, whose JoinThread waits for them the same way. Releasing the blocked call (e.g. writing to the pipe) completes teardown promptly.
  • A worker spinning in an infinite WebAssembly loop: wasm execution does not reach the VM trap check that JS loop back-edges do, so terminate() on such a worker already hung before the worker lifetimes rework, without any nesting (Node interrupts it). With the rework it also blocks an exiting parent's join. Separate pre-existing bug.
  • Bun.sleepSync is a native sleep with the same blocking shape; Bun.sleepSync: make worker.terminate() interrupt a worker blocked in sleepSync #35103 addresses it separately.

Prior attempt: #29179

A previous attempt at #29173 (#29179, against the Zig codebase) was closed unmerged after CI surfaced two residual crashes. Both were caused by what that PR added beyond the one-line flag, and both of those parts have since landed separately on main (the trap is already fired by JSC__VM__notifyNeedTermination, and worker teardown already runs ~VM() which stops the SignalSender; teardown also already clears the flag before 'exit' handlers). This PR does not touch teardown.

Related

This change also closes the Atomics route into the fuzz-reported ASSERTION FAILED: vm.hasTerminationRequest() at VMTraps::deferTerminationSlow: with the request set by the requester, throwTerminationException() from atomicsWaitImpl no longer runs with the request flag unset.

One reader of hasTerminationRequest() gets a wider window from this change: napi.h's mustDeferFinalizers(), which decides whether a non-experimental napi module's finalizer runs synchronously during GC or is deferred to the next tick. That state is already reached deterministically on every napi worker teardown (teardown sets the flag and then runs a full collection), so this is not a new failure mode; the delta is only that a parent-thread terminate() can also flip it during an earlier collection. Keying mustDeferFinalizers() on a worker-thread-set teardown flag would be its own hardening in napi.h.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:25 AM PT - Aug 9th, 2026

@robobun, your commit f9540be has 1 failures in Build #90923 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32802

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

bun-32802 --bun

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3dfd3f14-a477-44bf-9869-79088e2d7832

📥 Commits

Reviewing files that changed from the base of the PR and between 9008ae7 and f9540be.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/node/worker_threads/worker_threads.test.ts

Walkthrough

The VM termination notification now marks the termination request before waking waiters. A regression test was added that spawns a worker blocked in Atomics.wait, calls terminate(), and verifies a clean exit.

Changes

Worker termination during Atomics.wait

Layer / File(s) Summary
Termination flag before notify
src/jsc/bindings/bindings.cpp
JSC__VM__notifyNeedTermination sets the termination request flag before notifying waiters and updates the related comments.
Worker terminate regression
test/js/web/workers/worker-terminate-lifetime.test.ts
The worker termination test imports tempDir and adds a case that launches a worker blocked in Atomics.wait, calls terminate(), and checks for CLOSED, empty stderr, and exit code 0.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The change directly addresses #29173 by ensuring worker termination completes when a worker is parked in Atomics.wait.
Out of Scope Changes check ✅ Passed The code changes and regression test are both directly scoped to the Atomics.wait termination fix.
Title check ✅ Passed The title clearly and concisely describes the main change: interrupting workers blocked in Atomics.wait during termination.
Description check ✅ Passed The description explains the problem, cause, fix, affected paths, regression coverage, and verification results in sufficient detail.

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

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Fixes #29173 (verified with that issue's exact Tinypool repro: hangs 3/3 without the diff, exits cleanly 3/3 with it).

Reproduced on main, deterministically: worker.terminate() against a worker parked in Atomics.wait(sab, 0, 0) never completes; the worker thread stays in the futex and the process hangs. The regression test fails on every run without the fix (14/14) and passes with it.

Fix: one line in src/jsc/bindings/bindings.cpp (JSC__VM__notifyNeedTermination): set hasTerminationRequest before firing the NeedTermination trap, the pairing JSC's own VMTraps::handleTraps performs and the one WaiterListManager::waitForSync keys on. Full analysis in the PR description. All review threads resolved; the one real sibling a reviewer found (SigintWatcher bypassing this helper, so Ctrl+C cannot interrupt a breakOnSigint script parked in Atomics.wait) was verified with a deterministic repro and handed to a dedicated fix session, because fixing it unmasks a second latent empty-JSValue bug on its unwind path that needs its own root-cause work; details in this review reply.

CI

Head f9540beae0. The last two completed builds show the same picture: no worker-related failures, with this PR's test green on every lane that contains it.

  • Build #90892: 194 passed; only reds were the two :darwin: 14 aarch64 shards on one broken CI host (darwin-ciabatta-arm64-tart-15, Tart guest rejected the agent's SSH key before any test ran).
  • Build #90923: 194 passed; only reds were the two :darwin: 14 x64 shards, failing on test/napi/napi.test.ts (reported to main-break triage) and a filesystem-permission test artifact. This PR's test appears in neither shard's log.

The diff has nothing left to change; a maintainer can retry the darwin shards or merge.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun process does not exit after Tinypool destroy() completes (worker threads remain alive) #29173 - Tinypool workers use Atomics.wait internally for coordination; worker.terminate() hangs because hasTerminationRequest was never set, which is exactly what this PR fixes

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #29173

🤖 Generated with Claude Code

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed: this fixes #29173. I ran the exact repro from that issue (tinypool@0.8.3, the reporter's foo.mjs / worker.mjs / package.json) against both builds. Without the change it hangs 3/3; with it, the process exits cleanly 3/3. Tinypool's idle workers block in Atomics.wait waiting for work, and pool.destroy() calls worker.terminate() on them, which is exactly this path. Added Fixes #29173 to the description.

Worth flagging explicitly: there was a prior attempt at #29173, #29179, which was closed unmerged after it introduced two crashes in CI. I dug into its review history to make sure this is not the same mistake twice. Both of its failures came from code that PR added beyond the one-line flag, and both of those parts have since been implemented on main:

  1. The worker_destruction.test.ts / broadcast-channel-worker-gc.test.ts segfaults: ai slop #29179 was the first thing to ever fire the NeedTermination trap (the notifyNeedTermination of the time was a TODO stub), and its SignalSender kept signaling a recycled thread id after the worker exited. Main now already fires the trap, and WebWorker__teardownJSCVM runs ~VM(), which stops the SignalSender.
  2. The test-worker-uncaught-exception-async.js exception-scope crash: ai slop #29179 added a clearTerminationException() call inside worker teardown. Main already handles this correctly in WebWorker::shutdown() before on_exit(). This PR does not touch teardown.

I re-ran all three of those, plus the process.on('exit')-in-a-terminated-worker concern that review raised, against this branch. All clean. Details are in the updated PR description.

Comment thread src/jsc/bindings/bindings.cpp
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

About the darwin/asan CI failures on build #65244

Before pushing anything else I investigated every failed lane on build #65244 in detail, since one of them initially looked like it could be a regression from this change. Putting the full findings here so nobody has to redo that work.

:darwin: 26 aarch64 - test-bun (exit 1): buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun' before running a single test, on two consecutive builds at two different shas. Pure infrastructure; see the status comment above.

:windows: 2019 x64 / x64-baseline / 11 aarch64 (exit 2): my test file worker-terminate-lifetime.test.ts is not mentioned anywhere in those shards' logs (those shards did not even contain it). The annotated failures there are bun-install.test.ts (annotated as flaky by Bun's own CI, 1 retry) and spawn-pipe-leak.test.ts (an RSS budget test, 109 MB / 106% vs an 80% budget). Neither is related to worker termination.

:debian: 13 x64-asan - test-bun (exit 2): the one that needed real scrutiny.

test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js - SIGABRT
ASSERTION FAILED: !scope.exception() || !hasSlot
  JSCJSValuePropertyInlines.h(51): JSValue JSC::JSValue::get(JSGlobalObject*, PropertyName, PropertySlot&)

That assertion fires when JSValue::get finds a property while an exception is already pending on the VM, i.e. JSC native code was re-entered on a worker VM that still has the sticky TerminationException pending after the outermost VMEntryScope exited. That is a worker-terminate code path, so I treated it as a potential regression from this PR until proven otherwise. Evidence that it is not:

  1. There is no mechanical route from this diff to that assertion. The entire diff is one vm.setHasTerminationRequest() before the trap fire. I enumerated every reader of hasTerminationRequest() in JSC and in Bun's bindings: the WaiterListManager Atomics wait loop (the intended fix), a DFG-compile delay in JITOperations.cpp:3087 (benign), the node:vm / napi termination checks (not on this test's path), and several assertions that my change makes pass in strictly more cases, never fewer. Critically, the inconsistent state the fuzz report in the description is about (hasPendingTerminationException() true, hasTerminationRequest() false) is produced by VM::executeEntryScopeServicesOnExit (VM.cpp:1812) clearing the flag on scope exit, which it does identically with and without this change. After that point both builds have the exact same state.
  2. 370/370 clean local runs of the exact test on debug+ASAN, on both sides of the diff: 25 on an unfixed build, 25 serial plus 320 under 8-way parallel load on the fixed build. No SIGABRT, no assert.
  3. test-worker-message-port-transfer-terminate.js has been on Bun's ASAN exclusion list (test/no-validate-leaksan.txt) since 2025-09-19, nine months before this PR, along with the rest of the test-worker-* node parallel tests.
  4. This assertion is exactly the pre-existing "Bun re-enters a worker VM that has the sticky TerminationException pending" bug class that motivated this PR in the first place: the fuzz campaign that triggered this investigation has 13 captured hits of the same class on unmodified main, at roughly 1 in 3000 iterations of its SAB/Workers scenario. The CI run rolled that die once and it came up.

That pre-existing re-entry class is real and should be hardened (Bun's worker event loop needs a pre-dispatch guard once the sticky exception has escaped the outermost entry scope), but it is not caused by this change, I could not reproduce it deterministically in 370 attempts, and neither could the fuzz campaign that reported it. It needs its own fix with its own reproduction and is out of scope here, as already noted in the description's "Related" section.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/workers/worker-terminate-lifetime.test.ts`:
- Around line 111-114: The test in worker-terminate-lifetime.test.ts is using a
fixed Atomics.wait(ia, 1, 0, 100) delay to time terminate(), which can miss the
intended blocked-wait state. Replace that timeout-based pause with a second
shared-state handshake in the worker test flow that explicitly signals when the
worker has re-entered Atomics.wait(ia, 0, 0), and only call w.terminate() after
that observable parked-again condition is confirmed. Keep the change localized
to the worker lifetime test logic so the termination assertion proves the
blocked-wait path rather than relying on elapsed time.
🪄 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: 126e2351-30c7-491d-8975-13be9a0f4a20

📥 Commits

Reviewing files that changed from the base of the PR and between 96a7627 and af521b2.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/web/workers/worker-terminate-lifetime.test.ts

Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
Comment thread src/jsc/bindings/bindings.cpp
Comment thread src/jsc/bindings/bindings.cpp
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated

@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.

No issues found on this pass — the last nit (file-level timeout) was applied in 4a16bda93c and all earlier threads are resolved. Deferring final sign-off to a maintainer given this is a cross-thread write to VM termination state on the worker lifecycle path.

What was reviewed:

  • The setHasTerminationRequest() ordering vs. notifyNeedTermination() and the WaiterListManager::waitForSync loop condition it targets.
  • Readers of hasTerminationRequest() across JSC/Bun (napi mustDeferFinalizers, node:vm checkForTermination, VM::executeEntryScopeServicesOnExit) — no new failure mode beyond the already-documented follow-ups.
  • The regression test's park-proof handshake and file-convention timeout; confirmed it matches the sibling tests' subprocess/assert shape.
Extended reasoning...

Overview

One production line: vm.setHasTerminationRequest() added to JSC__VM__notifyNeedTermination in src/jsc/bindings/bindings.cpp before the existing vm.notifyNeedTermination() trap fire, plus a ~50-line subprocess regression test in test/js/web/workers/worker-terminate-lifetime.test.ts. The helper is the shared entry point for every worker termination path (Web Worker#terminate, worker_threads, in-worker process.exit, process-exit sweep).

Security risks

None. No user-controlled input, no parsing, no auth/crypto surface. The change sets an existing atomic VM flag one call earlier on a path that already fires a cross-thread trap on the same VM.

Level of scrutiny

High. Despite being one line, this is a parent-thread write to worker VM termination state — exactly the class REVIEW.md calls out under "know the thread affinity of every line you touch". The prior attempt at this issue (#29179) was closed after CI crashes, and the PR's own analysis documents two adjacent-but-out-of-scope concerns (napi mustDeferFinalizers window widening; the ~1/3000 deferTerminationSlow assertion class) plus a pre-existing node:vm RELEASE_ASSERT reader. The mechanism is well-argued and matches WebKit's WorkerOrWorkletScriptController::scheduleExecutionTermination pattern, but a maintainer with JSC termination-lifecycle context should confirm the scoping decisions.

Other factors

All prior review threads on this PR are resolved: comment-length nit (af521b2), CodeRabbit's re-park handshake suggestion (withdrawn after fail-before measurements), the CI SIGABRT investigation (370-run local repro + main flakiness baseline established), the napi finalizer note (acknowledged as follow-up in the PR description), and the file-level timeout nit (4a16bda). The bug hunting system found nothing this run. The test follows the file's existing subprocess pattern including the expect(stderr).toBe("") shape used by all sibling tests.

dylan-conway added a commit that referenced this pull request Aug 8, 2026
…e ordered VM teardown (#37075)

Makes `Worker` / `node:worker_threads` stable rather than experimental:
every crash, use-after-free, assertion, leak and hang class around a
VM's lifetime and teardown, on all platforms. Missing `worker_threads`
API surface (`resourceLimits`, `trackUnmanagedFds`,
`moveMessagePortToContext`, …) is out of scope.

### Lifetime model

- WebCore's `ActiveDOMObject` / `ScriptExecutionContext` registry is
restored, so `Worker`, `MessagePort`, `BroadcastChannel` and `WebSocket`
are stopped in a real stop phase before the JSC VM is destroyed instead
of from inside `~VM`. `Worker` is split, as upstream, into the script
object and a `WorkerMessagingProxy` that owns the parent↔thread
relationship.
- Worker threads are refcounted and **joined** by their parent (Node's
model). A parent tracks its children, stops them in its own stop phase
and joins them before its VM goes away, so `terminate()` propagates
through nested workers and resolves only once the thread is gone. No
`pthread_exit`.
- `VirtualMachine::teardown()` is the one ordered sequence for a
finished worker and for main-thread exit: exit handlers run, then script
is forbidden and everything the VM owns is stopped natively (WebCore
objects, servers, listeners, watchers, sockets, dns, sqlite, in-flight
`fetch`/S3 requests, `Bun.build` passes waiting on this VM's plugins —
as in Node, no `'close'`/`'error'` handler runs after `'exit'`) → timers
cancelled, children joined, in-flight off-thread work waited for or
released, VM handle closed, queued work released → JSC VM destroyed →
loops freed (uSockets; libuv on Windows) → destroy.
- Every off-thread completion — thread pool (fs, crypto, zlib,
transpiler, `dns.lookup`, shell builtins, `Bun.Archive`, password
hashing), the HTTP thread (`fetch`, S3), the bundle thread,
child-process waiter, fs watcher threads, napi async work / threadsafe
functions, JSC helper threads — reaches a VM only through a per-VM
`VmHandle` that teardown closes. Pool work is one typed carrier
(`bun_jsc::Job`) whose JS-affine half only the owning thread can touch
and teardown releases; work whose storage lives in JS objects or on
another thread is counted and waited for; work that can block on an
external party is registered so the stop phase aborts it. A late
completion is refused and released by its producer instead of touching a
dead VM. `EventLoop` has no cross-thread entry points any more.
- A worker's "may run script" gate closes the moment its stop is
requested — a parent's `terminate()`, its own `process.exit()`, or an
uncaught error — not when its thread gets around to tearing down (Node's
`can_call_into_js` / `is_stopping`). Every native→JS entry consults it
(timer and immediate callbacks, event listeners, socket/server
callbacks, pool-job completions, JSC deferred work, N-API), so nothing
dispatches into a worker that is being stopped, whichever event source
it came from. Promise settlement is the one native→promise boundary and
never accepts an empty value: a JS conversion that a termination
interrupted becomes "reject with the pending exception", which itself
yields to the termination.
- The event loop stays fair under producers that outpace it: one turn
refills from the concurrent queue a bounded number of times; message
drains take a fixed budget per task (a bounded batch per lock
acquisition, never a whole-queue hand-back) and continue after the loop
has polled; a UDP socket is read a bounded number of batches per
readiness event. A worker posting faster than its parent deserializes,
or a datagram socket that never runs dry, no longer holds that loop's
timers, I/O — or its own pending stop.
- Cross-thread costs of the handle are kept off hot paths: its
read-mostly state sits on its own cache line away from the counters
other threads update, and C++ tests the "may run script" byte inline
rather than calling out per callback.

### Behaviour changes (Node parity)

- `parentPort` is a real `MessagePort`: `parentPort.close()` ends the
worker, `.ref()`/`.unref()` work, `receiveMessageOnPort` returns falsy
messages, and parent messages are delivered only after the worker's
entry module has run (a preload's un-awaited `import()` does not count
as the entry running).
- A worker with a pending top-level await starts and receives messages;
it exits 13 if the await never settles, and a top-level await rejecting
later fails the worker at that moment.
- `await worker.terminate()` resolves the exit code (`1` for a running
worker); `threadId` stays valid until exit; everything a worker posted
before it exited is delivered before `'exit'`/`'close'`; `postMessage()`
to a terminated worker is a no-op rather than an error; a rejection that
is only a consequence of `terminate()` (a lookup or request cancelled by
the stop) is not reported as the worker's `'error'`.
- `process.exit()` / worker exit no longer runs microtasks or
`nextTick`s queued before it. A worker's own `process.exit()` or
uncaught error runs its `'exit'` handlers; a parent `terminate()` does
not. `process.exit()` from inside (nested) `node:vm` contexts in a
worker unwinds like any exception, and a `node:vm` `timeout` inside a
worker no longer leaves the worker unable to run script afterwards.
- Workers inside a process that has an IPC channel do not get a
`process.send()` of their own over the process's channel fd.
- N-API's pure constructors/accessors are callable while an exception is
pending (as in Node), so node-addon-api can build the `Error` for a call
a termination interrupted instead of aborting the process.
- Assigning a non-function to `port.onmessage` releases the keep-alive a
handler took.
- Servers, listeners, sockets, UDP sockets, watchers and `dns.Resolver`s
are closed by the exiting VM rather than left to GC finalizers; sqlite
connections a VM opened are checkpointed and closed by that VM's exit; a
`Bun.build` whose VM goes away mid-build is cancelled (its plugin
requests failed, the pass finished) rather than abandoned or waited on,
and one still queued behind other builds is released without waiting for
them.
- A connect-path DNS lookup (`Bun.connect`, `net`, `WebSocket` to a
hostname) is process-wide and outlives the thread that happened to issue
it: on macOS a worker exiting mid-lookup no longer answers every other
thread's coalesced waiters with an error (and caches it for the TTL).
- An addon's external-buffer finalizers run when the Worker that loaded
it exits (`napi_create_external_{arraybuffer,buffer}`), as Node's
environment teardown finalizes every remaining reference.
- Releasing the last keep-alive from an immediate or a late promise
reaction (e.g. `port.close()` inside `setImmediate`) is noticed before
the loop parks.
- Windows: a worker thread closes its loops. Open pipe/tty/process
handles and readers mid file-read are closed through their owners in the
stop phase, and requests still in flight are drained there — against a
live VM that still accepts (and then awaits) the follow-on work a
completion may start — before anything is released; sockets over named
pipes and TLS-over-duplex sockets join the stop phase; a reader dropped
mid-read keeps the buffer its pending read lands in.

Two of the crashes were in JavaScriptCore rather than Bun: a
`TerminationException` raised while the module loader resolves an import
continued into `finishLoadingImportedModule` (fixed in
oven-sh/WebKit#391, picked up by the WebKit version bump here). A worker
parked in `Atomics.wait` with no timeout still cannot be terminated
(#32802); that needs a JSC change and is tracked separately.

### Testing

New tests accompany each behaviour fix (worker_threads, Web Worker
lifecycle edges — `terminate()` at every phase of
dns/fs/build/vm/napi/http work, message ordering and flooding, process
exit ordering, sqlite). Seven more upstream `test-worker-*` files are
vendored (one of them, the message-port infinite-message-loop test,
passes only with these changes) and previously todo/skipped
worker-related napi and regression cases run again. LeakSanitizer
validation is turned back on for the ~70 worker / MessagePort /
BroadcastChannel test files that were exempt. A source lint rejects
laundering a `JsResult<JSValue>` into an empty `JSValue`. Main-thread
`process.exit()` keeps its current fast path by default; the full
main-thread teardown stays behind `BUN_DESTRUCT_VM_ON_EXIT=1`. Workers
always tear down fully.

### Known / not in this PR

- A worker parked in `Atomics.wait` with no timeout still cannot be
terminated (#32802; needs a JSC change).
- `worker_threads` message throughput through the real `MessagePort` is
~0.8× the previous ad-hoc path in a flood microbenchmark (round-trip
latency and Web `Worker` messaging are unchanged); a follow-up, not a
behaviour regression.
- Windows: several concurrent connects to `localhost` can leave one
connect stuck (pre-existing; reproduces on current releases;
DNS-coalescing on the connect path).
- A UDP socket whose receive buffer never drains (e.g. one echoing
datagrams to itself on a fast machine) keeps its event loop from running
anything else, including a worker's own exit; pre-existing loop-fairness
issue, most visible on Windows, follow-up.
- Memory a burst of concurrent workers used stays resident after they
exit (sequential worker churn plateaus; it is the concurrent peak that
is not returned to the OS) — allocator thread-exit policy, follow-up.
- `Worker` start semantics for a never-settling top-level await,
file-stream fairness on a saturated loop, and a few diagnostics-only
items found while fuzzing are tracked separately.

Fixes #31281
Fixes #30421
Fixes #15964
Fixes #29173
Fixes #34690
Fixes #31880
Fixes #33936
Fixes #32073
Fixes #33313
Fixes #32828
Fixes #11760
Fixes #26501
Fixes #18661
Fixes #15408
Fixes #23102
Fixes #21101
Fixes #13570
Fixes #31224
Fixes #28643
Fixes #37163
Fixes #25860

Likely also addressed (mechanism matches, not verified end-to-end):
#34095; #22376 and the other emscripten-pthread reports (#25454, #19453,
#29211, #29635) whose glue installs both a `parentPort` listener and
`self.onmessage` — the double delivery behind them (#25860) is fixed,
the packages themselves were not run; and the
`parentPort.on('message').unref()` hang half of #32609 (its
`Worker.performance` half is API surface, not addressed here).

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
springmin pushed a commit to springmin/bun that referenced this pull request Aug 8, 2026
…e ordered VM teardown (oven-sh#37075)

Makes `Worker` / `node:worker_threads` stable rather than experimental:
every crash, use-after-free, assertion, leak and hang class around a
VM's lifetime and teardown, on all platforms. Missing `worker_threads`
API surface (`resourceLimits`, `trackUnmanagedFds`,
`moveMessagePortToContext`, …) is out of scope.

- WebCore's `ActiveDOMObject` / `ScriptExecutionContext` registry is
restored, so `Worker`, `MessagePort`, `BroadcastChannel` and `WebSocket`
are stopped in a real stop phase before the JSC VM is destroyed instead
of from inside `~VM`. `Worker` is split, as upstream, into the script
object and a `WorkerMessagingProxy` that owns the parent↔thread
relationship.
- Worker threads are refcounted and **joined** by their parent (Node's
model). A parent tracks its children, stops them in its own stop phase
and joins them before its VM goes away, so `terminate()` propagates
through nested workers and resolves only once the thread is gone. No
`pthread_exit`.
- `VirtualMachine::teardown()` is the one ordered sequence for a
finished worker and for main-thread exit: exit handlers run, then script
is forbidden and everything the VM owns is stopped natively (WebCore
objects, servers, listeners, watchers, sockets, dns, sqlite, in-flight
`fetch`/S3 requests, `Bun.build` passes waiting on this VM's plugins —
as in Node, no `'close'`/`'error'` handler runs after `'exit'`) → timers
cancelled, children joined, in-flight off-thread work waited for or
released, VM handle closed, queued work released → JSC VM destroyed →
loops freed (uSockets; libuv on Windows) → destroy.
- Every off-thread completion — thread pool (fs, crypto, zlib,
transpiler, `dns.lookup`, shell builtins, `Bun.Archive`, password
hashing), the HTTP thread (`fetch`, S3), the bundle thread,
child-process waiter, fs watcher threads, napi async work / threadsafe
functions, JSC helper threads — reaches a VM only through a per-VM
`VmHandle` that teardown closes. Pool work is one typed carrier
(`bun_jsc::Job`) whose JS-affine half only the owning thread can touch
and teardown releases; work whose storage lives in JS objects or on
another thread is counted and waited for; work that can block on an
external party is registered so the stop phase aborts it. A late
completion is refused and released by its producer instead of touching a
dead VM. `EventLoop` has no cross-thread entry points any more.
- A worker's "may run script" gate closes the moment its stop is
requested — a parent's `terminate()`, its own `process.exit()`, or an
uncaught error — not when its thread gets around to tearing down (Node's
`can_call_into_js` / `is_stopping`). Every native→JS entry consults it
(timer and immediate callbacks, event listeners, socket/server
callbacks, pool-job completions, JSC deferred work, N-API), so nothing
dispatches into a worker that is being stopped, whichever event source
it came from. Promise settlement is the one native→promise boundary and
never accepts an empty value: a JS conversion that a termination
interrupted becomes "reject with the pending exception", which itself
yields to the termination.
- The event loop stays fair under producers that outpace it: one turn
refills from the concurrent queue a bounded number of times; message
drains take a fixed budget per task (a bounded batch per lock
acquisition, never a whole-queue hand-back) and continue after the loop
has polled; a UDP socket is read a bounded number of batches per
readiness event. A worker posting faster than its parent deserializes,
or a datagram socket that never runs dry, no longer holds that loop's
timers, I/O — or its own pending stop.
- Cross-thread costs of the handle are kept off hot paths: its
read-mostly state sits on its own cache line away from the counters
other threads update, and C++ tests the "may run script" byte inline
rather than calling out per callback.

- `parentPort` is a real `MessagePort`: `parentPort.close()` ends the
worker, `.ref()`/`.unref()` work, `receiveMessageOnPort` returns falsy
messages, and parent messages are delivered only after the worker's
entry module has run (a preload's un-awaited `import()` does not count
as the entry running).
- A worker with a pending top-level await starts and receives messages;
it exits 13 if the await never settles, and a top-level await rejecting
later fails the worker at that moment.
- `await worker.terminate()` resolves the exit code (`1` for a running
worker); `threadId` stays valid until exit; everything a worker posted
before it exited is delivered before `'exit'`/`'close'`; `postMessage()`
to a terminated worker is a no-op rather than an error; a rejection that
is only a consequence of `terminate()` (a lookup or request cancelled by
the stop) is not reported as the worker's `'error'`.
- `process.exit()` / worker exit no longer runs microtasks or
`nextTick`s queued before it. A worker's own `process.exit()` or
uncaught error runs its `'exit'` handlers; a parent `terminate()` does
not. `process.exit()` from inside (nested) `node:vm` contexts in a
worker unwinds like any exception, and a `node:vm` `timeout` inside a
worker no longer leaves the worker unable to run script afterwards.
- Workers inside a process that has an IPC channel do not get a
`process.send()` of their own over the process's channel fd.
- N-API's pure constructors/accessors are callable while an exception is
pending (as in Node), so node-addon-api can build the `Error` for a call
a termination interrupted instead of aborting the process.
- Assigning a non-function to `port.onmessage` releases the keep-alive a
handler took.
- Servers, listeners, sockets, UDP sockets, watchers and `dns.Resolver`s
are closed by the exiting VM rather than left to GC finalizers; sqlite
connections a VM opened are checkpointed and closed by that VM's exit; a
`Bun.build` whose VM goes away mid-build is cancelled (its plugin
requests failed, the pass finished) rather than abandoned or waited on,
and one still queued behind other builds is released without waiting for
them.
- A connect-path DNS lookup (`Bun.connect`, `net`, `WebSocket` to a
hostname) is process-wide and outlives the thread that happened to issue
it: on macOS a worker exiting mid-lookup no longer answers every other
thread's coalesced waiters with an error (and caches it for the TTL).
- An addon's external-buffer finalizers run when the Worker that loaded
it exits (`napi_create_external_{arraybuffer,buffer}`), as Node's
environment teardown finalizes every remaining reference.
- Releasing the last keep-alive from an immediate or a late promise
reaction (e.g. `port.close()` inside `setImmediate`) is noticed before
the loop parks.
- Windows: a worker thread closes its loops. Open pipe/tty/process
handles and readers mid file-read are closed through their owners in the
stop phase, and requests still in flight are drained there — against a
live VM that still accepts (and then awaits) the follow-on work a
completion may start — before anything is released; sockets over named
pipes and TLS-over-duplex sockets join the stop phase; a reader dropped
mid-read keeps the buffer its pending read lands in.

Two of the crashes were in JavaScriptCore rather than Bun: a
`TerminationException` raised while the module loader resolves an import
continued into `finishLoadingImportedModule` (fixed in
oven-sh/WebKit#391, picked up by the WebKit version bump here). A worker
parked in `Atomics.wait` with no timeout still cannot be terminated
(oven-sh#32802); that needs a JSC change and is tracked separately.

New tests accompany each behaviour fix (worker_threads, Web Worker
lifecycle edges — `terminate()` at every phase of
dns/fs/build/vm/napi/http work, message ordering and flooding, process
exit ordering, sqlite). Seven more upstream `test-worker-*` files are
vendored (one of them, the message-port infinite-message-loop test,
passes only with these changes) and previously todo/skipped
worker-related napi and regression cases run again. LeakSanitizer
validation is turned back on for the ~70 worker / MessagePort /
BroadcastChannel test files that were exempt. A source lint rejects
laundering a `JsResult<JSValue>` into an empty `JSValue`. Main-thread
`process.exit()` keeps its current fast path by default; the full
main-thread teardown stays behind `BUN_DESTRUCT_VM_ON_EXIT=1`. Workers
always tear down fully.

- A worker parked in `Atomics.wait` with no timeout still cannot be
terminated (oven-sh#32802; needs a JSC change).
- `worker_threads` message throughput through the real `MessagePort` is
~0.8× the previous ad-hoc path in a flood microbenchmark (round-trip
latency and Web `Worker` messaging are unchanged); a follow-up, not a
behaviour regression.
- Windows: several concurrent connects to `localhost` can leave one
connect stuck (pre-existing; reproduces on current releases;
DNS-coalescing on the connect path).
- A UDP socket whose receive buffer never drains (e.g. one echoing
datagrams to itself on a fast machine) keeps its event loop from running
anything else, including a worker's own exit; pre-existing loop-fairness
issue, most visible on Windows, follow-up.
- Memory a burst of concurrent workers used stays resident after they
exit (sequential worker churn plateaus; it is the concurrent peak that
is not returned to the OS) — allocator thread-exit policy, follow-up.
- `Worker` start semantics for a never-settling top-level await,
file-stream fairness on a saturated loop, and a few diagnostics-only
items found while fuzzing are tracked separately.

Fixes oven-sh#31281
Fixes oven-sh#30421
Fixes oven-sh#15964
Fixes oven-sh#29173
Fixes oven-sh#34690
Fixes oven-sh#31880
Fixes oven-sh#33936
Fixes oven-sh#32073
Fixes oven-sh#33313
Fixes oven-sh#32828
Fixes oven-sh#11760
Fixes oven-sh#26501
Fixes oven-sh#18661
Fixes oven-sh#15408
Fixes oven-sh#23102
Fixes oven-sh#21101
Fixes oven-sh#13570
Fixes oven-sh#31224
Fixes oven-sh#28643
Fixes oven-sh#37163
Fixes oven-sh#25860

Likely also addressed (mechanism matches, not verified end-to-end):
`self.onmessage` — the double delivery behind them (oven-sh#25860) is fixed,
the packages themselves were not run; and the
`parentPort.on('message').unref()` hang half of oven-sh#32609 (its
`Worker.performance` half is API surface, not addressed here).

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
JSC's WaiterListManager::waitSyncImpl only exits its park loop on
vm.hasTerminationRequest(), but that flag was only set by
VMTraps::handleTraps at a JS safepoint on the waiting thread itself. A
cross-thread NeedTermination trap woke the sync waiter, which saw the
flag still false and re-parked forever.

So worker.terminate() on a worker blocked in Atomics.wait never
completed, and because worker teardown joins child workers, a worker
whose own child was parked in Atomics.wait could never finish its
process.exit() and its parent never received 'exit'.

Set the flag in JSC__VM__notifyNeedTermination before firing the trap;
the woken waiter observes it and returns WaitSyncResult::Terminated.
@robobun
robobun force-pushed the farm/26492eef/worker-terminate-atomics-wait branch from 4a16bda to 7a7f2d0 Compare August 9, 2026 11:45
Comment thread src/jsc/bindings/bindings.cpp
@robobun robobun changed the title worker: make terminate() interrupt a worker blocked in Atomics.wait worker: make termination interrupt a worker blocked in Atomics.wait Aug 9, 2026

@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.

Beyond the inline SigintWatcher note: the other notifyNeedTermination() grep hit at bindings.cpp:5183 (JSC__VM__ensureTerminationExceptionPending) runs on the JS thread and branches on hasTerminationRequest() in the very next lines, so it is not a same-class sibling. The tests' new home in worker_threads.test.ts already sets setDefaultTimeout(isDebug ? 90_000 : 10_000), so my earlier per-test timeout nit from worker-terminate-lifetime.test.ts does not recur.

Extended reasoning...

This run's only finding is the SigintWatcher sibling nit (pre-existing, not regressed). I checked the remaining notifyNeedTermination() call sites: bindings.cpp:5183 is on the JS thread inside ensureTerminationExceptionPending and immediately reads/handles hasTerminationRequest() itself, so it does not need the off-thread pairing this PR adds. I also verified the tests' new location has the file-level 90s/10s default timeout and all required harness imports (bunEnv, bunExe, tempDir, once), so the timeout concern I raised on the previous file does not apply here. Not approving because this is a cross-thread JSC VM termination path where a prior attempt (#29179) introduced crashes; a maintainer should sign off on the scoping (the acknowledged napi mustDeferFinalizers and deferTerminationSlow follow-ups).

Comment thread src/jsc/bindings/bindings.cpp
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Re-checked against current main (165dc9f) while closing the worker PRs made obsolete by #37075. This one is not obsolete: #37075 lists the Atomics.wait case as not addressed, and on main the PR's "terminate() interrupts a worker blocked in Atomics.wait" test still times out (debug build), with the test process then unable to exit because the parked worker cannot be joined. The standalone repro also still hangs on the 1.4.0 canary that includes #37075, where node resolves terminate() with 1. Leaving open.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Another report of this bug, for the record: Piscina.destroy() never resolves on 1.3.14 and on the current 1.4.0 canary (piscina@5.3.0; this is what hangs Analog/vitest and @angular/build at pool teardown). Piscina's idle workers sit in Atomics.wait() in their message handler and destroy() awaits once(worker, 'exit') after terminate(), so it is exactly this path. PISCINA_DISABLE_ATOMICS=1 makes the same program exit cleanly on current main, and with this PR's change it exits cleanly as is.

I ended up with the same setHasTerminationRequest() in JSC__VM__notifyNeedTermination before finding this PR; the branch is farm/c719aec1/worker-terminate-atomics-wait (not opening a second PR). One difference that may be worth folding in here: after vm.notifyNeedTermination() it also calls vm.syncWaiter()->condition().notifyOne() unconditionally. VMTraps::requestThreadStopIfNeeded only notifies the sync waiter when this trap is the one that transitions the VM into a thread stop (another pending async trap, for example a debugger break, skips it), and the 1ms re-notify from SignalSender that otherwise covers that is compiled out where ENABLE(SIGNAL_BASED_VM_TRAPS) is off (Windows has no HAVE(MACHINE_CONTEXT)) and under usePollingTraps. #37268 does the same on the breakOnSigint path.

The branch's tests are the piscina shape (wait entered from a message handler, asserting both the terminate() value and the 'exit' event are 1) and terminate() of a worker whose own child is parked (the join during the worker's teardown); both time out on main and pass with the change, and the full worker_threads.test.ts plus every family of worker-terminate-funnels-fixture.ts pass with it on a debug ASAN build.

dylan-conway added a commit that referenced this pull request Aug 14, 2026
…ifetimes; WebKit bump for Atomics.wait (#38457)

### What does this PR do?

Follow-up to #38436 with further fixes for stopping a worker
(`worker.terminate()`, or `process.exit()` inside it) while it still has
native work in flight.

**`terminate()` during the `'beforeExit'` re-run of the loop is acted
on.** After a worker's loop drains it emits `'beforeExit'` and, if the
listeners scheduled more work, re-runs the loop until idle. That inner
drain only watched for idleness: a `terminate()` arriving during it
closed the VM's gate and woke the loop, but nothing there checked for
the stop, so the loop went back to sleep — and because a stopped VM no
longer has completions delivered to it, the in-flight work (e.g. a
`fetch`) never released it. The worker slept forever and `terminate()`
never settled (Node exits the worker within milliseconds). The drain now
ends as soon as the stop is requested, as the worker's main loop already
does; teardown cancels what is left in flight.

**`node:path`'s binding creator checks for an exception before storing
each `createPath()` result.** `createNodePathBinding()` passed
`Zig::createPath()`'s result straight into `putDirectIndex()` and
checked the scope only afterwards; `createPath()` returns `nullptr` when
its own `RETURN_IF_EXCEPTION` fires (a worker terminated while its entry
point is materialising `node:path`), and `putDirectIndex()` then
inspected a null cell. The other object-building lazy binding creators
were audited for the same pattern; this was the only instance.

**JSC's termination-request flag is kept set for as long as a stopped
worker's TerminationException is kept pending.** Bun deliberately leaves
the TerminationException that unwound a stopped worker's script pending
until teardown, while it finishes draining the current loop tick. JSC
resets `VM::hasTerminationRequest()` when the outermost `VMEntryScope`
exits and expects the two to agree while the exception is pending — its
own clients never keep the exception past that point without also
ceasing to touch the VM (WebCore's worker run loop runs no further task
once terminating). Host code that ran in the rest of the tick and
initialised a lazy structure (building an error or result object)
therefore tripped `VMTraps::deferTerminationSlow()`'s
`ASSERT(vm.hasTerminationRequest())` on debug builds and had the pending
termination silently dropped on release builds. The invariant is now
kept on our side, next to where it was already maintained for teardown
(`Zig__GlobalObject__forbidExecution` /
`Bun__GlobalObject__clearExceptionsForExit`): when a call into JSC comes
back with the TerminationException pending and the request already
reset, it is set again — on the cold error arm of every Rust→JSC
exception-check boundary (the generated `*_is_throw` wrappers and the
C++ shim behind `return_if_exception()`), in the timer callback landing
frame, and in the microtask drain.

**WebKit bump: `worker.terminate()` stops a worker blocked in
`Atomics.wait()`** (oven-sh/WebKit@f0f60fd23248, oven-sh/WebKit#432). A
worker parked in `Atomics.wait()` / wasm `memory.atomic.wait` with no
timeout could not be terminated — the terminate promise never settled
and the thread leaked; Node stops such a worker immediately.
`WaiterListManager::waitSyncImpl`'s wake-up predicate only looked at
`VM::hasTerminationRequest()`, which since the last upstream merge is
only ever set by the parked thread itself, so the notify woke it and it
parked again; the wake-up is also delivered under the waiter's lock now
so it cannot be lost where VM traps are polled (Windows).
Sync-over-async worker pools (synckit, Prettier/eslint plugins,
piscina's Atomics mode) park exactly there. Fixes #32802.

**Errors built for a stopped worker are always objects.** Every `ERR::*`
helper, `Bun__createErrorWithCode` and the WebStreams code throw or
reject with what `Bun::createError()` returns; it built the error
through `ErrorInstance::create(JSGlobalObject*, …)`, which converts the
message first and hands back `nullptr` when that conversion is
interrupted — and now that a stopped worker keeps draining its tick with
its TerminationException pending, sites like `ERR::OUT_OF_RANGE` (zlib
option validation), `writableStreamDefaultWriterRelease`'s "released"
error and the `Response`/`Request` body readers passed that null to
`ThrowScope::throwException` / `JSPromise::rejectedPromise` (SEGV
inspecting a null cell). `ErrorCodeCache::createError` now converts
message/`cause` itself and constructs through the infallible `VM&`
overload (the termination stays pending for the caller; anything else
thrown while building the message becomes the error, as before), and
`JSC__JSPromise__rejectedPromise` returns an inert promise if it is ever
handed an empty value.

**Prime generation gives up once its worker has been asked to stop.**
`crypto.generatePrime()`/`generatePrimeSync()`/`checkPrime()`/`checkPrimeSync()`
with `safe: true` or awkward `add`/`rem` constraints can run for
minutes; a worker's teardown waits for its pool jobs and the sync forms
cannot observe a termination at all, so `terminate()` / `process.exit()`
hung for as long as BoringSSL took. The `BN_GENCB` progress callback (a
`return true` stub with a TODO) now returns whether the VM the work is
for may still run script, aborting the generation as soon as the stop is
requested — where Node checks `is_stopping()`. A failed/aborted
generation is reported as `ERR_CRYPTO_OPERATION_FAILED` rather than
converting a half-made BIGNUM (`checkPrimeSync` previously returned
`true` for `BN_is_prime_ex`'s -1). Key-pair generation goes through
`EVP_PKEY_keygen`, which has no progress hook in BoringSSL, and
pbkdf2/scrypt/argon2 with extreme parameters have none either (as in
Node); those still make the teardown wait.

**A streaming fetch's teardown no longer writes into a freed response
stream source** (worker exit; heap-use-after-free WRITE under ASAN).
With both a streaming request body (its sink cell holds the
FetchTasklet) and a JS-touched `response.body` (a ByteStream source
owned by the stream's source cell) alive at exit, the VM's last sweep
destroys cells in no particular order and the tasklet unhooked itself as
the stream's producer through the ReadableStream wrapper into a source
that sweep had already freed. The tasklet now holds a counted ref on the
source while it is its producer and unhooks through that, touching no JS
cell (which also lets the Response weak-finalizer path unhook instead of
skipping it).

**Subprocess: no pending-activity bookkeeping once the wrapper is
finalized.** A worker exiting while a spawned child still had a pending
pipe-backed stdin (a Blob the child never read — the default stdin path
on Windows) finalized the Subprocess in the last sweep; `finalize()`
marks the wrapper finalized and then closes stdio, whose close path
re-evaluated pending activity and tried to re-root the dead wrapper
(debug assert).

**valkey: `close()` returns what a half-open socket's `onclose` left
pending instead of folding it.** `close()` runs the close event itself
for a half-open socket and folded the result on the spot, but it is also
reached beneath frames that go on to return their own `Err`
(`fail_with_js_value`, the HELLO-failure path, `fail_handshake`); a
dispatcher fold beneath a frame that still propagates can take the
exception that frame's `Err` refers to, and the timer fold above then
finds nothing pending. Callers now sequence the result with their own
and only the deferred-close task folds.

### How did you verify your code works?

New tests in `test/js/web/workers/worker-terminate-lifetime.test.ts`: a
worker whose `'beforeExit'` listener starts a `fetch()` to a server that
never answers and tells the parent, which then calls `terminate()`; it
must settle with exit code 1. Times out without the change, passes with
it (release and debug). Also checked that the natural `'beforeExit'`
cycle (listener re-scheduling work N times, then `'exit'`) and
`process.exit()` from a `'beforeExit'` listener behave as before and as
in Node. The `node:path` change has no dedicated test
(termination-timing window only); `node:path` in the main thread and in
a worker still behaves. For the termination flag: (debug only) workers
that start a refused redis connect in the same immediate tick as
`process.exit()` must exit cleanly — asserts in `deferTerminationSlow`
3/3 without the change, passes with it; the earlier
`Bun.serve`-in-a-stopped-worker repro from #38436 also stays clean with
that PR's server-side gate disabled, i.e. the flag maintenance alone
covers it. For the WebKit bump: `terminate()` of a worker blocked in
`Atomics.wait(i32, 0, 0)` must complete with exit code 1 — times out on
the previous pin, passes now (this ran green on every platform, Windows
included, as #38447 before being folded in here). Prime generation:
`terminate()` of workers grinding `generatePrimeSync(2048, {safe})`,
`generatePrime(2048, {safe})` and a 200-round `checkPrime` of a 4423-bit
Mersenne prime resolves promptly (times out on the current release
binary). Streaming-request-body fetches with touched `response.body` at
worker exit: heap-use-after-free 3/3 before, clean after (new test).
Blob-stdin child at worker exit with `BUN_FEATURE_FLAG_DISABLE_MEMFD`:
asserts before, clean after (new test).

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Closed by #38457 (merge commit 97a4363), which fixes this on the WebKit side instead: the bump to oven-sh/WebKit@f0f60fd (oven-sh/WebKit#432) makes a termination request from another thread wake a parked Atomics.wait() directly, so the bindings.cpp change here is no longer needed.

Verified against main at 97a4363 (debug build) that it covers both cases from this PR, which both still hang on the 1.4.0-canary.1 binary built before that merge:

  • terminate() of a worker parked in Atomics.wait(i32, 0, 0) resolves with 1 and the process exits.
  • process.exit(5) in a worker whose own child is parked in Atomics.wait completes: the parent gets exit with code 5 and the process exits (same output as Node).

Both tests from this PR's diff also pass unchanged on main. Only the first shape is covered by the tests #38457 added (worker-terminate-lifetime.test.ts); the second one (the worker's own teardown joining a parked child) is not covered on main, noted on #38457.

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.

Bun process does not exit after Tinypool destroy() completes (worker threads remain alive)

2 participants