Skip to content

Compile events.once with an AbortSignal and over streams, and combine Promise.race entries around it - #1531

Merged
ctate merged 8 commits into
mainfrom
opt/events-once-race
Oct 20, 2026
Merged

ctate merged 8 commits into
mainfrom
opt/events-once-race

Conversation

@cramforce

@cramforce cramforce commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Base: opt/node-stream-dyn (#1524, stacked on #1518). The events.once receiver in the motivating shape is a NodeJS.ReadableStream slot, which these two PRs represent as a runtime stream class or as a checked-dynamic slot.

This PR makes the following shape compile and run with Node's behavior:

function copyPart(src: NodeJS.ReadableStream, out: PassThrough) {
  const ac = new AbortController();
  src.pipe(out, { end: false });
  return Promise.race([
    once(src, "end", { signal: ac.signal }),
    once(src, "error", { signal: ac.signal }).then(([error]) => {
      throw error;
    }),
    once(out, "close", { signal: ac.signal }).then(() => {
      throw new Error("output closed before the part ended");
    }),
  ]).finally(() => {
    ac.abort();
    src.unpipe(out);
  });
}

events.once with { signal }

events.once(emitter, name, { signal }) lowers to a new libCall, events.onceSignal, served by a new runtime unit, scr_events_once_signal.c. It is linked only through the new eventsOnceSignal feature, so programs that do not pass options link the same objects as before. The unit follows Node 24's lib/events.js:

  • an undefined signal is the plain form;
  • a value that is not an AbortSignal rejects with ERR_INVALID_ARG_TYPE;
  • an aborted signal rejects with an AbortError whose cause is signal.reason, without registering anything;
  • otherwise a once, resist-stop-propagation 'abort' listener removes the event and 'error' listeners (with their 'removeListener' events) and rejects with the AbortError;
  • the event or the error removes the abort listener before settling;
  • a throwing 'newListener' listener rejects the promise, as before.

The listener closures reach the shared state through link objects that clear the state's pointer when a closure is freed, so removals never compare a stale identity. The abort listener holds the emitter, like the closure Node's abortListener keeps; the cycle through the signal ends when the promise settles or the signal aborts, or at the exit teardown of the signal's listeners. scr_events_emitter.c gains one exported wrapper, scr_emitter_unadd_quiet, for the 'newListener' undo.

Options that are not an object literal with one signal property stay refused.

Settlement turn of events.once

Node's once is an async function that returns the executor's promise, so its result adopts that promise through a thenable job and settles two microtask turns after the executor's promise. The existing plain form returned the executor's promise itself and settled two turns early. Both forms now adopt through a queued job and match Node's order against other promise chains (6490 pins it). This changes the binaries of programs that call the plain events.once (see the gate below).

events.once over streams

Stream receivers no longer fence: the runtime-emitted events (end, close, finish, error, ...) use their fixed tuples, as stream.on does. A stream's 'data' and 'keypress' events stay refused. A NodeJS stream interface slot that holds a stdio handle requests the general stream representation (#1518). Under checked-dynamic stream slots (#1524), a native stream unboxes by identity; any other value throws the not-supported Error that for await and pipeline stages already throw for JavaScript stream objects. The fallback declaration of events.once accepts NodeJS.ReadableStream and NodeJS.WritableStream like @types/node does.

Promise.race

The second commit applies the Promise.race part of main's 465abda unchanged (Promise entries race under any result type and their fulfillment adapter traps), so the change that follows merges with main cleanly. On top of it: when the checker's combined type has no representation (events.once's any[] under @types/node, or void | any[]), the result takes the one type the entries that can fulfill share, or unknown when each of them widens into it. An entry whose payload widens into the result type converts through the checked promise payload widening, which keeps its settlement turn. Programs whose race already compiled are unchanged.

Function-typed stream callbacks

A stream subclass that declares _transform(chunk, encoding, cb: Function) (or _write, _final, _destroy, _flush with a broad Function callback) was rejected because Function has no lowering, which also blocked every new of the class. The callback parameter now takes the checked-dynamic callable the runtime already passes implicitly typed callbacks, and calls through it are dynamic calls.

Tests

  • 6490: events.once with a signal (abort, aborted signal, undefined signal, abort-listener order, 'removeListener' events, throwing 'newListener', settlement turns, pending onces at exit).
  • 6491: stream receivers and a NodeJS.ReadableStream part raced against its destination.
  • 6492: races over events.once results with entries that only reject and entries of different value types.
  • 6493: stream subclasses with Function-typed completion callbacks.
  • 6494: events.once over checked-dynamic stream slots.
  • tests/fixtures/node-types/events-once-signal-race.ts: the race shapes under @types/node (new project-config test).
  • tests/diagnostics/events-once-fences.ts now pins the forms that stay refused.

All pass under Node and native in the plain and SCRIPTC_SAN=1 lanes. In the sandbox, the focused differential run (event, emitter, promise, race, stream, abort and signal programs, 260 per lane) fails only on programs that fail the same way on the base (2599, 5342, 5343, 5372-5374, 5423, 5564, 5614, 5761, 5762). The diagnostics and errors suites pass, the node-types tests pass apart from the known environment failures, the self-hosting IR build reports no diagnostics, tests/harness/self-hosting-native-driver.test.ts passes, and the order-parity baselines for the new fixtures are recorded. The docs check passes.

Zero-cost gate

scripts/zero-cost-gate.mjs --corpus=all against opt/node-stream-dyn (8284e61): 3317 programs identical, 3 differ, 1 fails in both trees (node24-file-url-options.ts, environment). The three that differ are the programs that call the plain events.once, whose scr_events_once now queues the adoption job: 5622-events-once.mjs (+184 B), 5623-events-once.ts (+320 B) and 5885-events-max-listener-helpers.ts (+4.4 KB, it now links the microtask and race-join helpers). Runtime benchmarks: geomean +0.19% (95% CI -0.55%, +0.83%), self-hosted compiler -0.52%, both within tolerance.

Not covered

  • events.once over JavaScript stream objects in checked-dynamic slots throws the not-supported Error.
  • EventTarget emitters, a stream's 'data' event, and options other than a literal { signal } stay refused.

🤖 Generated with Claude Code

cramforce and others added 7 commits October 20, 2026 08:47
events.once(emitter, name, { signal }) lowers to events.onceSignal, served
by a new runtime unit (scr_events_once_signal.c, linked only through the
eventsOnceSignal feature). It follows Node 24's lib/events.js: an
undefined signal is the plain form, a value that is not an AbortSignal
rejects with ERR_INVALID_ARG_TYPE, an aborted signal rejects with an
AbortError whose cause is signal.reason, and otherwise a once,
resist-stop-propagation 'abort' listener removes the event and 'error'
listeners and rejects. The event or the error removes the abort listener.
Options other than an object literal with one signal stay refused.

events.once now accepts stream receivers: a stream's runtime-emitted
events use their fixed tuples, and its 'data' and 'keypress' events stay
refused. A NodeJS stream interface slot that holds a stdio handle requests
the general stream representation.

Node's once is an async function that returns the executor's promise, so
its result adopts that promise through a thenable job. The plain form
settled its result two turns early; both forms now settle on Node's turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Promise.race portion of 465abda on main, applied unchanged so the
race changes that follow build on it and merge with main cleanly: a
Promise<never> entry can only reject, so it races under any result type
and its fulfillment adapter traps; a Promise<never> executor's
one-parameter resolve validates against the void inner. Includes that
commit's corpus program 6450.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the checker's combined type has no representation (events.once's
any[] arrays, or 'void | any[]'), the race result takes the one type the
entries that can fulfill share, or 'unknown' when each of them widens into
it; Promise<never> entries do not take part. An entry whose payload widens
into the result type converts through the checked promise payload
widening, which keeps its settlement turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stream subclass that declares `_transform(chunk, encoding, cb: Function)`
(or _write, _final, _destroy, _flush with a broad `Function` callback) was
rejected because 'Function' has no lowering, which also blocked every
construction of the class. The callback parameter now takes the
checked-dynamic callable the runtime already passes implicitly typed
callbacks, and calls through it are dynamic calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under checked-dynamic NodeJS stream slots, events.once over a
NodeJS.ReadableStream or NodeJS.WritableStream slot unboxes a native
stream by identity and takes the stream path; any other value throws the
not-supported Error that for await and pipeline stages already throw for
JavaScript stream objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent its boundary

The fallback declaration of events.once takes NodeJS.ReadableStream and
NodeJS.WritableStream emitters like @types/node does. The diagnostics
fixture now pins the forms that stay refused (options that are not an
object literal, a computed name, a stream's 'data' event), and the
limitations page states the supported events.once forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in races

6490 covers events.once with an AbortSignal: abort, an aborted signal, an
undefined signal, abort-listener order, 'removeListener' events, a
throwing 'newListener' listener and the settlement turns. 6491 covers
stream receivers (file streams, Readable.from, a destroyed PassThrough, a
Writable) and a NodeJS.ReadableStream part raced against its destination.
6492 covers races over events.once results with entries that only reject
and entries of different value types. 6493 covers stream subclasses whose
completion callbacks are typed `Function`. 6494 covers events.once over
checked-dynamic stream slots. The node-types fixture runs the race shapes
under @types/node, where events.once returns Promise<any[]>.

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 20, 2026 6:02pm UTC

Base automatically changed from opt/node-stream-dyn to main October 20, 2026 17:32
@ctate
ctate merged commit 178e524 into main Oct 20, 2026
21 of 22 checks passed
@ctate
ctate deleted the opt/events-once-race branch October 20, 2026 18:04

This branch was successfully deployed

1 active deployment
Preview — 3751afa5 Deployed Oct 20, 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.

2 participants