Skip to content

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

Open
cramforce wants to merge 4 commits into
mainfrom
opt/emitter-mixed-events
Open

cramforce wants to merge 4 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).

Evidence:

  • Corpus 6580–6584 pin the behavior against Node in the plain and sanitized lanes.
  • Focused corpus (emitter, event, stream, worker, cjs, class) in both lanes, the diagnostics suite, the npm suites and the docs check show only failures that also occur on the base commit in the sandbox.
  • Zero-cost gate against 139542b: the full run (3450 programs: benchmarks, self-hosted compiler, all corpus programs) left 3157 identical; the self-hosted compiler's IR and LLVM are identical. A rerun of the 293 differing programs on the final runtime: 286 have identical IR and LLVM and differ only in the executable, because the emitter runtime unit they link changed (text +776 to +840 bytes in the binaries inspected: scr_ee_add/emit_core/emit_meta and the new mismatch thrower; file size median +88 B, 49 programs cross a segment page and grow by 4–8 KiB; benchmarks: channel-messages +120 B, typed-array-scan-workers +128 B, thread-slots +128 B, atomics-contended +4216 B, intern-table +4224 B). The other 7 make untyped calls on compiled emitters (5137, 6469, 6480–6482, 6494, cjs-class-hierarchy-npm); their class dispatch now calls the new entry points, which is the intended change.
  • A typed emit loop (20M emits to three listeners, plus periodic on/off) built with base and candidate, 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.

Follow-up: EventEmitter methods read as values (merged origin/main c635475 first):

  • 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.listeners, const f = o.on; f.call(o, …), o.emit.bind(o)), and EventEmitter.prototype.on in JavaScript was a fence. Those reads, typed value reads (const f: unknown = emitter.on), dynamic keys, in, and EventEmitter.prototype.<method> now answer the runtime's EventEmitter.prototype functions (the ones constructor-function emitters already inherit), so o.on === o.addListener === EventEmitter.prototype.on, and name/length match Node.
  • New libCall dyn.emitterMethod (scr_dyn_builtin_class.c, dynBuiltinClass feature): the first read installs a lifted program dispatch; the runtime prototype methods hand a typed-ref receiver to it (after the stream half), and it invokes the method through the existing untyped dispatch with the call's argument count. A detached emit forwards up to 4 arguments after the event name and fences above that.
  • An untyped emit on, or read of emit from, a class that overrides emit now throws the SC2020 refusal; before, the untyped emit bypassed the override for the native emit.
  • A subclass visited under the boxed %EventEmitter capsule keeps its own dispatch plan for values boxed as itself (before, new EventEmitter() boxed first left such subclasses without arms: untyped calls answered "is not a function").
  • Corpus 6600–6602 (TS through any/unknown, JS with constructor-function emitters and class expressions, TS typed reads and EventEmitter.prototype); focused corpus both lanes, diagnostics, npm suites: only sandbox-environment failures. The limitations page drops the undefined divergence and lists the new refusals and the stream gap (a stream's emit and introspection methods still read as undefined through an untyped value).
  • Zero-cost gate against c635475 (whole PR, 3453 programs): 3159 identical, 294 differ. The 7 IR/LLVM differences are the PR's untyped emitter calls (5137, 6469, 6480–6482, 6494, cjs-class-hierarchy-npm); 11 constructor-function emitter programs differ in exe only (bc_emit and siblings +80..256 B, the receiver check for compiled emitters); the rest are the emitter unit's exe-only change described above. cjs-class-hierarchy-npm reads emitter instances through dynamic (symbol) keys, so those reads can now answer method values and the program links the method-value dispatch: 747,872 -> 966,392 B (after capping the emit table at 4; 1,313,056 B with 16). Self-hosted compiler IR/LLVM identical; time within tolerance.

🤖 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 1:23am UTC

const events: { name: string; tuple: IrType[] }[] = [];
for (const [key, sig] of table) {
if (key === "error" || key === ERROR_MONITOR_EVENT || META_EVENTS.has(key)) continue;
if (sig.conflict !== null || (!sig.fromEmit && sig.typedListener !== true)) continue;

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.

An event registered only with a zero-argument typed listener is excluded from the bridge, so emitting it through an any reference throws instead of invoking the listener.

Fix on Vercel

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

This branch was successfully deployed

1 active deployment
Preview — 3a27a92c 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.

1 participant