What
#3117 lets a build keep its Node island decision in a file and start the next build from it. This PR makes that something a project has rather than a flag someone passes, and closes the cases where a recorded island could outlive its reason.
Base: feat/node-island-decision-wp1, which is #2881 with #3117 merged in (the two conflict in executable/prepare.ts; that branch is the resolution). The diff here is only this PR. It retargets to main when those two land.
The file serves a project. It holds one section per entry file, target platform and set of options that steer the decision (--node-island, --native, --npm-static, --tier), so a project with several programs, built on macOS and Linux, keeps one file and each build rewrites only its own section. A section lists every island of the program with the rule that put it there: load for the islands the first decision makes from the load, and refusal, preflight, lowering, esm-importer, fence-report or fence-sure for the ones a round grew. The growth rules stamp the rule on what they add.
A replay asks what the record cannot know.
- Does the program still import each recorded island? The first round asks its load's scan. An island nothing imports leaves the set, with every ES-module importer that was an island only because of it. Before, such a package stayed an island for good, with its files in the executable.
- Do the islands a fence rule added still stand? The fence report runs only for a build that fails, and a fence that surely fires islands its package only when the build with the island compiles. So a section records how its build ended (only while it holds such islands), and when a replayed build ends the other way those islands are dropped and the build is decided once more from the rest, which is the round a build from scratch would be in. A
fence-reportisland recorded by a build that compiled is what made it compile and never goes stale this way. - A section another compiler release recorded is decided from scratch.
- A build that stops before its lowering (a type error) decided nothing and leaves the file as it is.
What a replay still cannot notice is a still-imported package that would now compile: only lowering it natively can tell, which is the work the file saves. That is what the check is for.
A check. scriptc build <entry> --node-island-decision-check decides from scratch with the frontend only, compares with the section a build would write, prints one line per island that differs and exits 1 if any does. It writes nothing and generates no code. scripts/island-decision-sweep.mjs records, replays and checks every corpus program and npm-suite case.
A project key. "scriptc": { "nodeIslands": { "decision": true } } in package.json names scriptc-islands.json beside it (a string names another file). With the key, build and run replay and record without a flag. coverage replays the file and never writes it. --node-island-decision=<file> overrides the key, and --ignore-node-island-decision turns it off for one command.
What a build prints. Nothing when the replayed decision stands. When it writes the file, one line on stderr and one per island that changed:
scriptc: updated the Node island decision in scriptc-islands.json (no islands)
~ outcome: compiles -> none
- ejs@7.0.2: the program no longer imports itHow
node-islands/decision-file.ts: the sections, the per-entry checks at read (readIslandDecision), the import check (admitRecorded), the outcome rule (staleByOutcome, two lines inholdsWhile), the write (re-reads the file and replaces its own section) and the comparison.executable/prepare.ts:prepareExecutableBuildruns the rounds from the record, applies the outcome rule at most once, and writes. In the first round of a replay, when the load shows a recorded island is gone, the round returnsretrywith the rest; the next round decides from the captured load, so nothing loads twice. Each round reports whether it reached its lowering and every island it ran.checkIslandDecisionis the same rounds from scratch.node-islands/growth.ts,graph-islands.ts: each rule stampsrule(andfollows) on theIslandPackageit adds.frontend/analysis.ts,node-islands/report.ts: an analysis lowers with the recorded packages, applies the same two checks, and its island report names a replayed package with the class and reason the build recorded (it never lowers that package natively, so it has no refusal of its own to show).project-config.ts,cli/*,index.ts,native/*: the key, the three options,checkIslandDecisionfor both hosts. Options are resolved once wherecompileandanalyzebegin, so every cache key reads the file from one field. The native executable cache no longer stands aside for a decision file: its key is the generated LLVM, which a different decision changes.
Proof
Against the base feat/node-island-decision-wp1 (f9242bac8), in 32 vCPU Vercel Sandboxes (Intel Xeon @ 2.90GHz). The sweep, the island-program comparison, the large-program builds and the lanes ran on df95bba22; the head, e1dd21d7f, adds the lock around the file's write and two tests, and the tests, the gate and the bootstraps were run again on it.
-
Tests.
packages/compiler/test/island-decision-replay.test.tsbuilds real island fixtures throughprepareExecutableBuildfrom scratch and from the record, and usescheckIslandDecisionas the oracle throughout: a replay is one round with the same serialized IR, warnings and island list, or the same failure, for a fence-report island, a lowering refusal and a sure-fence island; a package the program stopped importing leaves the set in one lowering and the result is the build from scratch's; a failing build's fence-report island is decided again when the program compiles (a package that compiles natively ends native, as from scratch) and comes back when the error does; a sure-fence island is decided again when the program stops compiling; a build that stops at a type error leaves the file byte for byte; an island the load decides is listed and not replayed; an analysis lowers once instead of twice with the record and reports the same statements and diagnostics.node-islands/decision-file.test.tscovers the file (sections, other sections left byte for byte, the compiler release, each way an entry leaves, each kind of difference the comparison names, the project key).packages/cli/test/node-island-decision.test.tscovers the command line: the project key records and replays without a flag,--ignore-node-island-decision, a second entry's section, coverage, the check's output and exit status for a match, a missing file, a missing island, an extra island and a program that fails before its lowering, and that the check writes nothing. 278 tests pass across these, the island round suites andtests/harness/node-island.test.ts. -
The whole corpus and the npm suite: record, replay, check.
node scripts/island-decision-sweep.mjs --jobs=30: 5,422 programs and cases, 5,421 recorded a decision, 85 with an island (87 island packages, 12 island modules), 1 stops before its lowering. 0 differ: every replay writes the IR and prints the diagnostics of its recording build, leaves the file as it is, and the check from scratch reports no difference. 1,043 s. -
Without a decision file nothing changes. Zero-cost gate,
--gate-corpus=all, no--gate-allow: PASS on this head: 5,207 programs identical (IR, LLVM, release executables), 96 fail on both sides, 0 differ. Compile time within tolerance: benchmarks −0.56 % [95 % CI −1.79 %, +1.17 %], the compiler compiling itself −0.88 % [−3.02 %, +0.88 %], fixtures −0.70 % [−1.40 %, +0.01 %]. (The same gate ondf95bba22was also identical for all 5,207, with the self-compile time +1.64 % [+0.10 %, +4.91 %], which the gate called inconclusive; this run settles it.) The gate links executables, so it cannot compare programs that need a libnode pack: separately, all 85 island programs build to byte-identical IR and diagnostics with the base compiler and with this one. -
A large multi-package program (14 island packages, 2 island modules, stops at the end of lowering with 54 errors), one build at a time in one sandbox, the same diagnostics byte for byte in every build:
build wall peak RSS rounds no option 819.39 s 8.15 GB 2 option given, no file yet: decides from scratch and records 640.95 s 8.06 GB 2 replay 315.38 s 6.30 GB 1 --node-island-decision-check: matches, exit 0725.00 s 7.66 GB 2 replay 361.33 s 5.21 GB 1 The file is byte for byte the same after both replays. The three runs that decide from scratch do the same work and range from 641 to 819 s, so read the gain as 1.8 to 2.6 times. The recorded section lists 7
lowering, 5fence-report, 1esm-importerand 1loadpackage. -
Lanes with the libnode pack: plain 21,674 passed, 0 failed. Sanitized 18,439 passed, 3 failed, all in the one shard that also runs the sanitized LLVM-emitter bootstrap (2,576 s there): two are test timeouts (
npm-staticEffect generator at 900 s,three.jsnodes at 1,200 s) and one is a compile of an Effect 4 fixture that did not finish. None builds with a decision file or an island. Run again on their own under the sanitizer on this head, all three pass (195 s, 382 s and 697 s). -
Native bootstraps (
self-hosting-native-driver,self-hosting-llvm-emitter; the driver's round loop and the decision file are compiled by the self-hosted compiler). Ondf95bba22: plain pass (3 tests, 2,397 s), and withSCRIPTC_SAN=1pass (3 tests, 4,155 s). On this head: plain pass (3 tests, 2,845 s), and withSCRIPTC_SAN=1pass (3 tests, 4,197 s). -
pnpm lint,pnpm format:check: clean.
Not in this PR
- With the project key, the CLI's start-up fast path (an unchanged build answered without loading the compiler) is not taken, because its key does not carry the file's digest; the build is still answered from the compile cache.
coveragedoes not replay a recorded island module on main: its analysis never runs a program module in an island and type-checks its first load, which still holds the module (a test pins this). #3186 gives the analysis the build's rounds; the replay then seeds its modules too.coveragedoes not record. On main its island set is not the build's (it admits deferred imports a build leaves to its lowering and has no fence report), so a section it wrote would hand the next build islands it should not have. With #3186 it is onewriteIslandDecisionafter the analysis's loop. On the large program, coverage takes 915 s without the file and 360 s with the build's file.- Documentation is in a separate PR on top of #3131, which adds the Node islands page.
🤖 Generated with Claude Code
Activity
205Review the following changes in direct dependencies. Learn more about Socket for GitHub.
| Diff | Package | Supply Chain Security | Vulnerability | Quality | Maintenance | License |
|---|---|---|---|---|---|---|
| npm/@types/semver@7.8.0 | ||||||
| npm/@types/lodash@4.17.27 | ||||||
| npm/lodash@4.18.1 | ||||||
| npm/date-fns@4.4.0 | ||||||
| npm/chalk@6.0.1 | ||||||
| npm/yaml@2.9.1 | ||||||
| npm/uuid@14.0.3 | ||||||
| npm/semver@7.8.6 | ||||||
| github/pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 |
A replay that lowered twice: the import check dropped islands the program still imports
Found on a large program: its replay lowered the program twice. The cause is in this PR, and three commits on top of a merge of
mainfix it. The reviewer's04d5c3727is merged in on top.Cause. A replay asks its first load which recorded islands the program still imports and drops the others (
admitRecorded). It asked the island scan, and the scan is not the list of files the lowering compiles: it follows the startup module order and literalimport()calls, and it collects package edges only when the first load holds an npm package. An island whose only import is in a module the lowering reaches another way, or in a program whose packages onlyimport()loads, had no edge. The check dropped it, the lowering refused its use again (SC2013), and the replay paid the round the record exists to save. It ended with the recorded set, so the file, the IR and the diagnostics showed nothing; the sweep did not count rounds.On the large program the check dropped four of 17 recorded packages. All four are static imports of one module that the program reaches through a wrapper around
import().Small reproduction, an existing npm-suite case,
tests/fixtures/npm/cases/dynamic-import(two packages that onlyawait import("pkg")loads):e1dd21d7f)round 1 admit islands=[], then both found againFix (
d8d1ac632).Frontend.islandImports(names)returns the imports of the recorded packages in every implementation file the loaded program holds. That is a superset of what the lowering compiles, so the check cannot drop an island the lowering would find again. The island scan and the first decision read what they read before. A file the program holds and never lowers can keep an island a build from scratch would not have; the check reports it like any island the program no longer needs.greeter,cjszooagainstcjszoo,greeteron the case above). The file now keeps the order the rounds grew the islands in; the islands the load decides follow, by name.main'smarkedoption it was given another one).loadentries are not involved: the first decision makes them again from the first load in every round, which costs no round, so they cannot be stale. A new test builds a program with a module that patchesfs, a user module and a package that read through the patched object, and a lowering island beside them: the three are listed asload, the replay is one lowering with the same result, and when the patch goes the check names each (- direct-reader@1.0.0,- module loader.ts,- module read.ts).The sweep (
f8e1d668f) now counts each build's lowerings and fails a replay that lowers more than once, and with--executablesit builds every program that records an island three ways (no decision file, recording, replaying) and requires the same bytes. One kind of build lowers again by design and is reported apart: a native build that tries a package as an island because a fence in it surely fires, and keeps itself when that attempt fails (SC8023,npm/cases/create-require). Nothing cheap says the attempt would fail again, and skipping it on a stale record would leave a native package whose fence throws at run time, so every build makes the attempt.Proof
In 32 vCPU Vercel Sandboxes (Intel Xeon @ 2.90GHz), on
47fb01435(the head before the registry row below: the three commits, and a merge of the reviewer's04d5c3727, which was pushed while the proof ran; one conflict, at the admission call inanalysis.ts).node scripts/island-decision-sweep.mjs --jobs=30 --executables: 5,521 programs and cases, 5,520 record a decision, 95 with an island (96 island packages, 13 island modules), 1 stops before its lowering. 0 differ. Every replay lowers the program once, except the one SC8023 case (it records no island and lowers twice with or without a record). 25 of the 95 lower once where their recording build lowers twice (24 before the fix; the 25th isdynamic-import). The other 70 are decided at the load and lower once either way.npm/cases/2584-cjs-conditional-exports) fails to build the same way three times.main(1346abd42), the same sweep counts 2 lowerings for the replay ofdynamic-import; its IR, file, check and executables agree, which is why nothing reported it. (That earlier run's comparison of diagnostics text is not usable: the script did not yet drop the timing lines it now turns on.)main's (below). New: islands only animport()loads are replayed in one round, with the same result and the same file, and one of them leaves with itsimport(); theloadislands of a patched builtin are listed, decided again and checked; the unit tests of the import check cover a deferred import and one that does not resolve. Under the sanitizer the replay tests pass too (19).tests/harness/node-island*.test.ts109 pass plain and 109 sanitized; the island programs ofdifferential.test.ts114 pass plain and 114 sanitized;npm.test.ts153 pass plain.main50bcfe1b3,--gate-corpus=all, no--gate-allow: PASS, 5,295 programs identical (IR, LLVM, release executables), 108 fail on both sides, 0 differ. Compile time: benchmarks +0.25 % [95 % CI −1.21 %, +2.07 %], the compiler compiling itself +0.85 % [+0.12 %, +1.64 %], fixtures +0.36 % [−0.54 %, +1.25 %]. (The round onf8e1d668f, before the reviewer's commit was merged in: also 5,295 identical, benchmarks +0.38 % [−0.33 %, +0.92 %], self −0.70 % [−2.25 %, +2.94 %], fixtures −0.95 % [−2.02 %, +0.12 %].) The base ismain, so this round also covers what the branch holds of #2881 and #3117.self-hosting-native-driver,self-hosting-llvm-emitter; the driver's first round and the frontend's new question are compiled by the self-hosted compiler): plain pass (3 tests, 2,073 s), and withSCRIPTC_SAN=1pass (3 tests, 3,023 s). The self-hosting IR check compiles (exit 0, noerror SC).pnpm lint,pnpm format:check: clean.The merge
4e8caafb6's parent1346abd42mergesmain(50bcfe1b3). Three conflicts with #3218: thebuildcommand's option list takes both sides, and the frontend thatmainwraps inprepareLoweredProgramcallsprepareExecutableBuild, in the Node driver and in the native one.4e8caafb6gives the three decision options their rows inmain's cache-key guard (#3260). Untilfeat/node-island-decision-wp1also takesmain, this PR's diff showsmain's commits beside its own.After the first CI run
97db6d1ebadds one row tomain's registry of whole-program scans (program-facts.ts): the frontend's new answer iterates the program's implementation files, a third scan inloadFrontend. Without the rowprogram-facts.test.tsfails, which the first run on47fb01435showed (Linux plain and san, shard 11/20). That guard is newer than this PR's old base and was not among the suites run before the push; the fix is the row, nothing else changed.The other failures of that run are
main's own: the cache-key guard for #3218's three options (below, #3306), and five.ctorcells oftests/harness/island-value-matrix.test.tsthat #3165 left pinned as refused (#3298).One test is red on
mainitself and here for the same reason:executable-cache.test.ts> "every build and run option is in the cache keys or says why it need not be" namesemit-lowered,from-loweredandfrom-lowered-accept; #3306 adds those rows. With #3306's rows applied the test passes, this PR's three included.