Skip to content

node:events: mutate listener arrays in place; copy only when emit() has iterated them - #35823

Open
robobun wants to merge 4 commits into
mainfrom
farm/843594f7/events-inplace-mutate
Open

node:events: mutate listener arrays in place; copy only when emit() has iterated them#35823
robobun wants to merge 4 commits into
mainfrom
farm/843594f7/events-inplace-mutate

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Since #34519 the listener array is copy-on-write: every on() / prependListener() / removeListener() allocates a fresh N±1 array so emit() can iterate the stored one with no defensive clone. That makes each call O(N), so N adds or a tail-first drain of N listeners is O(N²). Node's push/spliceOne is O(1) from the tail, so the same drain is linear there.

import { EventEmitter } from "node:events";
for (const n of [4000, 8000, 16000, 32000, 64000]) {
  const ee = new EventEmitter(); ee.setMaxListeners(0);
  const fns = Array.from({ length: n }, () => () => {});
  for (const f of fns) ee.on("x", f);
  const t0 = performance.now();
  for (let i = n - 1; i >= 0; i--) ee.removeListener("x", fns[i]);
  console.log(n, (performance.now() - t0).toFixed(1), "ms");
}

Release build, this machine:

N main (drain) PR (drain) main (add) PR (add)
4000 23.5 ms 0.10 ms 26.5 ms 0.18 ms
8000 84.7 ms 0.18 ms 64.3 ms 0.32 ms
16000 335 ms 2.55 ms 243 ms 0.49 ms
32000 1512 ms 1.57 ms 1000 ms 3.57 ms
64000 5744 ms 0.71 ms 3794 ms 2.65 ms

Fix

Mutate in place ($arrayPush / ArrayPrototypeUnshift / spliceOne) by default. emit() sets a sticky kIterated symbol on the array before its loop; a mutator that sees the mark installs a fresh copy instead, so any running emit loop keeps a stable snapshot. The copy carries no mark, so the next mutation is in-place again. The mark is never cleared: that is one property read per emit (plus a single write the first time a given array is emitted), so the hot emit() path stays within noise of main (6.4 → 6.6 ns for 3 listeners over 5e6 iters), and it also sidesteps the nested-emit hazard a clear-after-loop boolean would have.

This restores Node's observable shape: _events[type] is the same array object across on/off (until emit() has walked it), which is what the new test asserts deterministically. Semantics for listeners that add/remove during their own emit are unchanged and covered by test-event-emitter-modify-in-emit.js / test-event-emitter-remove-listeners.js, plus a new nested-emit case.

Fixes #3770.
Fixes #3734.


[review] 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
bun test v1.4.0 (019e53d3f)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [4.95ms]
(pass) node:events > once [60.93ms]
(pass) node:events > once (abort) [20.34ms]
(pass) node:events > once (two events in same tick) [33.62ms]
(pass) node:events > once removes the listener afterwards [42.16ms]
(pass) node:events > once is an async function [2.40ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [13.75ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [22.28ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [8.94ms]
(pass) EventEmitter > getEventListeners [9.52ms]
(pass) EventEmitter > constructor [5.64ms]
(pass) EventEmitter > removeAllListeners() [11.56ms]
(pass) EventEmitter > removeAllListeners(type) [5.83ms]
(pass) EventEmitter > emit > different tick [7.30ms]
(pass) EventEmitter > emit > async microtask before [11.39ms]
(pass) EventE
... (truncated)

release without fix: 4 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [0.06ms]
(pass) node:events > once [1.04ms]
(pass) node:events > once (abort) [0.37ms]
(pass) node:events > once (two events in same tick) [11.48ms]
(pass) node:events > once removes the listener afterwards [1.21ms]
(pass) node:events > once is an async function [0.03ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [0.29ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [0.33ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [0.14ms]
(pass) EventEmitter > getEventListeners [0.24ms]
(pass) EventEmitter > constructor [0.09ms]
(pass) EventEmitter > removeAllListeners() [0.49ms]
(pass) EventEmitter > removeAllListeners(type) [0.10ms]
(pass) EventEmitter > emit > different tick [0.09ms]
(pass) EventEmitter > emit > async microtask before [0.12ms]
(pass) EventEmitter > emit > async microtask after [0.11ms]
(pass) EventEmitter > emit > same tick [0.04ms]
(pass) EventEmitter > emit > setTimeout task [1.23ms]
(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 (019e53d3f)

test/js/node/events/event-emitter.test.ts:
(pass) node:events > captureRejectionSymbol [4.14ms]
(pass) node:events > once [68.41ms]
(pass) node:events > once (abort) [13.71ms]
(pass) node:events > once (two events in same tick) [27.63ms]
(pass) node:events > once removes the listener afterwards [55.28ms]
(pass) node:events > once is an async function [3.20ms]
(pass) node:events > once with already-aborted signal rejects (not a synchronous throw) [9.44ms]
(pass) node:events > once with invalid options.signal rejects (not a synchronous throw) [12.21ms]
(pass) node:events > once with non-object options rejects (not a synchronous throw) [7.57ms]
(pass) EventEmitter > getEventListeners [9.84ms]
(pass) EventEmitter > constructor [8.12ms]
(pass) EventEmitter > removeAllListeners() [17.34ms]
(pass) EventEmitter > removeAllListeners(type) [8.88ms]
(pass) EventEmitter > emit > different tick [6.54ms]
(pass) EventEmitter > emit > async microtask before [10.40ms]
(pass) EventEm
... (truncated)

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     019e53d3f4
  features     baseline

22 deps, 108 codegen, 1171 objects in 1222ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [31.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [2.00ms]
[3/1234] fetch zlib
[zlib] up to date
[4/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1234] fetch tinycc
[tinycc] up to date
[6/1234] fetch picohttpparser
[picohttpparser] up to date
[7/1234] gen ErrorCode+*.h
[8/1234] gen bindgenv2
[9/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [9.00ms]
[10/1234] subst deps/zlib/zconf.h
[11/1234] subst deps/zlib/zlib.h
[12/1234] subst deps/libjpeg-turbo/jconfig.h
[13/1234] gen .bind.ts → GeneratedBindings.cpp
[14/1234] subst
... (truncated)
diff hotspot
src/js/internal/streams/legacy.ts         |  3 +-
 src/js/node/events.ts                     | 57 +++++++++++++++++-----------
 test/js/node/events/event-emitter.test.ts | 63 +++++++++++++++++++++++++++++++
 3 files changed, 98 insertions(+), 25 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

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

…as iterated them

Adding or removing a listener allocated a fresh N-element array per call so
emit() could iterate without a defensive clone. That made N adds, or a
tail-first drain of N listeners, O(N^2): 64k listeners took ~5.7s to drain
and ~3.8s to add.

Listener arrays are now mutated in place (push / unshift / spliceOne, as in
Node). emit() marks the array it walks with a sticky symbol; a mutator that
sees the mark installs a fresh copy instead, so the running loop still sees
its original snapshot. The copy is unmarked, so the next mutation is
in-place again. The mark is one property read (and a write only on the
first emit of a given array), so the emit hot path is unchanged within
noise.

Fixes #3770, fixes #3734.
@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: 1 minute

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: bfa223b1-c46e-4603-9182-9a6d5e70bd8b

📥 Commits

Reviewing files that changed from the base of the PR and between 916492f and 019e53d.

📒 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 11:34 PM PT - Jul 25th, 2026

@robobun, your commit 019e53d has 1 failures in Build #81993 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35823

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

bun-35823 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:events: make on() O(1) again by appending in place #35801 - Also fixes the O(N^2) copy-on-write regression from node:events: single listeners stored bare like node; copy-on-write arrays #34519 by mutating listener arrays in place in src/js/node/events.ts

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #35801. That PR makes on() append in place (fixes #3734) but leaves removeListener on the copy-remove path, so a tail-first drain of N listeners is still O(N²) there (#3770). This PR covers both: on/prependListener/removeListener all mutate in place, and the sticky kIterated mark on the array is what keeps an in-flight emit() loop stable instead of relying on the latched length (which only works for append). Merging this supersedes #35801.

@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 didn't find any bugs, but this reworks the copy-on-write strategy in node:events — the hottest path in the runtime — and the sticky-kIterated design is a trade-off (post-emit mutations always pay one copy) that's worth a maintainer's eyes.

What was reviewed:

  • Traced kIterated through all three emit paths (emitWithoutRejectionCapture, emitWithRejectionCapture, applyHandlers) — mark is set before the loop in each, so mutators inside a listener always see it.
  • Verified the nested-emit case and the removeAllListeners LIFO walk stay correct when the array shrinks in place under it (matches Node's uncloned loop).
  • Checked once() wrappers: first removal COWs the marked array, subsequent removals splice the unmarked copy — outer emit's snapshot is untouched.
  • legacy.ts change is comment-only; the fresh-array behavior there is preserved.
Extended reasoning...

Overview

This PR changes src/js/node/events.ts from unconditional copy-on-write (every on/off/prependListener allocates a fresh array) to in-place mutation gated by a sticky kIterated symbol that emit() stamps on arrays it iterates. The goal is to fix O(N²) behavior when adding or draining N listeners. Also touched: a comment-only update in src/js/internal/streams/legacy.ts and two new tests in event-emitter.test.ts.

Security risks

None. This is pure JS data-structure management with no I/O, auth, or untrusted-input parsing. The new spliceOne uses list.pop() (user-overridable), but the file already uses .map/.slice on the same arrays and Node's own spliceOne does the same, so this doesn't change the tamper-resistance posture.

Level of scrutiny

High. node:events underlies every stream, socket, HTTP request, and child process in the runtime. The correctness argument depends on the mark being set at every iteration site before user code runs, and on the interaction between in-place spliceOne and the removeAllListeners LIFO loop (which now walks an array that shrinks under it). I traced these and they look right — including the length-2 → bare-function transition mid-loop — and the behavior matches Node's uncloned loop. But the sticky-vs-counter design choice and the emit hot-path cost (one symbol read + conditional write per emit) are the kind of trade-off a maintainer should sign off on per the repo's Performance guidance.

Other factors

The new tests are deterministic (identity check on _events.x and exact call-order for nested emit), and the PR description references existing Node parallel tests (test-event-emitter-modify-in-emit.js, test-event-emitter-remove-listeners.js) for the modify-during-emit semantics. CI is still building. No prior human reviews on the thread.

Comment thread src/js/internal/streams/legacy.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
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/internal/streams/legacy.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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/node/events.ts:491-494spliceOne calls list.pop() directly, which dispatches through the runtime (user-overridable) Array.prototype.pop, while the add path in this same PR uses the tamper-safe $arrayPush intrinsic and the module-load-captured ArrayPrototypeUnshift.$call. For consistency with the hardening you applied on the add side, capture const ArrayPrototypePop = Array.prototype.pop; at module load and call it via .$call(list) here.

    Extended reasoning...

    What the issue is

    The new spliceOne helper at src/js/node/events.ts:491-494 ends with a bare list.pop():

    function spliceOne(list, index) {
      for (; index + 1 < list.length; index++) list[index] = list[index + 1];
      list.pop();
    }

    This looks up pop on list at call time, which resolves through Array.prototype.pop — a userland-overridable slot. That is asymmetric with the add path this same PR introduces at lines 341-342:

    if (prepend) ArrayPrototypeUnshift.$call(existing, fn);
    else $arrayPush(existing, fn);

    There, $arrayPush is a JSC intrinsic and ArrayPrototypeUnshift is captured at module load (line 42) and invoked via .$call, so neither can be intercepted by userland monkey-patching. The PR author clearly considered tamper-safety on the add side (choosing $arrayPush(existing, fn) over existing.push(fn)), so leaving the symmetric remove-side pop() bare is an inconsistency within the diff.

    Why this matters per the repo guidelines

    REVIEW.md's section on built-in JS modules is explicit:

    Built-in JS modules (src/js/) are hot-path code in a hostile environment. Tamper-resistance: $-prefixed intrinsics and primordial-safe calls ($isJSArray, map.$get, $call), globals captured at module load … never route internal logic through user-overridable machinery

    removeListener is one of the hottest paths in node:events, and list.pop() routes through user-overridable machinery.

    Step-by-step failure mode

    Concrete walkthrough with a hostile override:

    1. Userland runs Array.prototype.pop = () => {}; (a no-op).
    2. An emitter has three listeners on "x": [f0, f1, f2], and the array carries no kIterated mark (never emitted).
    3. ee.removeListener("x", f0) is called. position = 0, list.length === 3, so control reaches spliceOne(list, 0).
    4. The shift loop copies list[1]→list[0] and list[2]→list[1], leaving [f1, f2, f2].
    5. list.pop() dispatches to the userland no-op; length stays 3 and the trailing f2 is not removed.
    6. The stored listener array is now [f1, f2, f2]. A subsequent ee.emit("x") invokes f2 twice.

    A throwing or side-effecting override would similarly break removeListener mid-operation. Meanwhile addListener on the same emitter is unaffected because it uses the intrinsic — that's the asymmetry.

    Why existing code doesn't prevent it

    Nothing else in removeListener's in-place branch touches the array length; spliceOne is the sole place the trailing slot is dropped, and it relies entirely on .pop(). The kIterated copy-on-write branch and the length === 2 bare-function branch don't reach here, so any unmarked array of length ≥ 3 hits the overridable call.

    Fix

    Mirror the pattern already used for unshift:

    const ArrayPrototypePop = Array.prototype.pop;
    // …
    function spliceOne(list, index) {
      for (; index + 1 < list.length; index++) list[index] = list[index + 1];
      ArrayPrototypePop.$call(list);
    }

    Why nit, not blocking

    Node's own internal/util spliceOne also uses list.pop(), and this file already has pre-existing non-primordial calls (.slice(), .map(), another .pop() in listenersController). So this is not a regression in Node compat or a new class of vulnerability — it's a consistency/hardening nit within this PR's own choices, worth the one-line fix but not worth blocking merge over.

Comment thread src/js/node/events.ts

@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 earlier nit (raw .pop() in spliceOne) is addressed — thanks. I didn't find correctness issues, but this replaces the copy-on-write invariant in node:events with a novel sticky-mark scheme (kIterated) that governs reentrancy safety across emit/on/off/removeAllListeners, so it's worth a maintainer sign-off on the design and the hot-path perf claims.

What was reviewed:

  • Traced kIterated through all three emit paths (emitWithoutRejectionCapture, emitWithRejectionCapture, applyHandlers for error/errorMonitor) — each marks before iterating.
  • Checked reentrant once() wrappers, nested emit, and add/remove-during-emit against the new snapshot rule; the fresh copy correctly drops the mark so the next mutation is in-place again.
  • removeAllListeners LIFO walk over the live array: tail-shrink stays in step with i, and the length-2 branch replaces events[type] without mutating the local listeners reference, so indices stay valid.
  • legacy.ts prependListener fallback still allocates a fresh array (can't see kIterated), which is the safe choice.
Extended reasoning...

Overview

This PR changes src/js/node/events.ts from unconditional copy-on-write for listener arrays (introduced in #34519) to in-place mutation ($arrayPush / ArrayPrototypeUnshift.$call / spliceOne), gated by a sticky kIterated symbol that emit() stamps on the array before iterating. Mutators that see the mark fall back to installing a fresh copy so any in-flight emit loop keeps a stable snapshot; the copy is unmarked, so subsequent mutations are in-place again. It also updates the comment in src/js/internal/streams/legacy.ts and adds two tests (array-identity across on/off; nested-emit snapshot preservation) to test/js/node/events/event-emitter.test.ts.

Security risks

None identified. The change is internal listener-array bookkeeping in a JS builtin; no new user-controlled input reaches native code, no auth/crypto/permissions surface. Tamper-resistance was checked: the add path uses $arrayPush / captured ArrayPrototypeUnshift, and after the follow-up commit spliceOne uses captured ArrayPrototypePop.$call, so no new user-overridable prototype lookups are introduced on the hot path.

Level of scrutiny

High. node:events is one of the hottest and most widely-depended-on Node compat modules — every stream, HTTP request, and process signal flows through it. The PR replaces a simple invariant ("stored arrays are never mutated") with a more subtle one ("stored arrays may be mutated iff no emit has ever iterated them"). The design is sound as far as I can trace — the mark is sticky (never cleared) precisely so a nested emit can't unmark an outer emit's array, and every iteration site (emitWithoutRejectionCapture, emitWithRejectionCapture, applyHandlers) sets it — but this is exactly the kind of load-bearing invariant a maintainer should ratify rather than a bot.

Other factors

  • My prior inline nit (raw list.pop() in spliceOne) was addressed in 019e53d with a captured ArrayPrototypePop.
  • The PR carries benchmark numbers and a hot-path claim ("emit stays within noise, 6.4 → 6.6 ns"); per REVIEW.md's Performance guidance, perf claims on hot paths warrant maintainer verification.
  • removeAllListeners now iterates the live array rather than an implicit snapshot. I traced the LIFO walk: tail spliceOne and tail push preserve indices [0, i-1], and the length === 2 branch swaps events[type] to a bare function without touching the local listeners array, so listeners[0] is still correct on the final iteration. Node itself clones before this loop, so an exotic removeListener handler that prepends during removeAllListeners could in theory diverge — I judged this too obscure to block on but note it for the human reviewer.
  • Existing Node parallel tests (test-event-emitter-modify-in-emit.js, test-event-emitter-remove-listeners.js, test-event-emitter-remove-all-listeners.js) cover the standard reentrancy cases; the new nested-emit test covers the specific hazard the sticky mark exists to prevent.
  • There is a competing PR (#35801) that only fixes the add path; the author has explained why this one supersedes it, which is another reason a human should pick between them.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff itself is green. Build 81993 ran 54 test lanes with no event-emitter.test.ts or node:events failures; the Node parallel test-event-emitter-* set passes locally as well. The red is five native build jobs timing out (darwin-aarch64, linux-aarch64-musl, linux-aarch64-android, freebsd-x64, windows-aarch64) plus the test lanes that depend on them, which a JS-only change to src/js/node/events.ts cannot affect. The three yellow tests (cpu-prof, jsc-stress, bun-install) are unrelated and passed on retry.

Ready for review.

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