Skip to content

Capture rejections from any thenable in the browser node:events polyfill - #33369

Open
robobun wants to merge 2 commits into
mainfrom
farm/440c192e/events-capture-rejections-thenable
Open

robobun wants to merge 2 commits into
mainfrom
farm/440c192e/events-capture-rejections-thenable

Conversation

@robobun

@robobun robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

The node:events polyfill used for --target=browser only captured rejections from a native, same-realm Promise. Any other thenable returned by a listener was dropped silently: no 'error' event, no Symbol.for("nodejs.rejection") hook, no unhandled rejection. captureRejections is the safety net for async listeners, so a rejection that disappears is worse than no net at all.

Separately, a rejection from a .once() listener was dropped for the same reason, even for a native promise.

Repro

// bun build --target=browser entry.js
import { EventEmitter } from "node:events";

const ee = new EventEmitter({ captureRejections: true });
ee.on("error", err => console.log("captured", err.message));
ee.on("boom", () => ({ then(_, reject) { reject(new Error("kaboom")); } }));
ee.emit("boom");
// node:  captured kaboom
// bun:   (nothing)

const once = new EventEmitter({ captureRejections: true });
once.on("error", err => console.log("captured", err.message));
once.once("bang", async () => { throw new Error("kaboom"); });
once.emit("bang");
// node:  captured kaboom
// bun:   unhandled rejection, the script dies

Cause

Two independent gaps in src/node-fallbacks/events.js, both on the path from a listener's return value to addCatch.

emitWithRejectionCapture gated on

result !== undefined && typeof result?.then === "function" && result.then === Promise.prototype.then

The identity check against Promise.prototype.then excludes plain thenables, cross-realm promises, and promise-likes from userland libraries. Node duck-types .then on any non-nullish listener return value instead. A second divergence came out of the same line: the .then read already happened during the check, so a throwing then getter propagated out of emit() rather than reaching the 'error' event, and it could be read twice.

onceWrapper never returned its listener's result, so result was always undefined for a .once() or .prependOnceListener() listener and the gate was never entered at all. Node's onceWrapper and the runtime's own copy (src/js/node/events.ts:314) both return it.

Fix

addCatch now reads .then once, checks it is callable, and calls it as then.call(value, undefined, onRejected). A throw from the getter or from the call itself is routed to the 'error' event. The call site gates on result !== undefined && result !== null. onceWrapper returns the listener's result.

All five behaviors were checked against Node v26.3.0: the then getter is read exactly once (Promises/A+ compat), then receives undefined as onFulfilled, a throwing getter emits 'error' synchronously, emit() still returns true, and a .once() listener's rejection reaches 'error'.

The runtime's own copy of the addCatch bug, src/js/node/events.ts, is already fixed in #32814 (reviewed, CI green, awaiting a maintainer), which does not touch the browser polyfill, so that file is left alone here.

Verification

Three tests in test/bundler/bundler_browser.test.ts, all of which fail against main's src/:

  • browser/NodeEventsCaptureRejectionsThenable : a rejecting thenable reaches the 'error' listener, and onFulfilled is undefined. Before: no output.
  • browser/NodeEventsCaptureRejectionsThrowingThenGetter : a throwing then getter reaches the 'error' listener and emit() returns true. Before: the throw escapes emit() and kills the script.
  • browser/NodeEventsCaptureRejectionsOnceListener : .once() and .prependOnceListener() rejections reach the 'error' listener. Before: unhandled rejection, the script dies.

bun bd test test/bundler/bundler_browser.test.ts is green (16 pass, 1 skip, 1 todo).

The browser node:events polyfill only attached the captureRejections
rejection handler when the listener returned a native same-realm Promise
(result.then === Promise.prototype.then). A plain thenable, a cross-realm
promise, or a promise-like from a userland library was dropped silently:
no 'error' event, no nodejs.rejection hook, no unhandled rejection.

Duck-type .then on any non-nullish return value, read it once, and call it
with (undefined, onRejected), routing a throw from the getter or the call
itself to the 'error' event. This matches Node.
@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Rejection-capture logic in the events fallback module was modified to treat handler return values as generic thenables rather than only native promises, adding synchronous error handling for faulty then accessors/calls, and preserving onceWrapper's return value. New browser bundler tests validate these capture-rejections scenarios.

Changes

Capture-rejections thenable support

Layer / File(s) Summary
Generic thenable capture and return-value propagation
src/node-fallbacks/events.js
addCatch now checks for a callable then, wraps access/invocation in try/catch to emit "error" on synchronous failure, the rejection-capture guard triggers on any non-null/undefined return instead of only promise-like values, and onceWrapper returns the listener's result.
Browser bundler tests for capture-rejections
test/bundler/bundler_browser.test.ts
Adds three itBundled test cases covering captured rejections from thenables, a throwing then getter, and rejection capture via once/prependOnceListener.

Sequence Diagram(s)

sequenceDiagram
  participant emit as emitWithRejectionCapture
  participant listener as Listener/onceWrapper
  participant addCatch as addCatch
  participant then as value.then

  emit->>listener: invoke listener
  listener-->>emit: return value (via onceWrapper)
  emit->>addCatch: pass return value if non-null/undefined
  addCatch->>then: check typeof value.then
  alt then is function
    addCatch->>then: then.call(value, undefined, onRejected)
    then-->>addCatch: rejection triggers queueMicrotask
    addCatch->>emit: emitUnhandledRejectionOrErr
  else then access/call throws
    addCatch->>emit: emit "error" synchronously
  end
Loading

Estimated code review effort: High — changes rejection-capture semantics with subtle synchronous/asynchronous error paths.

Related issues: None found in provided context.

Related PRs: None found in provided context.

Suggested labels: node:events, bundler, tests

Suggested reviewers: None specified in provided context.

🐰 A promise or a plain old thing,
Now caught alike, whatever it may bring,
Once-wrapper hands its value back,
Errors surface, none go slack,
Tests confirm the rabbit's sing!

🚥 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 summarizes the main change: browser node:events now captures rejections from any thenable.
Description check ✅ Passed The description covers what changed and how it was verified, even though it uses custom headings instead of the template's exact section names.

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

@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:16 AM PT - Jul 5th, 2026

❌ @robobun, your commit f0592e2 has 1 failures in Build #68537 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33369

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

bun-33369 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — small, correct alignment of the browser polyfill's addCatch with Node's duck-typed thenable handling; the .once() sibling noted inline is pre-existing and non-blocking.

Extended reasoning...

Overview

Two files touched. src/node-fallbacks/events.js rewrites addCatch to read .then once, check it's callable, invoke via then.call(value, undefined, onRejected), and wrap the whole thing in try/catch so a throwing .then getter routes to 'error' instead of escaping emit(). The call site in emitWithRejectionCapture drops the result.then === Promise.prototype.then identity check and now gates only on result !== undefined && result !== null. This mirrors Node's own addCatch in lib/events.js essentially line-for-line. test/bundler/bundler_browser.test.ts adds two itBundled cases exercising a plain thenable and a throwing then getter.

Security risks

None. This is the --target=browser polyfill for EventEmitter — bundled userland JS, no native code, no auth/crypto/permissions surface. The change strictly widens what gets routed to the emitter's own 'error' handler; it doesn't expose or leak anything.

Level of scrutiny

Low-to-medium. The blast radius is limited to browser bundles that opt into captureRejections, and the diff is ~15 lines of production JS plus tests. The new shape is a near-verbatim port of the reference implementation, and the PR description documents the four observable behaviors were cross-checked against Node v26.3.0. The extra cost of calling addCatch on every non-nullish return (vs. only native promises before) is negligible — the function immediately returns when .then isn't a function.

Other factors

The one finding from the bug hunt is a pre-existing gap: onceWrapper discards the listener's return value, so .once() listeners still bypass the capture. That's real and worth folding in, but it's not introduced or worsened by this diff — .once() + captureRejections was already broken on this path before the PR. Left as an inline nit rather than a blocker. Tests are placed alongside the existing browser/NodeUrl* polyfill test using the same itBundled pattern, and the PR body confirms the suite is green. No prior reviewer comments to address.

Comment thread src/node-fallbacks/events.js
…lyfill

The browser polyfill's onceWrapper discarded its listener's return value, so
the result never reached the captureRejections gate in emit and a rejected
promise from a .once() or .prependOnceListener() listener was dropped. Node's
onceWrapper and the runtime's src/js/node/events.ts both return it.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed f0592e2 for the .once() finding. It was a real hole in the same file for the same feature, so it belongs here rather than in a follow-up.

onceWrapper discarded its listener's return value, so result was always undefined in emitWithRejectionCapture and the gate was never entered for .once() / .prependOnceListener(). That dropped native-promise rejections too, not just thenables: on the released binary a --target=browser bundle of

ee.once("boom", async () => { throw new Error("kaboom"); });

dies with an unhandled rejection where Node prints captured kaboom. Node's onceWrapper and the runtime copy at src/js/node/events.ts:314 both return the result; only the browser polyfill diverged.

Added browser/NodeEventsCaptureRejectionsOnceListener. All three new tests now fail against main's src/ and pass with the fix. onceWrapper was the only other place in the file sitting between a listener and the gate, so the class is closed.

On the earlier red build (68531): the single failed job was :darwin: 26 aarch64 - test-bun, which never ran a test. It died in getExecPathFromBuildKite with buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. The other reds in that build were on the flaky annotation and passed on retry.

@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 — thanks for folding in the onceWrapper return and the .once()/.prependOnceListener() test.

Extended reasoning...

Overview

This PR fixes the captureRejections path in the browser node:events polyfill (src/node-fallbacks/events.js). Previously addCatch was gated on result.then === Promise.prototype.then, which excluded plain thenables, cross-realm promises, and userland promise-likes, and also read .then twice while letting a throwing getter escape emit(). The new addCatch reads .then once, checks it's callable, invokes it via then.call(value, undefined, onRejected), and routes any synchronous throw to the 'error' event — matching Node's lib/events.js implementation. The call site now gates on result !== undefined && result !== null.

Since my earlier review, commit f0592e2 addressed the onceWrapper sibling gap: it now returns listener.apply(this, args) so .once()/.prependOnceListener() results reach addCatch, and a third test (browser/NodeEventsCaptureRejectionsOnceListener) covers both entry points.

Security risks

None. This is Node-compat behavior in a browser bundler polyfill; no auth, crypto, filesystem, or network surface is touched. Reading .then on a listener's return value is exactly what Node and the Promises/A+ resolution procedure already do.

Level of scrutiny

Low-to-moderate. The file is a browser-target polyfill (not the runtime's own src/js/node/events.ts), the diff is ~20 lines of straightforward JS that mirrors Node's reference implementation, and it only affects the captureRejections: true opt-in path. The onceWrapper change adds a return whose only consumer is the rejection-capture gate — harmless in the non-capture path.

Other factors

Three new itBundled tests in test/bundler/bundler_browser.test.ts exercise the thenable case, the throwing-getter case (verifying emit() still returns true), and the .once()/.prependOnceListener() case. The PR description confirms behavior was cross-checked against Node v26.3.0 and that the runtime copy is being fixed separately in #32814. The bug hunter found no issues on the current revision, and my one prior comment has been fully addressed.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for whoever merges this: both red jobs in build 68537 are infrastructure, and the diff is green. Not re-rolling, because neither failure is the kind a re-run fixes.

:ubuntu: 25.04 x64 - test-bun — test/js/bun/util/v8-heap-snapshot.test.ts killed by SIGKILL, main process killed by SIGKILL but no core file found, and no cores found in /var/bun-cores-ubuntu-25.04-x64. That is the OOM killer, not a crash. The job log interleaves the database service containers starting up into the middle of that test:

--- [43/239] test/js/bun/util/v8-heap-snapshot.test.ts
...coordinator: mysql_plain ready
coordinator: mysql_tls ready
coordinator: mysql_native_password ready
.
--- [43/239] test/js/bun/util/v8-heap-snapshot.test.ts - SIGKILL

The one test on the lane that allocates multi-hundred-MB V8 heap snapshots got squeezed while the MySQL containers came up. Unrelated to this diff, which is a --target=browser bundler asset plus tests.

:darwin: 26 aarch64 - test-bun — died in getExecPathFromBuildKite before running a single test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

Same failure on build 68531 (03:45Z) and build 68537 (11:33Z), eight hours apart on the same agent pool, and the same one #32814 hit on two different macOS 26 aarch64 agents. A re-roll will almost certainly reproduce it.

Positive evidence the diff is fine, rather than just an absence of evidence:

  • test/bundler/bundler_browser.test.ts ran as file [3/239] on that exact ubuntu 25.04 x64 agent and passed: 16 pass, 1 skip, 1 todo, 0 fail — identical to the local run.
  • The binary-size annotation reports +0.0 KB on all fifteen targets. src/node-fallbacks/events.js is only resolved by the bundler for --target=browser; it is not the runtime's node:events (src/js/node/events.ts), so it cannot affect a heap-snapshot test's memory.
  • Both review bots report nothing further to flag, and the one review thread is resolved.

Happy to re-roll if a maintainer wants to see it go green, but the macOS artifact-download timeout looks like it needs an agent-side fix first.

@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Stale PR review: keep open, rework.

The fix is still needed. On main (29d9638da3) the browser polyfill still calls promise.then with no try (events.js:68), still accepts only native promises (events.js:159), and onceWrapper still drops the listener result (events.js:229). The diff merges into main with no conflict.

The current diff is an incomplete port of Node's addCatch. In Node v26.3.0 the function starts with if (!that[kCapture]) return;. This diff has no such guard. Repro: create an emitter with captureRejections: true, add a listener that returns a thenable which rejects, and add an 'error' listener that emits the same event again. Node calls the 'error' listener once. With this diff the loop does not end (stopped at 5000 calls). With the guard added as the first statement of addCatch, the result matches Node.

Wanted shape:

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.

1 participant