Skip to content

RuntimeTranspilerStore: don't report TerminationException as uncaught on worker.terminate() - #36581

Open
robobun wants to merge 2 commits into
mainfrom
farm/ee492185/transpiler-store-termination-not-uncaught
Open

RuntimeTranspilerStore: don't report TerminationException as uncaught on worker.terminate()#36581
robobun wants to merge 2 commits into
mainfrom
farm/ee492185/transpiler-store-termination-not-uncaught

Conversation

@robobun

@robobun robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Repro

A worker dynamic-importing in a loop and terminated mid-iteration aborts the process on debug builds:

ASSERTION FAILED: isCompilationThread() || Thread::mayBeGCThread() || object->structure() == this
StructureInlinesLight.h(56) : JSValue JSC::Structure::storedPrototype(const JSObject *) const

Stack (raw-stack symbolized; gdb DWARF is broken on bun-debug):

Structure::storedPrototype                  (StructureInlinesLight.h:56)
JSObject::getPropertySlot<false>            (JSObject.h:1220)
JSObject::get                               (JSObjectInlines.h:135)
Bun__handleUncaughtException                (BunProcess.cpp:1225, process->get("_fatalException"))
VirtualMachine::report_uncaught_exception   (VirtualMachine.rs:5037)
report_uncaught_exception_from_error        (JSGlobalObject.rs:1398)
RuntimeTranspilerStore::run_from_js_thread  (RuntimeTranspilerStore.rs:251)
run_task                                    (dispatch.rs:353)

Cause

When worker.terminate() lands between a TranspilerJob dispatching back to the JS thread and its AsyncModule::fulfill call completing, from_js_host_call_generic's post-FFI trap check raises the TerminationException and returns Err(JsError::Thrown). RuntimeTranspilerStore::run_from_js_thread handed that straight to report_uncaught_exception_from_error, which has no termination filter (unlike report_active_exception_as_unhandled / report_error_or_terminate), so Bun__handleUncaughtException ran its process->get("_fatalException") prototype walk with the TerminationException still armed.

_fatalException is a Function entry in Process's static hash table. getOwnNonIndexPropertySlotgetOwnStaticPropertySlotsetUpStaticFunctionSlot reifies it via putDirectNativeFunction, which transitions process's Structure. The Structure* loaded at the top of getPropertySlot's loop is then stale when structure->storedPrototype(object) checks object->structure() == this, tripping the assert.

Fix

Route the error through report_error_or_terminate (the same helper the AnyTask / ManagedTask / CppTask dispatch arms already use), which recognises both JsError::Terminated and a pending TerminationException value and propagates JsTerminated to unwind the tick loop instead of reporting. run_from_js_thread now returns Result<(), JsTerminated> so dispatch::run_task can ?-propagate it like the other arms.

A genuine non-termination exception from AsyncModule::fulfill still reaches report_uncaught_exception inside report_error_or_terminate.

Verification

New debug-gated stress test in test/js/web/workers/worker-terminate-lifetime.test.ts spawns 2 lanes × 100 workers, each dynamic-importing syntax-error modules in a loop and terminated after 20-140 ms. On an unfixed debug build: 3/3 abort with the storedPrototype assert within 3-5 s. With the fix: 5/5 survive.

The imported modules intentionally have a syntax error so the fetch promise rejects and the ModuleLoadTopSettled microtask takes its Rejected branch (no loadModulehostLoadImportedModuleresolve()), avoiding the separate WebKit-level scope.assertNoException() termination bug in continueDynamicImport (tracked separately). The test uses the global Web Worker for the same reason: node:worker_threads injects a "node:worker_threads" preload whose load_preloads spin uses the non-termination-aware wait_for_promise and independently hits that WebKit assert.

Related

  • workers: join in-flight transpiler jobs before freeing the VM on terminate #33939 fixes the TranspilerJob UAF (VM freed while the pool-side parse is still reading it). That PR's body notes this storedPrototype assert as out-of-scope; this is the follow-up.
  • process: skip uncaught-exception dispatch on a terminating worker #35678 adds a scriptExecutionStatus() != Running guard in Bun__handleUncaughtException itself, which would also happen to stop this symptom; this PR fixes it at the source (the dispatch site that should not be reporting termination as uncaught in the first place) and matches the existing report_error_or_terminate pattern.
  • The continueDynamicImport assertNoException() bug (also surfaced by the original multi-lane repro once this assert no longer fires first) lives in JSModuleLoader.cpp in the prebuilt WebKit and is tracked separately.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/workers/worker-terminate-lifetime.test.ts

… on worker.terminate()

A worker dynamic-importing in a loop and terminated mid-iteration has an
in-flight TranspilerJob whose JS-thread completion (AsyncModule::fulfill)
raises the TerminationException at the from_js_host_call_generic trap check.
run_from_js_thread handed that to report_uncaught_exception_from_error, which
has no termination filter, so Bun__handleUncaughtException ran
process->get("_fatalException") on a terminating VM. The static-hash-table
reification inside that lookup transitions process's Structure mid-walk and
trips ASSERT(object->structure() == this) in Structure::storedPrototype
(debug-build SIGABRT).

Route the error through report_error_or_terminate instead (the same helper the
AnyTask/ManagedTask/CppTask dispatch arms use), which recognises both
JsError::Terminated and a pending TerminationException and propagates
JsTerminated to unwind the tick loop. run_from_js_thread now returns
Result<(), JsTerminated> so dispatch can short-circuit.
@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. ASAN CI: ExceptionScope::assertNoException during worker terminate (worker-transfer-terminate-stress, separate from #34095) #34690 - Fixes ASAN assertion ExceptionScope::assertNoException during worker terminate by properly handling TerminationException instead of misrouting it through uncaught exception reporting
  2. ASAN CI: JSC assertion in JSObject::getOwnPropertyDescriptor during worker terminate (test-worker-message-port-transfer-terminate) #34095 - Fixes ASAN JSC assertion in JSObject::getOwnPropertyDescriptor during worker terminate, caused by the same pattern of property/prototype walks triggered by mishandled TerminationException
  3. Worker create+terminate cycle aborts process after ~100k–900k iterations on macOS arm64 #30421 - Fixes Worker create+terminate cycle abort by preventing TerminationException from racing through RuntimeTranspilerStore into crash-inducing uncaught exception handling

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

Fixes #34690
Fixes #34095
Fixes #30421

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Runtime transpiler jobs now propagate JavaScript termination errors through event-loop dispatch. A debug-only worker regression test covers repeated termination during invalid dynamic imports and verifies clean process completion.

Changes

Worker termination handling

Layer / File(s) Summary
Propagate transpiler termination errors
src/jsc/RuntimeTranspilerStore.rs, src/runtime/dispatch.rs
run_from_js_thread returns JsTerminated errors from microtask draining and transpiler jobs. Runtime dispatch propagates the result through run_task.
Validate worker termination during imports
test/js/web/workers/worker-terminate-lifetime.test.ts
The test creates invalid dynamic-import modules, repeatedly terminates concurrent workers, and checks completion without stderr or signals.

Possibly related PRs

  • oven-sh/bun#36342: Adds worker-termination regression coverage in the same test file for a different runtime scenario.
  • oven-sh/bun#36579: Addresses worker termination propagation through a different runtime path with related regression coverage.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main fix: preventing termination errors from being reported as uncaught exceptions during worker termination.
Description check ✅ Passed The description explains the problem, cause, fix, verification results, and related issues, although it does not use the template headings.

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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. process: skip uncaught-exception dispatch on a terminating worker #35678 - Both PRs prevent TerminationException from crashing through the uncaught-exception reporting path during worker.terminate(); process: skip uncaught-exception dispatch on a terminating worker #35678 guards Bun__handleUncaughtException directly while this PR fixes the dispatch site in RuntimeTranspilerStore

🤖 Generated with Claude Code

@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 — routes TranspilerJob completion errors through the termination-aware report_error_or_terminate helper, matching the AnyTask/ManagedTask/CppTask arms.

What was reviewed:

  • Confirmed report_error_or_terminate filters both JsError::Terminated and is_termination_exception() before falling through to the same report_uncaught_exception the old path used, so non-termination errors are still reported.
  • dispatch::run_task is the only caller of RuntimeTranspilerStore::run_from_js_thread; ?-propagation there matches every sibling arm.
  • Early-return on termination mid-batch mirrors the pre-existing drain_microtasks_with_global early-return, so no new job-leak path.
  • Test follows the file's existing debug-gated stress-test pattern (tempDir, concurrent pipe drain, combined {stderr, stdout, exitCode, signalCode} assert).
Extended reasoning...

Overview

Two-line Rust change plus a return-type widening: RuntimeTranspilerStore::run_from_js_thread now hands JsError from TranspilerJob::run_from_js_thread() to crate::task::report_error_or_terminate (instead of report_uncaught_exception_from_error) and returns Result<(), JsTerminated>. dispatch::run_task ?-propagates it. A debug-gated stress test is added to worker-terminate-lifetime.test.ts.

Security risks

None. This is error-routing on the worker-termination path; no untrusted input, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-medium. The change is mechanical — swapping one error-reporting helper for its termination-aware sibling — and copies the exact pattern already used by the AnyTask, ManagedTask, and CppTask arms in the same match (dispatch.rs:270-285). I verified report_error_or_terminate (Task.rs:43-65): it short-circuits on JsError::Terminated and on is_termination_exception(), otherwise reaches report_uncaught_exception — identical to the old path for genuine exceptions. The drain_microtasks_with_global .is_err() → return was already returning Result<(), JsTerminated>, so ? is a straight rewrite. The new early-return on termination after the first job leaves later batch jobs un-run, but that matches the pre-existing mid-loop drain_microtasks early-return and the VM is tearing down anyway.

Other factors

  • Single caller of the changed signature (grep-verified), updated in the same PR.
  • The PR body demonstrates a traced root cause (static-hash-table reification transitioning process Structure mid-prototype-walk while the TerminationException is armed) and verified fails-before/passes-after (3/3 abort → 5/5 survive).
  • Test uses tempDir/bunEnv/bunExe, drains stdout/stderr/exited concurrently, asserts signalCode: null, and is skipIf(!isDebug) (the assert is debug-only). The setTimeout delays are the deliberate race-window sweep, documented in the comment; timeout * 2 (120 s on debug) has headroom over the ~8-25 s expected wall-clock for 2 lanes × 100 rounds.
  • The fix sits at the layer that owns the invariant (the dispatch site should recognise termination), consistent with REVIEW.md guidance and the note about #35678 in the PR body.

Addresses review: the previous version started the terminate timer at
new Worker() with delays capped at 139ms, while debug+ASAN worker startup
measured 112-137ms, so on a slower host no round would reach the import
loop. Waiting for the ready postMessage guarantees every round is inside
the while(true){await import()} when terminate lands, which lets the round
count drop (100 -> 60) and the delay sweep shrink (20-139 -> 0-29). Also
moves w.mjs and main.mjs into the tempDir object with relative ./modN
imports instead of writeFileSync from inside the -e child.
@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review addressed in 0b45bbe:

  • terminate timer now starts from the worker's postMessage("ready") instead of new Worker(), so every round is guaranteed to be inside the import loop when terminate lands (debug+ASAN startup was measured at 112-137ms vs the previous 20-139ms delay range)
  • w.mjs and main.mjs are now declared in tempDir() with relative imports instead of writeFileSync from the spawned child

Re-verified: 5/5 abort with the storedPrototype assert on an unfixed debug+ASAN build (0.8-4.3s), 5/5 survive with the fix (~9s).

CI build 86443: the new test passes on all lanes. Remaining failures are unrelated:

Diff is green; ready for a maintainer.

@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 — routes the transpiler-job error through the same report_error_or_terminate helper the sibling AnyTask/ManagedTask/CppTask arms already use, so TerminationException now propagates JsTerminated instead of reaching Bun__handleUncaughtException.

What was reviewed:

  • report_error_or_terminate still calls report_uncaught_exception for genuine non-termination errors, so the observable behavior for real exceptions is unchanged.
  • dispatch.rs is the only caller of RuntimeTranspilerStore::run_from_js_thread; the return-type change is fully covered.
  • Mid-batch early-return on JsTerminated already existed via the drain_microtasks_with_global .is_err() path — no new leak of un-drained TranspilerJobs.
  • Test keys terminate off a postMessage("ready") signal and asserts signalCode: null, so it fails on the abort rather than timing out.
Extended reasoning...

Overview

Two-line Rust change in RuntimeTranspilerStore::run_from_js_thread: swap report_uncaught_exception_from_error for crate::task::report_error_or_terminate (×2 call sites), change the return type from () to Result<(), JsTerminated>, and ?-propagate at the one caller in dispatch::run_task. The drain_microtasks_with_global result, previously mapped to a bare return, is now ?-propagated too. A new debug-gated stress test in worker-terminate-lifetime.test.ts reproduces the Structure::storedPrototype assert.

Security risks

None. This is worker-termination unwind plumbing; no untrusted-input parsing, auth, or crypto touched.

Level of scrutiny

Low-to-moderate. The fix is a mechanical alignment with the pattern already used by three adjacent dispatch arms (AnyTask, ManagedTask, CppTask) in the same match. I confirmed report_error_or_terminate (src/jsc/Task.rs:43) checks both JsError::Terminated and is_termination_exception() before falling through to report_uncaught_exception, so a real thrown exception from AsyncModule::fulfill still surfaces. The signature change has exactly one caller, updated in the same diff.

Other factors

  • The mid-batch early-return concern (jobs left in batch.iterator() when ? fires) is not new: the old code already early-returned on drain_microtasks_with_global().is_err(), and this only happens during VM teardown.
  • The test was re-verified after self-review (0b45bbe) to abort 5/5 unfixed and survive 5/5 fixed, with terminate keyed off the worker's ready signal to avoid the debug+ASAN startup-latency race.
  • The PR description acknowledges overlap with #35678 (guard in Bun__handleUncaughtException) and correctly argues this fixes the dispatch site rather than the symptom.

@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Update from a fresh investigation on current main (9008ae7, which includes the Worker lifetimes rework from #37075): this door is still open and its crash signature has changed. The same workload (workers looping dynamic import() of fresh query-string specifiers, terminated 20-200ms in) now dies with

ASSERTION FAILED: vm.hasTerminationRequest()
vendor/WebKit/Source/JavaScriptCore/runtime/VMTraps.cpp(539) : void JSC::VMTraps::deferTerminationSlow(DeferAction)

Captured stack (first one for this door, debug build):

VMTraps::deferTerminationSlow
DeferTermination<DeferForAWhile>::DeferTermination
LazyProperty<JSGlobalObject, Bun::Process>::callFunc   <- lazy process init
Zig::GlobalObject::processObject
Bun__handleUncaughtException
VirtualMachine::uncaught_exception
VirtualMachine::report_uncaught_exception
JSGlobalObject::report_uncaught_exception_from_error
RuntimeTranspilerStore::run_from_js_thread             <- the call this PR reroutes
bun_runtime::dispatch::run_task
EventLoop::tick
WebWorker::spin

Mechanism: after terminate(), the worker's TerminationException stays pending across the FFI boundary (tryClearException refuses to clear it), while JSC's entry-scope exit clears hasTerminationRequest once the NeedTermination trap has been serviced (VM.cpp, executeEntryScopeServicesOnExit). Any DeferTermination scope opened in that state asserts. The transpiler completion's error path is what walks into one: it reports the termination as an uncaught exception, and Bun__handleUncaughtException materializes the lazy process object, whose LazyProperty initializer opens DeferTerminationForAWhile.

I verified two things on current main:

  1. This PR's src diff applies cleanly (git apply, offsets only) and fixes the crash: with it, a 25s three-lane churn test passes; without it, SIGABRT within a few seconds (observed 1.9s / 5.4s / 16.8s across runs).
  2. A more reliable repro shape: keying terminate() off the worker's first postMessage (so it never lands during worker boot) plus batches of 8 concurrent imports per iteration took the hit rate from roughly 1 in 3 to 9 of 9 runs at a 20s budget on a debug ASAN build. A test in that shape, written against the current signature, is on branch farm/6e8834d2/worker-terminate-dynimport-deferassert (test/js/web/workers/worker-terminate-lifetime.test.ts) if it is useful when rebasing; it fails before this fix and passes after.

One small note on the diff: on the early-return paths the jobs already popped from the queue but not yet run are stranded until VM teardown (their slots, Strong promise handles and poll refs are only reclaimed when the hive drops). release_queued_jobs_for_teardown only drains the queue, and these jobs are no longer in it. Releasing the remainder the same way that function does (promise.deinit, reset_for_pool, store.put) would keep the terminate path tidy.

Related: #35678 fixes the second half of this (the guard ordering inside Bun__handleUncaughtException itself); each fix independently stops the crash above, and both are worth landing.

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.

1 participant