node:events: one emit() for emitters that capture rejections

Draft
RO
9d ago
+853-357

Problem

  • With captureRejections, emitter.emit() never calls a subclass's emit(), and it bypasses a replaced EventEmitter.prototype.emit. Object.keys(emitter) ends with "emit". Node v26.3.0 does neither.
  • The constructor stores a second function, emitWithRejectionCapture, as an own emit property (src/js/node/events.ts:204), so that other emitters read no flag.
  • No user reported it. A review of #31828 asked for it, and node:domain needs it.

Fix

  • One EventEmitter.prototype.emit. When a listener returns a promise, it reads this[kCapture], as node's addCatch does. Nothing stores emit.
  • Correct because the flag decides, when node reads it. super.emit and a wrapper capture too.
  • Verified: test/js/node/events/emitters-that-capture-rejections.test.ts (main fails 10 of 12). It inverts the test from #45631 that held the own emit.
  • Self-reviewed: rework asked for (Notes). Draft until the design is settled.

Background

  • kCapture is the flag that the constructor sets from the captureRejections option.
  • bun build --compile --aot compiles events.ts with no profile, so emit() has a second text there. test/aot/perf holds pins of its instruction counts, outside CI.
  • Rejected a flag read at the head of emit(): a symbol lookup per emit. Rejected a swap of the prototype's emit in the setter (#45597): the own emit stays.

Downsides

  • Ahead of time, 37 of 126 event rows exceed the +2% bar of src/aot/CLAUDE.md. Four rows with two listeners cost 37% to 54% and exceed two pins, which stay as they are.
  • Interpreter: a listener that returns a promise costs 1,918 to 2,419 instructions per emit.
  • JIT: an emit that is not inlined costs 62.0 instructions, 68.2 on main. The first commit alone gives 58.9.
Notes

State on 2026-10-31. The first commit of this branch is now a PR of its own, #47520. A rework of this PR on top of it is written and not pushed: the non-enumerable flag on the prototype as node has it, no noInline marks, four more ledger rows. It waits for the answer to https://github.com/oven-sh/bun/pull/47381#issuecomment-6520392535. The numbers below are of the first version.

Rework. A full review of this PR measured costs that this body did not count, and it built a second design. Both are below. A second design review weighs them now, and the PR changes with its result.

Costs of this text that were not counted:

  • Ahead of time, no row of table 6 has a listener that returns a promise. With 48 such rows, 47 are over +2%: one listener that returns a settled promise takes 64 to 131 instructions per emit, one async listener 87 to 154.
  • bun run: a handled emit("error") takes 28 to 92 instructions, and 57 to 354 with one array per emit when a method of a class emits it. The wrapper of the first commit causes that.
  • bun run: the kept result lets emit() lose its top-tier code in two new ways. test/aot/perf/plain-bun-costs.measure.ts "the top tier compiles no function twice" fails for events-and-streams.js.
  • Without the first commit, the change costs +5.8% and +6.7% on two JIT rows of plain-bun-costs that are bounded at 3%.
  • The three noInline marks decide few programs: 2 to 4 of 18 capturing profiles that the review tried.
  • Node's gate for a thenable (#45597) would charge every listener that returns a value, on every emitter.

The second design, capture at registration: emit() stays the text of main. on() wraps the listener when the emitter captures, and the wrapper reads the flag when the listener returns a promise. Nothing stores emit. Built by the review on this branch:

  • The capture tests of this PR pass 12 of 12, and 137 tests of test/js/node/events pass.
  • Ahead of time, 2 of 126 rows are over +2% (on-and-off, +2.2%), and no pin is exceeded. The emit rows of bun run do not move.
  • on() reads the flag: an on()/off() pair takes 4,508 to 4,934 instructions interpreted and 124.1 to 130.5 under the JIT. That read is not tuned yet.
  • An emitter that captures pays one more call per listener: 1,901 to 2,911 instructions per emit interpreted.
  • It is not node where this text is: emitter._events.x === listener is false on an emitter that captures, a listener stored by hand into _events is not captured, and a listener registered before the flag turned on is not captured.

Behaviour of this text that the review found:

  • EventEmitter.prototype[kCapture] is enumerable in Bun and not in node. Object.assign({}, EventEmitter.prototype) made while the default is on copies the flag, so such an object captures here. Node and main do not. The fix is node's definition of the property.
  • A response that captures now reaches Bun's own ServerResponse.prototype.emit. A program's own res.emit("close") on a live response then ends the exchange, as it does on main without captureRejections. Node delivers the response in both cases.

Where the ask comes from. No issue reports this defect. These are the written statements, in order of time:

This PR is the "own change" of the first statement. It keeps one part of the reason for the own emit: in bun run, a listener that returns nothing pays one test of the returned value and no read of the flag (tables 2 and 3). It does not keep the other part: a listener that returns a promise pays the read of the flag on every emitter, and the ahead-of-time rows of table 6 cost more. An emitter that captures also makes one copy of the arguments for each promise that a listener returns, where main handed the one array of the emit to each.

What this PR inverts. On main, test/js/node/events/emitters-that-capture-rejections.test.ts:6 expects the own emit, and expects that the emit() of the class is not called. Three expected files change with it: test/js/node/http/node-http-own-keys-through-an-exchange.expected.txt, test/js/node/stream/node-stream-proxy-traps.expected.txt and test/js/node/stream/stream-reads-seen-without-proxy-receiver.expected.txt. So does the output that test/aot/parallel/library-modules/stream-reads-seen-stream-made-awaited.js carries. Two more tests held main's behaviour: test/js/node/http/node-http-proxy-traps.test.ts expected two stores of kCapture, and a program of test/aot/missing-bun-types.test.ts expected that the emit() of its class is not called.

Other open PRs on these lines.

  • #45597 captures a thenable and the promise of an 'error' listener, which this PR leaves as on main. It keeps the this.emit store and swaps EventEmitter.prototype.emit in the setter of EventEmitter.captureRejections. The two PRs change the same lines, so the second to land needs a rebase. On top of this PR, #45597 needs no swap and no store.
  • #45234: the first commit here is its change to emit("error"), with a Co-authored-by trailer.
  • #31828 (node:domain) replaces EventEmitter.prototype.emit. Its own one-emit commits go away when it is rebased on this PR.
  • #33369 makes the browser polyfill capture a thenable. It changes lines of src/node-fallbacks/events.js that this PR changes too, so the second to land needs a rebase.

The pins. bun test ./test/aot/perf/pinned-everyday-idioms-{1..8}.measure.ts -t "everyday-emitters/" on linux-x64, instructions per turn, typed and untyped:

pinstoredPR
everyday-emitters/two-listeners151, 147226, 222
everyday-emitters/one-listener-and-two-listeners-in-turns122, 118162, 159

The PR does not change the pin files. test/aot/perf/README.md says that a pin only goes down, and the owner of the compiler decides whether the text or the pin changes. Two more pins of the group fail on this machine, on main and on the PR alike: many-emitters-events-and-listeners (246.4 against 217.5) and emitter-made-listened-to-and-dropped (843.4 against 500.4). everyday-pins.linux-arm64.json holds the same two pins (154, 150 and 128, 125). They were not measured, because no arm64 machine was at hand. The budgets that CI holds pass. test/aot/servers/budgets-of-node.test.ts counts what it counts on main, with two exceptions: the bytes of code (3,976,368 to 3,959,980) and one count of closures (1,808 to 1,806).

Other ahead-of-time texts that were measured, on the 96 rows of built-in/events-emit and everyday-emitters. About 50 texts were built. These two are the ends of the range:

  • The flag read at the head, in one test with "error" (if (type === "error" || this[kCapture])), and main's text behind it. 47 rows are over +2%, and the worst is +30% (no-listener, 40 to 52). The small rows with two listeners cost +5%. A row with one listener costs about +10% (built-in/events-emit: 58 to 64). It is not node's timing: it misses a flag that a listener switches on, and a Proxy sees one more get per emit. bun run and --aot would differ there.

  • The capture called in the place of the listener, so that the loop has no call of its own. The small rows with two listeners cost +3% to +7%, but built-in/events-emit costs +17% (58 to 68) and no-listener +47% (40 to 59).

  • The flag read once in the loop function, and main's loop behind it for an emitter that does not capture (a review asked for it). The program with two listeners takes 230 instructions per emit, against 222 with this PR and 140 on main. The loop function is still not inlined: one direct call in it keeps it out of emit(), and a call in a rare block, a call in tail position and $callNoInline change nothing.

The text of this PR has 37 of those 96 rows over +2%.

An executable without bun:jsc. bun run marks three small functions with noInline of bun:jsc at the first captured promise, so that the JIT keeps emit() small enough to inline (measurement 4). Plain --trim keeps bun:jsc, and --aot has no JIT. Only Bun.build({ compile: { trim: { remove: ["bun-jsc"] } } }) without aot has a JIT and no bun:jsc. There the marks are skipped: a rejection is captured all the same, but a plain emit then takes about 120 to 138 instructions where it takes 32 to 56 with the marks. No test covers that executable yet.

Review. The ahead-of-time text and the pins are a question for Jarred-Sumner. #45597 is dylan-conway's. #31828 is cirospaciari's, and alii reviewed it.

How the numbers were taken. Release builds with LTO for linux-x64 of main 000ef50373 and of this branch. A number is the count of instructions of the first thread in user mode (perf_event_open), as test/aot/perf/start/measure-start.ts counts them. "Commit 1" is the first commit of this branch alone, measured on the previous base (73b53109f7), where main measured within 0.3% of what it measures now.

1. Arrays per 10,000 emits once the top tier compiled emit() (bun:jsc heapStats().objectTypeCounts.Array. One listener, two listeners, and a listener that returns a promise give the same counts).

emit()mainPR
inlined into its caller, constant name33
noInline(emitter.emit)10,0033
name read out of an array10,0033
not inlined, after another emitter captured 800 promises and emitted 'error' 200 times10,0033

2. test/aot/perf/plain-bun-costs programs, million instructions.

rowmaincommit 1PRPR against main
interpreter emit-to-one-listener.js3,792.43,782.83,755.0-1.0%
jit emit-to-one-listener.js526.9321.5340.5-35.4%
interpreter emit-to-two-listeners.js4,615.24,608.34,530.0-1.8%
jit emit-to-two-listeners.js737.7444.3474.1-35.7%
interpreter events-and-streams.js4,112.24,112.44,082.9-0.7%
jit events-and-streams.js1,075.61,053.21,079.6+0.4%

Against commit 1 the three JIT rows cost +5.8%, +6.6% and +2.3%. In emit-to-one-listener.js that is 58.9 to 62.0 instructions per emit and 204 to 216 million of compile time on the program's own thread.

3. Instructions per emit (the difference of a loop of 900,000 and one of 300,000 emits).

listenersinterpreter maininterpreter PRJIT mainJIT PR
none for the event1,373.11,343.224.924.9
one, returns nothing1,896.41,884.831.832.0
one, four arguments2,423.52,400.632.733.0
two, return nothing2,783.32,734.080.076.0
one, returns a number1,860.21,943.332.132.0
one, returns a promise1,918.22,419.331.839.0

The interpreter has no inline cache for a read by symbol, so this[kCapture] costs about 500 instructions there. It is read only behind the promise test.

4. A plain emitter after another emitter ran first in the process (JIT, one listener, one argument, instructions per emit).

what ran firstmainPR
another plain emitter43.443.0
an emitter that captures, listeners return nothing22.042.9
an emitter that captured 800 promises22.043.1
the same, and 200 'error' emits21.751.0

On main an emitter that captures runs a function of its own, so it leaves the profile of emit() alone. Here it counts like any other emitter. The FTL inlines emit() into a caller only while its graph has at most 89 nodes. It has 32 to 70 nodes here and 85 to 89 once promises were captured. It stays under the limit because the first capture marks addCatch, arrayOf and emitErrorApplied with noInline of bun:jsc. Without the marks it has 113 nodes and costs 133.5 instructions per emit.

5. Construction, instructions per new.

interpreter maininterpreter PRJIT mainJIT PR
new EventEmitter()4,464.93,492.86.16.0
a class that extends it5,561.64,564.6110.7110.3
{ captureRejections: true }3,563.23,350.776.976.0
new Readable()12,239.311,186.862.762.2

bun:jsc describe(): a plain emitter and one that captures now share one Structure (5 of 6 inline fields, nothing out of line). On main the one that captures has a sixth field, emit.

6. Ahead of time. bun test/aot/perf/compare-builds.ts --only 'built-in/events-|everyday-emitters|stream-new' --servers none, 126 rows, instructions per turn.

rows
within 2%82
faster by 2.0% to 5.3%7
slower by 2% to 5%12
slower by 5% to 12%20
small typed everyday-emitters/a-class-below-emits-to-itself71.0 to 83.0 (+16.9%)
small typed everyday-emitters/one-listener-and-two-listeners-in-turns118.0 to 162.0 (+37.3%)
small untyped everyday-emitters/one-listener-and-two-listeners-in-turns115.0 to 159.0 (+38.3%)
small typed everyday-emitters/two-listeners148.0 to 226.0 (+52.7%)
small untyped everyday-emitters/two-listeners144.0 to 222.0 (+54.2%)
built-in/events-emit, small and called58.0 to 61.0 and 60.0 to 63.0 (+5%)
built-in/events-emit, very large184.0 to 188.0 (+2.2%)

What was learned about the compiler on the way, and why the text is as it is:

  • A rest array stays out of the heap while every function that takes it keeps no reference to it. A function that keeps it makes every emit allocate, in cold code too. So the capture road copies the arguments by a loop (addCatchOfCopy).
  • $apply of the array counts as a use that the compiler cannot see. So the ahead-of-time text calls emitError(this, args) as main does, and only bun run applies.
  • A call in a rare block still counts against inlining. One more call site in emit() cost the inlining of emit() where the event name is no constant (79 to 163). So the promise of one listener leaves through the call of emitToEach that emit() already has.
  • The loop function gains one rare call, and that costs its inlining in a small program. That is the 37% to 54% above. A text that reads the flag before the listeners run keeps the loop inlined (148 to 155), but costs every emit 6 instructions and is not what node does when a listener changes the flag.

7. bench/emitter, wall clock, median of 10 interleaved runs of main and the PR. No difference is above the spread of the runs, which this machine does not bring under 30%.

benchmainspreadPRspread
microbench.mjs, single emit11.96 ns29.9%10.95 ns54.2%
microbench.mjs, on x 10_000 (handler)2.635 ms13.3%2.635 ms5.7%
microbench_once.mjs, test 1149.28 ns44.7%121.23 ns167.9%
microbench_once.mjs, test 2112.70 ns54.2%125.70 ns46.4%
realworld_stream.mjs, stream simulation14.70 µs57.3%14.18 µs50.1%

8. Text. build/release/js/node/events.js: 26,403 to 25,130 bytes.

What stays as on main. A thenable that is no promise is not captured, and a promise that an 'error' listener returns is not captured. Node captures both. #45597 is about those.

Suites run. Debug build: the four test files of this PR, with main's src/ (they fail) and with this branch's (they pass), and node-http-proxy-traps.test.ts. Release build: 218 test files under test/js/node (http, events, stream, async_hooks, net, tls, http2, child_process, worker_threads and others: the five files that fail there fail on main alike), the 21 files of test/js/first_party/ws, 42 upstream files test-event-*, test-events-*, test-eventemitter-* and *capture-rejection*, the 145 programs of test/aot/parallel/library-modules, and bun src/codegen/check-types-of-modules.ts.

The commits. The first keeps the arguments of emit("error") out of the heap, after #45234. The second is the change. The third stores kCapture once where the constructor stored it twice, and can be judged apart. Three small ones follow from review: the debug log line of emit() no longer reads this.constructor (a debug build showed one more get to a Proxy than a release build), the marks are skipped where bun:jsc is not in the executable, and each comment is one line.