#3240

Build Node island programs with the native compiler

Opencramforcewants to merge intofrom

What

The natively compiled compiler (the one the scriptc package ships) now builds programs that need a Node island. Until now it built them into executables that could not start.

The defect

For a program with a Node island, the native compiler printed the SC8001 note, exited 0 and wrote an executable that exits 1 at start:

$ scriptc build main.ts -o prog
/vercel/sandbox/nci-repro/mini/main.ts:2:1 - note SC8001: package 'tiny@1.0.0' runs in a Node island: marked: marked "node" in scriptc.packages

  hint: it runs in Node.js loaded into the program on first use; --node-islands=off makes this an error
/tmp/nci-out/native-main/mini/prog
$ ./prog
scriptc: this program's Node island module manifest is malformed

(Output of main 549aa83d6's native compiler, trimmed: the note's code frame and the deprecation line of the --emit-ir the run also passed; the command is shown without its directories. main.ts is import { greet } from "tiny"; console.log(greet("island")); with "scriptc": {"packages": {"tiny": "node"}}.)

The Node-hosted compiler builds the same program correctly against the same libnode pack (bridge ABI 10, Node 24.22.1), so the pack is not the cause. prepareExecutableModule is shared by both drivers and decides and lowers the islands. Three steps that follow it existed only in index.ts, and each shows when the two compilers' outputs are compared:

step (Node-hosted driver)Node-hosted outputnative output on main
libnodeRecord"nodeIsland":{"libnode":"/…/libnode-pack","nodeVersion":"24.22.1",…}"nodeIsland":{"libnode":"","nodeVersion":"",…}
finalizeDeferredIslandsnodeIsland.defer(path, <snapshot>)nodeIsland.defer(path, "")
readNodeIslandModulesscr_node_island_install_modules(manifest, 332, @sc_node_island_modules, 266, …)scr_node_island_install_modules(manifest, 332, null, 0, …)

The last row is the message: the manifest lists files and the executable carries none. It reproduced for every way an island comes about (a package marked "node", a package whose lowering is refused, the pre-scan, the fences, a module of the program, the @scriptc/node call API). requireModule() failed differently: the native compiler had no island module resolver, so the load became a runtime fence and the executable ended with Uncaught Error: 'requireModule (@scriptc/node) of './plugin.mjs', which does not resolve from …. --libnode was accepted and ignored. No test built an island program with the native compiler.

The change

Two commits. The first refuses such programs by name in the native driver (SC3002) and stands alone, in case the second needs more review. The second replaces the refusal with the build.

  • One copy of the island steps. executable/islands.ts finishNodeIslands holds the three steps. index.ts calls it and loses its copy (57 lines out, 15 in); native/prepare.ts calls it after the shared prepare and passes the module bytes to the emitter. Nothing was copied into the native driver.
  • An island build host (node-islands/host.ts, replacing resolve.ts) answers what depends on where the compiler runs: module resolution for island code, the libnode pack installed with the compiler, the runtime sources the deferral probe host is built from, and the static effect gate. The Node-hosted compiler installs Node's own resolution (host-node.ts); the native compiler uses the shared filesystem resolver, as a compiled program's require.resolve does, and its toolchain manifest (the distribution already ships runtime-sources). The gate parses with the TypeScript 5 parser, which the native compiler does not have; it only words the deferral trace (the probe decides), so the native trace says "recorded load is deterministic" without the gate's remark.
  • The probe walks typed IR. snapshot.ts found island loads by reflecting over the module's objects; it now uses everyStmtList, which the native compiler compiles and the Node-hosted one runs the same way. libnodeRecordFor / libnodeCacheKeyFor take the native target from the driver, so the native compiler never reaches the host inspection behind libnodeTarget (process.report, which it does not compile).
  • Native caches. A frontend cache entry of a program that calls into Node keeps the libnode pack's identity and is reused only while it is unchanged; a --libnode=beside build always lowers, since it places the pack and the modules from the lowered module; island builds record no unchanged build and take no token-equivalent reuse (their LLVM carries the island modules' bytes). A program without islands takes none of these paths: finishNodeIslands returns at once when the module has no island record.
  • Tests. The bootstrap contract's command phase builds one island program per kind with the native command (6870, 8810, 6817, 6819, 8424, 6850, and a package named with --node-island=), runs each without Node on PATH against the program under Node, and compares its LLVM (bytes) and IR (as a value) with the Node-hosted compiler's build of the same command line. CI's bootstrap command phase gets the libnode pack. native/prepare.test.ts covers the module bytes, the eager form without a pack, SC8005 without a pack, and the cache rules.

Proof

Sandboxes are 32 vCPU / 64 GB Xeon 2.50GHz or 2.90GHz, named per line; the libnode pack is the candidate's (bridge ABI 10, Node 24.22.1). Runs marked "pre-rebase" were on the same two commits over main a09254dd3. "This head" in the tables is 67234eba7, the two commits over c3e73cbfd. The head pushed is the same two commits rebased once more, without conflict, over 716cb8626 (17 commits later): on it the build, lint, format check and the 381 focused tests were rerun and pass; the bootstrap contract and the gate were not rerun there.

The native compiler against Node, every corpus program that needs a libnode pack (95 programs with // @requires-libnode; native compiler from pnpm build:native-compiler, 955 s; 2.50GHz; pre-rebase):

count
built by the native compiler / by the Node-hosted compiler95 / 95
native-built executable matches Node (stdout, stderr, exit status)94
Node-hosted-built executable matches Node94
native differs from Node where Node-hosted matches0
LLVM byte-identical between the two compilers93
IR equal as a value between the two compilers93

The program that differs from Node under both compilers is 6847, by the process id in Node's deprecation warning, which this plain runner does not normalise. The two whose LLVM and IR differ are 6825 and 6845: see "Not in this PR". Main's native compiler was tried on nine of these programs; none of its executables started.

The Node-hosted compiler, base against candidate, on the same 95 programs (the zero-cost gate has no libnode pack, so island programs fail to build on both of its sides; this is the comparison it cannot make; 2.50GHz; pre-rebase): 95 of 95 build with both, LLVM byte-identical 95, IR byte-identical 95, diagnostics identical 95. Moving the steps into one function changed nothing the Node-hosted compiler writes.

The island steps compiled natively against the same steps under Node (a scratch program around finishNodeIslands, fed the un-finalized IR of main's native compiler; five programs × exe, ir, llvm; the port over 549aa83d6): 15 of 15 give the same libnode record, module bytes (length and SHA-256) and load calls with their full snapshot text, including the eager decision for the module with effects (6842).

Bootstrap contracts (seed built as CI builds it, then pnpm vitest run tests/harness/self-hosting-native-driver.test.ts, SCRIPTC_LIBNODE set):

lanephasesresultseedcontractisland step in the logmachine
plain, pre-rebaseallpass1,264 s1,379 snot labelled yet2.50GHz
plain, pre-rebasecommandspass887 s516 sran, 29.4 s2.50GHz
sanitized, pre-rebaseallpass1,359 s2,832 snot labelled yet2.50GHz
sanitized, pre-rebasecommandspass1,224 s824 sran, 44.3 s2.50GHz
plain, this headcommandspass771 s388 sran, 20.5 s2.90GHz

The two full runs used the contract before its island block was a named step; the command-phase runs use the contract as committed and show bootstrap: Node island programs match Node and the Node-hosted compiler took …s. The rebuild phases were not rerun on this head.

Zero-cost gate, --gate-corpus=all, this head 67234eba7 against c3e73cbfd: PASS. 5,241 programs identical, 102 fail to build on both sides (all 102 are SC8005, island programs without a pack), none differ, no runtime-only row. Compile time: bench +0.25% [95% CI −0.83%, +1.53%], self −0.05% [−1.61%, +0.57%], fixtures −0.44% [−1.86%, +0.28%], all within tolerance (five pairs per fixture). Pre-rebase, against a09254dd3: PASS, 5,214 identical, 97 both-fail, none differ.

This head also: pnpm lint and pnpm format:check clean; 381 tests in 28 files pass (packages/compiler/src/node-islands, native, executable, and tests/harness/node-island{,-deferral,-handles,-order}.test.ts with the pack).

--libnode (the port over 549aa83d6): --libnode=/nonexistent/pack on an island program is SC8005 from both compilers (main's native compiler exited 0). --libnode=beside writes the same <out>.libnode/ (pack and modules) from both; the moved output runs without SCRIPTC_LIBNODE and matches Node; a native rebuild after deleting the directory places it again.

The first commit alone (the refusal, over 549aa83d6): unit test; plain bootstrap pass (seed 1,104 s, contract 1,539 s, 2.90GHz) after a first attempt whose seed build ran into the contract's 30-minute limit on a sandbox busy with other jobs; sanitized bootstrap pass (4,026 s); gate PASS on a second round (5,195 identical, 96 both-fail, none differ; fixtures −0.13% [−1.25%, +1.34%], five pairs) after a first round that reported fixtures +2.58% [+2.13%, +3.04%] from two pairs per fixture with identity clean.

Not in this PR

  • The native compiler puts more packages in islands than the Node-hosted one. It refuses a package's require() of another package (SC1010: the 'pb' module is not supported yet) where the Node-hosted compiler compiles both natively. With this PR such a program builds and matches Node, with the requiring package in an island; with --node-islands=off it is refused by name. That is why 6825 and 6845 are the two corpus programs whose LLVM differs between the two compilers. It predates this change (main's native compiler makes the same decisions) and belongs to the native program loader.
  • The two compilers serialize a module's top-level members in different orders (globals before or after records and unions), for any program, so their IR is equal as a value and not byte-identical. The contract compares it the way it compares the compiler's own IR.

🤖 Generated with Claude Code

Activity

11
vercel[bot]6d ago

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
scriptc ReadyPreview, v0Nov 7, 2026 2:14pm UTC
vercel-internal-security-reviewer[bot]6d ago
C
1620462
cramforce5d ago

Brought up to date with main (50bcfe1b3): two merge commits on top of the earlier merge 16204625c, 5c1f4916d (main 895e57f12) and 398f2098c (main moved again while the first was being proven).

What conflicted, both times in the native driver's frontend cache (native/prepare.ts):

  • #3249 replaced the condition under which a frontend result is published with unpublishable(), which also says why a build is not cached. This branch's island rule joins it as two more reasons: a --libnode=beside build is not published (it places the pack and the island modules from the lowered module), and neither is an island build whose libnode pack changed during the build.
  • #3251 made equivalentFrontendInput return a reason instead of null and reuses an entry after comment edits. This branch's rule joins it as one more reason: an island program's output carries its island modules' bytes, which no token comparison of sources covers, so such an entry is not reused. main's per-file warning check replaces the blanket warning condition, as on main.
  • native/prepare.test.ts and the bootstrap contract's imports: both sides added tests and an import at the same place; all kept.

One thing I changed that the earlier merge had decided differently. 16204625c kept SCRIPTC_BOOTSTRAP_IR_INPUT and preparedFromIr in native/prepare.ts, with the island steps skipped for an IR input. #3218 removed that hook from main: the bootstrap contract builds its sanitized rebuild with --from-lowered, and nothing in the tree sets the variable any more. Because this branch edits the lines around the hook, a merge presents it as the branch's own text. I removed it again, as on main, so the native driver has one way to take a lowered program (prepareLoweredProgram, with its checks of compiler, options and inputs), and the island steps follow it in both drivers. The merge commit itself is untouched. If the hook was kept on purpose, say so and I will put it back.

#3216 (the island module tree's install and its manifest name) changed node-islands/modules.ts, which both drivers reach through finishNodeIslands; it did not touch the steps this PR moved, so there is nothing to port. The contract below is the check that the native path does what the Node-hosted one does.

Re-run on the pushed head 398f2098c (Vercel Sandboxes, 32 vCPU / 64 GB, Intel Xeon @ 2.90GHz; libnode pack built from this tree's bridge, ABI 10, Node 24.22.1):

  • Workspace build with the runtime pack rebuilt, pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only: pass.
  • Bootstrap contract, plain, all phases (tests/harness/self-hosting-native-driver.test.ts with SCRIPTC_LIBNODE): pass in 1,748 s. Its command phase ran the island block ("Node island programs match Node and the Node-hosted compiler", 22.8 s: the seven island programs built by the native command, run without Node on PATH, LLVM and IR compared with the Node-hosted compiler's). The same run on the first merge 5c1f4916d passed in 1,823 s (island block 27.1 s).
  • Differential and LLVM differential tests over every corpus program matching island: 228 pass plain, 228 pass with SCRIPTC_SAN=1.
  • packages/compiler/src/node-islands, native (with prepare.test.ts: both sides' tests), executable, tests/harness/node-island{,-deferral,-handles,-order}.test.ts, tests/harness/lowered-program.test.ts: 435 pass, 1 fails. The failure is main's: executable-cache.test.ts > "every build and run option is in the cache keys or says why it need not be" (#3218's options have no rule in #3260's guard). #3306 fixes it; CI here shows it in the plain Linux lane until #3306 is on main.

Still running: the sanitized bootstrap contract on this head; I will add its result here. Not re-run: the zero-cost gate (the resolution is in the native driver's cache, which the gate's Node-hosted builds do not run; the PR's own gate result is from 67234eba7), the 95-program native-against-Node sweep, the full lanes.

🤖 Generated with Claude Code

cramforce5d ago

The sanitized bootstrap contract on 398f2098c (the result my comment above said was still running): pass in 2,791 s (SCRIPTC_SAN=1, all phases, 32 vCPU / 64 GB, Intel Xeon @ 2.90GHz). The island block ran in its command phase (25.4 s), the sanitized native emitter rebuilt the compiler (1,527 s) and the sanitized native frontend lowered it (1,746 s), and both compared equal to the Node-hosted compiler's LLVM and IR. The emitter rebuild is the phase that builds from a lowered program (--from-lowered), which is what replaced the removed SCRIPTC_BOOTSTRAP_IR_INPUT hook on main.

🤖 Generated with Claude Code

cramforce5d ago

A third merge commit, 6d122e372, for one file: #3293 refreshed tests/harness/island-conformance/known-failures.json on main, and the branch conflicted with it again.

How it is resolved. The earlier merge of main into this branch (16204625c) had regenerated the same list inside the merge commit; the two lists differ in one entry (npm/hono-zod-default-hook, which #3293 corrected). main's list is taken as it is. This PR changes the native compiler, and the lane builds with the Node-hosted one, so the PR moves no entry of its own.

Re-run on 6d122e372 (one Vercel Sandbox, 32 vCPU, Intel Xeon @ 2.90GHz; libnode pack built from this tree):

  • Workspace build, pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only, native/prepare.test.ts (46 pass).
  • The island conformance lane (SCRIPTC_ISLAND_CONFORMANCE=1, all 234 programs): 413 pass, 69 skipped, 1 fails, and it fails on main without this branch: packages/semver > island:semver, "packages/semver runs semver in an island by default now: move its island:semver entry to default". I ran that case on plain main 97d372266 in the same sandbox and it fails the same way, so #3293's list is one entry behind main. CI's "Node island conformance" job will be red here for that reason until the list is refreshed again; I am regenerating it on main for a separate PR.

The bootstrap contracts (plain and sanitized) and the island tests reported above ran on 398f2098c; this commit changes only the list.

🤖 Generated with Claude Code