Skip to content

worker: re-throw TerminationException from Bun__reportUnhandledError so terminate() breaks a microtask-bound ReadableStream loop - #36806

Open
robobun wants to merge 5 commits into
mainfrom
claude/farm/ed614daa/worker-terminate-microtask-readablestream
Open

worker: re-throw TerminationException from Bun__reportUnhandledError so terminate() breaks a microtask-bound ReadableStream loop#36806
robobun wants to merge 5 commits into
mainfrom
claude/farm/ed614daa/worker-terminate-microtask-readablestream

Conversation

@robobun

@robobun robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

worker.terminate() never resolves when the worker is in a microtask-bound loop that constructs a JS-source ReadableStream (or calls queueMicrotask) each turn. The worker thread keeps spinning at ~0.5-0.7 core forever, no exit event fires, and a second terminate() also never resolves.

import { Worker } from "node:worker_threads";
const src = `require("worker_threads").parentPort.postMessage("up");
(async () => { for (;;) { new ReadableStream({ pull(c) { c.enqueue(new Uint8Array(8)); c.close(); } }); await 0; } })();`;
const w = new Worker(src, { eval: true });
await new Promise(r => w.once("message", r));
await Bun.sleep(30);
await w.terminate(); // never resolves

Reproduces on main since the C++ streams rewrite (#33193); Node terminates the identical worker in a few ms.

Cause

InternalMicrotask::BunPerformMicrotaskJob (the runner for queueMicrotask and the C++ stream start/pull reaction jobs added in #33193) catches any exception from the job callback with an unconditional clearException() and then calls Bun__reportUnhandledError with the Exception*:

if (auto* exception = catchScope.exception()) {
    catchScope.clearException();
    if (Bun__reportUnhandledError)
        Bun__reportUnhandledError(globalObject, JSValue::encode(exception));
}

When the caught exception is the TerminationException, that clear consumes the one shot the NeedTermination trap fired (the trap bit was already cleared by VMTraps::handleTraps), so MicrotaskQueue::runMicrotask's clearExceptionExceptTermination() check never trips and the drain keeps processing microtasks forever.

A plain for(;;){ await 0 } loop terminates because the promise-reaction microtask path goes through runMicrotask's termination-aware clear; it's only BunPerformMicrotaskJob with its own catch scope that swallows it.

Separately, under debug/ASAN the new Response(new ReadableStream({...})) shape aborts with:

ASSERTION FAILED: Memory leak detected: new Response() allocated memory without checking for exceptions.
!ptr
codegen/ZigGeneratedClasses.cpp : JSResponseConstructor::construct

because check_body_stream_ref (called from Response::constructor / Request::construct_into after the native object has been heap-allocated) re-tagged the stored stream via ReadableStream::from_js, whose Rust-side post-call exception check is a VMTraps safepoint. A TerminationException thrown there left the generated construct holding a non-null ptr with an exception pending.

Fix

Bun__reportUnhandledError already skipped reporting a TerminationException; it now re-throws it via VM::throwTerminationException (guarded on hasTerminationRequest() && !hasPendingTerminationException()) so MicrotaskQueue::runMicrotask sees it on return and breaks out of the drain. This covers every BunPerformMicrotaskJob caller (streams reactions, queueMicrotask, promise-reject deferrals).

check_body_stream_ref now reads the stored JSValue directly from the Strong's slot instead of going through ReadableStream::from_js; it only needed the value, never the source tag.

The BunPerformMicrotaskJob handler itself lives in JavaScriptCore's prebuilt JSMicrotask.cpp and should eventually use clearExceptionExceptTermination(); this change makes terminate() work correctly regardless.

Verification

New describe block in test/js/web/workers/worker-terminate-lifetime.test.ts spawns a worker in each of five microtask-bound loop shapes (ReadableStream pull/start, Response/Request wrapping a stream, queueMicrotask), sweeps a few terminate() timing offsets, and asserts terminate() resolves. On main the ReadableStream/Request shapes print HUNG round 0 and fail; with this change all five pass (144/144 terminations across 3 local stress runs).


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

…so terminate() breaks a microtask-bound ReadableStream loop

InternalMicrotask::BunPerformMicrotaskJob (the runner for queueMicrotask
and the C++ stream start/pull reaction jobs) catches any exception from
the job callback with an unconditional clearException() and then calls
Bun__reportUnhandledError with the Exception*. When the caught exception
is the TerminationException that clear consumes the one shot the
NeedTermination trap fired, so the microtask drain never observes
termination and a worker in a microtask-bound loop that constructs a
JS-source ReadableStream (or calls queueMicrotask) each turn spins
forever with terminate() never resolving.

Bun__reportUnhandledError already skipped reporting a
TerminationException; now it re-throws it via
VM::throwTerminationException so MicrotaskQueue::runMicrotask sees it on
return and breaks out of the drain.

Also: check_body_stream_ref (called from Response::constructor /
Request::construct_into after the native object has been heap-allocated)
was re-tagging the stored stream via ReadableStream::from_js, whose
trap-handling exception check is a VMTraps safepoint. A
TerminationException thrown there left the generated construct holding a
non-null ptr with an exception pending, tripping its 'Memory leak
detected: new Response()' assertion. check_body_stream_ref only needs
the raw JSValue, so read it directly from the Strong's slot.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 801d3bd8-771d-4e4d-b3a8-a81ff1b5f16b

📥 Commits

Reviewing files that changed from the base of the PR and between da497fb and c5e47d4.

📒 Files selected for processing (1)
  • test/js/web/workers/worker-terminate-lifetime.test.ts

Walkthrough

Worker termination exceptions now propagate through JSC VM, event listener, and error reporting paths. ReadableStream migration avoids JavaScript conversion during cleanup. Regression tests cover workers trapped in microtask loops.

Changes

Worker termination handling

Layer / File(s) Summary
Termination exception propagation
src/jsc/bindings/bindings.cpp, src/jsc/bindings/webcore/EventEmitter.cpp, src/jsc/virtual_machine_exports.rs
The VM rethrows termination exceptions. Event listener dispatch stops after termination. Non-termination values continue to uncaught_exception.
ReadableStream migration
src/runtime/webcore/ReadableStream.rs, src/runtime/webcore/Body.rs
Strong::value is crate-visible. Body stream migration reads the stored value directly and validates liveness before updating the cache.
Worker termination regression coverage
test/js/web/workers/worker-terminate-lifetime.test.ts
Tests repeatedly terminate workers running stream, request/response, and queueMicrotask loops with timeout and clean-exit assertions.

Possibly related issues

  • oven-sh/bun#34690: The issue covers termination-exception handling during worker termination, which this change updates.

Possibly related PRs

  • oven-sh/bun#35976: Both changes preserve worker termination exceptions in virtual_machine_exports.rs.
  • oven-sh/bun#36331: Both changes stop JavaScript callback processing when worker termination occurs.
  • oven-sh/bun#36581: Both changes propagate worker termination exceptions without reporting them as uncaught errors.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the worker termination fix and its microtask-bound ReadableStream scenario.
Description check ✅ Passed The description explains the problem, cause, fix, and verification results, although it does not use the template headings.
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.

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

@github-actions github-actions Bot added the claude label Aug 3, 2026
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:39 PM PT - Aug 2nd, 2026

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


🧪   To try this PR locally:

bunx bun-pr 36806

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

bun-36806 --bun

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. ASAN CI: ExceptionScope::assertNoException during worker terminate (worker-transfer-terminate-stress, separate from #34095) #34690 - PR re-throws TerminationException in reportUnhandledError instead of silently clearing it, which should fix the assertNoException assertion failure during worker terminate under ASAN
  2. ASAN CI: JSC assertion in JSObject::getOwnPropertyDescriptor during worker terminate (test-worker-message-port-transfer-terminate) #34095 - PR prevents TerminationException from being consumed by clearException(), which should fix the exception-check invariant violation (!scope.exception() || !result) during worker terminate

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

Fixes #34690
Fixes #34095

🤖 Generated with Claude Code

@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Re the find-issues suggestions: this PR does not fix #34690 or #34095. Those are different termination races (ExceptionScope::assertNoException during worker bootstrap and !scope.exception() || !result in JSObject::getOwnPropertyDescriptor respectively), neither of which goes through BunPerformMicrotaskJob / Bun__reportUnhandledError. Leaving them open.

Comment thread src/jsc/virtual_machine_exports.rs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts
…est subprocess after PASS

innerInvokeEventListeners uses the NakedPtr<Exception>& call() overload
which clears the exception unconditionally. Now that
Bun__reportUnhandledError re-throws a TerminationException, continuing
the loop into the next listener's call() would trip executeCallImpl's
assertNoException(). Break out of the loop when the caught exception is
the TerminationException.

Also add process.exit(0) after PASS in the new test's subprocess so the
losing Bun.sleep() timers don't hold the event loop open for the full
deadline.
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/webcore/EventEmitter.cpp Outdated
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/runtime/webcore/Body.rs Outdated

@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 217-220: Update the worker startup flow around the Worker instance
and message wait so the startup promise rejects on the first error event instead
of discarding it. Use one-shot message and error handlers, remove the alternate
listener when either settles, and preserve visibility of any worker errors
occurring after successful startup.
🪄 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: e5542a50-8303-4424-949e-a3897f6cf92e

📥 Commits

Reviewing files that changed from the base of the PR and between 52af832 and 15deddd.

📒 Files selected for processing (6)
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/webcore/EventEmitter.cpp
  • src/jsc/virtual_machine_exports.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/ReadableStream.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts

Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build 87992: the new tests in worker-terminate-lifetime.test.ts pass on every lane. The one red lane (debian 13 x64-asan) has test-worker-message-port-transfer-terminate.js SIGABRTing on assertNoException at ExceptionScope.h:61, tagged [pre-existing] by the annotation tagger (tracked as #34095 / #34690 and unrelated to BunPerformMicrotaskJob/Bun__reportUnhandledError), plus three retry-passed flakes (bun-install-patch, napi, inspect-error-leak). Diff is green.

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

The two issues I raised earlier are addressed and I didn't find anything new, but this changes Bun__reportUnhandledError's post-condition (may now return with a pending TerminationException) across every caller, so it's worth a human look before merge.

What was reviewed:

  • Audited every Bun__reportUnhandledError caller for the new re-throw: napi/BunProcess/EventEmitter:193/NodeTimerObject:40 pass non-TerminationException values; NodeTimerObject:65 uses tryClearException so the guard makes the re-throw a no-op; jsFunctionPerformMicrotaskVariadic and reportUncaughtExceptionAtEventLoop return immediately after, where propagating is the intent.
  • check_body_stream_ref: Strong::value() returns the same JSValue the old stream.value carried, minus the from_js_host_call_generic safepoint; ensure_still_alive and downgrade() ordering preserved.
  • Test: startup promise now rejects on error/early-exit; process.exit(0) after PASS drops the idle sleep tail.
Extended reasoning...

Overview

The PR fixes worker.terminate() never resolving when the worker is in a microtask-bound loop that goes through BunPerformMicrotaskJob (queueMicrotask, C++ stream start/pull reactions). It touches: bindings.cpp (new JSC__VM__rethrowTerminationException, guarded on hasTerminationRequest() && !hasPendingTerminationException()), virtual_machine_exports.rs (report_unhandled_error now re-throws instead of silently skipping a TerminationException), EventEmitter.cpp (breaks the listener loop on TerminationException — added in response to my earlier review), Body.rs/ReadableStream.rs (avoid the from_js VMTraps safepoint in check_body_stream_ref post-allocation), and a new 5-variant describe block in worker-terminate-lifetime.test.ts.

Security risks

None. This is termination-exception propagation inside a worker VM being torn down; no auth, crypto, or untrusted-input parsing is involved.

Level of scrutiny

High. TerminationException handling is one of the most fragile areas in the JSC integration — the timeline references at least four prior PRs (#35976, #36331, #36579, #36581) touching adjacent paths, and my first-pass review of this PR found a real regression (EventEmitter listener loop re-entering call() with the exception pending). The core change alters a function's post-condition for every caller. I re-audited each caller after the EventEmitter fix and they look correct now, but a maintainer familiar with the BunPerformMicrotaskJob / MicrotaskQueue::runMicrotask interaction should confirm the re-throw is the right layer (the PR itself notes the JSC-side clearException() should eventually become clearExceptionExceptTermination()).

Other factors

All prior feedback (mine, CodeRabbit's startup-rejection point, comment-cop) is resolved. The test covers five loop shapes with a timing sweep and asserts {stderr, stdout, exitCode} in the right order. CI build #87992 was still running at review time. The Body.rs change is a semantics-preserving simplification: it reads the same stored JSValue without the ReadableStreamTag__tagged round-trip, so no source-tag reload happens — but the caller only ever needed the value for stream_set_cached, never the tag.

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