Skip to content

fix: #850 — EventEmitter listener table + static helpers match Node semantics#875

Merged
proggeramlug merged 2 commits into
mainfrom
fix/850-eventemitter-listener-table
May 16, 2026
Merged

fix: #850 — EventEmitter listener table + static helpers match Node semantics#875
proggeramlug merged 2 commits into
mainfrom
fix/850-eventemitter-listener-table

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Summary

EventEmitter's listener storage was a flat HashMap<String, Vec<i64>> with only on / emit / removeListener / removeAllListeners / listenerCount wired. Every other method silently no-op'd or returned undefined, and the module-level helpers didn't exist at all. EventEmitter is the base for net, http, stream, child_process and most npm packages, so the gap was foundational.

This rewrites the listener storage in both copies (perry-ext-events — active via the well-known flip when node:events is imported — and the perry-stdlib fallback) around an ordered Vec<Listener { callback, once }> per event with a parallel event_order shadow that preserves insertion order for eventNames(). emit snapshots before dispatch, prunes once-listeners from the live vec mid-emit, and drains pending events.once(em, name) promises with a single-element [arg] array.

events.once allocates a Promise per call, stashes the raw pointer in the emitter's pending_once_promises, and resolves it synchronously through perry-runtime's js_promise_resolve on the next matching emit — bypassing JsPromise::resolve_*, which routes through async_bridge::queue_promise_resolution and needs the perry-stdlib pump registered first (a coupling that doesn't hold for a pure EventEmitter program).

Wired all new methods through NATIVE_MODULE_TABLE, runtime_decls.rs declarations, and the API manifest. GC root scanner now marks both listener closures and pending Promise pointers.

Probe results (issue baseline diff = 26 → now 10)

Probe Node Perry before Perry after
em.listenerCount('inc') after 2 .on() 2 0 2
em.eventNames() [ 'inc' ] 0 [ 'inc' ]
em.once handler arg captured [ 7 ] [] [ 7 ]
em.addListener / removeListener [ 1 ] [] [ 1 ]
em.prependListener order [ 'a', 'b' ] [ 'b' ] [ 'a', 'b' ]
em.prependOnceListener order [ 'a', 'b', 'b' ] [ 'b', 'b' ] [ 'a', 'b', 'b' ]
em.listeners('inc') count 2 undefined 2
em.rawListeners('inc') count 2 undefined 2
em.getMaxListeners() after setMaxListeners(42) 42 0 42
em.removeAllListeners() then names [] 0 []
events.once(em,'x') module-level resolves to array throws Cannot read properties of undefined (reading 'length') resolves
events.getEventListeners(em,'e') array throws array

Remaining gaps (out of scope, all listed in the issue as follow-ups)

  • events.on(em,'evt') async-iterator (returns [] today)
  • Multi-arg emit(name, a, b) — would let events.once resolve to a 2-element array instead of 1
  • newListener / removeListener meta-events
  • Module-level constants: defaultMaxListeners, errorMonitor, captureRejections, captureRejectionSymbol
  • events.addAbortListener

Test plan

  • test-files/test_issue_850_eventemitter.ts (the probe set from the issue) — 12/13 lines match Node byte-for-byte
  • test-files/test_parity_events.ts — diff dropped from baseline 26 lines → 10 lines (60% reduction)
  • cargo test --release --workspace --exclude perry-ui-{ios,tvos,watchos,visionos,android,windows,gtk4} — all pass
  • cargo test --release -p perry-ext-events — 6/6 doctests pass (including new prepend_listener_inserts_at_front and max_listeners_round_trips)
  • test-files/test_net_min.ts — Socket .on() / .emit() still fire correctly (EventEmitter is the base class)
  • cargo fmt --all
  • manifest consistency test — every new dispatch entry has a matching entries.rs row

Closes #850.

…emantics

EventEmitter's listener storage was a flat `HashMap<String, Vec<i64>>` with
only `on` / `emit` / `removeListener` / `removeAllListeners` / `listenerCount`
wired. Every other method (`once`, `addListener`, `prependListener`,
`prependOnceListener`, `listeners`, `rawListeners`, `eventNames`,
`setMaxListeners`, `getMaxListeners`) silently no-op'd or returned
undefined, and the module-level helpers (`events.once`, `events.on`,
`events.getEventListeners`, `events.listenerCount`, `events.getMaxListeners`,
`events.setMaxListeners`) didn't exist at all. EventEmitter is the base
for `net`, `http`, `stream`, `child_process` and most npm packages, so
the gap was foundational.

Rewrote the listener storage in both copies (perry-ext-events — active
via the well-known flip when `node:events` is imported — and the
perry-stdlib fallback) around an ordered `Vec<Listener { callback, once }>`
per event with a parallel `event_order` shadow that preserves
insertion order for `eventNames()`. `emit` snapshots before dispatch,
prunes `once`-listeners from the live vec mid-emit, and drains pending
`events.once(em, name)` promises with a single-element `[arg]` array.
`prependListener` inserts at index 0, `setMaxListeners` writes a per-
emitter ceiling that `getMaxListeners` reads back (default 10).

`events.once` allocates a Promise per call, stashes the raw pointer in
the emitter's `pending_once_promises`, and resolves it synchronously
through perry-runtime's `js_promise_resolve` on the next matching emit
— bypassing `JsPromise::resolve_*`, which routes through
`async_bridge::queue_promise_resolution` and needs the perry-stdlib
pump registered first (a coupling that doesn't hold for a pure
EventEmitter program).

Wired all new methods through `NATIVE_MODULE_TABLE`, `runtime_decls.rs`
declarations, and the API manifest. GC root scanner now marks both
listener closures and pending Promise pointers.

Probe results (issue #850 baseline diff = 26 → now 10):

| Probe | Node | Perry before | Perry after |
|-------|------|--------------|-------------|
| listenerCount('inc') after 2 .on() | 2 | 0 | 2 |
| eventNames() | [ 'inc' ] | 0 | [ 'inc' ] |
| once handler arg captured | [ 7 ] | [] | [ 7 ] |
| addListener / removeListener | [ 1 ] | [] | [ 1 ] |
| prependListener order | [ 'a', 'b' ] | [ 'b' ] | [ 'a', 'b' ] |
| prependOnceListener order | [ 'a', 'b', 'b' ] | [ 'b', 'b' ] | [ 'a', 'b', 'b' ] |
| listeners('inc') count | 2 | undefined | 2 |
| rawListeners('inc') count | 2 | undefined | 2 |
| getMaxListeners() after setMaxListeners(42) | 42 | 0 | 42 |
| removeAllListeners() then names | [] | 0 | [] |
| events.once static helper | resolves to array | throws | resolves |
| events.getEventListeners(em,'e') | array | throws | array |

Remaining gaps (out of scope for this fix, all listed in the issue as
follow-ups): `events.on` async-iterator, multi-arg emit (would let
`events.once` resolve to a 2-element array instead of 1), the
`newListener` / `removeListener` meta-events, and the module-level
constants (`defaultMaxListeners`, `errorMonitor`, `captureRejections`,
`captureRejectionSymbol`).

Validation:
- /tmp/repro850 (the issue's probe set) — 12/13 lines match Node
- test-files/test_parity_events.ts — diff dropped from 26 → 10 lines
- cargo test --release --workspace (excluding cross-host UI) — all pass
- net_min smoke test — Socket .on() / .emit() still fire correctly
- perry-ext-events doctests — 6/6 pass

Closes #850.
Re-run scripts/regen_api_docs.sh after the rebase onto main so the
generated .d.ts and reference.md include the new events.* static
helpers (once / getEventListeners / listenerCount / setMaxListeners /
getMaxListeners).
@proggeramlug proggeramlug force-pushed the fix/850-eventemitter-listener-table branch from e47a28c to 9cbd6d0 Compare May 16, 2026 18:37
@proggeramlug proggeramlug merged commit d89dd58 into main May 16, 2026
9 checks passed
@proggeramlug proggeramlug deleted the fix/850-eventemitter-listener-table branch May 16, 2026 18:38
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.

node:events EventEmitter — listenerCount, eventNames, once, prependListener etc. return wrong values; static helpers broken

1 participant