Skip to content

Host the runtime's event loop on Node's libuv once Node loads (NI-3) - #1696

Open
cramforce wants to merge 3 commits into
ni/1-libnode-loaderfrom
ni/3-hosted-loop
Open

cramforce wants to merge 3 commits into
ni/1-libnode-loaderfrom
ni/3-hosted-loop

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Host the runtime's event loop on Node's libuv once Node loads (NI-3)

Scope-B milestone NI-3, stacked on #1685 (NI-1: in-process libnode loader, with @scriptc/node from #1686).

Once a program loads Node in-process, there is one event loop, Node's, and one set of queues. The jobs of both sides interleave as they do in Node.

The switch (scr_loop_host, the SCR_NODE_ISLANDS build of scr_async.c)

Right after Node starts, the runtime moves its pending work onto Node's queues, in order:

  • promise jobs and queueMicrotask envelopes become V8 microtasks;
  • ticks become process.nextTick callbacks;
  • timers become Node timers. They are created in (deadline, sequence) order, with the remaining delay measured from just before Node began loading, so the load time does not affect timer order;
  • immediates become Node immediates.

Every later scheduling call goes through the bridge: scr_ready_push, the tick functions, scr_timer_push and the timer ref, unref, refresh, hasRef and clear calls, and the immediate functions.

A switch can happen in the middle of a loop pass. In that case:

  • the work of the interrupted phase finishes first: the rest of the current check phase, then timers that were already due;
  • a switch during a timers or poll phase resumes at Node's poll phase (UV_RUN_ONCE);
  • a microtask drain that was in progress finishes before the ticks run.

Calls into JavaScript no longer drain queues themselves. The runtime's checkpoint (the bridge's checkpoint) and Node's own dispatch do that. A throw that escapes a runtime job, or an unhandled runtime rejection decided after a V8 microtask checkpoint, ends the process the same way the runtime's main would.

I/O

The runtime's scr_loop_set_* slots stay where they are. On Node's side:

  • io_state reports their fds (watched as uv_poll handles, which are stopped rather than closed when an fd goes away and comes back);
  • their liveness becomes the ref of one prepare handle;
  • work that is ready without an fd becomes an idle handle;
  • a cap timer covers work that no fd announces.

io_dispatch runs the dispatch stations with Node's checkpoint after each one. The bridge re-reads the state before each poll phase, after each check phase, and after every microtask checkpoint, so liveness is current wherever Node next decides it. Deferred fs callbacks are delivered on the turn after they are queued, as the runtime's own loop pass delivers them.

Process state (the SCR_NODE_ISLANDS build of scr_events.c, scr_node_island_host.cjs)

  • Signal and exit listeners that island code adds join the runtime's registries at their registration position. The bootstrap removes Node's startListeningIfSignal/stopListeningIfSignal hooks, so libuv never owns a signal. Removing an island listener therefore no longer resets the signal to SIG_DFL underneath the program's own handler.
  • Node's own exit emission is left to the runtime's exit sequence, which runs the listeners of both sides in order.
  • An exit code that island code leaves in process.exitCode becomes the program's when the loop ends.
  • Node's process.exit already runs the runtime's exit sequence (NI-1).
  • V8's stack limit follows the stack the runtime calls in from (an async fiber or the main thread). Without this, an async main that calls into Node got "Maximum call stack size exceeded".

Bridge ABI

ScrNodeBridge and ScrNodeBridgeHost gain the hosted-loop entries listed in scr_node_bridge.h. NI-1's pump slot (backend_fd, backend_timeout, alive, pump) is gone. The bridge ABI goes from 2 to 3. NI-1's dedicated Node stack is kept: Node's loop runs on it once it is hosted, and V8's limit also follows an async fiber that a hosted job resumes and that calls into Node again (the new stack_limit host callback). The bridge smoke test drives the hosted entry points.

Tests (Linux sandbox, community libnode 24.22, node 24.22 as the oracle)

  • Ordering fidelity: tests/harness/node-island-order.test.ts runs 36 programs in tests/corpus-node-islands-order/:

    • P1–P12, except P4 and P6, which need island-to-native callbacks (NI-5);
    • the promise-job and async-context programs, plus native promise jobs, async/await and ticks against island jobs;
    • the G robustness programs, except the addon ones;
    • the E liveness programs;
    • six switch programs (s01–s06): work pending at load in the main body, a timer, an immediate and a promise job, an open socket, fs callbacks, and unref'd and refreshed timers.

    At 20 Node runs against 20 native runs, 664/720 native runs produced an order Node produced, and every queue-determined program matched 20/20. The 56 misses are timer-against-I/O races in p05, p08, p11, q04, s01 and s02, where the duration of the I/O differs between the sides (scriptc's fs callbacks complete in one turn, while Node's readFile takes several; the first module load inside Node is slower than in a Node process). These programs are marked // @order: race, and for them the harness requires the same lines in any order.

  • Process state, corpus (byte-exact against Node):

    • 6804: signals added and removed from island JavaScript while the program's handlers stay intact;
    • 6805: exit code and exit-listener order across sides;
    • 6806: process.exit inside a call;
    • 6807: process.exit from an island timer;
    • 6808: the program's process.exit with island listeners;
    • 6809: stdio interleaving, including 200 KB writes;
    • 6810: children of both sides (libuv's SIGCHLD handler does not break scriptc's reaping);
    • 6811: the program's uncaughtException listener after the switch.
  • Process state, harness: tests/harness/node-island-process.test.ts covers uncaught exceptions and unhandled rejections on either side. Exit status and stdout match Node, and stderr carries the error.

  • Cost (sandbox, per callback, Node loaded / Node itself / scriptc without Node):

    • native setImmediate chain: 3.6–4.2 / 2.2 / 0.11 µs;
    • nextTick: 0.31 / 0.14 / 0.01 µs;
    • await: 81 / 72 / 2 ns;
    • island setImmediate: 2.1 µs against Node's 1.7 µs.

    The budget is 12 µs per callback.

Zero cost

Every change to the shared units is behind SCR_NODE_ISLANDS. In the island-free build, unifdef shows only macro aliases that expand to the old code, plus scoping braces. Zero-cost gate against the NI-1 base, --set=bench,self,corpus --corpus=all, 3546 programs: 3540 identical, 4 allowed (6800-6803, the programs that call into Node), and 2 that differed:

  • 2314-process-introspection: scr_active_resources was rewritten through a local variable. Fixed (the island-free path is now the original code), and a re-run over the bench, process, timer and event programs found 137 identical.
  • fs-extra-package: the IR records the checkout path of the compiler's fs-extra shim (/vercel/sandbox/prev against /vercel/sandbox/cand). The two IR files are identical once that path is normalized, and two builds from the same checkout are identical. This is an artifact of the gate, not of this change.

Not in this PR

  • P4 and P6 need callbacks from island code into native code (NI-5).
  • The cwd cache (Node caches process.cwd() in JavaScript, so a native process.chdir is not visible to island code): a hook in scr_lib.c would change island-free binaries, so it waits for a variant.
  • beforeExit stays a documented divergence.
  • Island code's process.listeners() does not see native signal listeners.
  • macOS has not been run (there is no libnode pack yet).

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 21, 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 22, 2026 3:13am UTC

Comment on lines +265 to +267
const registered = process
.rawListeners(entry.event)
.find((raw) => (raw.listener ?? raw) === entry.listener);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Registering the same signal callback with both process.on and process.once makes both runtime registrations invoke the first raw listener, so the once registration can fire indefinitely.

Fix on Vercel

Comment on lines +4571 to +4574
case SCR_HOSTED_REFRESH:
if (j->firing) j->refreshed = true;
if (j->handle != 0) scr_loop_hosted->timer_refresh(j->handle);
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Refreshing a migrated timer uses its remaining delay rather than its original delay, and does nothing if the timer was already due at handoff.

Fix on Vercel

Comment on lines +247 to +252
process.on("removeListener", (event, listener) => {
const kind = eventKind(event);
if (kind === 0) return;
for (let i = islandListeners.length - 1; i >= 0; i--) {
const entry = islandListeners[i];
if (entry.event === event && entry.listener === listener) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When a once signal listener removes itself while another listener is registered for the same signal, its runtime registration is left behind and can keep swallowing the signal after all JavaScript listeners are gone.

Fix on Vercel

Scope-B milestone NI-3. When a program loads Node in-process, its pending
ticks, promise jobs, timers and immediates move onto Node's queues in
order (timers keep their deadlines relative to the moment Node began
loading; the work of a phase the switch interrupted finishes first) and
every later one is scheduled there, so the jobs of both sides interleave
as in Node. The runtime's I/O hooks are watched from Node's loop, and its
liveness is re-read before each poll phase, after each check phase and
after every microtask checkpoint. Node's loop runs on Node's stack, and
V8's limit follows whichever stack an entry comes from.

Island signal and exit listeners join the runtime's registries at their
position, so the runtime keeps owning every signal disposition; Node's own
'exit' emission is left to the runtime's exit sequence, and the exit code
island code leaves in process.exitCode becomes the program's. Bridge ABI 3.

Ordering programs (tests/corpus-node-islands-order, harness
node-island-order.test.ts), corpus programs 6804-6811 and the
node-island-process harness pin the behavior against Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pcontext warning, fix a race in 6811

- ni_stack_limit classifies the stack by the frame address. Under ASan's
  use-after-return detection a local lives on a fake stack, so a call into
  Node from a hosted promise reaction got the wrong V8 stack limit and hit
  "Maximum call stack size exceeded" (6802, dev, sanitized).
- The ordering harness drops ASan's once-per-process makecontext/swapcontext
  warning from stderr, as the other harnesses do.
- 6811's island timers fire at 15 and 40 ms, after the program's immediate
  and its 5 and 10 ms timers, instead of racing the first loop turn.

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

This branch was successfully deployed

1 active deployment
Preview — b9ab8cfa Deployed Oct 22, 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.

1 participant