Skip to content

Share one listener list between typed and untyped uses of an EventEmitter - #1563

Merged
ctate merged 7 commits into
mainfrom
opt/emitter-mixed-events
Oct 21, 2026
Merged

ctate merged 7 commits into
mainfrom
opt/emitter-mixed-events

Conversation

@cramforce

@cramforce cramforce commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

An EventEmitter used both through its static type and through an untyped reference aborted the process. A typed on followed by an untyped emit of the same event, or a typed emit from a class body reaching a listener added through any (the shape of function-local class expressions whose instances escape untyped), hit scr_trap("scriptc: computed event collided with a fixed event"), and an untyped emit('error', ...) trapped unconditionally.

Root cause: a listener entry held either a typed adapter (called with the emit site's fixed tuple as C variadics) or a checked-dynamic thunk (called with the untyped argument vector), and the emit core trapped when an entry lacked the form the emit carried.

Runtime (scr_events_emitter.c):

  • An entry may carry both forms. scr_emitter_on_dyn keeps the listener's thunk beside its typed adapter, so typed emits call the adapter and untyped emits call the thunk with the arguments as given (extra arguments included).
  • scr_emitter_emit_mixed carries both the untyped arguments and a compiler-converted typed tuple, for untyped emits of an event that has listeners with typed parameters. scr_emitter_has_typed says whether the conversion is needed.
  • scr_emitter_on_any / scr_emitter_emit_any serve registrations and emits through untyped values: runtime-built typed adapters under error and the meta events; emit('error') emits to errorMonitor first, then to listeners, and with none throws an Error payload itself and primitive payloads as ERR_UNHANDLED_ERROR. Untyped newListener/removeListener listeners receive the listener function when it was registered through an untyped value.
  • The emit core throws a catchable error instead of trapping when an entry has no usable form.
  • The meta-event argument vectors and rebuilt listener values sit behind a hook that only scr_emitter_on_any installs, and scr_emitter_on_flex/emit_flex keep their plain behavior for computed names and the worker registries, so programs without untyped emitter calls link none of the new dynamic code.

Compiler:

  • The class dispatch of compiled emitters (non-stream) routes untyped on/once/prepend* under an event typed code emits or listens to with typed parameters through emitter.onDyn with a typed adapter for the event's tuple, and untyped emit of such an event converts its arguments to the tuple (a mismatch throws the checked conversion's TypeError) when hasTyped says a typed listener is registered. These helpers exist only in programs that have such events and untyped emitter calls.
  • listenerCount, eventNames, removeAllListeners, listeners, rawListeners, setMaxListeners and getMaxListeners called through an untyped value on a compiled emitter (a program class extending EventEmitter, or new EventEmitter()) answered "is not a function"; they now dispatch to the native registry with Node's results. In TypeScript the last four names join the dyn-dispatch method set so such calls lower to a dispatch.

Explicit refusals remain for what is not modeled, documented on the EventEmitter limitations page: listeners() through an untyped value of an event with a typed listener, untyped emit('error') of a non-Error object with no listener, untyped emits whose arguments do not convert to a typed listener's tuple (TypeError before any listener runs), and typed listener functions as the second meta-event argument (undefined).

Scope: under the scope-B plan, this PR keeps only the typed/untyped listener sharing and the catchable throw in place of the trap. The follow-up that answered EventEmitter methods read as values (o.on, EventEmitter.prototype.on in JavaScript, constructor-function emitters, dyn.emitterMethod, corpus 6600–6602) was reverted in 3f3e90b, because those forms belong to Node islands. Reading EventEmitter methods as properties through an untyped value still answers undefined, as the limitations page says. main (0237d70) is merged in.

Evidence (Linux sandbox, final branch against main 0237d70, both trees built with their own runtime pack):

  • Corpus 6580–6584 pin the behavior against Node in the plain and sanitized lanes. Differential -t "658[0-4]|emitter" 18 passed, sanitized 6 passed, -t "event|5137|6469|648[0-2]|6494|cjs-class-hierarchy" 56 passed.
  • packages/runtime/test and packages/compiler/src/ir: 492 passed, 1 failed (bigint runtime builds for wasm32, the known wasm32-wasi environment failure in the sandbox).
  • pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only pass; the self-hosted --emit=ir build of packages/compiler/src/native/cli.ts reports no diagnostics.
  • Zero-cost gate (scripts/zero-cost-gate.mjs, bench, self and TypeScript corpus, 2718 programs): 2502 identical, 216 differ. The self-hosted compiler is identical. 209 differ only in the executable, because the emitter runtime unit they link changed (scr_ee_add in 207, scr_ee_emit_core in 6; growth +0.01% to +3.3%). Six bench workloads are among them (channel-messages, atomics-contended, intern-table, thread-slots, typed-array-scan-workers, worker-messages: +120 to +128 B each); their IR and LLVM are identical. 7 differ in IR/LLVM: 6469, 6480, 6481, 6482, 6494 and cjs-class-hierarchy-npm make untyped calls on compiled emitters, and their class dispatch now calls the new entry points (the intended change); fs-extra-package differs only by the shim's absolute checkout path. Compile time: bench geomean -0.27% [95% CI -1.71%, +0.96%], self-hosted -1.6%, both within tolerance.
  • Earlier, on 139542b: a typed emit loop (20M emits to three listeners, plus periodic on/off), 21 interleaved runs: median 928 ms vs 899 ms (no slowdown; the difference is layout noise).

Overlap: #1544 (opt/ee-class-value-crash) also touches EventEmitter handling through untyped values (builtin-class-dyn.ts, relational.ts, lowerer.ts, lower-classes.ts); this change touches none of those files.

🤖 Generated with Claude Code

…tter

An emitter used both through its static type and through an untyped
reference aborted: a typed `on` followed by an untyped `emit` (or a typed
`emit` from a class body reaching a listener added through `any`) hit
"computed event collided with a fixed event", and an untyped
`emit('error', ...)` trapped outright. Listener entries held either a
typed adapter or a checked-dynamic thunk, and the emit core trapped when
an entry lacked the form the emit carried.

Entries may now carry both forms. Listeners registered through
scr_emitter_on_dyn keep their thunk beside the typed adapter; typed
emits call the adapter and untyped emits call the thunk with the
arguments as given. The class dispatch of compiled emitters registers
untyped listeners under an event typed code fixes with a typed adapter
for its tuple, and converts the arguments of an untyped emit of such an
event to the tuple for listeners with typed parameters
(scr_emitter_emit_mixed; a mismatch throws the checked conversion's
TypeError). Registrations and emits through untyped values otherwise go
through scr_emitter_on_any/emit_any: runtime adapters under 'error' and
the meta events, and Node's unhandled-'error' contract. The emit core
throws instead of trapping when an entry has no usable form. The
dynamic half of the registry sits behind a hook only on_any installs,
and on_flex/emit_flex keep their plain behavior, so programs without
untyped emitter calls link none of it.

listenerCount, eventNames, removeAllListeners, listeners, rawListeners,
setMaxListeners and getMaxListeners through an untyped value on a
compiled emitter answered "is not a function"; they now dispatch to the
native registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 21, 2026 3:44pm UTC

@cramforce
cramforce requested a review from ctate October 20, 2026 23:02
cramforce and others added 2 commits October 20, 2026 17:36
Reading an EventEmitter.prototype method off a compiled emitter (a program
class extending EventEmitter, or new EventEmitter()) through an untyped
value answered undefined (`typeof o.on`, `const f = o.on; f.call(o, ...)`,
`o.emit.bind(o)`), and EventEmitter.prototype.on in JavaScript was a fence.

Such reads, typed value reads, computed keys, `in`, and
EventEmitter.prototype.<method> now answer the runtime's
EventEmitter.prototype functions, the ones constructor-function emitters
inherit, so `o.on === o.addListener === EventEmitter.prototype.on` and
names and lengths match Node. The first read installs a program dispatch
that those functions call for a compiled emitter receiver; it invokes the
method through the existing untyped dispatch with the call's argument
count (emit forwards up to 16 arguments after the name, then fences).

An untyped emit on, or read of emit from, a class that overrides emit now
fences instead of bypassing the override for the native emit. A subclass
visited under the boxed EventEmitter capsule keeps its own dispatch plan
for values boxed as itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dispatch instantiates one bridged emit per argument count and emitter
class; capping it at four keeps programs that only read emitter members
through computed keys from growing by the full table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cramforce and others added 2 commits October 21, 2026 07:53
Revert 3a27a92 ("Forward at most four emit arguments through
EventEmitter method values") and 0cf62eb ("Answer EventEmitter methods
read as values off compiled emitters"). Under the scope-B plan,
EventEmitter methods read as values, `EventEmitter.prototype.on` in
JavaScript and constructor-function emitters belong to Node islands, not
the native compiler. The typed/untyped listener sharing and the
catchable throw in place of the trap stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An event that typed code only listened to with a listener taking no
parameters had no bridge entry: the scan marked a listener as typed only
when one of its parameters was not a checked-dynamic value. Emitting the
event through an `any` reference then threw instead of calling the
listener. A TypeScript listener with no parameters now marks the event as
typed. A JavaScript function without parameters still registers as before,
since it can read `arguments`.
@ctate
ctate merged commit 9a504b6 into main Oct 21, 2026
19 of 21 checks passed
@ctate
ctate deleted the opt/emitter-mixed-events branch October 21, 2026 16:18

This branch was successfully deployed

1 active deployment
Preview — cd38fd22 Deployed Oct 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants