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 output | native output on main |
|---|---|---|
libnodeRecord | "nodeIsland":{"libnode":"/…/libnode-pack","nodeVersion":"24.22.1",…} | "nodeIsland":{"libnode":"","nodeVersion":"",…} |
finalizeDeferredIslands | nodeIsland.defer(path, <snapshot>) | nodeIsland.defer(path, "") |
readNodeIslandModules | scr_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.tsfinishNodeIslandsholds the three steps.index.tscalls it and loses its copy (57 lines out, 15 in);native/prepare.tscalls 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, replacingresolve.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'srequire.resolvedoes, and its toolchain manifest (the distribution already shipsruntime-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.tsfound island loads by reflecting over the module's objects; it now useseveryStmtList, which the native compiler compiles and the Node-hosted one runs the same way.libnodeRecordFor/libnodeCacheKeyFortake the native target from the driver, so the native compiler never reaches the host inspection behindlibnodeTarget(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=besidebuild 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:finishNodeIslandsreturns 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 onPATHagainst 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.tscovers 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 compiler | 95 / 95 |
| native-built executable matches Node (stdout, stderr, exit status) | 94 |
| Node-hosted-built executable matches Node | 94 |
| native differs from Node where Node-hosted matches | 0 |
| LLVM byte-identical between the two compilers | 93 |
| IR equal as a value between the two compilers | 93 |
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):
| lane | phases | result | seed | contract | island step in the log | machine |
|---|---|---|---|---|---|---|
| plain, pre-rebase | all | pass | 1,264 s | 1,379 s | not labelled yet | 2.50GHz |
| plain, pre-rebase | commands | pass | 887 s | 516 s | ran, 29.4 s | 2.50GHz |
| sanitized, pre-rebase | all | pass | 1,359 s | 2,832 s | not labelled yet | 2.50GHz |
| sanitized, pre-rebase | commands | pass | 1,224 s | 824 s | ran, 44.3 s | 2.50GHz |
| plain, this head | commands | pass | 771 s | 388 s | ran, 20.5 s | 2.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=offit is refused by name. That is why6825and6845are 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 (
globalsbefore or afterrecordsandunions), 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
11Brought 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=besidebuild 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
equivalentFrontendInputreturn a reason instead ofnulland 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 onmain. native/prepare.test.tsand 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.tswithSCRIPTC_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 onPATH, LLVM and IR compared with the Node-hosted compiler's). The same run on the first merge5c1f4916dpassed 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 withSCRIPTC_SAN=1. packages/compiler/src/node-islands,native(withprepare.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 ismain'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 onmain.
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
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
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 onmainwithout 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 plainmain97d372266in the same sandbox and it fails the same way, so #3293's list is one entry behindmain. CI's "Node island conformance" job will be red here for that reason until the list is refreshed again; I am regenerating it onmainfor 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
The latest updates on your projects. Learn more about Vercel for GitHub.