Skip to content

fix(events): drop orphaned native-async token after synchronous events.once settle#5191

Merged
proggeramlug merged 2 commits into
mainfrom
fix/stream-remaining-diffs
Jun 15, 2026
Merged

fix(events): drop orphaned native-async token after synchronous events.once settle#5191
proggeramlug merged 2 commits into
mainfrom
fix/stream-remaining-diffs

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Root-caused and fixed the one genuine, byte-matchable Perry bug remaining in the node-suite/stream cluster: events.once(...) makes the process hang after the awaited event already fired (exit 124). Both stream/events/once-returns-promise and stream/events/once-static-promise now match node v26 byte-for-byte.

Root cause

events.once(emitter, name) (and the stream / EventTarget variants in perry-ext-events) allocate their Promise through perry-ffi's JsPromise::new()perry_ffi_promise_new(), which registers a native-async completion token (and pins the Promise) so a worker thread could resolve it later.

But events.once settles synchronously from emit(...) and deliberately bypasses the deferred completion machinery — it calls js_promise_resolve/js_promise_reject directly (see the long extern comment in perry-ext-events for why). That bypass never runs js_native_async_process_pending, so the token is never removed from the registry. js_native_async_has_active() then keeps reporting work, the generated event loop's js_stdlib_has_active_handles() check stays hot, and the process idle-hangs forever — even though the awaited event already fired and the correct output already printed.

Minimal repro (prints done, then hangs at exit 124):

import { EventEmitter, once } from "node:events";
const e = new EventEmitter();
once(e, "end").then(() => console.log("done"));
e.emit("end");

Localized by instrumenting the generated event loop's liveness checks (js_microtasks_pending, js_native_async_has_active, the stdlib active-handle gate) — only js_native_async_has_active stayed at tokens=1 after settle.

Fix

  • perry-runtime: new js_native_async_drop_promise_token(promise) that removes the orphaned token from the native-async registry, mirroring the cleanup js_native_async_process_pending already performs after a normal completion (state → COMPLETED, remove_token_from_registry).
  • perry-ext-events: call it right after every synchronous settle in the events.once paths — EventEmitter resolve / error-reject, abort-signal reject, stream resolve / reject, EventTarget resolve, and the early arg-type / signal rejects in js_events_once.

Not a codegen/dispatch entry and not JS-visible, so no API_MANIFEST row needed.

Verification

  • Fixed: events/once-returns-promise + events/once-static-promise now MATCH node (exit 0). Verified the EventEmitter, stream, EventTarget, abort, and error-reject once() paths all exit cleanly under both the default auto-optimize and --no-auto-optimize link paths, with no GC use-after-free.
  • cargo test -p perry-runtime -p perry-ext-events -p perry-stdlib -p perry-codegen: green. perry-runtime shows 5 failures under parallel test threads (closure::dynamic_props, url::node_compat, object::tests — all unrelated subsystems); they pass single-threaded (1042 passed; 0 failed) — pre-existing parallel-isolation flakes.
  • No regressions: the other stream-suite diffs are unchanged and provably independent (the new function is only ever called from events.once paths). Spot-checked fs/http/net/buffer: the only diffs found (net/server/connection-state-limits hang, http/agent/* crashes) reproduce identically on pristine origin/main and don't use events.once — pre-existing, out of scope.

Remaining stream-suite diffs (classified, intentionally not touched)

Out of 801, after this fix the residual diffs are all node-failing-itself or non-byte-matchable:

  • finished/error-false-option (n=0): not byte-matchable — console.log of an Error prints node-internal stack frames (node:internal/modules/esm/module_job:439:25) Perry's runtime can't reproduce.
  • web/transform-* cluster (~10, n=13): node v26.3.0 itself deadlocks ("Detected unsettled top-level await", exit 13) on default-HWM TransformStream writes-before-reads. Node failing itself.
  • n=1 cluster (~11: abort/*, compose/sync-generator, readable/from-*, transform/no-options-default, error/missing-write-option, pipeline/*): node correctly throws on invalid input the tests mis-use (e.g. Readable.from(Promise), new Transform() with no _transform, callback-form pipeline(..., {opts}, cb)). Perry is more lenient; matching would require adding strict validation that risks regressing compiled npm packages — skipped per the node_err guidance.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a resource leak in events.once that could cause the event loop to hang when Promises are settled synchronously.
    • Ensured native async completion tokens are released promptly across additional events.once resolve/reject and abort paths.
  • Refactor
    • Reorganized events.once listener trampolines to use shared iterator-based implementations.

…s.once settle

`events.once(emitter, name)` (and the stream / EventTarget variants) allocate
their Promise through perry-ffi's `JsPromise::new()` → `perry_ffi_promise_new()`,
which registers a native-async completion token (and pins the Promise) so a
worker thread could resolve it later. But `events.once` settles *synchronously*
from `emit(...)` and deliberately bypasses the deferred completion machinery
(it calls `js_promise_resolve`/`js_promise_reject` directly — see the extern
comment in perry-ext-events for why). That bypass never runs
`js_native_async_process_pending`, so the token stays in the registry forever.
`js_native_async_has_active()` then keeps reporting work and the generated event
loop never exits — the process hangs after the awaited event already fired.

Repro (hangs at exit 124, prints the right output first):

    import { EventEmitter, once } from "node:events";
    const e = new EventEmitter();
    once(e, "end").then(() => console.log("done"));
    e.emit("end");

Fix: add `js_native_async_drop_promise_token(promise)` to perry-runtime, which
removes the orphaned token from the native-async registry (mirroring the cleanup
`js_native_async_process_pending` performs after a normal completion). Call it
right after every synchronous settle in perry-ext-events' `events.once` paths:
the EventEmitter resolve / error-reject, the abort-signal reject, the stream
resolve / reject, the EventTarget resolve, and the early arg-type / signal
rejects in `js_events_once`.

Closes node-suite stream/events/once-returns-promise and
stream/events/once-static-promise (both were exit-124 hangs; now match node
v26 byte-for-byte). Verified the EventEmitter, stream, EventTarget, abort, and
error-reject once() paths all exit cleanly with no GC use-after-free (the token
drop mirrors the existing post-resolution cleanup lifecycle).
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new runtime function js_native_async_drop_promise_token that marks a native async completion token as completed and removes it from the GC registry. The function is exposed via FFI and called after every synchronous js_promise_resolve/js_promise_reject in the events.once code paths to prevent orphaned tokens from keeping the event loop alive.

Changes

Native async token cleanup for events.once

Layer / File(s) Summary
Runtime: js_native_async_drop_promise_token implementation
crates/perry-runtime/src/promise/native_async.rs, crates/perry-runtime/src/promise/mod.rs
Adds js_native_async_drop_promise_token(promise: *mut Promise): null-checks the pointer, retrieves the completion token from registry.by_promise, sets STATE_COMPLETED, and calls remove_token_from_registry. Re-export list in mod.rs is reformatted to include the new symbol.
Events extension: FFI declaration and all call sites
crates/perry-ext-events/src/lib.rs, crates/perry-ext-events/src/module_iterators.rs
Declares the extern "C" FFI binding for js_native_async_drop_promise_token with inline comments. Imports four listener trampolines from module_iterators and removes their local definitions. Calls the cleanup function after every synchronous promise settlement: in drain_pending_once_promises, reject_pending_once_promises_for_error, early-rejection branches in js_events_once (invalid target and signal validation), and in all four listener trampolines (events_once_event_target_listener, events_once_abort_listener, events_once_stream_resolve_listener, events_once_stream_reject_listener).

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5190: Adds a regression test that asserts events.once/events.on with AbortSignal exit cleanly, directly validating the token cleanup behavior introduced in this PR.

Poem

🐇 A token once lost made the loop never end,
So I hopped through the code and taught it to mend.
After resolve or reject, drop the token with care—
No ghost in the registry, no phantom in there.
The event loop exits now, light as a hare! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main fix: dropping orphaned native-async tokens after synchronous events.once settlement, which directly addresses the process hang bug fixed in this PR.
Description check ✅ Passed The description comprehensively covers the Summary, Changes, Related issues, and Test plan sections of the template. The root cause analysis is detailed, the fix is clearly explained with file-level specifics, and verification steps are thoroughly documented with test results.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/stream-remaining-diffs

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-ext-events/src/module_iterators.rs (1)

105-107: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add token-drop after synchronous reject in events_on_abort_listener.

abort_promise is synchronously rejected on Line 106 but its native-async token is not retired, which can leave active-work bookkeeping stuck for this path too.

Suggested fix
         if !abort_promise.is_null() {
             js_promise_reject(abort_promise, js_abort_error_value());
+            js_native_async_drop_promise_token(abort_promise);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-ext-events/src/module_iterators.rs` around lines 105 - 107, In
the events_on_abort_listener function, after the synchronous rejection of
abort_promise via js_promise_reject call (line 106), add a token-drop operation
to retire the native-async token. This ensures that the active-work bookkeeping
is properly cleaned up in this synchronous reject path, preventing the token
from remaining in an active state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/perry-ext-events/src/module_iterators.rs`:
- Around line 105-107: In the events_on_abort_listener function, after the
synchronous rejection of abort_promise via js_promise_reject call (line 106),
add a token-drop operation to retire the native-async token. This ensures that
the active-work bookkeeping is properly cleaned up in this synchronous reject
path, preventing the token from remaining in an active state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bfeea816-972b-469c-aa71-65763ec5ec4a

📥 Commits

Reviewing files that changed from the base of the PR and between 9343f63 and 88fae3e.

📒 Files selected for processing (4)
  • crates/perry-ext-events/src/lib.rs
  • crates/perry-ext-events/src/module_iterators.rs
  • crates/perry-runtime/src/promise/mod.rs
  • crates/perry-runtime/src/promise/native_async.rs

Keeps perry-ext-events/src/lib.rs under the 2000-line file-size gate after
the once-token-drop additions: the abort / stream-resolve / stream-reject
events.once listener trampolines move into module_iterators.rs alongside the
existing events_once_event_target_listener (pure code move, no logic change).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-ext-events/src/module_iterators.rs (1)

169-171: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Drop the native-async token for events.on abort promises too.

js_events_on creates abort_promise with JsPromise::new() in crates/perry-ext-events/src/lib.rs Lines 1732-1733, but this abort listener settles it synchronously with js_promise_reject() and never retires the native-async token. This is the same orphaned-token keepalive pattern this PR fixes for events.once.

🐛 Proposed fix
         if !abort_promise.is_null() {
             js_promise_reject(abort_promise, js_abort_error_value());
+            js_native_async_drop_promise_token(abort_promise);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-ext-events/src/module_iterators.rs` around lines 169 - 171, The
abort_promise in the js_events_on function is settled synchronously with
js_promise_reject() but the corresponding native-async token is never retired,
creating an orphaned-token keepalive pattern. After calling
js_promise_reject(abort_promise, js_abort_error_value()), you must also drop or
retire the native-async token associated with the abort_promise to match the fix
applied to the events.once case. This ensures the token is properly cleaned up
when the abort listener completes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/perry-ext-events/src/module_iterators.rs`:
- Around line 169-171: The abort_promise in the js_events_on function is settled
synchronously with js_promise_reject() but the corresponding native-async token
is never retired, creating an orphaned-token keepalive pattern. After calling
js_promise_reject(abort_promise, js_abort_error_value()), you must also drop or
retire the native-async token associated with the abort_promise to match the fix
applied to the events.once case. This ensures the token is properly cleaned up
when the abort listener completes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 98c8ca1b-df03-4273-b5aa-b51d307afe58

📥 Commits

Reviewing files that changed from the base of the PR and between 88fae3e and d60e36b.

📒 Files selected for processing (2)
  • crates/perry-ext-events/src/lib.rs
  • crates/perry-ext-events/src/module_iterators.rs

@proggeramlug proggeramlug merged commit ec97c92 into main Jun 15, 2026
14 of 15 checks passed
@proggeramlug proggeramlug deleted the fix/stream-remaining-diffs branch June 15, 2026 12:05
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