Skip to content

process: honor the EventEmitter contract for removeListener, instanceof, and rawListeners - #33495

Closed
robobun wants to merge 6 commits into
mainfrom
farm/c0f06870/process-event-emitter-contract
Closed

robobun wants to merge 6 commits into
mainfrom
farm/c0f06870/process-event-emitter-contract

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #5121

Note

The instanceof part of this PR (dropping IsImmutablePrototypeExoticObject and the node:events splice, ~20 lines across 2 files) is the same change as a hunk in #31831. The 'removeListener' meta-event and rawListeners() fixes are independent of it. Happy to drop the overlapping hunks and rebase if #31831 lands first.

Repro

import { EventEmitter } from "node:events";
const got = [], f = () => {};
process.on("removeListener", n => (n === "foo" || n === "SIGWINCH") && got.push("rm:" + n));

process.on("foo", f); process.off("foo", f);
process.on("SIGWINCH", f); process.removeListener("SIGWINCH", f);
process.on("SIGWINCH", f); process.removeAllListeners("SIGWINCH");
console.log("meta-events:", JSON.stringify(got));

console.log("instanceof:", process instanceof EventEmitter);
process.once("SIGWINCH", f);
console.log("backpointer:", process.rawListeners("SIGWINCH")[0].listener === f);
node: meta-events: ["rm:foo","rm:SIGWINCH","rm:SIGWINCH"]   instanceof: true    backpointer: true
bun : meta-events: []                                       instanceof: false   backpointer: false

A plain new EventEmitter() gets all three right, so this is specific to process.

Cause

process is backed by the native WebCore::EventEmitter (src/jsc/bindings/webcore/EventEmitter.cpp) rather than node:events, and that implementation drifted from Node in three places:

  • JSEventEmitter::addListener emits 'newListener', but nothing ever emitted 'removeListener'.
  • JSEventEmitterPrototype's [[Prototype]] is Object.prototype, so EventEmitter.prototype is not in process's chain.
  • rawListeners was wired straight to the listeners host function (there was a TODO on the line), so once() listeners came back unwrapped.

Two further divergences fell out of the same code while fixing the first one:

  • EventEmitter::removeAllListeners() (no args) cleared the map without calling onDidChangeListener, so process.removeAllListeners() left OS signal handlers installed and the IPC channel ref'd.
  • Node emits 'removeListener' when a once() listener fires (its onceWrapper removes itself); Bun removes the registration in innerInvokeEventListeners without notifying.

'newListener'/'removeListener' is the documented way to mirror the listener table, so signal-handler managers and graceful-shutdown libraries that install a real handler only while a user listener exists saw every add and no removal, and leaked handlers.

Fix

'removeListener' is emitted from EventEmitter::removeListener, the one place every removal funnels through, so off(), removeListener(), removeAllListeners(), and the auto-removal of a once() listener all report. removeAllListeners(type) drains LIFO like Node; the no-arg form drains one event type at a time (handling 'removeListener' itself last), which also gets onDidChangeListener running again and fixes the leaked signal handler. Both overloads protect this because the meta-event runs user JS in their frame.

instanceof: node:events splices EventEmitter.prototype in underneath the native prototype. Doing it there rather than at process creation keeps node:events off the startup path for programs that never load it, and you cannot observe process instanceof EventEmitter without loading it anyway. JSEventEmitterPrototype no longer carries IsImmutablePrototypeExoticObject, which was copied from JSEventTarget where WebIDL mandates it (EventTarget.idl); Node's EventEmitter.prototype is an ordinary object.

rawListeners gets its own host function that materializes a once-wrapper exposing the documented .listener back-pointer, built by the new EventEmitterOnce.ts builtin. Registrations store once() listeners unwrapped, so the wrapper is created on demand and cached in a JSC::Weak on the registration to keep its identity stable. It is weak deliberately: once nothing holds the wrapper its identity is unobservable. removeListener() resolves such a wrapper back to the registration it was made for, and calling the wrapper removes that registration and invokes the original, as Node's does.

listeners() keeps returning unwrapped functions, and 'newListener' keeps receiving the original for once() — both now have regression coverage.

Verification

test/js/node/events/event-emitter.test.ts gains a process block. 7 of the 9 new tests fail on main and pass here; the other 2 are the regression guards above. Anything mutating process-wide listener state runs in a child so it cannot disturb the test runner.

The signal-handler leak is asserted directly: a child does process.on("SIGUSR2", …); process.removeAllListeners(); process.kill(process.pid, "SIGUSR2"). With the handler still installed it prints survived; with it uninstalled the default action terminates the child, which is what Node does.

Suites run against the debug (ASAN) build
  • test/js/node/events/event-emitter.test.ts — 76 pass
  • test/js/node/test/parallel/test-event* — 39 pass, 1 pre-existing failure (test-events-add-abort-listener.mjs, also fails on main)
  • test/js/node/test/parallel/test-{event,process,signal}* — 100 pass, 3 pre-existing/timeout
  • test/js/bun/spawn/spawn.ipc.test.ts, spawn-ipc-gc.test.ts, test/js/web/workers/worker.test.ts — 35 pass
  • test/js/node/process/{process,process-on,process-signal-listener-count,process-memory-pressure} — pass

An ASAN stress pass (2000 once()/rawListeners()/emit()/removeAllListeners() cycles with forced GC, plus removeListener handlers that remove and re-register listeners re-entrantly) is clean. Verified that process instanceof EventEmitter, the back-pointer, and the meta-event all hold inside worker threads, which get their own VM and prototype.

Bun.inspect(process) and for…in process are unchanged, and the latter matches Node.

@coderabbitai

coderabbitai Bot commented Jul 6, 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: 34 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: 0b21bf8e-923a-43bb-a933-87266d4b94ea

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and e3ebbd6.

📒 Files selected for processing (7)
  • src/js/builtins/EventEmitterOnce.ts
  • src/js/node/events.ts
  • src/jsc/bindings/webcore/EventEmitter.cpp
  • src/jsc/bindings/webcore/EventEmitter.h
  • src/jsc/bindings/webcore/IdentifierEventListenerMap.h
  • src/jsc/bindings/webcore/JSEventEmitter.cpp
  • test/js/node/events/event-emitter.test.ts

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

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

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:24 PM PT - Jul 6th, 2026

❌ @robobun, your commit e3ebbd6 has some failures in Build #69466 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33495

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

bun-33495 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. process is not instanceof EventEmitter #5121 - PR splices EventEmitter.prototype into the process prototype chain, directly fixing process instanceof EventEmitter returning false

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #5121

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. process: port Node.js v26.3.0 process compatibility tests and fix the gaps they surface (env exotic-object/TZ semantics, warnings pipeline + CLI flags, uncaught origin/exit codes, execve throw, threadCpuUsage/finalization/loadEnvFile, native-module identity; +26 tests) #31831 - Also implements process instanceof EventEmitter by removing IsImmutablePrototypeExoticObject from JSEventEmitterPrototype and splicing EventEmitter.prototype into the process prototype chain in events.ts

🤖 Generated with Claude Code

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Confirming the two bot findings, since they point at different things:

#5121 is an exact match and is now in the description. It is the instanceof third of this PR.

#31831 overlaps on exactly that third, and nothing else. Concretely, it makes the same two edits:

  • drops IsImmutablePrototypeExoticObject from JSEventEmitterPrototype
  • splices EventEmitter.prototype under the native prototype from node:events

It does not touch EventEmitter.cpp or IdentifierEventListenerMap.h, so the other two fixes here are independent of it:

  • 'removeListener' is never emitted, by any removal path, which is what makes listener-table mirroring leak handlers. This also covers removeAllListeners() (no args) never calling onDidChangeListener, so it left OS signal handlers installed and the IPC channel ref'd.
  • rawListeners() was wired straight to the listeners host function, so once() listeners came back without the documented .listener back-pointer.

The overlap is ~20 lines across 2 files. I'm happy to drop those hunks and rebase on #31831 if it lands first, or leave them so this stands alone if it doesn't. Whichever is less work for you.

Comment thread src/js/builtins/EventEmitterOnce.ts
Comment thread src/jsc/bindings/webcore/EventEmitter.cpp
Comment thread test/js/node/events/event-emitter.test.ts Outdated
Comment thread src/jsc/bindings/webcore/EventEmitter.cpp
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, these were good. Three of the four were real bugs, and I checked each against node v26.3.0 rather than reasoning from the source, which changed the answer twice. Fixed in 8cc9215 (and a4728f4).

1. Signal handler added mid-drain (EventEmitter.cpp)

Real, and worse than the "nit" framing. The reasoning concluded node has the same desync. It doesn't:

$ node sig2.mjs ; echo $?
140          # killed by SIGUSR2
$ bun sig2.mjs
survived, listeners=0

The mechanism is different from what the comment described. Node's startListeningIfSignal is itself a 'newListener' listener, and newListener is the first key ReflectOwnKeys(events) returns, so it gets drained before the user handler runs. The late process.on('SIGUSR2') never installs a handler at all. Bun's hook is a C++ callback that always fires, so it installs and then map.clear() wipes the listener without uninstalling.

Fixed by notifying onDidChangeListener for whatever survives the drain, after the wipe. The listener is still dropped silently with no 'removeListener' event, matching node's _events = {}.

2. once() wrapper's fired guard (EventEmitterOnce.ts)

Real. heldAcrossEmit is 2 in bun, 1 in node.

Went with the second suggestion, not the first. Bailing when the listener is no longer registered looked right but diverges:

process.once('x', f);
const w = process.rawListeners('x')[0];
process.removeAllListeners('x');
w();   // node calls f: the wrapper gates on `fired`, not on still being registered

Node's wrapper checks only whether it has run, so the native emit path now invokes the cached wrapper when one exists and its own guard does the work. Both cases are pinned down in tests.

3. Two once('removeListener') handlers (EventEmitter.cpp:92)

Real, the trace is exactly right. Confirmed ["h2:removeListener","h1:foo","h2:foo"] against node's ["h2:removeListener","h1:foo"].

The suggested isOnce() && wasRemoved() -> continue is not safe, though. Node gates on "has this wrapper run", and a once() listener that a prior handler unregistered mid-emit still fires:

const h2 = () => order.push('h2');
process.on('y', () => { process.removeListener('y', h2); order.push('h1'); });
process.once('y', h2);
process.emit('y');   // node: ['h1', 'h2'] -- wasRemoved() would give ['h1']

So the registration gets a m_hasFired bit instead, which is the direct analogue of the wrapper's fired. Both behaviors now have a test, including that one as a guard against the wasRemoved shortcut.

4. Undrained stderr in the signal test

Fair, fixed. Folded the two signal tests into one helper that drains all three.

robobun added 4 commits July 6, 2026 14:41
…of, and rawListeners

`process` is backed by the native WebCore::EventEmitter rather than node:events,
and that implementation diverged from Node in three ways:

- The 'removeListener' meta-event was never emitted. Libraries that mirror the
  listener table via 'newListener'/'removeListener' saw every add and no removal,
  so they leaked handlers. Emit it from EventEmitter::removeListener so every
  removal path is covered, including the auto-removal of a once() listener when
  it fires. removeAllListeners(type) now drains LIFO like Node, and the no-arg
  form drains per event type, which also fixes onDidChangeListener never running
  and leaving OS signal handlers installed.

- `process instanceof EventEmitter` was false. node:events now splices
  EventEmitter.prototype underneath the native prototype. That prototype no
  longer carries IsImmutablePrototypeExoticObject, which was copied from
  EventTarget (where WebIDL mandates it); Node's EventEmitter.prototype is an
  ordinary object.

- rawListeners() was aliased to listeners(). It now returns a wrapper for once()
  listeners carrying the documented `.listener` back-pointer, cached per
  registration so its identity is stable. removeListener() accepts such a
  wrapper and resolves it back to the registration it was made for.
…ed mid-drain

Two gaps found in review:

- rawListeners() hands out a wrapper, but emit() invoked the stored function
  directly, so the wrapper's one-shot guard never tripped and a wrapper held
  across an emit would invoke the listener a second time. Fire the wrapper when
  one exists, as Node does. A wrapper that never fired still invokes its
  listener even once the registration is gone, matching Node, which gates only
  on whether the wrapper itself ran.

- A 'removeListener' handler can register listeners for event types the no-arg
  removeAllListeners() drain already snapshotted past. Those are wiped silently,
  as in Node, but the native side was never told, so an OS signal handler
  installed by that late registration outlived its listener.
…nt emit

Removing a once() listener now emits 'removeListener', so the auto-removal of
one once('removeListener') handler re-entrantly invokes the next one while the
outer emit still holds it in its snapshot, firing it twice.

Node's stored once() wrapper carries a `fired` flag for exactly this, so give
the registration the same bit. It has to key off the listener having already
run rather than off it having been removed: an emit in flight still invokes a
once() listener that an earlier handler unregistered, which is what Node does.
@robobun
robobun force-pushed the farm/c0f06870/process-event-emitter-contract branch from 8cc9215 to 39fb0f5 Compare July 6, 2026 14:49
Build 69072 passed 277 jobs with 0 failures; 9 jobs expired waiting for agents
(6 of them on the darwin queue).
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review, CI is red on the darwin lane only

The diff is green. Every lane that actually executed the test suite passed it: linux x64, linux aarch64, linux x64-baseline, alpine musl, windows x64-baseline, windows aarch64. event-emitter.test.ts does not appear in a single CI annotation across three builds.

The one red job never ran a test. It died downloading the build artifact:

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

CI history

build result
69036 7 real failures, all cookie-map.test.ts. My branch predated #33425, which updated those Expires assertions to IMF-fixdate. Fixed by rebasing onto main.
69072 277 passed, 0 failed, 9 expired waiting for agents (6 on the darwin queue).
69142 281 passed, 1 failed (the artifact-download timeout above, zero tests run), 4 darwin jobs still queued.

The darwin queue has been starved or timing out on artifact downloads across all three. I've used my one retrigger and won't push again, so this needs a maintainer to either re-run the darwin lane or merge past it.

Two things still worth a maintainer's call

  1. Overlap with process: port Node.js v26.3.0 process compatibility tests and fix the gaps they surface (env exotic-object/TZ semantics, warnings pipeline + CLI flags, uncaught origin/exit codes, execve throw, threadCpuUsage/finalization/loadEnvFile, native-module identity; +26 tests) #31831. The instanceof third of this PR is the same change as a hunk there (drop IsImmutablePrototypeExoticObject, splice EventEmitter.prototype in node:events). The 'removeListener' meta-event and rawListeners() fixes are independent. Happy to drop the overlapping ~20 lines and rebase if process: port Node.js v26.3.0 process compatibility tests and fix the gaps they surface (env exotic-object/TZ semantics, warnings pipeline + CLI flags, uncaught origin/exit codes, execve throw, threadCpuUsage/finalization/loadEnvFile, native-module identity; +26 tests) #31831 lands first, or leave them so this stands alone. Whichever is less work.

  2. Out of scope, flagged for the record. process.on(x, f) twice still registers one listener where Node registers two, because IdentifierEventListenerMap::add dedupes. Pre-existing, untouched here, and worth a separate fix.

… tests

macOS x64 terminates the child with SIGSYS where Linux delivers SIGUSR2, for the
same default-terminate outcome once the handler is uninstalled. Assert the
portable shape (killed by a signal, empty stdout) and pin SIGUSR2 only on Linux.
The empty-stdout check is still the discriminator: an unfixed build leaves the
handler installed, so the child reaches the 'survived' print.
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my status note above: build 69142 had two darwin reds, not one, and they were different in kind.

  • :darwin: 26 aarch64 — the artifact-download timeout (infra), zero tests run.
  • :darwin: 14 x64 — a real failure in one of my own new tests. Not infra. I under-reported this earlier.

The macOS failure was narrow and not a fix regression: the child was still terminated (empty stdout, never reached the "survived" print), so removeAllListeners() does uninstall the OS handler on macOS too. But macOS x64 reports the death as SIGSYS where Linux delivers SIGUSR2, for the same default-terminate outcome, and my assertion pinned the exact signal.

Fixed in e3ebbd6 by asserting the portable shape (killed by a signal, empty stdout) and pinning SIGUSR2 only on Linux. The empty-stdout check is still the discriminator, since an unfixed build leaves the handler installed and the child survives. No source change.

The SIGSYS-vs-SIGUSR2 difference is in pre-existing signal-delivery code that this PR only newly reaches via removeAllListeners(); flagging it in case it is worth a separate look, but it is out of scope here.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green, darwin-aarch64 lane is blocked on infra

Build 69466 is final: 282 passed, 2 failed, 2 expired. Every platform that actually executed the suite passed it, including darwin 14 x64, which is the lane that caught my over-strict SIGSYS assertion on build 69142. e3ebbd6 fixed that and it now passes on CI. event-emitter.test.ts does not appear in any annotation.

Everything still red is darwin-aarch64, and none of it ran a test:

  • :darwin: 26 aarch64 - test-bun ×2 — buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. The darwin-aarch64 build jobs all passed; the test job cannot fetch the built binary from the artifact store in time. Same failure on builds 69142 and 69466.
  • :darwin: 14 aarch64 - test-bun ×2 — expired waiting for an agent.

This is a persistent darwin-aarch64 artifact-store/queue problem across four consecutive builds (69072 agent capacity, 69142 artifact timeout, 69466 both), independent of this diff. I've used my one retrigger and won't push again; this needs a maintainer to re-run the darwin-aarch64 lane or merge past it.

The two open decisions from earlier are unchanged: the instanceof hunk overlaps #31831, and the pre-existing duplicate-listener dedupe in IdentifierEventListenerMap::add is left out of scope.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status against current main (165dc9f), now that #31831 has landed and closed #5121.

The describe("process") block from this PR, run on main without this change: 7 pass, 9 fail.

Already on main: process instanceof EventEmitter (#31831), and removeAllListeners() with no arguments now tears down signal handlers (EventEmitter::removeAllListeners() on main calls onDidChangeListener for every event type). Those hunks of this PR are redundant, as the note in the description anticipated.

Still failing on main:

  • 4 tests: process never emits the 'removeListener' meta-event, whether the listener goes away via off(), removeListener(), removeAllListeners() or a once() listener firing. Nothing in src/jsc/bindings/webcore/EventEmitter.cpp emits it.
  • 5 tests: process.rawListeners() returns once() listeners unwrapped, with no .listener back-pointer. JSEventEmitter.cpp still maps rawListeners to the listeners host function.

The repro from the description on Node v26.3.0 prints meta-events ["rm:foo","rm:SIGWINCH","rm:SIGWINCH"] and backpointer: true; main prints [] and false.

So the meta-event and rawListeners parts are still needed. This needs a rebase that drops the instanceof and removeAllListeners hunks, and the Fixes #5121 line no longer applies since that issue is closed. Leaving open.

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Checked this PR against main after #31831 merged. Method: debug build of main at 731aa92, then this PR's process block from test/js/node/events/event-emitter.test.ts run against main's sources with none of this PR's src/ changes. Result: 7 of 16 tests pass, 9 fail. So this PR is only partially superseded.

Landed on main (the passing tests):

Still open on main (the failing tests):

  • The 'removeListener' meta-event is not emitted from off(), removeListener(), removeAllListeners(type), removeAllListeners(), or when a once() listener fires. EventEmitter.cpp has no emit for it. All 4 meta-event tests fail.
  • rawListeners() is still aliased to listeners() in JSEventEmitter.cpp (the TODO comment is still there). There is no once-wrapper with a .listener back-pointer, removeListener() does not accept a wrapper, a held wrapper is not a no-op after the emit, and a re-registered wrapper does not fire once. All 5 wrapper tests fail.

The branch conflicts with main in events.ts, EventEmitter.cpp, EventEmitter.h, JSEventEmitter.cpp, and event-emitter.test.ts. A rebase can drop the instanceof hunks and the no-argument removeAllListeners() teardown, and keep the 'removeListener' emission and the rawListeners() wrapper.

Per-test result on main
(fail) emits 'removeListener' from off(), removeListener() and removeAllListeners()
(fail) emits 'removeListener' when a once() listener fires
(fail) each once('removeListener') handler fires exactly once
(pass) a once() listener removed mid-emit still fires
(fail) removeAllListeners() with no arguments emits 'removeListener' for every listener
(pass) removeAllListeners() uninstalls the OS signal handler
(pass) removeAllListeners() uninstalls a signal handler added mid-drain
(pass) is an instanceof EventEmitter
(fail) rawListeners() exposes once() wrappers with a .listener back-pointer
(pass) rawListeners() returns plain listeners for on()
(fail) removeListener() accepts a wrapper returned by rawListeners()
(fail) a rawListeners() wrapper held across an emit() becomes a no-op
(pass) a rawListeners() wrapper that never fired still invokes its listener
(fail) a re-registered rawListeners() wrapper still fires only once
(fail) symbol event names survive the once() wrapper round-trip
(pass) newListener still receives the original function for once()

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-06, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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.

process is not instanceof EventEmitter

1 participant