Skip to content

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

Merged
ctate merged 75 commits into
mainfrom
ni/3-hosted-loop
Oct 28, 2026
Merged

ctate merged 75 commits into
mainfrom
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 28, 2026 2:07am UTC

Comment thread packages/runtime/src/scr_node_island_host.cjs Outdated
Comment thread packages/runtime/src/scr_async.c
Comment thread packages/runtime/src/scr_node_island_host.cjs Outdated
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>
cramforce and others added 4 commits October 21, 2026 17:52
Add the @scriptc/libnode-<platform> packs (linux-x64-gnu, linux-arm64-gnu,
darwin-arm64, darwin-x64): the pinned Node.js 24.22.0 built as a stripped
shared library with full ICU and its startup snapshot, the scriptc bridge
compiled against that release's headers, Node's LICENSE and a manifest
with the Node version, NODE_MODULE_VERSION, bridge ABI and file hashes.

packages/libnode-common holds the pin, the build (fetch and verify the
source tarball, configure --shared, build only the libnode target, strip,
stage with headers in a cache keyed by release, flags, platform and
toolchain; the bridge rebuilds in seconds against the cached headers),
a verifier, and a smoke test: a C program that loads a pack the way the
runtime does, starts Node from the snapshot and evaluates expressions
synchronously, asynchronously and throwing.

The libnode-packs workflow builds the packs on native runners, Linux in
the manylinux_2_28 images for a glibc 2.28 floor, caches each libnode
build, smoke-tests and uploads every pack. It never runs in the ordinary
build or test path. The compiler gains the pack lookup (SCRIPTC_LIBNODE,
then the installed package), validation, the nodeIslands.libnode
placements and the license notices for the Node island loader to use.

The bridge source and its C ABI header are the NI-1 bridge agent's,
included here so the packs build; the packs stay private until the
in-process island runtime ships.

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

Stack the libnode packs on the in-process loader (#1685) and use its
pack assembler and manifest format instead of a second one:
build-pack.mjs keeps its CLI and fields (schema, nodeVersion,
moduleVersion, bridgeAbi, platform, arch, files), adds the libnode and
bridge names, strips and (on macOS) re-signs the bridge and links it with
-z defs, and exports buildPack(), which the platform packs call with their
package, target and build inputs as extra manifest fields.

libnodeRecord falls back to the installed @scriptc/libnode-<platform>
pack, found from the entry's directory and then next to the compiler,
when neither --libnode nor SCRIPTC_LIBNODE names one.

The Linux test lanes restore the libnode build that the libnode-packs
workflow cached, assemble the linux-x64 pack from it in seconds
(build.mjs --from-cache never builds Node) and export SCRIPTC_LIBNODE, so
the Node island corpus and harness tests run instead of skipping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An installed @scriptc/libnode-<platform> pack versions in lockstep with
the compiler, whose version every executable cache key already holds;
keying on it as well made the full compiler's key differ from the
bootstrap's routed key wherever a pack directory was present, so routed
cache hits stopped. Only directories with a manifest.json count as
installed packs, so an unbuilt workspace package is not one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cramforce and others added 4 commits October 22, 2026 14:17
CI's island lanes fall back to the development pack (dev-pack.json: Node
24.22.1, no package.json) until the libnode-packs cache exists, so the
smoke test, which verifies a @scriptc/libnode-<platform> package against
the 24.22.0 pin, failed there. It now runs only when SCRIPTC_LIBNODE names
a package directory. The pack package versions follow the 0.4.2 release
(scripts/sync-versions.mjs), which pin.test.ts checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
g01 (twenty fs.stat round trips against a 2 ms timer), p10 (two 0 ms
timers armed on either side of an island call) and s06 (fs callbacks and
timers pending when Node loads) each failed one CI run in a different
lane: their order depends on how long the island call or the I/O takes,
which differs between the sides and grows under ASan. Their native runs
now have to produce the lines of one of Node's outputs, like the other
race programs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cramforce and others added 2 commits October 22, 2026 18:49
Node islands stack, part 1 of 11 (scriptc-perf/results/islands-prs/PLAN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hosted loop's scheduling entries (a tick, a timer or an immediate goes
through the bootstrap's helpers; a checkpoint drains ticks and microtasks)
and island process-event listeners called the bridge on whatever stack the
runtime was on: the main thread's for the first checkpoint, or an async
fiber's. JavaScript then ran there, and a collection it triggered failed
V8's "Check failed: isolate_->IsOnCentralStack()" (node-island-handles,
about 1 run in 15 sanitized: promise reactions run by the first checkpoint
on the main stack allocated a microtask). They now run on Node's stack like
every other entry, below the mark when they come from a fiber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cramforce and others added 3 commits October 26, 2026 13:34
The nested trampoline never returns (setcontext back), so the redzones
around its locals stayed poisoned in the shadow of Node's stack, and
Node's own frames tripped over them later (stack-buffer-underflow in
__bzero, sanitized corpus 6822/6823/6831/6832/6894 on macOS). Linux's
ASan clears a context's stack in its swapcontext interceptor; macOS's has
none.

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

socket-security Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedgithub/​actions/​cache@​0057852bfaa89a56745cba8c7296529d2fc398309810010010080

View full report

6809-node-island-stdio armed a 5 ms island timer and a 10 ms native timer
next to immediates. On a loaded host the main script can run past 5 ms
after arming, so Node runs the island timer before the immediates and the
output order depends on load. The timers are now 50 ms and 100 ms, well
after the script and the immediates finish; the expected order is
unchanged.
cramforce and others added 2 commits October 27, 2026 05:40
# Conflicts:
#	packages/compiler/src/index.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cramforce
cramforce requested a review from ctate October 27, 2026 14:17
Base automatically changed from ni/1-libnode-loader to main October 27, 2026 23:48
An island signal registration is now tagged with the raw listener it
installed, so a once registration and an on registration of one function
dispatch through their own listeners instead of both resolving to the plain
function, and the once registration no longer fires indefinitely. The
removal handler forgets the entry whose raw listener is no longer on
process, so a once wrapper that removes itself leaves its runtime
registration behind no longer; duplicate registrations of one function
share a raw listener, and the most recent of those is removed.

refresh() of a timer armed before Node loads re-arms at now + the delay the
timer was created with. Its Node handle was created with the remaining delay
at handoff and a due timer had no handle at all, so the handle is replaced
rather than refreshed.

The signal program registers one function once and on; a new timer program
refreshes a timer most of whose delay passed before Node loaded.

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

Copy link
Copy Markdown
Contributor Author

Fixes for the three open Vercel Agent findings are pushed as 8887f35 (also merged into #1899 so the islands stack stays consistent).

What changed

  1. Signal listener bookkeeping (scr_node_island_host.cjs) — each island entry now records the raw listener its registration installed (on/addListener/prependListener are wrapped, and process.once registers through process.on), so emit() applies exactly that entry's listener or once wrapper. This stops once(f) + on(f) on the same signal from both resolving to the plain function and the once registration firing indefinitely.
  2. Removal handler (scr_node_island_host.cjs) — the handler now accepts either the entry's listener or the raw listener it installed and forgets the entry whose raw listener is no longer on process, falling back to the last match only for duplicate registrations that share one raw function. A once wrapper removing itself no longer leaves a runtime registration behind that swallows the signal.
  3. Migrated timer refresh (scr_async.c) — scr_hosted_timer_rearm clears any handle and re-arms with the timer's original delay_ms; a timer that was already due at handoff leaves the due list and is armed, and a firing timer keeps its re-armed handle after the callback. refresh() now restarts the full delay instead of the remaining one.

Tests

  • tests/corpus/6804-node-island-signals/main.mjs gains the once(f) + on(f) case: the once registration fires once and is gone afterwards, the on registration survives.
  • New tests/corpus/6890-node-island-timer-refresh (@requires-libnode) covers refresh() on a migrated timer while the host is busy; it has its corpus-cost entry and a ts7 order-parity baseline.

Zero cost

All C changes are inside #ifdef SCR_NODE_ISLANDS, and the new pack variants (node-island, stdio-extended-node-island) are only added when the program uses Node islands, so programs without islands keep their default runtime objects byte for byte.

CI

The earlier red runs were the Object.isFrozen of this native value has no lowering [SC2020] regression in tests/harness/effect4.test.ts, which landed on main and is reverted in a4a844f; main is merged in. The runs on 8887f35 are in progress.

🤖 Generated with Claude Code

@cramforce

Copy link
Copy Markdown
Contributor Author

Validation note — zero-cost gate

bin/validate … --skip=lanes,bench,t262,san-bootstrap reports one non-island difference, plus the six @requires-libnode corpus programs as build errors (the gate sandbox has no libnode pack):

DIFFERS  corpus/4032-inline-rc-fast-paths.ts  exe: resized scr_arr_new; size 758552 -> 799608 B (+5.41%)
         SYMBOLS: gained scr_dyn_add_primitive.scrunit.scr_json, scr_dyn_define_symbol.scrunit.scr_json, …
zero-cost gate: FAIL — 3280 identical, 6 build-error, 1 differs over 7 shards

IR and LLVM for that program are byte-identical; only the link differs. gate-self passes (the compiler source builds with the candidate, 394.69 s → 401.45 s, +1.71%, within tolerance), and a second run with --rebuild-base reproduces exactly the same single difference.

The cause is the cached base runtime pack, not this branch. 4032-inline-rc-fast-paths.ts carries // @optimization: speed, and runtimePackFlavorKey links the pack's speed flavor when present and falls back to release when it is not. The base snapshot's pack has six flavors and no speed; a pack rebuilt with the LLVM helper present has seven. The gained symbols are exactly the helper-promoted speed units (artifacts/speed/runtime/default/scr_json.o alone defines 596 *.scrunit.scr_json symbols; the release object defines none). Every C change in this branch is inside #ifdef SCR_NODE_ISLANDS and touches none of those units.

Proven by rebuilding the base tree's pack with the helper: the base compiler at --optimization=speed then produces 799608 B for 4032, and this branch's compiler with its own helper-built pack produces a byte-identical executable (cmp clean, sha256 65b2f101fab7b8c2417f…).

Root cause in the tooling: validate/remote/prep.sh starts the LLVM helper build and the runtime pack build in parallel (both backgrounded, then wait). A base build passes --runtime --llvm, so the runtime build resolves the helper before it exists and silently produces a pack without import bitcode or the speed flavor, while the candidate build (which reuses the base's helper) gets both. The fix belongs in prep.sh — wait for the LLVM build before starting the runtime build when --llvm is requested — and is outside this PR.

🤖 Generated with Claude Code

@ctate
ctate merged commit 93081ea into main Oct 28, 2026
118 checks passed
@ctate
ctate deleted the ni/3-hosted-loop branch October 28, 2026 03:19

This branch was successfully deployed

1 active deployment
Preview — 8887f35f Deployed Oct 28, 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