Skip to content

Report a ShadowRealm's unhandled rejections and uncaught errors through the realm that created it - #42097

Open
robobun wants to merge 1 commit into
mainfrom
robobun/3a87f342/shadowrealm-error-reporting
Open

robobun wants to merge 1 commit into
mainfrom
robobun/3a87f342/shadowrealm-error-reporting

Conversation

@robobun

@robobun robobun commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • An unhandled rejection inside a ShadowRealm is silently dropped: no output, exit code 0. An exception from a microtask or an EventTarget listener inside a realm skips process.on("uncaughtException") and is fatal. Node reports all of them through process.
  • Cause: deriveShadowRealmGlobalObject (ZigGlobalObject.cpp:919) makes the realm global a full Zig::GlobalObject. JSC reports a rejected promise to its own global, so it landed on the realm global's rejection list, which the event loop never drains. Bun__handleUncaughtException used the realm global's own process, which code outside the realm never sees.

Fix

  • The realm global records its host global: the non-ShadowRealm global it derives from (m_shadowRealmHost, the outermost one when realms nest). hostGlobal() returns it, or this for other globals.
  • promiseRejectionTracker uses the host's list, the process-event emitters use the host's process, and the rejected-listener path (JSEventListener.cpp:144) queues on the host's nextTick queue.
  • Correct because only the thread's main global has its list and nextTick queue drained, and its process is the one the program observes. No host object becomes reachable inside the realm. Other globals are unchanged.
  • Verified: test/js/bun/jsc/shadow.test.js (7 new cases, 6 fail on 1.4.3-canary) and the suites in Notes. Self-reviewed, see Notes.

Background

  • A ShadowRealm (TC39 stage 3) is a fresh global with its own intrinsics on the same JSC::VM. Bun builds it as a full Zig::GlobalObject, so the realm has its own process, queueMicrotask and EventTarget.
  • JSC tracks unhandled rejections per global through promiseRejectionTracker. Bun's event loop drains that list on vm.global only.
Notes
  • Self-review: 10 concerns raised, each addressed in the diff or scoped in these notes.
  • node:vm contexts had the same shape of bug and already forward their tracker to the thread's default global (NodeVM.cpp:946). A realm created inside a node:vm context maps through defaultGlobalObject() to that same target.
  • Supersedes Report unhandled promise rejections from ShadowRealm globals #35646 (July, never reviewed, now conflicting), which forwarded only promiseRejectionTracker through a second method table. This change also covers the uncaughtException lookup, the EventTarget rejection path and nested realms, with one field instead of a copied table. I closed Report unhandled promise rejections from ShadowRealm globals #35646 in favour of this one.
  • Merge order with process: deliver exceptions reported against a node:vm context to uncaughtException #39992 (the matching process lookup for node:vm contexts, which left ShadowRealm out of scope): the two touch the same statement in Bun__handleUncaughtException. Whichever lands second writes defaultGlobalObject(lexicalGlobalObject)->hostGlobal() there.
  • Out of scope, on purpose: process.nextTick called by realm code (Node.js http/https response callback not called in ShadowRealm #25608, handled by ShadowRealm: route process.nextTick to the event loop's queue #37931, which can replace its != defaultGlobalObject() check with hostGlobal()), and the worker self.onerror dispatch for a realm error when no process handler claims it (the Worker error event on the parent side does fire, verified below).
  • Other suites run on the debug ASAN build: the node test-shadow-realm*, test-eventtarget*, test-events-* and test-promise*-unhandled* parallel tests, test/js/node/vm/vm.test.ts, test/regression/issue/29519.test.ts (realm creation under collectContinuously), the worker and worker_threads error tests, and the ShadowRealm (and probably node:vm) Errors not logged to the console with async functions #11845 repro (importValue plus an async function, now prints the ReferenceError and exits 1).
  • Probed, each matching node v26 where node can express it: a realm created inside a vm.createContext() context (reported to the main process), a realm inside a worker_threads Worker with and without a worker-side handler (handler runs, else the Worker error event and worker exit code 1), worker.terminate() with 200 pending realm rejections and a throwing realm microtask under Malloc=1 with and without BUN_JSC_collectContinuously=1 (clean), 20 dropped realms with 50 pending rejections each plus Bun.gc(true) under collectContinuously (all 1000 reported, no crash), and bun test (the rejection fails the running test, the next test is clean).
  • Timer callbacks created inside a realm already reached the outer uncaughtException handler before this change: Bun's timers run and report on vm.global. Only the JSC-attributed paths (promise rejection, microtask exception, FinalizationRegistry callbacks, listener functions) went to the realm global.
  • GC: the new WriteBarrier makes a realm global keep its host alive. The host is the thread's main global (already protected) or a bun test --isolate file global that is itself the only thing able to reach the realm, so this adds no retention in practice.

Fixes #11845


[human-review] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/jsc/shadow.test.js
bun test v1.4.3 (f42e98025)

test/js/bun/jsc/shadow.test.js:
(pass) shadow realm works [40.03ms]
30 |     const [stdout, stderr, exitCode] = await run(`
31 |       const r = new ShadowRealm();
32 |       r.evaluate("Promise.reject(new Error('rejected-in-realm')); 1");
33 |     `);
34 |     expect(stdout).toBe("");
35 |     expect(stderr).toContain("error: rejected-in-realm");
                        ^
error: expect(received).toContain(expected)

Expected to contain: "error: rejected-in-realm"
Received: ""

      at <anonymous> (/workspace/bun/test/js/bun/jsc/shadow.test.js:35:20)
(fail) errors inside a ShadowRealm > an unhandled rejection is reported and fails the process [285.63ms]
46 |       r.evaluate("Promise.resolve().then(() => { throw new Error('from a reaction') }); 1");
47 |       const viaWrapped = r.evaluate("() => { (async () => { throw new Error('from a wrapped function') })(); }");
48 |       viaWrapped();
49 |     `);
50 |     expect(stderr).toBe("");
51 |     expect(stdout).toBe(`["from a r
... (truncated)

release without fix: 6 FAILED
bun test v1.4.3-canary.1 (f42e98025)

test/js/bun/jsc/shadow.test.js:
(pass) shadow realm works [0.49ms]
71 |       const attach = r.evaluate("var p = Promise.reject(new Error('late')); () => { p.catch(() => {}); }");
72 |       process.on("unhandledRejection", reason => { console.log("unhandledRejection", reason.message); setImmediate(attach); });
73 |       process.on("rejectionHandled", () => console.log("rejectionHandled"));
74 |     `);
75 |     expect(stderr).toBe("");
76 |     expect(stdout).toBe("unhandledRejection late\nrejectionHandled\n");
                        ^
error: expect(received).toBe(expected)

- "unhandledRejection late
- rejectionHandled
- "
+ ""

- Expected  - 3
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/jsc/shadow.test.js:76:20)
(fail) errors inside a ShadowRealm > handling a reported rejection later emits 'rejectionHandled' [5.53ms]
82 |       process.on("uncaughtException", (err, origin) => console.log(origin, err.message));
83 |       const r = new ShadowRealm();
84 |       r.evaluate("queueMicrotask(() => { throw new Error('thrown in a realm microtask') }); 1");
85 |       process.on("exit", code => console.log("ex
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/jsc/shadow.test.js
bun test v1.4.3 (f42e98025)

test/js/bun/jsc/shadow.test.js:
(pass) shadow realm works [67.79ms]
(pass) errors inside a ShadowRealm > an unhandled rejection is reported and fails the process [605.39ms]
(pass) errors inside a ShadowRealm > unhandled rejections reach process.on('unhandledRejection') [597.07ms]
(pass) errors inside a ShadowRealm > a rejection handled inside the realm is not reported [651.64ms]
(pass) errors inside a ShadowRealm > an exception thrown from a microtask reaches process.on('uncaughtException') [623.73ms]
(pass) errors inside a ShadowRealm > handling a reported rejection later emits 'rejectionHandled' [756.27ms]
(pass) errors inside a ShadowRealm > a nested ShadowRealm reports to the outermost realm's process [447.57ms]
(pass) errors inside a ShadowRealm > EventTarget listeners that throw or reject reach process.on('uncaughtException') [527.38ms]

 8 pass
 0 fail
 23 expect() calls
Ran 8 tests across 1 file. [4.42s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     7e3f4d977e
  features     baseline

23 deps, 131 codegen, 1172 objects in 1170ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (f42e98025)

Checked 22 installs across 61 packages (no changes) [9.00ms]
[2/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (f42e98025)

Checked 1 install across 2 packages (no changes) [2.00ms]
[3/1244] gen ErrorCode+*.h
[4/1244] gen bindgenv2
[5/1244] fetch zlib
[zlib] up to date
[6/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (f42e98025)

Checked 111 installs across 104 packages (no changes) [8.00ms]
[7/1244] gen node-fallbacks/react-refresh.js
Bundled 1 module in 14ms

  react-refresh.js  4.81 KB  (entry point)

[8/1244] gen .bind.ts → GeneratedBindings.cpp
[9/1244] fetch tinycc
[tinycc] up to date
[10/1243] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[11/1216] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/bu
... (truncated)
diff hotspot
src/jsc/bindings/BunProcess.cpp              |  11 +--
 src/jsc/bindings/ZigGlobalObject.cpp         |   7 +-
 src/jsc/bindings/ZigGlobalObject.h           |  17 ++++
 src/jsc/bindings/webcore/JSEventListener.cpp |   6 +-
 test/js/bun/jsc/shadow.test.js               | 115 ++++++++++++++++++++++++++-
 5 files changed, 147 insertions(+), 9 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                          reads  edits  tests
src/jsc/bindings/BunProcess.cpp                   2      4      9
src/jsc/bindings/ZigGlobalObject.cpp              5      4      9
src/jsc/bindings/ZigGlobalObject.h                6      4      9
src/jsc/bindings/webcore/JSEventListener.cpp      2      1      9
test/js/bun/jsc/shadow.test.js                    2      3      9

root cause · written by the author bot

A ShadowRealm's global object in Bun is a full Zig::GlobalObject, so JSC reported realm-originated promise rejections into that realm global's own rejection list and dispatched realm uncaught errors to that global's own process object, neither of which is ever drained or observed by user listeners; the result was that errors thrown or rejected inside a realm vanished silently with a zero exit code instead of reaching the outer process as they do in Node. The fix gives each realm global a reference to the global that created it (m_shadowRealmHost, exposed via hostGlobal()) and routes…

…gh the realm that created it

A ShadowRealm global is a full Zig::GlobalObject. JSC reports a rejected
promise, and an exception that escapes a microtask, to the global of the
realm they happened in. The realm global put rejected promises on its own
list, which the event loop never drains, so they were dropped. Uncaught
errors looked up the realm global's own process object, which code outside
the realm never sees, so they skipped the program's
process.on('uncaughtException') and were fatal. A rejected promise returned
by an EventTarget listener inside a realm went to the realm global's own
nextTick queue, which is never drained either.

The realm global now records its host global, the non-ShadowRealm global it
derives from, and reports through it: promiseRejectionTracker uses the
host's list, the process event emitters use the host's process object, and
the EventTarget rejection path queues on the host's nextTick queue.

Fixes #11845
@robobun

robobun commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:36 PM PT - Sep 8th, 2026

✅ @robobun, your commit 7e3f4d977ef1c0ce9e1e616c2c3df401b815ee7d passed in Build #113371! 🎉


🧪   To try this PR locally:

bunx bun-pr 42097

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

bun-42097 --bun

@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on 1.4.3-canary (and per the report on 1.4.2 and main d745f03):

const r = new ShadowRealm();
process.on("unhandledRejection", e => console.log("outer handler saw:", e.message)); // never called before this change
r.evaluate(`Promise.reject(new Error("realm-unhandled")); 1`);
await new Promise(res => setTimeout(res, 50));
console.log("still running; exitCode=" + process.exitCode); // printed, exit 0, nothing else

Same shape for r.evaluate('queueMicrotask(() => { throw new Error("x") }); 1') with an outer process.on("uncaughtException"): the handler was skipped and the process exited 1. With this change both reach the outer handlers, as in node v26 --experimental-shadow-realm.

test/js/bun/jsc/shadow.test.js: 6 of the 7 new cases fail with USE_SYSTEM_BUN=1, all pass on the debug ASAN build.

@coderabbitai

coderabbitai Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: Essentials

Run ID: bab549b9-f314-49d5-9f94-e408f75df51b

📥 Commits

Reviewing files that changed from the base of the PR and between f3e5bdd and 7e3f4d9.

📒 Files selected for processing (5)
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/webcore/JSEventListener.cpp
  • test/js/bun/jsc/shadow.test.js

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

Changes

ShadowRealm host event routing

Layer / File(s) Summary
Host global association
src/jsc/bindings/ZigGlobalObject.h, src/jsc/bindings/ZigGlobalObject.cpp
Zig::GlobalObject stores and resolves the host global for ShadowRealm globals. Promise rejection tracking uses that host global.
Host event handling
src/jsc/bindings/BunProcess.cpp, src/jsc/bindings/webcore/JSEventListener.cpp
Exception callbacks, process events, fatal exits, and next-tick queues use the host global.
ShadowRealm regression coverage
test/js/bun/jsc/shadow.test.js
Subprocess tests cover rejection handling, escaped exceptions, nested realms, output, and exit codes.

Suggested reviewers: cirospaciari, dylan-conway, jarred-sumner

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 7e3f4

ShadowRealm-originated rejection and exception handling is routed to the creating global, with no outstanding concrete merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy the objectives in [#35646] and [#11845]. They route ShadowRealm rejection and exception handling through the host global, preserve handled-rejection behavior, cover async errors, a…
Out of Scope Changes check ✅ Passed The code and tests remain focused on ShadowRealm error and rejection routing. Nested realms, node:vm contexts, workers, garbage collection, and regressions are relevant validation for the stated objec…
Title check ✅ Passed The title clearly describes the main change: routing ShadowRealm unhandled rejections and uncaught errors through the creating realm. It is specific and related to the changeset.
Description check ✅ Passed The description explains the problem, cause, fix, scope, issue linkage, and verification results. It does not use the exact template headings, but it provides the required information in equivalent se…

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this PR and didn't find any bugs. Because it adds a new GC-visited field to ZigGlobalObject and reroutes cross-realm error/rejection reporting through JSC bindings — with a stated merge-order interaction with #39992 on the same line in Bun__handleUncaughtException — a human look would still be worthwhile.

What was reviewed:

  • m_shadowRealmHost is declared via FOR_EACH_GLOBALOBJECT_GC_MEMBER, which visitChildrenImpl walks via the offset table — the WriteBarrier is visited.
  • Each uncheckedDowncast<Zig::GlobalObject> redirected through hostGlobal() is still guarded by the preceding inherits(Zig::GlobalObject::info()) check; promiseRejectionTracker's static_cast is on the Zig method-table path.
  • deriveShadowRealmGlobalObject sets the barrier with (vm, shadow, host) and collapses through defaultGlobalObject(...)->hostGlobal(), so nested realms and node:vm contexts land on the outermost drained global; no host object leaks into the realm.
  • Tests follow harness conventions: bunExe()/bunEnv, -e subprocesses under describe.concurrent, await using, concurrent pipe drain, stderr/stdout asserted before exit code, no sleep-based waits.
Extended reasoning...

Overview

The PR routes ShadowRealm-originated unhandled rejections and uncaught exceptions to the creating (host) realm's process object. It adds a WriteBarrier<GlobalObject> m_shadowRealmHost field to Zig::GlobalObject via the FOR_EACH_GLOBALOBJECT_GC_MEMBER V-macro, an inline hostGlobal() accessor that returns the outermost non-ShadowRealm global (or this), and wires deriveShadowRealmGlobalObject to set it via defaultGlobalObject(globalObject)->hostGlobal(). Four entry points — Bun__handleUncaughtException, Bun__handleUnhandledRejection, Bun__emitHandledPromiseEvent, and GlobalObject::promiseRejectionTracker — plus jsFunctionEmitUncaughtExceptionNextTick in JSEventListener.cpp are redirected through hostGlobal(). Seven subprocess tests are added to the existing test/js/bun/jsc/shadow.test.js.

Security risks

None identified. This is error-reporting plumbing; no parsing of untrusted input, no auth/crypto/permissions. The redirection does not expose host-realm objects into the ShadowRealm (the realm's own process is untouched; only the outer realm's list/emitters receive the events), so the ShadowRealm callable-boundary invariant is preserved.

Level of scrutiny

Medium-high. The change is small and focused, but it lives in JSC bindings territory: GC-visited state on ZigGlobalObject, cross-realm error routing, and subtle lifetime concerns. REVIEW.md explicitly names "no new fields on ZigGlobalObject" as a god-file rule; the field here is per-global state whose lifetime matches the global (the accepted exception), and it is threaded through the existing visit-macro so it is GC-safe, but a maintainer should confirm this placement is acceptable rather than, say, keying off the existing ShadowRealmObject linkage. The PR description also flags a merge-order interaction with #39992 on the same statement in Bun__handleUncaughtException, which a human should be aware of when landing.

Other factors

I verified visitChildrenImpl (ZigGlobalObject.cpp:3183-3193) builds an offset table from FOR_EACH_GLOBALOBJECT_GC_MEMBER and visits every entry, so m_shadowRealmHost is visited without a hand-written visitor.append. The WriteBarrier::set(vm, shadow, host) call passes the correct owner. Each redirected uncheckedDowncast remains behind its inherits() guard. The new field creates a realm→host edge only; the host is the thread's main global (already a GC root) or an isolate file global that itself owns the realm, so no new retention cycle. Tests are well-structured per test/CLAUDE.md conventions (existing file, describe.concurrent, bunExe/bunEnv, await using, concurrent pipe drain, output asserted before exit code, event-driven waits via setImmediate/process.on('exit') rather than sleeps). No CODEOWNERS entries cover the changed files. The bug-hunt ran to a dry_streak with no findings.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ShadowRealm (and probably node:vm) Errors not logged to the console with async functions

2 participants