Skip to content

node:events: make on() O(1) again by appending in place - #35801

Open
robobun wants to merge 6 commits into
mainfrom
farm/389ba5ef/events-on-linear
Open

node:events: make on() O(1) again by appending in place#35801
robobun wants to merge 6 commits into
mainfrom
farm/389ba5ef/events-on-linear

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

#34519 switched the listener array to copy-on-write so emit() could skip cloning. That made every on() copy the whole array, so registering N listeners on one event type cost O(N^2).

const { EventEmitter } = require("node:events");
for (const n of [5000, 10000, 20000, 40000]) {
  const ee = new EventEmitter(); ee.setMaxListeners(0);
  const t0 = performance.now();
  for (let i = 0; i < n; i++) ee.on("x", () => {});
  console.log(n, (performance.now() - t0).toFixed(1), "ms");
}
N before after node v26
5000 138 ms 0.3 ms 2.8 ms
10000 183 ms 0.5 ms 11.7 ms
20000 766 ms 0.9 ms 0.8 ms
40000 2558 ms 1.7 ms 2.1 ms

Fix: append in place via $arrayPush. emit() already latches length before iterating, so a listener pushed mid-emit sits past the latched length and is never visited, which matches Node's "listeners added during emit are not called in that emit". Only prependListener and removeListener, which shift existing indices, still install a fresh array so an in-flight emit keeps a stable handler[0..length) view. emit() stays clone-free.

How did you verify your code works?

  • New test asserts 12k adds finish well under 500ms (was ~1.6s under debug+ASAN); fails on main, passes with the fix.
  • New test asserts a listener appended during emit is not called in that emit round.
  • bun bd test test/js/node/events/event-emitter.test.ts (77 pass).
  • All 34 test/js/node/test/parallel/test-event-emitter-*.js / test-events-*.js pass, including test-event-emitter-modify-in-emit.js which exercises add/remove during emit.
  • test/js/node/stream/node-stream.test.js (98 pass).

[review] gate passed · iteration 9 · 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
bun test v1.4.0 (c4a265417)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [2.38ms]
(pass) node:events > once [37.82ms]
(pass) node:events > once (abort) [11.86ms]
(pass) node:events > once (two events in same tick) [24.14ms]
(pass) node:events > once removes the listener afterwards [31.24ms]
(pass) node:events > once is an async function [1.70ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [8.68ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [11.79ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [4.58ms]
(pass) EventEmitter > getEventListeners [5.37ms]
(pass) EventEmitter > constructor [4.04ms]
(pass) EventEmitter > removeAllListeners() [10.02ms]
(pass) EventEmitter > removeAllListeners(type) [4.83ms]
(pass) EventEmitter > emit > different tick [3.48ms]
(pass) EventEmitter > emit > async microtask before [5.86ms]
(pass) EventEmi
... (truncated)

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

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [0.03ms]
(pass) node:events > once [0.63ms]
(pass) node:events > once (abort) [0.21ms]
(pass) node:events > once (two events in same tick) [10.47ms]
(pass) node:events > once removes the listener afterwards [0.51ms]
(pass) node:events > once is an async function [0.02ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [0.31ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [0.17ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [0.06ms]
(pass) EventEmitter > getEventListeners [0.07ms]
(pass) EventEmitter > constructor [0.05ms]
(pass) EventEmitter > removeAllListeners() [0.24ms]
(pass) EventEmitter > removeAllListeners(type) [0.06ms]
(pass) EventEmitter > emit > different tick [0.04ms]
(pass) EventEmitter > emit > async microtask before [0.07ms]
(pass) EventEmitter > emit > async microtask after [0.09ms]
(pass) EventEmitter > emit > same tick [0.03ms]
(pass) EventEmitter > emit > setTimeout task [1.12ms]
(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
bun test v1.4.0 (c4a265417)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [2.50ms]
(pass) node:events > once [40.36ms]
(pass) node:events > once (abort) [12.13ms]
(pass) node:events > once (two events in same tick) [24.72ms]
(pass) node:events > once removes the listener afterwards [31.26ms]
(pass) node:events > once is an async function [1.75ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [8.92ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [12.16ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [4.73ms]
(pass) EventEmitter > getEventListeners [5.45ms]
(pass) EventEmitter > constructor [3.96ms]
(pass) EventEmitter > removeAllListeners() [9.91ms]
(pass) EventEmitter > removeAllListeners(type) [5.18ms]
(pass) EventEmitter > emit > different tick [3.64ms]
(pass) EventEmitter > emit > async microtask before [5.73ms]
(pass) EventEmit
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 793ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (10305ms)
Bundle modules (51ms)
Postprocesss modules (234ms)
Bundle Functions (816ms)
Generate Code (31ms)

[11.45s] Bundled "src/js" for production
  2570 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[build] done
bun test v1.4.0-canary.1 (c4a265417)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [0.03ms]
(pass) node:events > once [0.62ms]
(pass) node:events > once (abort) [0.20ms]
(pass) node:events > once (two events in same tick) [10.38ms]
(pass) node:events > once removes the listener afterwards [0.54ms]
(pass) node:events > once is an async function [0.02ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [0.35ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [0.18ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [0.06ms]
(pass) EventEmitter > ge
... (truncated)
diff hotspot
src/js/internal/streams/legacy.ts         |  2 +-
 src/js/node/events.ts                     | 37 +++++++++++------------------
 test/js/node/events/event-emitter.test.ts | 39 ++++++++++++++++++++++++++++++-
 3 files changed, 52 insertions(+), 26 deletions(-)

gate history · 3 passed · 1 rejected · iteration 9

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

root cause · written by the author bot

The listener storage used copy-on-write semantics, so every on() call allocated a fresh array and copied all existing listeners into it, making each registration O(N) and N registrations on the same event O(N²). The fix replaces the per-add array clone with an in-place push onto the existing listener array, matching Node's behavior and making each add O(1). The same change removes the shared copy-on-write root that was also inflating removeListener() cost.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status: rebased onto main 59242d6 (2026-07-29), force-pushed c4a2654. Release re-measurement:

N on() add (this PR) removeListener tail drain (not this PR)
10000 0.8 ms 116 ms
20000 1.0 ms 504 ms
40000 1.8 ms 1861 ms

This PR fixes on() only. removeListener tail drain is the same COW root but an in-place splice shifts indices under a running emit(), so it needs a different scheme; #35823 covers both via a sticky kIterated mark and supersedes this PR.

Reproduced with (release, before fix: 138/183/766/2558 ms; after: 0.3/0.5/0.9/1.7 ms):

bun -e 'const {EventEmitter}=require("node:events");for(const n of[5e3,1e4,2e4,4e4]){const ee=new EventEmitter();ee.setMaxListeners(0);const t=performance.now();for(let i=0;i<n;i++)ee.on("x",()=>{});console.log(n,(performance.now()-t).toFixed(1),"ms")}'

@coderabbitai

coderabbitai Bot commented Jul 25, 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: 12 minutes

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: 7947fad9-4bd5-4c85-a3ac-3035dacdbc83

📥 Commits

Reviewing files that changed from the base of the PR and between 59242d6 and c4a2654.

📒 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 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Jul 29th, 2026

@robobun, your commit c4a2654 has 3 failures in Build #84872 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35801

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

bun-35801 --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 nit, I traced the in-place append against the reentrancy cases and they hold: remove-then-add during emit (remove installs a fresh array so the subsequent push lands there, emit's local handler stays the old object), removeAllListeners(type) LIFO walk (first removeListener reassigns events[type] before emitting removeListener, so a handler's on() pushes onto the copy, not the snapshot), and rawListeners()/listeners() still return copies via .slice()/.map() so callers can't observe later pushes.

Extended reasoning...

The only finding is a stale comment in removeListener — no runtime effect. The core invariant change (append mutates in place; only index-shifting ops copy) is sound because every iteration site latches length up front (applyHandlers, both emit variants). I checked the non-obvious reentrancy paths — remove→add mid-emit, once() wrapper self-removal, newListener handlers that add/remove, removeAllListeners with a removeListener handler that re-adds — and in each case the in-flight iterator holds a reference to an array whose [0..latched) slots are never overwritten. Deferring rather than approving only because node:events is the hottest path in the runtime and the invariant relaxation deserves a maintainer's eyes.

Comment thread src/js/node/events.ts Outdated
Comment thread src/js/node/events.ts Outdated
Comment thread src/js/node/events.ts Outdated
Comment thread src/js/node/events.ts Outdated
Comment thread src/js/node/events.ts Outdated
Comment thread src/js/node/events.ts Outdated
Comment thread src/js/node/events.ts Outdated

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

Thanks for addressing the stale removeListener comment (03f1fb5) and compressing the invariant comments (4514c21) — all seven sites now state the new invariant consistently. I couldn't find a hole in the append-in-place reasoning, but this relaxes the mutation invariant on node:events' hottest path, so I'd like a maintainer to confirm before merge.

What was reviewed

  • Traced every iterator over a stored _events[type] array (emitWithoutRejectionCapture, emitWithRejectionCapture, applyHandlers, removeAllListeners LIFO loop) — each either latches length or only ever sees a fresh copy after the first reentrancy, so an in-place push is never visited mid-iteration.
  • Checked reentrancy combinations: onceremoveListener mid-emit followed by on(), on() then prependListener()/removeListener() mid-emit, newListener handlers mutating the list — emit's captured handler reference plus latched length keeps [0..length) stable in each.
  • .warned carryover: preserved naturally on the in-place array, still copied by copyWithPrepended and removeListener; rawListeners()/listeners() still hand out fresh copies so userland can't observe later pushes.
  • copyWithInserted had no other callers; rename to copyWithPrepended is clean.
Extended reasoning...

Overview

This PR reverts the O(N²) cost of EventEmitter#on() introduced when #34519 made listener arrays copy-on-write. The functional change is one branch in _addListener (src/js/node/events.ts): when appending to an existing array, use $arrayPush(existing, fn) instead of copyWithInserted. copyWithInserted is simplified to copyWithPrepended (append branch deleted), and seven invariant comments across events.ts and streams/legacy.ts are updated from "arrays are never mutated in place" to the weaker-but-accurate "index-shifting mutations install a copy; append lands past emit()'s latched length". Two tests are added: a timing bound (12k adds < 500ms) and a behavioral test that on() during emit doesn't fire in that round.

Security risks

None. Pure JS, no untrusted input parsing, no auth/crypto.

Level of scrutiny

High. node:events underpins every stream, socket, HTTP server, child process, and FS watcher in the runtime, and on()/emit() are on essentially every hot path. The change deliberately relaxes a safety invariant (full COW → append-only mutation) whose whole point was to make emit()'s clone-free iteration safe against reentrancy. A subtle mistake here would surface as rare listener-skips or double-fires deep inside unrelated subsystems. That said, the diff is small and the argument is precise: every in-tree iterator over a stored array latches length at loop entry (for (let i = 0, { length } = handler; ...)), and $arrayPush only writes past that index; the two operations that shift existing indices (prepend, remove) still install a fresh array so an in-flight emit's captured handler reference stays byte-stable over [0..length).

I walked the reentrancy combinations that #34519's COW was defending against and could not construct a case where a pushed slot is visited or an existing slot is skipped: once wrappers call removeListener (fresh array) before invoking the target, so a subsequent on() in the same round pushes into the new array while emit iterates the old; removeAllListeners' LIFO loop captures listeners before its first removeListener call replaces events[type]; applyHandlers (errorMonitor path) latches length identically. .warned naturally persists on the same array object, and copyWithPrepended/removeListener still carry it over. rawListeners()/listeners() return .slice()/.map() copies, so userland snapshots remain immutable. No remaining callers of copyWithInserted.

Other factors

My prior review flagged one stale comment; that and the comment-cop feedback are both addressed and all threads resolved. Test coverage is solid — the PR reports the full test-event-emitter-*/test-events-* node-parallel suite (including test-event-emitter-modify-in-emit.js) and node-stream.test.js passing, plus the two new targeted tests. The "addListener in callback" test in the existing suite already exercises append-during-emit ordering. The 500ms perf assertion has a wide margin (author measured ~1.6s vs tens of ms under debug+ASAN), so it shouldn't be flaky.

I'm deferring rather than approving because relaxing a reentrancy invariant on node:events is exactly the kind of change where a maintainer's sign-off is worth more than a bot's — even one that couldn't find a counterexample.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nobody is adding more than like 9 event listeners.

this seems dumb

@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 test-threshold nit: I verified all three iterate-without-clone sites (emitWithoutRejectionCapture, emitWithRejectionCapture, applyHandlers) latch length before the loop, so the in-place push can't be visited mid-emit; rawListeners()/listeners() return copies so external holders don't observe the mutation; and removeAllListeners iterates a local snapshot that removeListener replaces rather than mutates, so an on() from a removeListener handler can't corrupt that walk. Node's own _addListener does existing.push(listener), so this restores parity rather than diverging.

Extended reasoning...

The inline nit (perf-test threshold only discriminates on debug+ASAN) stands on its own. This note just records the correctness concerns I checked around the weakened invariant — every no-clone iterator of the stored array either latches length up front or holds a reference that index-shifting mutators replace with a fresh array — so a human reviewer looking at the invariant change doesn't have to re-derive them.

Comment thread test/js/node/events/event-emitter.test.ts Outdated
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Fair, 40k listeners is a synthetic benchmark. The reason I thought it was worth touching anyway:

At the common case (2-9 listeners) the fix is also never worse. Before, every on() past the second listener does $newArrayWithSize(n+1) + copies n elements + installs the new array; after, it's one $arrayPush. So at N=5 that's 3 fewer array allocations per request-scoped emitter. It doesn't move the needle at that scale, but it's not a tradeoff, just fewer allocations.

Node's _addListener also pushes in place (existing.push(listener)); #34519 switched us to copy-on-write so emit() could drop its clone, but emit() already latches length before the loop, so an appended slot is past it regardless. The only mutations that need a fresh array are the index-shifting ones (prepend/remove), and those still get one.

Happy to close if you'd rather keep the simpler "stored arrays are immutable" invariant; the diff is small either way.

robobun added 6 commits July 29, 2026 04:43
#34519 switched the listener array to copy-on-write so emit() could
iterate without cloning. That made every on() copy the whole array,
so N adds on one event type cost O(N^2): 40k adds went from ~2ms to
~2.5s.

Append in place instead: emit() already latches the array length
before iterating, so a listener pushed mid-emit sits past the latched
length and is never visited (matching Node's semantics). Only
prepend and remove, which shift existing indices, still install a
fresh array so an in-flight emit keeps a stable view.
@robobun
robobun force-pushed the farm/389ba5ef/events-on-linear branch from ab8e560 to c4a2654 Compare July 29, 2026 04:54

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

No bugs found; both earlier nits (stale removeListener comment, unbranched perf threshold) are addressed. Deferring because this changes the mutation invariant for the stored listener array in node:events — foundational hot path — and the author notes #35823 supersedes it with a different scheme (kIterated), so a maintainer should pick which lands.

What was reviewed:

  • Traced emit()'s latched-length loop against every mutator: append in place lands past the latch; prepend/remove/removeAllListeners install a fresh array so the handler local stays stable; combinations (append-then-remove mid-emit, once() self-removal then append) all keep handler[0..length) intact.
  • Checked rawListeners()/listeners() still return copies (slice/map), so the now-mutable internal array doesn't leak.
  • Checked .warned propagation — in-place append keeps it on the same array; copyWithPrepended carries it over.
Extended reasoning...

Overview

Restores O(1) EventEmitter.prototype.on() by switching the append path in _addListener from copy-on-write (copyWithInserted) to in-place $arrayPush. The safety argument is that both emit() variants and applyHandlers latch length before iterating, so a slot pushed mid-emit sits at an index ≥ the latched length and is never visited. Only index-shifting mutations (prependListener, removeListener) still install a fresh array. Also renames copyWithInsertedcopyWithPrepended (dropping the dead append branch), rewrites the invariant comments at all seven sites, updates the streams/legacy.ts prependListener comment to match, and adds two tests (perf regression guard branched on isDebug || isASAN; append-during-emit ordering).

Security risks

None. Pure JS, no untrusted-input parsing, no auth/crypto surface. The only user-observable change is performance and preserving Node's documented "listeners added during emit are not called in that emit" semantics — which the previous COW implementation also preserved.

Level of scrutiny

High. src/js/node/events.ts is one of the hottest, most foundational built-in modules — every stream, every server, every process is an EventEmitter. The correctness argument is subtle: it depends on the interaction between the length latch in three iteration sites and every mutation path (including mid-emit combinations like once-wrapper self-removal followed by an append, or append-then-remove where the append mutated the same array remove reads from). I walked each combination and believe the invariant holds, but a maintainer who owns this file should sign off on relaxing "stored arrays are never mutated" to "only index-shifting mutations install a fresh array".

Other factors

  • The author's own status comment says #35823 covers both on() and the removeListener tail-drain O(N²) via a sticky kIterated mark and "supersedes this PR" — a maintainer should decide whether to land this narrower fix now or wait for the broader one.
  • Both of my earlier inline comments are resolved in the current diff (the removeListener comment now reads "Copy-remove so an in-flight emit() keeps a stable view"; the perf test threshold branches on isDebug || isASAN).
  • Test coverage: the PR ran the 34 upstream test-event-emitter-* / test-events-* parallel tests including test-event-emitter-modify-in-emit.js, plus the full event-emitter and node-stream suites. The new append-during-emit test directly asserts the load-bearing invariant.

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