fix: #850 — EventEmitter listener table + static helpers match Node semantics#875
Merged
Merged
Conversation
…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).
e47a28c to
9cbd6d0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
EventEmitter's listener storage was a flat
HashMap<String, Vec<i64>>with onlyon/emit/removeListener/removeAllListeners/listenerCountwired. Every other method silently no-op'd or returned undefined, and the module-level helpers didn't exist at all. EventEmitter is the base fornet,http,stream,child_processand 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 whennode:eventsis imported — and theperry-stdlibfallback) around an orderedVec<Listener { callback, once }>per event with a parallelevent_ordershadow that preserves insertion order foreventNames().emitsnapshots before dispatch, prunesonce-listeners from the live vec mid-emit, and drains pendingevents.once(em, name)promises with a single-element[arg]array.events.onceallocates a Promise per call, stashes the raw pointer in the emitter'spending_once_promises, and resolves it synchronously through perry-runtime'sjs_promise_resolveon the next matching emit — bypassingJsPromise::resolve_*, which routes throughasync_bridge::queue_promise_resolutionand 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.rsdeclarations, and the API manifest. GC root scanner now marks both listener closures and pending Promise pointers.Probe results (issue baseline diff = 26 → now 10)
em.listenerCount('inc')after 2.on()202em.eventNames()[ 'inc' ]0[ 'inc' ]em.oncehandler arg captured[ 7 ][][ 7 ]em.addListener/removeListener[ 1 ][][ 1 ]em.prependListenerorder[ 'a', 'b' ][ 'b' ][ 'a', 'b' ]em.prependOnceListenerorder[ 'a', 'b', 'b' ][ 'b', 'b' ][ 'a', 'b', 'b' ]em.listeners('inc')count2undefined2em.rawListeners('inc')count2undefined2em.getMaxListeners()aftersetMaxListeners(42)42042em.removeAllListeners()then names[]0[]events.once(em,'x')module-levelCannot read properties of undefined (reading 'length')events.getEventListeners(em,'e')Remaining gaps (out of scope, all listed in the issue as follow-ups)
events.on(em,'evt')async-iterator (returns[]today)emit(name, a, b)— would letevents.onceresolve to a 2-element array instead of 1newListener/removeListenermeta-eventsdefaultMaxListeners,errorMonitor,captureRejections,captureRejectionSymbolevents.addAbortListenerTest plan
test-files/test_issue_850_eventemitter.ts(the probe set from the issue) — 12/13 lines match Node byte-for-bytetest-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 passcargo test --release -p perry-ext-events— 6/6 doctests pass (including newprepend_listener_inserts_at_frontandmax_listeners_round_trips)test-files/test_net_min.ts— Socket.on()/.emit()still fire correctly (EventEmitter is the base class)cargo fmt --allentries.rsrowCloses #850.