Skip to content

Bound V8's stack scan by Node's own stack in Node islands - #1898

Merged
ctate merged 51 commits into
ni/3-hosted-loopfrom
ni/i1-stack-bound
Oct 27, 2026
Merged

ctate merged 51 commits into
ni/3-hosted-loopfrom
ni/i1-stack-bound

Conversation

@cramforce

@cramforce cramforce commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

First PR of the Node islands stack (plan: NI-4 to NI-7). Base: ni/islands-base, a merge-only branch of #1696, #1684 and #1691 with no other changes; it retargets to ni/3-hosted-loop once #1684 and #1691 are in. This PR can also be folded into #1696.

What it does

A garbage collection during a call into Node crashed. V8 records the thread's stack start, which is the main thread's, as the bound of its conservative stack scan. Node runs on a stack that the runtime maps elsewhere, so the scan walked from there into unmapped memory.

  • The bridge answers v8::base::Stack start queries with the runtime's stack for callers on it. This is the host stack_start callback, bridge ABI 4.
  • An entry from an async fiber that a runtime job resumed inside Node's frames now runs on Node's stack, below those frames, instead of on the fiber's stack.
  • The compiler's libnode pack check and the harness's ABI mismatch test expect ABI 4.
  • p12-island-io-vs-busy is marked @order: race. It is a timer-versus-I/O race that Node also loses sometimes, and it failed under the gate's parallel load.

Evidence

  • New corpus program 6822-node-island-gc. It allocates enough inside sync and async Node calls, from the main program and after timers on Node's loop, for V8 to collect while JavaScript runs. Its output matches Node.

bridge.test.ts reads the expected ABI from scr_node_bridge.h, so every later ABI bump keeps it current.

Shortcuts

None of its own. The rest of the islands work follows in the stacked PRs.

Gate results

  • Island set in a Vercel Sandbox, with the libnode dev pack rebuilt from this branch's bridge (node-island*.test.ts, npm.test.ts, packages/compiler/src/node-islands, differential 68xx): plain 206/206 + diff 36/36; sanitized 206/206 + 36/36.
  • pnpm lint, pnpm format:check and pnpm test:ts7 --baselines-only pass.
  • The self-hosted compiler (packages/compiler/src/native/cli.ts --emit=llvm) builds.
  • Zero-cost gate: 2838 programs identical (benchmarks, self-hosted compiler, TypeScript corpus) against the base branch. The only DIFFERS is corpus/fs-extra-package (%m0.settleVoid). It differs in every gate of the stack, including Bound V8's stack scan by Node's own stack in Node islands #1898, which does not touch the compiler, so it is nondeterministic on the base. both-fail covers only the island programs, because the gate host has no libnode pack. Compile time stays within tolerance.

CI on the first push found failures outside the focused island set: the ABI in bridge.test.ts, the runtime ABI header declarations, the effect gate on the ts7 allowlist, weak collections in the compiler, the pipeline scout and the Node version line. The fixes are folded into the PRs that introduce each piece, and they are re-checked in a Sandbox on the final tree.

Stack

→ #1898
#1899
#1900
#1901
#1902
#1903
#1904
#1905
#1906
#1907
#1908

🤖 Generated with Claude Code

cramforce and others added 10 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>
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>
@vercel

vercel Bot commented Oct 23, 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 26, 2026 11:00pm UTC

Node islands stack, part 1 of 11 (scriptc-perf/results/islands-prs/PLAN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/runtime/src/scr_node_island.c Outdated
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>
A host callback marks where it runs on Node's stack, and a call into
Node.js from a fiber starts below that mark. Runtime code the callback
runs may go deeper on Node's stack before it starts or resumes a fiber,
and those frames stay live while the fiber runs: a call into Node.js
from the fiber overwrote them. scr_switch now reports where it leaves
Node's stack, and the mark moves down to it for the rest of the
callback.

6823 runs a timer callback 200 frames deep that starts an async
function calling into Node.js before its first await.
# Conflicts:
#	packages/cli/src/bootstrap.ts
#	packages/compiler/src/diagnostics/diagnostic.ts
#	packages/compiler/src/node-islands/libnode.ts
# Conflicts:
#	packages/cli/src/bootstrap.ts
#	packages/compiler/src/node-islands/libnode.ts
#	packages/libnode-common/smoke/smoke.c
# Conflicts:
#	packages/compiler/src/diagnostics/diagnostic.ts
#	packages/compiler/src/node-islands/libnode-pack.ts
#	packages/libnode-darwin-arm64/package.json
#	packages/libnode-darwin-x64/package.json
#	packages/libnode-linux-arm64-gnu/package.json
#	packages/libnode-linux-x64-gnu/package.json
#	packages/node/package.json
ctate and others added 5 commits October 26, 2026 13:25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The libnode-linux action becomes .github/actions/libnode, which serves
the Linux x64 and macOS lanes: the libnode-packs cache for the runner's
platform, else the development pack.

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

A nested entry below the mark makes its context with ni_make_context,
so macOS clears only the top slice of the range instead of the whole of
Node's stack below the mark on every nested call (17 ms per call, and
the cross-heap GC test ran past its timeout).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
@ctate
ctate merged commit f5b8ff9 into ni/3-hosted-loop Oct 27, 2026
220 of 223 checks passed
@ctate
ctate deleted the ni/i1-stack-bound branch October 27, 2026 01:11

This branch was successfully deployed

1 active deployment
Preview — 4063f1e1 Deployed Oct 26, 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