Skip to content

node:events: validate emitter in static listenerCount like Node - #34605

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/325ed6e1/events-listenercount-validate
Jul 18, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/325ed6e1/events-listenercount-validate

Conversation

@robobun

@robobun robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator

What

events.listenerCount(emitter, type) (the deprecated static) now throws ERR_INVALID_ARG_TYPE when emitter is neither an EventEmitter (anything with a callable .listenerCount) nor an EventTarget, matching Node.

Repro

import * as events from "node:events";
import { EventEmitter } from "node:events";

events.listenerCount({}, "y");   // node: TypeError ERR_INVALID_ARG_TYPE, bun before: 0
events.listenerCount(42, "y");   // node: TypeError ERR_INVALID_ARG_TYPE, bun before: 0

const np = Object.create(null);
EventEmitter.call(np);
EventEmitter.prototype.on.call(np, "y", () => {});
events.listenerCount(np, "y");   // node: TypeError ERR_INVALID_ARG_TYPE, bun before: 1

Cause

Bun's listenerCount fell through to a listenerCountSlow helper that read emitter._events directly and returned 0 for anything without one. Node has no such fallback: after the duck-typed .listenerCount check and the isEventTarget check, it throws.

Fix

Replace the _events fallback with the same ERR_INVALID_ARG_TYPE("emitter", ["EventEmitter", "EventTarget"], emitter) throw Node uses, and drop the now-unused listenerCountSlow helper. The EventEmitter path (callable .listenerCount) and EventTarget path (native listener count) are unchanged.

Verification

  • bun bd test test/js/node/events/event-emitter.test.ts passes (68 tests)
  • New test fails on main / USE_SYSTEM_BUN=1
  • test/js/node/test/parallel/test-events-once.js and test-events-on-async-iterator.js still pass

[stamp-90s] gate passed · iteration 2 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/events/event-emitter.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (52cdccac8)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [27.31ms]
(pass) node:events > once [47.02ms]
(pass) node:events > once (abort) [46.36ms]
(pass) node:events > once (two events in same tick) [27.54ms]
(pass) node:events > once removes the listener afterwards [58.99ms]
(pass) node:events > once is an async function [1.83ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [57.74ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [16.75ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [5.62ms]
(pass) EventEmitter > getEventListeners [6.89ms]
(pass) EventEmitter >
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (ed663e0f3)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [0.07ms]
(pass) node:events > once [0.96ms]
(pass) node:events > once (abort) [0.34ms]
(pass) node:events > once (two events in same tick) [10.52ms]
(pass) node:events > once removes the listener afterwards [1.20ms]
(pass) node:events > once is an async function [0.03ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [0.25ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [0.39ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [0.10ms]
(pass) EventEmitter > getEventListeners [0.14ms]
(pass) EventEmitter > constructor [0.08ms]
(pass) EventEmitter > removeAllListeners() [1.09ms]
(pass) EventEmitter > removeAllListeners(type) [0.12ms]
(pass) EventEmitter > emit > different tick [0.11ms]
(pass) EventEmitter > emit > async microtask before [0.12ms]
(pass) EventEmitter > emit > async microtask after [0.11ms]
(pass) EventEmitter > emit > same tick [0.05ms]
(pass) EventEmitter > emit > setTimeout task [4.36ms]
(pass) EventEmitter > em
... (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/mechgate.xml" test/js/node/events/event-emitter.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (52cdccac8)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [2.63ms]
(pass) node:events > once [36.93ms]
(pass) node:events > once (abort) [12.98ms]
(pass) node:events > once (two events in same tick) [22.53ms]
(pass) node:events > once removes the listener afterwards [40.97ms]
(pass) node:events > once is an async function [1.64ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [13.51ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [13.62ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [5.23ms]
(pass) EventEmitter > getEventListeners [6.49ms]
(pass) EventEmitter > 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 1039ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/20] gen JS modules (bundle-modules)
Preprocess modules (12312ms)
Bundle modules (54ms)
Postprocesss modules (779ms)
Bundle Functions (2013ms)
Generate Code (117ms)

[15.30s] Bundled "src/js" for production
  2036 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[build] done
bun test v1.4.0-canary.1 (52cdccac8)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [0.05ms]
(pass) node:events > once [0.58ms]
(pass) node:events > once (abort) [0.26ms]
(pass) node:events > once (two events in same tick) [10.34ms]
(pass) node:events > once removes the listener afterwards [0.72ms]
(pass) node:events > once is an async function [0.02ms]
(pass) node:events > once with already-aborted sig
... (truncated)
diff hotspot
src/js/internal/streams/legacy.ts         |  2 +-
 src/js/node/events.ts                     | 16 +---------------
 test/js/node/events/event-emitter.test.ts | 29 ++++++++++++++++++++++++++++-
 3 files changed, 30 insertions(+), 17 deletions(-)

gate history · 3 passed · 1 rejected · iteration 2

evidence per changed file
file                                       reads  edits  tests
src/js/internal/streams/legacy.ts              2      1      0
src/js/node/events.ts                          2      1      0
test/js/node/events/event-emitter.test.ts      2      2      0

events.listenerCount(emitter, type) now throws ERR_INVALID_ARG_TYPE when
the argument is neither an EventEmitter (something with a callable
.listenerCount) nor an EventTarget, matching Node. Previously Bun fell
through to reading _events and silently returned 0 (or the real count
for a null-prototype pseudo-emitter).
@robobun

robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green; CI red is unrelated flake. Ready for review/merge.

Reproduced with:

USE_SYSTEM_BUN=1 bun test test/js/node/events/event-emitter.test.ts -t "events.listenerCount validates"

(fails: Received function did not throw / Received value: 0)

Passes with bun bd test on this branch (68/68 in the file). Also updated src/js/internal/streams/legacy.ts to use this.listenerCount?.("error") like Node, so test-stream-pipe-without-listenerCount.js keeps passing.

CI: event-emitter.test.ts passed on every lane in builds 75295 and 75332. Remaining reds are flaky/pre-existing on unrelated lanes (express-memory-leak win-aarch64 timeout, solc win-x64 timeout, issue/30205 debian-asan, http-agent-keepalive debian-asan, no-orphans darwin, serve-protocols darwin, worker-message-port-transfer-terminate asan assertion). None touch node:events or legacy stream pipe.

PR: #34605

@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 46 seconds

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: 62fb62f5-2839-406f-9e3b-894d2c74ad5c

📥 Commits

Reviewing files that changed from the base of the PR and between 511caaa and 52cdcca.

📒 Files selected for processing (3)
  • src/js/internal/streams/legacy.ts
  • src/js/node/events.ts
  • test/js/node/events/event-emitter.test.ts

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

@robobun

robobun commented Jul 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:44 AM PT - Jul 18th, 2026

✅ @robobun, your commit 52cdccac8709d44438c2426a3a55adbd93151aea passed in Build #75332! 🎉


🧪   To try this PR locally:

bunx bun-pr 34605

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

bun-34605 --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.

Beyond the inline finding, I grepped src/js/ for other internal callers of the static EE.listenerCount / EventEmitter.listenerCount — src/js/internal/streams/legacy.ts:58 is the only one, so no other sibling sites need the ?. update.

Extended reasoning...

Verified the inline bug is real: test/js/node/test/parallel/test-stream-pipe-without-listenerCount.js sets r.listenerCount = undefined (own property shadowing the prototype), and legacy.ts:58 still calls EE.listenerCount(this, "error"), which after this PR throws instead of falling through to the removed _events fallback. Grepped src/js/ for other callers of the static — legacy.ts is the only one, so the fix scope is just that file plus the events.ts change.

Comment thread src/js/node/events.ts
Match Node's lib/internal/streams/legacy.js: use this.listenerCount?.()
instead of the static EE.listenerCount(), which now throws
ERR_INVALID_ARG_TYPE when .listenerCount is shadowed with undefined
(test-stream-pipe-without-listenerCount.js).

@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 — the legacy.ts caller is now updated to this.listenerCount?.("error"), which resolves the test-stream-pipe-without-listenerCount.js regression I flagged.

Grepped src/js/ and confirmed there are no remaining internal callers of the static listenerCount and no remaining references to listenerCountSlow. The EE import in legacy.ts is still live (used by EE.$call / $toClass).

Extended reasoning...

Overview

Three-file Node-compat change: src/js/node/events.ts replaces the listenerCountSlow fallback in the deprecated static events.listenerCount(emitter, type) with an ERR_INVALID_ARG_TYPE throw (matching Node's lib/events.js); src/js/internal/streams/legacy.ts updates the sole internal caller to this.listenerCount?.("error") === 0 (matching Node's lib/internal/streams/legacy.js); and a new test in event-emitter.test.ts covers both the happy paths (EventEmitter, EventTarget) and the throw paths ({}, 42, null-prototype object with _events but no .listenerCount).

Security risks

None. This tightens validation on a deprecated static helper to throw a typed error instead of silently returning 0. No auth, crypto, filesystem, or network surface.

Level of scrutiny

Low-to-moderate. node:events is core, but the change is a small, surgical alignment with Node's reference implementation. My previous review found the one internal call site (legacy.ts) that would regress; commit 21a8604 fixed it exactly as Node does. I re-grepped src/js/ for EE.listenerCount / events.listenerCount / EventEmitter.listenerCount and for listenerCountSlow — both empty, so no stragglers. The vendored test-stream-pipe-without-listenerCount.js now takes the optional-chaining path (undefined === 0 → false → no re-emit), preserving its behavior.

Other factors

The new test asserts a specific error class + code (not a bare toThrow()), covers both accepted emitter kinds and three rejected shapes, and lives in the existing module test file per repo convention. PR evidence shows the test fails on main / USE_SYSTEM_BUN=1 and passes on the branch in both debug-ASAN and release. The EE import in legacy.ts remains live via the Stream constructor and $toClass, so no dead import was introduced.

@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 — the earlier legacy.ts regression is fixed and no other internal callers remain.

What was reviewed:

  • events.listenerCount now throws ERR_INVALID_ARG_TYPE for non-EE/non-EventTarget, matching Node's lib/events.js; jsEventTargetGetEventListenersCount returns undefined for non-EventTargets so the fall-through is correct.
  • legacy.ts onerror switched to this.listenerCount?.("error") === 0 (Node's exact form), so test-stream-pipe-without-listenerCount.js keeps passing.
  • Grepped src/js/ for other static EE.listenerCount/events.listenerCount callers and for listenerCountSlow — none remain.
Extended reasoning...

Overview

Three files: src/js/node/events.ts replaces the listenerCountSlow _events-reading fallback in the deprecated static events.listenerCount with a $ERR_INVALID_ARG_TYPE("emitter", ["EventEmitter", "EventTarget"], emitter) throw and deletes the now-dead helper; src/js/internal/streams/legacy.ts updates the sole internal caller (Stream.prototype.pipe's onerror) to this.listenerCount?.("error") === 0, mirroring Node's lib/internal/streams/legacy.js; and test/js/node/events/event-emitter.test.ts adds a test covering EventEmitter, EventTarget, and three invalid emitters ({}, 42, and a null-prototype object with _events but no listenerCount method) asserting { name: "TypeError", code: "ERR_INVALID_ARG_TYPE" }.

Security risks

None. This tightens validation on a deprecated static helper — turning a silent 0 into a typed error for garbage input. No auth, crypto, native memory, or untrusted-parse surface involved.

Level of scrutiny

Low-to-moderate. It's a small Node-compat delta in a builtin JS module with a clear upstream reference. The one real risk — internal callers relying on the old lax fallback — was flagged in my previous review and has been addressed: legacy.ts now matches Node's optional-chaining form, and I re-grepped src/js/ for EE.listenerCount / events.listenerCount / EventEmitter.listenerCount and for listenerCountSlow — zero hits. I also checked jsEventTargetGetEventListenersCount in JSEventTarget.cpp: it dynamicDowncasts and returns jsUndefined() for non-EventTargets, so plain objects and primitives correctly fall through to the throw rather than getting stuck in the C++ path.

Other factors

The new test asserts specific error name + code (not a bare toThrow()), covers both happy paths (EE and EventTarget) plus three distinct invalid shapes, and lives in the existing event-emitter.test.ts alongside related coverage. The PR was gated fails-without-fix / passes-with-fix, and the author confirmed test-stream-pipe-without-listenerCount.js still passes after the legacy.ts update. The EE import in legacy.ts remains live (EE.$call, $toClass). Net diff is ~16 lines removed, ~1 line changed, plus tests — well within auto-approve territory.

@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 — the legacy.ts caller update addresses the earlier regression concern.

What was reviewed:

  • listenerCount fallback replaced with ERR_INVALID_ARG_TYPE, matching Node's lib/events.js; listenerCountSlow fully removed (no remaining refs).
  • Grepped src/js/ for other static listenerCount(emitter, ...) callers — none; EE import in legacy.ts still used for $toClass/EE.$call.
  • null/undefined emitter still throws a plain TypeError on the .listenerCount property read — same as Node, pre-existing.
  • New test covers EventEmitter, EventTarget, and three invalid shapes with exact code/name assertions.
Extended reasoning...

Overview

Tightens the deprecated static events.listenerCount(emitter, type) to throw ERR_INVALID_ARG_TYPE when emitter has neither a callable .listenerCount nor is an EventTarget, replacing Bun's non-standard _events-reading fallback (listenerCountSlow). Also updates the sole internal caller in src/js/internal/streams/legacy.ts to Node's this.listenerCount?.("error") === 0 form so test-stream-pipe-without-listenerCount.js keeps passing. Adds a test in event-emitter.test.ts covering the valid EventEmitter/EventTarget paths plus three invalid inputs ({}, 42, and a null-proto object with _events but no prototype method).

Security risks

None. This is a validation-tightening change in a deprecated Node compat helper; it converts a silent 0/bogus-count return into an explicit typed error. No untrusted input parsing, no auth/crypto/fs surface.

Level of scrutiny

Low-to-moderate. The change is a direct port of Node's lib/events.js behavior (duck-type check → EventTarget check → throw) and Node's lib/internal/streams/legacy.js onerror shape. The only real risk was breaking internal callers that relied on the old lenient fallback — my earlier review flagged exactly that for legacy.ts:58, and it's now fixed and verified against the vendored Node test. I re-grepped src/js/ and confirmed no other static listenerCount call sites remain.

Other factors

  • The dead listenerCountSlow helper is removed in the same PR (per REVIEW.md's "delete dead code in the same PR that makes it dead").
  • The EE import in legacy.ts is not dead after this change — still used for EE.$call and $toClass.
  • The ruled-out candidate (null/undefined emitter throwing generic TypeError instead of ERR_INVALID_ARG_TYPE) matches Node: Node also reads emitter.listenerCount unguarded, so a null emitter throws the engine's own TypeError there too.
  • Test asserts exact { name: "TypeError", code: "ERR_INVALID_ARG_TYPE" } (not bare toThrow()), and exercises both success paths before the failure loop so the assertions can't be vacuous.
  • Gate evidence shows fails-without-fix / passes-with-fix on both debug-ASAN and release.

@Jarred-Sumner
Jarred-Sumner merged commit efab3b2 into main Jul 18, 2026
78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/325ed6e1/events-listenercount-validate branch July 18, 2026 23:18
Jarred-Sumner pushed a commit that referenced this pull request Jul 19, 2026
…nt (#34667)

## Repro

```js
const { EventEmitter } = require("events");
new EventEmitter().listenerCount("x");
```

```
ReferenceError: listenerCountSlow is not defined
      at listenerCount (node:events:450:29)
```

Every single-argument `emitter.listenerCount(type)` call hits this,
which broke `test/js/node/http2/node-http2.test.js` (38 fail) via
`emitSessionCloseNT` calling `listenerCount("close")`, along with
anything else that counts listeners.

## Cause

#34605 (efab3b2) removed the `listenerCountSlow` helper when it
dropped the fallback path from the static `events.listenerCount`, but
`EventEmitterPrototype.listenerCount` (added in a227ad9) still called
it for the common 1-argument form.

## Fix

Inline the lookup into the prototype method, structured the same way as
Node's own `listenerCount`: read `_events[type]` once, then handle the
bare-function and array storage shapes for both the 1-arg (`return 1` /
`handlers.length`) and 2-arg (match against `method`) forms.

## Verification

- `bun bd test test/js/node/events/event-emitter.test.ts`: 70 pass
- New `EventEmitter.prototype.listenerCount` test fails on main with
`ReferenceError: listenerCountSlow is not defined`, passes with the fix
- All 26 `test/js/node/test/parallel/test-event-emitter-*.js` pass
- Test expectations checked against Node.js
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