#3225

Give a project its Node island decision: sections, rules, a check and a project key

Opencramforcewants to merge intofrom

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-report island 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 it

How

  • node-islands/decision-file.ts: the sections, the per-entry checks at read (readIslandDecision), the import check (admitRecorded), the outcome rule (staleByOutcome, two lines in holdsWhile), the write (re-reads the file and replaces its own section) and the comparison.
  • executable/prepare.ts: prepareExecutableBuild runs 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 returns retry with 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. checkIslandDecision is the same rounds from scratch.
  • node-islands/growth.ts, graph-islands.ts: each rule stamps rule (and follows) on the IslandPackage it 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, checkIslandDecision for both hosts. Options are resolved once where compile and analyze begin, 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.ts builds real island fixtures through prepareExecutableBuild from scratch and from the record, and uses checkIslandDecision as 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.ts covers 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.ts covers 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 and tests/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 on df95bba22 was 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:

    buildwallpeak RSSrounds
    no option819.39 s8.15 GB2
    option given, no file yet: decides from scratch and records640.95 s8.06 GB2
    replay315.38 s6.30 GB1
    --node-island-decision-check: matches, exit 0725.00 s7.66 GB2
    replay361.33 s5.21 GB1

    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, 5 fence-report, 1 esm-importer and 1 load package.

  • 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-static Effect generator at 900 s, three.js nodes 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). On df95bba22: plain pass (3 tests, 2,397 s), and with SCRIPTC_SAN=1 pass (3 tests, 4,155 s). On this head: plain pass (3 tests, 2,845 s), and with SCRIPTC_SAN=1 pass (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.
  • coverage does 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.
  • coverage does 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 one writeIslandDecision after 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

205
cramforce5d ago

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 main fix it. The reviewer's 04d5c3727 is 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 literal import() 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 only import() 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 only await import("pkg") loads):

recording buildreplay
before (e1dd21d7f)2 lowerings2 lowerings: round 1 admit islands=[], then both found again
now2 lowerings1 lowering

Fix (d8d1ac632).

  • The question goes to the load itself: 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.
  • The file listed the islands by name. A round emits its SC8001 notes in the order of the set it starts from, so a replay that really replayed printed the notes of the same build in another order (greeter, cjszoo against cjszoo, greeter on the case above). The file now keeps the order the rounds grew the islands in; the islands the load decides follow, by name.
  • When the import check ends the first round, the next round decides from the capture of that load. The capture now gets the scan that decision asks for (with main's marked option it was given another one).

load entries 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 patches fs, a user module and a package that read through the patched object, and a lowering island beside them: the three are listed as load, 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 --executables it 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's 04d5c3727, which was pushed while the proof ran; one conflict, at the admission call in analysis.ts).

  • The whole corpus and the npm suite: record, replay, check, with rounds counted. 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 is dynamic-import). The other 70 are decided at the load and lower once either way.
  • Executables. The same run builds each of the 95 three ways, each with its own cache: 94 link, and for each the build without a decision file, the recording build and the replay write the same bytes; 1 (npm/cases/2584-cjs-conditional-exports) fails to build the same way three times.
  • Before the fix, on the merge of main (1346abd42), the same sweep counts 2 lowerings for the replay of dynamic-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.)
  • Tests. The decision suites, the island round suites and the command-line tests: 278 pass; the one failure is main's (below). New: islands only an import() loads are replayed in one round, with the same result and the same file, and one of them leaves with its import(); the load islands 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).
  • Island harness and island corpus, plain and sanitized, with a libnode pack: tests/harness/node-island*.test.ts 109 pass plain and 109 sanitized; the island programs of differential.test.ts 114 pass plain and 114 sanitized; npm.test.ts 153 pass plain.
  • Without a decision file nothing changes. Zero-cost gate against main 50bcfe1b3, --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 on f8e1d668f, 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 is main, so this round also covers what the branch holds of #2881 and #3117.
  • Native bootstraps (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 with SCRIPTC_SAN=1 pass (3 tests, 3,023 s). The self-hosting IR check compiles (exit 0, no error SC).
  • pnpm lint, pnpm format:check: clean.

The merge

4e8caafb6's parent 1346abd42 merges main (50bcfe1b3). Three conflicts with #3218: the build command's option list takes both sides, and the frontend that main wraps in prepareLoweredProgram calls prepareExecutableBuild, in the Node driver and in the native one. 4e8caafb6 gives the three decision options their rows in main's cache-key guard (#3260). Until feat/node-island-decision-wp1 also takes main, this PR's diff shows main's commits beside its own.

After the first CI run

97db6d1eb adds one row to main's registry of whole-program scans (program-facts.ts): the frontend's new answer iterates the program's implementation files, a third scan in loadFrontend. Without the row program-facts.test.ts fails, which the first run on 47fb01435 showed (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 .ctor cells of tests/harness/island-value-matrix.test.ts that #3165 left pinned as refused (#3298).

One test is red on main itself 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" names emit-lowered, from-lowered and from-lowered-accept; #3306 adds those rows. With #3306's rows applied the test passes, this PR's three included.