Skip to content

fix(events,fs,globals): node v26 parity — events.on async iterator, fs watch buffering, four globals diffs#5099

Merged
proggeramlug merged 3 commits into
mainfrom
fix/core-module-diffs-fs-events-globals
Jun 13, 2026
Merged

fix(events,fs,globals): node v26 parity — events.on async iterator, fs watch buffering, four globals diffs#5099
proggeramlug merged 3 commits into
mainfrom
fix/core-module-diffs-fs-events-globals

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Root-cause fixes for the real byte-parity gaps in the node-suite events, globals, and fs modules, validated against node v26.3.0. Triaged with the official run_parity_tests.sh harness (which skips node-nonzero-exit tests) rather than a naive 2>&1 diff.

events (events/on): 4/69 → 0

events.on(emitter, evt) returned a bare queue array whose [Symbol.asyncIterator] returned the array itself with no .next, so for await (const args of on(...)) threw next is not a function.

Reimplemented as a real Node-style async iterator: [Symbol.asyncIterator]() builds a { next, return } object over shared GC-rooted state — events are buffered as [arg] arrays, next() drains the buffer or blocks on a Promise the listener resolves on the next event, an AbortSignal makes a buffer-empty next() reject, and return() detaches the listener.

Fixes async-iterator-basic, async-iterator-abort, high-watermark-options, import-callable.

globals: 4/115 → 0

  • Atomics.isLockFree compared a floored ToInteger, so isLockFree(4.9) was true. V8/Node compares the raw ToNumber against {1,2,4,8} exactly → 4.9false, "4"true.
  • escape/unescape coerced a Symbol arg to "Symbol(x)". ToString of a Symbol throws TypeError — now rejected.
  • Array.prototype.slice.call(arr, i, undefined) — the generic array-like path treated an explicit undefined end as 0 (empty) instead of len. Only the direct arr.slice path used the i32::MAX "to-end" sentinel.
  • Promise.race/Promise.any over a thenable invoked the thenable's then synchronously. The spec's PromiseResolveThenableJob is a microtask; js_promise_resolved's thenable arm now routes through the existing deferred promise_resolve_assimilating. Primitives and native Promises keep their fast paths, so only real thenables defer (by one microtask the await loop drains) — resolved values are identical.

fs (fs/watch/promises-lazy-start)

fsPromises.watch() took its baseline snapshot lazily at the first .next(), dropping events emitted between watch() and the first iteration. Node registers the FSWatcher eagerly and buffers them. Now seeds the snapshot at creation so the first poll diffs against the creation-time state. Perry now deterministically matches node's dominant false/false.

Documented platform-variant remainder (not a Perry bug)

fs/watch/watchfile-delivery — node's own two-listener watchFile delivery is flaky; 5× node runs of the first assertion: false, true, false, false, false. Perry is consistently more correct; byte-matching node's flake is neither possible nor desirable. (The official harness already excludes the rmdir { recursive: true } and writeFile([...]) cases here because node exits non-zero on them.)

Verification (node v26.3.0, this Mac)

  • events module: 69/69 pass (was 65/4).
  • globals module: 114/114 pass (was 110/4; one unrelated builtin-name-length-descriptors is a node-nonzero-exit skip).
  • fs: 172 pass; the 2 remaining fs/watch items are node-side races documented above.
  • No regressions vs a pristine origin/main baseline built at the branch's merge-base: buffer 134/0, http 19/0, util 85/1 (the 1 fail is identical on baseline), and all 13 promise/thenable-sensitive stream tests match (the 2 Readable.from(promise) diffs are pre-existing node-crash cases, identical on baseline). The full stream module times out under concurrent load on both branches (pre-existing harness slowness).
  • cargo test --release -p perry-runtime -p perry-stdlib -p perry-codegen: green (0 failed across all binaries).

Summary by CodeRabbit

  • Bug Fixes

    • Array slice() now treats an explicit undefined end as “to the end” per ECMAScript
    • Atomics.isLockFree() stricter number validation to avoid treating non-integer values as valid widths
    • escape()/unescape() now reject Symbol values
    • fs.promises.watch() seeds its initial snapshot at watcher creation
  • New Features

    • events.on() rewritten into a proper async-iterator with buffered delivery and improved abort semantics
  • Chores

    • Test/tools allowlist updated for the rewritten events module

… fs watch buffering, and four globals diffs

Root-cause fixes for real byte-parity gaps surfaced by the node-suite
events/globals modules (and one fs/watch buffering gap), validated against
node v26.3.0.

events (events/on, 4/69 → 0):
- The bundled `events.on(emitter, evt)` returned a bare queue array whose
  `[Symbol.asyncIterator]` returned the array itself with no `.next`, so
  `for await (const args of on(...))` threw "next is not a function" (and
  the abort/high-watermark/import variants likewise). Reimplement as a real
  Node-style async iterator: `[Symbol.asyncIterator]()` builds a
  `{ next, return }` object over shared GC-rooted state — emitted events are
  buffered as `[arg]` arrays, `next()` drains the buffer or blocks on a
  Promise the listener resolves on the next event, an AbortSignal makes a
  buffer-empty `next()` reject, and `return()` detaches the listener.

fs (fs/watch/promises-lazy-start):
- `fsPromises.watch()` took its baseline snapshot lazily at the first
  `.next()`, dropping events emitted between `watch()` and the first
  iteration. Node registers the FSWatcher eagerly and buffers those events.
  Seed the snapshot at creation so the first poll diffs against the
  creation-time state.

globals (4/115 → 0):
- Atomics.isLockFree: compared a floored ToInteger, so `isLockFree(4.9)`
  was true; Node/V8 compares the raw ToNumber against {1,2,4,8} exactly, so
  4.9 is false (and "4" is true).
- escape/unescape: coerced a Symbol argument to a "Symbol(x)" string; ToString
  of a Symbol throws TypeError — reject it.
- Array.prototype.slice.call(arr, i, undefined): the generic array-like slice
  treated an explicit `undefined` end as 0 (empty) instead of len. Only the
  direct `arr.slice` path used the i32::MAX "to-end" sentinel.
- Promise.race/Promise.any over a thenable invoked the thenable's `then`
  synchronously. The spec's PromiseResolveThenableJob is a microtask; route
  `js_promise_resolved`'s thenable arm through the existing deferred
  `promise_resolve_assimilating`. Primitives and native Promises keep their
  fast paths, so only real thenables defer (by one microtask the await loop
  drains), leaving resolved values identical.
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7bd65416-9712-42a7-9dae-37880a84a7f7

📥 Commits

Reviewing files that changed from the base of the PR and between 5fbe638 and 0ebe8a8.

📒 Files selected for processing (3)
  • crates/perry-runtime/src/builtins/globals.rs
  • crates/perry-stdlib/src/events.rs
  • scripts/check_file_size.sh
✅ Files skipped from review due to trivial changes (1)
  • scripts/check_file_size.sh
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-stdlib/src/events.rs

📝 Walkthrough

Walkthrough

This PR contains targeted ECMA-262 compliance fixes across array slicing, atomics, legacy builtins, promise resolution, and file watching, plus a comprehensive rewrite of the events.on() async iterator implementation with shared state-driven buffering and abort handling.

Changes

Targeted ECMA-262 and Feature Correctness Fixes

Layer / File(s) Summary
Array.prototype.slice explicit undefined handling
crates/perry-runtime/src/array/generic.rs
When end is explicitly undefined (e.g., arr.slice(1, undefined)), the calculation now treats it as "to the end" by checking the TAG_UNDEFINED bit pattern, matching ECMA-262 instead of incorrectly clamping to 0.
Atomics.isLockFree exact numeric coercion
crates/perry-runtime/src/atomics.rs
isLockFree switches from integer-truncating coercion to full ToNumber semantics with exact equality tests against 1.0, 2.0, 4.0, and 8.0; non-integer inputs like 4.9 no longer match 4.
escape/unescape Symbol rejection
crates/perry-runtime/src/builtins/globals.rs
New throw_if_symbol helper detects Symbol values and throws TypeError; both escape() and unescape() now call it before string conversion, rejecting Symbols instead of coercing them.
Promise thenable assimilation refactoring
crates/perry-runtime/src/promise/async_step.rs
js_promise_resolved now delegates thenable assimilation and resolution to the promise_resolve_assimilating combinator, consolidating inline logic and updating block documentation for microtask scheduling behavior.
File watch snapshot seeding at creation time
crates/perry-runtime/src/fs/dir_glob_watch.rs
fs.promises.watch() captures an initial filesystem snapshot at watcher creation time via snapshot_watch_target, throws FS errors on failure, and stores it in PromiseWatchState; start_promise_watcher no longer re-snapshots on first poll iteration.

events.on() Async Iterator Rewrite

Layer / File(s) Summary
Shared async iterator state and queue listener
crates/perry-stdlib/src/events.rs (lines 1618–1714)
Introduces GC-anchored shared state array tracking FIFO-buffered events, pending next() Promises, done/abort flags, abort error, and emitter/listener info. Implements queue listener to resolve pending next() or buffer emitted args.
Iterator next() and return() implementations
crates/perry-stdlib/src/events.rs (lines 1716–1792)
next() drains buffered FIFO when available, otherwise blocks by returning a pending Promise; rejects with the stored AbortError when aborted and buffer empty. return() marks done, detaches the listener, and resolves pending next() Promises with completion.
Async iterator object construction and installation
crates/perry-stdlib/src/events.rs (lines 1798–1845)
Builds the async iterator object with next/return methods bound to shared state and installs [Symbol.asyncIterator] self-reference; wires the state-aware iterator onto the queue handle.
Abort handling and js_events_on integration
crates/perry-stdlib/src/events.rs, scripts/check_file_size.sh (lines 1859–2004)
Abort listener marks iterator aborted/done and rejects all pending next() Promises while preserving buffered events. js_events_on allocates shared state and queue handle, records emitter/listener for detachment, and wires abort handling. scripts/check_file_size.sh allowlist updated to exempt the rewritten events module.

🎯 3 (Moderate) | ⏱️ ~20 minutes


🐰 Events now queue with grace, buffering each pace,
Promises resolve in the right time and place,
Atomics exact, arrays precise,
Slices undefined, and Symbols think twice!
🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main changes: node v26 parity fixes for events.on async iterator, fs watch buffering, and four globals diffs, matching the core content.
Description check ✅ Passed The description provides comprehensive detail on all changes, includes test verification against node v26.3.0, and addresses all template sections including changes, related issues (none), test plan checklist, and contributor agreement.
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/core-module-diffs-fs-events-globals

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.

Actionable comments posted: 3

🤖 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.

Inline comments:
In `@crates/perry-runtime/src/promise/async_step.rs`:
- Around line 51-61: The async-function tail-return path uses synchronous
js_assimilate_thenable in resolve_trap_next_with_adoption(), causing a timing
mismatch with the new use of super::combinators::promise_resolve_assimilating in
js_promise_resolved/js_async_step_done; update resolve_trap_next_with_adoption()
to route thenables through the same queued assimilation helper (call
promise_resolve_assimilating or an equivalent queued-assimilation wrapper)
instead of calling js_assimilate_thenable(...) so both Promise.resolve(thenable)
and the async tail-return reuse the same scheduled-thenable semantics and
preserve PromiseResolveThenableJob parity.

In `@crates/perry-stdlib/src/events.rs`:
- Around line 1990-2001: The state saved via events_on_state_set currently only
stores EVENTS_ON_HANDLE and EVENTS_ON_LISTENER so events_on_return() cannot undo
the EventTarget/abort-listener cleanup; update the capture/state to include all
teardown metadata and/or a dedicated cleanup closure: when creating the
abort_listener in the shown block, also call events_on_state_set for the signal
pointer (signal_ptr), event_name_ptr (and any target-type indicator or original
listener pointer if different), or create and store a js_closure_alloc "cleanup"
closure that performs the same detach logic the abort handler uses, then store
that cleanup closure pointer in state (e.g. EVENTS_ON_CLEANUP). Finally ensure
events_on_return() reads and invokes that stored cleanup (or uses the additional
metadata) to detach EventTarget listeners and remove the abort_listener just
like the abort path does.
- Around line 1762-1789: In the explicit-return path (the block that calls
events_on_state_set(state, EVENTS_ON_DONE, TAG_TRUE_F64) and then detaches the
listener via EVENTS_ON_HANDLE / EVENTS_ON_LISTENER), also drain/clear the queued
events buffer (EVENTS_ON_BUFFER) so queued items don't surface to later next()
calls: get the buffer with events_on_state_array(state, EVENTS_ON_BUFFER) and
loop while js_array_length(buffer) > 0 calling
perry_runtime::array::js_array_shift_f64(buffer) (discarding the values) to
fully clear it before resolving pending promises; place this drain just after
detaching the listener and before resolving pending items so explicit return
drops queued events.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7de2211c-d165-464d-be09-27dcdeed3792

📥 Commits

Reviewing files that changed from the base of the PR and between 3c0675c and 5fbe638.

📒 Files selected for processing (6)
  • crates/perry-runtime/src/array/generic.rs
  • crates/perry-runtime/src/atomics.rs
  • crates/perry-runtime/src/builtins/globals.rs
  • crates/perry-runtime/src/fs/dir_glob_watch.rs
  • crates/perry-runtime/src/promise/async_step.rs
  • crates/perry-stdlib/src/events.rs

Comment on lines +51 to +61
// codegen-side `Expr::Await` lowering. `promise_resolve_assimilating`
// implements the spec PromiseResolveThenableJob: a thenable's `.then` is
// invoked from a SCHEDULED microtask, never synchronously during resolve.
// This matters for `Promise.race`/`Promise.any` over a thenable, where Node
// does not call the thenable's `then` until the job runs (so the count of
// synchronous `then` invocations stays 0). Primitives (fast path above) and
// native Promises (short-circuit above) never reach here, so the per-await
// steady state is untouched; only real thenables (drizzle's `QueryPromise`,
// object literals with `then`) defer by one microtask — which the await
// loop drains, leaving the resolved value identical.
super::combinators::promise_resolve_assimilating(promise, value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the async-function tail-return path on the same queued-assimilation semantics.

Line 61 now fixes js_promise_resolved by routing thenables through promise_resolve_assimilating, but resolve_trap_next_with_adoption() later in this file (Lines 350-375) still uses synchronous js_assimilate_thenable(...). That means Promise.resolve(thenable) and the js_async_step_done() reuse fast path for return thenable can now observe different timing, so this only partially fixes the PromiseResolveThenableJob parity issue.

Using the same combinator (or an equivalent queued-assimilation helper) in resolve_trap_next_with_adoption() would keep both adoption paths aligned.

🤖 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-runtime/src/promise/async_step.rs` around lines 51 - 61, The
async-function tail-return path uses synchronous js_assimilate_thenable in
resolve_trap_next_with_adoption(), causing a timing mismatch with the new use of
super::combinators::promise_resolve_assimilating in
js_promise_resolved/js_async_step_done; update resolve_trap_next_with_adoption()
to route thenables through the same queued assimilation helper (call
promise_resolve_assimilating or an equivalent queued-assimilation wrapper)
instead of calling js_assimilate_thenable(...) so both Promise.resolve(thenable)
and the async tail-return reuse the same scheduled-thenable semantics and
preserve PromiseResolveThenableJob parity.

Comment on lines +1762 to +1789
events_on_state_set(state, EVENTS_ON_DONE, TAG_TRUE_F64);
// Detach the queue listener from the emitter so no further events queue.
let handle = perry_runtime::array::js_array_get_f64(state, EVENTS_ON_HANDLE);
let listener = perry_runtime::array::js_array_get_f64(state, EVENTS_ON_LISTENER);
if handle.to_bits() != TAG_UNDEFINED_F64_BITS
&& listener.to_bits() != TAG_UNDEFINED_F64_BITS
{
let handle_id = handle as Handle;
let listener_ptr = js_nanbox_get_pointer(listener);
if let Some(emitter) = get_handle_mut::<EventEmitterHandle>(handle_id) {
remove_listener_by_callback(emitter, listener_ptr);
}
}
// Resolve any blocked `next()` with completion.
let pending = events_on_state_array(state, EVENTS_ON_PENDING);
if !pending.is_null() {
while js_array_length(pending) > 0 {
let promise =
js_nanbox_get_pointer(perry_runtime::array::js_array_shift_f64(pending))
as *mut Promise;
if !promise.is_null() {
js_promise_resolve(
promise,
events_iter_result(f64::from_bits(TAG_UNDEFINED_F64_BITS), true),
);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Drop queued events on explicit return().

next() drains EVENTS_ON_BUFFER before it checks done, which is correct for abort ordering. But return() never clears that buffer, so anything queued just before close will still come out of a later next() even though the iterator has already been ended. Clear the buffer here, or split explicit-close vs abort state so only abort preserves queued items.

Suggested fix
     unsafe {
+        events_on_state_set(
+            state,
+            EVENTS_ON_BUFFER,
+            js_nanbox_pointer(js_array_alloc(0) as i64),
+        );
         events_on_state_set(state, EVENTS_ON_DONE, TAG_TRUE_F64);
         // Detach the queue listener from the emitter so no further events queue.
         let handle = perry_runtime::array::js_array_get_f64(state, EVENTS_ON_HANDLE);
🤖 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-stdlib/src/events.rs` around lines 1762 - 1789, In the
explicit-return path (the block that calls events_on_state_set(state,
EVENTS_ON_DONE, TAG_TRUE_F64) and then detaches the listener via
EVENTS_ON_HANDLE / EVENTS_ON_LISTENER), also drain/clear the queued events
buffer (EVENTS_ON_BUFFER) so queued items don't surface to later next() calls:
get the buffer with events_on_state_array(state, EVENTS_ON_BUFFER) and loop
while js_array_length(buffer) > 0 calling
perry_runtime::array::js_array_shift_f64(buffer) (discarding the values) to
fully clear it before resolving pending promises; place this drain just after
detaching the listener and before resolving pending items so explicit return
drops queued events.

Comment on lines +1990 to 2001
// Record the emitter handle + listener so `return()` can detach cleanly.
events_on_state_set(state, EVENTS_ON_HANDLE, handle as f64);
events_on_state_set(state, EVENTS_ON_LISTENER, js_nanbox_pointer(listener as i64));

if let Some(signal) = signal {
if let Some(signal_ptr) = object_ptr_from_value(signal) {
let abort_listener = js_closure_alloc(events_on_abort_listener as *const u8, 5);
js_closure_set_capture_ptr(abort_listener, 0, handle);
js_closure_set_capture_ptr(abort_listener, 1, listener as i64);
js_closure_set_capture_ptr(abort_listener, 2, signal_ptr as i64);
js_closure_set_capture_ptr(abort_listener, 3, abort_promise as i64);
js_closure_set_capture_ptr(abort_listener, 3, state as i64);
js_closure_set_capture_ptr(abort_listener, 4, event_name_ptr as i64);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Capture full teardown state for return().

The shared state only records handle and the queue listener, so events_on_return() can only detach the EventEmitterHandle case. For EventTarget/stream targets—and for the abort listener registered here—explicit return() leaves callbacks installed, so a closed iterator can keep accumulating future events and stay retained until abort or GC. Store enough metadata to run the same cleanup path from return() that abort uses, or capture a dedicated cleanup closure in the state.

🤖 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-stdlib/src/events.rs` around lines 1990 - 2001, The state saved
via events_on_state_set currently only stores EVENTS_ON_HANDLE and
EVENTS_ON_LISTENER so events_on_return() cannot undo the
EventTarget/abort-listener cleanup; update the capture/state to include all
teardown metadata and/or a dedicated cleanup closure: when creating the
abort_listener in the shown block, also call events_on_state_set for the signal
pointer (signal_ptr), event_name_ptr (and any target-type indicator or original
listener pointer if different), or create and store a js_closure_alloc "cleanup"
closure that performs the same detach logic the abort handler uses, then store
that cleanup closure pointer in state (e.g. EVENTS_ON_CLEANUP). Finally ensure
events_on_return() reads and invokes that stored cleanup (or uses the additional
metadata) to detach EventTarget listeners and remove the abort_listener just
like the abort path does.

@proggeramlug proggeramlug merged commit 48f0935 into main Jun 13, 2026
14 checks passed
@proggeramlug proggeramlug deleted the fix/core-module-diffs-fs-events-globals branch June 13, 2026 20:18
proggeramlug added a commit that referenced this pull request Jun 14, 2026
… post-pull writes are delivered (#5099 regression) (#5117)

#5099 seeded the promises-watcher snapshot at watch() creation time AND
stopped re-snapshotting at the first .next() pull (in
start_promise_watcher), intending to deliver writes emitted between
watch() and the first iteration.

That broke post-pull delivery. With the baseline frozen at the empty
creation-time state, the first poll after the first .next() diffs that
empty baseline against the post-early-write directory and resolves the
*first* pending pull with the pre-iteration write. The promises-lazy-start
test reuses that same pending promise for the next race, so the later
write (late.txt) is never delivered to it — and the pre-iteration write
is wrongly surfaced. Result: both "pre-pull ignored" and "post-pull
delivered" assertions fail against Node.

Fix: re-take the baseline snapshot in start_promise_watcher at the first
.next() (folding the current directory state into the baseline, so
pre-iteration writes are ignored — matching Node, whose async iterator
only starts collecting once you iterate), then let promise_watcher_poll_impl
keep advancing the baseline after every poll (so a write *after* a pull
is detected as a fresh change and delivered). The creation-time
snapshot_watch_target call is kept solely as the synchronous path
validation / error path (Node throws ENOENT etc. at watch() call time).

Validation (node v26):
- fs/watch/promises-lazy-start.ts: deterministically prints
  "pre-pull ignored: true" / "post-pull delivered: true" (3/3).
- fs/watch/promises-watch.ts: still PASS.
- Isolated repro: a write after a pull begins is delivered to that pull.

Co-authored-by: Ralph Küpper <ralph2@skelpo.com>
proggeramlug added a commit that referenced this pull request Jun 15, 2026
Refresh floors from a clean node-26 full run (2810/2863, 98.1%):
- object 23/23, util 86/86, tty 32/32, events 69/69 -> full (#5144/#5106-7/#5099)
- stream 770, globals 111, diagnostics_channel 66, fs-promises 77 -> ratcheted up with small flake margins
- http floor 19 -> 17: verified 19/19 in isolation but the full-suite harness
  flakes to 17 under port contention; a real http regression is a far larger
  drop (link break / behavior break), which still trips the guard. Stops the
  false-alarm seen on prior runs.

Co-authored-by: Ralph Küpper <ralph2@skelpo.com>
proggeramlug added a commit that referenced this pull request Jun 15, 2026
events.once / events.on with an AbortSignal used to leave a leaked
keepalive (pending promise / abort listener) parking the event loop, so
the program produced correct output then never exited. The runtime fix
landed incidentally with the events.on async-iterator rework (#5099);
this adds an explicit guard so it cannot silently regress.

The node-suite differential runner does check exit codes, but a hang
only surfaces as a timeout there and that runner is not part of the
per-PR gate. This stand-alone script (same shape as
tests/test_issue_3730_timers_promises_unref_await.sh) compiles both
abort fixtures and asserts each exits 0 within a timeout with the
expected stdout — a hang trips the timeout (exit 124) and fails.

Co-authored-by: Ralph <ralph@skelpo.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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