Skip to content

Train startup 2: a deferred island load reads nothing unseen, island-closed statements, the island's compile cache, build-time notes for guarded refusals, lazy initializers - #3392

Merged
ctate merged 26 commits into
mainfrom
train/startup-2
Nov 8, 2026
Merged

ctate merged 26 commits into
mainfrom
train/startup-2

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Train startup-2: one rollup of five branches about what a compiled program's Node island does when the program starts. None of them had a PR of its own; each was proven by its owner in Vercel Sandboxes and handed to this train. The branch merges their heads (merge commits; the members' commits are unchanged), resolves what they meet in each other and in main once, and was validated once on the final tree.

Read this first: one start-up number moves, and one project setting brings it back

The load recorder becomes an allowlist (below). A package whose load draws a random number is then evaluated where it is imported, not deferred. For pi that is two packages, minimatch and @google/genai (Math.random() at load, in brace-expansion and bignumber.js). minimatch is the third load of a start, so Node starts there.

  • pi --version, HOME without settings, measured on the pi run tree (the headline build; medians of 15 launches alternating with Node, one Xeon 2.90 GHz sandbox): 92 ms before, 246 ms with this train and pi as it is, 88 ms with the two packages marked lazy. --help, -p and the first frame start Node anyway and do not move (272, 367, 272 ms against 272, 352, 280).
  • The setting, in pi's packages/coding-agent/package.json, beside the proper-lockfile entry the headline build already has: "scriptc": { "packages": { "minimatch": { "run": "node", "evaluate": "lazy" }, "@google/genai": { "run": "node", "evaluate": "lazy" } } }. "lazy" is the user's statement that what the load draws and leaves behind does not matter; the build cannot make it.
  • The rule lands anyway because of what it removes: of 220 one-source programs built under one machine setting and run under another, 54 differ from Node silently on main and none does with the change.

What lands

  • A deferred island load reads nothing of the machine or the moment unseen. The build's load recorder was a list of sources it knew to be dangerous; it is now a list of what it knows to be safe. Everything a load can reach is deterministic, guarded by a value the executable compares at the import without Node (an environment variable, also behind os.homedir(), os.tmpdir(), util.debuglog(), the default locale on Linux, a time zone TZ names, whether a standard stream is a terminal), an input, a reading no run repeats (a clock, a random number, the process id), or not classified; a load that read one of the last three evaluates where it is imported, and the build trace names the source and the place. "evaluate": "lazy" still defers such a load, and its snapshot then holds no constants.
  • Module-level statements and loops closed over an island are evaluated inside the island, only when needed. A top-level call or new on island values with closed operands, a for…of with const bindings over a closed iterable whose body is such calls, and a table only those statements name each become a generated module that waits like a closed constant does.
  • Node's module compile cache is the default for a program's island. The cache is beside the extracted module tree in the user's cache directory, in a directory the runtime creates private and checks before it hands it to Node; a program whose island never starts makes no directory and reads nothing.
  • A refused statement inside its own try is named at build time, not reported at every run. A JavaScript statement that does not compile and sits in the try block of a try/catch of the same function gets warning SC6012 at build time and prints nothing when it runs; every other reached refusal is reported on stderr as Report a deferred refusal on stderr the first time it fires #3211 does. SCRIPTC_DEFERRED_REPORT=all reports these sites too.
  • Module initializers that only compute run at the first use of what they make, wherever the module is loaded from (static import, import(), a require in a function).

Members

None of the members has a PR, so nothing is superseded on GitHub. Heads are the commits merged; each owner's hand-over, with its raw output, is in the fleet's records.

  • fix/load-recorder-inputs (aff4136efd, three commits): Defer an island load only when it read nothing a run could answer differently. Owner's proof: full round on its first commit, gate --gate-corpus=all PASS with no allow list (5,482 identical, 188 fail to build on both sides), both lanes with both native bootstraps (two tests red: one the base's own, one its own and fixed by its second commit), bench 68 workloads geomean +0.7%; island executables 179 of 180 byte-identical; the 220-row table (main 54 silent, the change 0); docs gate 25 of 25.
  • feat/closed-island-statements-train (a505b9a55b, four commits; the branch above is built on it): Evaluate island-closed module-level statements and loops inside the island. Owner's proof: gate PASS (5,353 identical, 161 both-fail), island corpus plain 364 of 364 and sanitized 182 of 182, island harness 140 of 140, three unit tests for the rule that a file belongs to the load that lists it first in the probe's order; a statement program that never reads the registry starts in 0.5 ms and 12.9 MB instead of 35.1 ms and 90.4 MB. Its first commit (the boot-guard fix) reached main with Train startup 1: start Node for island packages only when the program needs it, and answer island module resolution at build time #3350.
  • feat/island-compile-cache (2c0b592d6d, two commits): Keep Node's module compile cache for a program's Node island. Owner's proof: round with gate PASS and no allow list (5,493 identical, 125 both-fail), lanes red only in tests that were red on main that hour, 16 security and robustness cases as tests; a program that runs the typescript package in an island starts in 102 ms instead of 205 ms warm, and its first launch takes 244 ms instead of 218 ms.
  • fix/deferred-report-guarded-sites (c9d6bf5fc3, two commits and a merge of startup-1): Name a refused statement inside its own try at build time, not at every run (the orchestrator's decision of 00:16 UTC for the Vercel CLI, whose entry point calls module.enableCompileCache() in a try). Owner's proof: lanes plain 23,938 passed, 0 failed, sanitized 20,581 passed, 0 failed; gate PASS, 5,499 identical, 278 both-fail, 36 allowed by id.
  • perf/lazy-initializers-2 (be2dcc2b12, five commits): Run module initializers that only compute at the first use of what they make, wherever the module is loaded from. Owner's proof: gate PASS (5,457 identical, 8 allowed: the compiler's own three programs and the five the branch adds), both native bootstraps, bench 69 workloads with startup-unused-tables 3.09 ms to 1.97 ms; lanes red only in the island value matrix, which was red on main at its base.

Not on the train, and why: feat/binding-forms-native merged cleanly and was dropped before the round, because ten of its own eleven tests in tests/harness/errors.test.ts no longer fail the way they pin on this base (main now gives a builtin module held in an object literal a value); it goes back to its owner. feat/settled-var-forward conflicts with main in the analysis #3169 changed. The "late class" for loads that draw a random number is not provable (its owner's verdict). Shared globals' conditions have no hand-over yet.

Conflicts resolved on the train

  • frontend/lowering/closed-island-init.ts: a merge without a conflict that was wrong twice. Startup-1 (c5896507d0) keeps a closed constant where it is written when a module of the program patches a builtin module, because Node would read the generated module through the patch. On this train a statement and a loop are generated modules too. The rule's trace call had the signature from before the closed statements (it did not type-check), and the rule stood only in the function that decides a constant. One function, readThroughPatch, now answers for a constant, an expression statement and a loop; a table is the island's only through a statement that is, so it follows (9845861a02). Test: tests/harness/node-island.test.ts, "statements, a loop and a constant after a builtin patch are evaluated where they are written"; without the rule on statements that program ends with exit 1 and ReferenceError: patched is not defined in the generated module (measured on the train tree with the file as the first merge left it).
  • packages/runtime/src/scr_node_island.c, scr_node_island_host.cjs (the compile cache against islands-1 Train islands 1: errors, promises, process events and process.stdout.write keep Node's behaviour across the Node island boundary #3377 and startup-1 Train startup 1: start Node for island packages only when the program needs it, and answer island module resolution at build time #3350, which give the island the program's process.argv and exit state): the bootstrap carries main's process state first and then the cache's directory, and its buffer counts both; the host script enables the cache before anything else and then applies the process state; ni_node_ending is set right after the stop job, before main's scr_timers_teardown. scr_node_island_host.h is regenerated by scripts/node-island-host.mjs.
  • tests/harness/node-island-load-plan.test.ts (Answer island module resolutions from a load plan computed at build time #3236's test against the compile cache): the test counted the entries of the island cache directory, which now holds <digest>.compile-cache beside the tree; it takes the tree by its digest name, as node-island.test.ts does (0e2c05261d).
  • docs/content/docs/limitations.mdx (the include list): main's list plus the new bullets.
  • packages/compiler/src/diagnostics/codes.ts: main's SC6008 and this train's SC6012, both kept. ir/codec.generated.ts and ir/fields.generated.ts pass their generators' --check on the merged tree.
  • node-islands/snapshot.ts, the file startup-1 merged by hand: the allowlist's and the closed statements' hunks merge with startup-1's last head without a conflict, and the result is byte-identical to the merge another agent built and tested on that head. The maintainer's private-environment rule and its five tests are untouched.

Validation (once, on the final tree, in Vercel Sandboxes)

Everything below ran on 0bd2727c9e, the commit pushed, against main 58993e3690 (the commit merged into it), in one round (validate/runs/20261108T033059-train-startup-2) and one 32-vCPU sandbox beside it. Nothing was built or tested on a workstation.

  • Type check: passes (CI's pnpm build steps: tsc in packages/cli, packages/compiler, the hono starter fixture).
  • Zero-cost gate, --gate-corpus=all: PASS. 5,507 programs identical, 308 fail to build on both sides (programs that need a libnode pack; the gate has none), 44 allowed by program id, none other differs, no runtime-only row outside the list. Compile time within tolerance: bench geomean +0.63% [-0.20%, +1.08%], self-compile -0.84% [-1.96%, +0.48%] (four pairs), fixtures -0.09% [-1.57%, +1.38%]. The candidate compiler builds its own source.
    • 36 programs that hold a deferred refusal (the deferred report's rows; listed by id, the same 36 as its owner's gate). They link scr_deferred_fence.c, which the change edits: 35 executables grow by 104 to 280 bytes (at most +0.05%; IR and LLVM identical in 33 of them), and three have the refusal inside its own try, so their start function calls the guarded entry (%init.0 only: 8717-require-missing-module, create-require-base-evaluated, create-require-factory-base-evaluated). No program without a deferred refusal links the unit; no benchmark is among them.
    • 8 programs of the lazy initializers: the compiler's own three (self/cli, fixture/llvm-emitter, fixture/llvm-layouts: module initializers that only compute are deferred in them; the two native bootstraps below are the proof that the compiler built that way still compiles itself), and the five the member adds, which main's compiler builds without deferring anything (corpus/9651, 9652, 9653, bench/startup-unused-tables, bench/module-table-reads; -0.38% to +0.76%). No program that exists on main differs.
    • Runtime pack: two units differ from main's, scr_deferred_fence.c and scr_node_island.c (the compile cache; only island programs link it).
  • Lanes: plain 24,184 passed, 0 failed; sanitized 20,825 passed, 0 failed (92 jobs, the sanitized bootstrap's three among them). None of main's known flakes fired.
  • Native bootstraps: both pass, on this tree, in the round's own Sandboxes. Plain: commands (1,771 s) and rebuild (1,722 s). Sanitized: commands (1,432 s), rebuild-emit (2,145 s) and rebuild-frontend (2,861 s). CI runs the native bootstraps only for a PR that touches bootstrap paths, and this one does not, so this round is the evidence for them.
  • Island executables, which the gate leaves out (it builds without a libnode pack): every corpus program that needs a pack, built by main's compiler and by the train's, both on the train's runtime (the compile cache changes the island unit every such program links, +5,096 bytes on the bench's island program by the owner's round): 306 programs, 263 byte-identical, 43 differ. 19 of the 43 are programs the train adds. 21 programs of main hold a module-level statement the island now evaluates. 3 hold none and differ because their package's import now waits (6883-node-island-builtin-handles, 8918-node-island-closed-const-prints, 8932-node-island-closed-const-caught-refusal, each about 13 KB larger: the package's interface snapshot). The three programs of main with a module that patches a builtin (8722, 8840, 8883) are byte-identical: their statements stay where they are written.
  • Island harness (the fifteen node-island* files, the deferred report's file and errors.test.ts): 360 tests, 0 failed. Island value matrix: 239 of 239 cells.
  • Island conformance lane: update mode moves no entry, so the list is main's, unchanged; check mode, 8 shards: 434 passed, 68 skipped, 0 failed.
  • bench-runtime: 73 workloads, geomean -0.3%; 67 neutral, 3 faster, 3 slower.
    • startup-islands-cold +9.0% [+3.8%, +11.3%]: the first launch, which fills the compile cache; by design, and the owner's round measured the same (+6.8%). startup-islands-used -4.0% [-5.6%, -2.4%], startup-islands-unused neutral.
    • startup-unused-tables -23.9% [-33.4%, -6.8%] (the lazy initializers' workload).
    • atomics-contended +3.2%, regex-logs +3.8%, dynamic-class-tests -7.0%: the gate reports all three executables byte-identical to main's, so these are the machine.
  • Lint, format, generated files: pnpm lint 0 warnings, pnpm format:check clean, pnpm test:ts7 --baselines-only complete, generate-ir-codec.mjs --check, generate-ir-fields.mjs --check and node-island-host.mjs --check pass, pnpm manifest and pnpm node-compat --local change nothing, pnpm node-compat:check passes.
  • Docs gate (NEXT_DIST_DIR=.next-check pnpm check in docs/): passes, 25 of 25 route tests.
  • The rule fix fails without it: with closed-island-init.ts as the first merge left it, the new test ends with exit 1 and ReferenceError: patched is not defined; with 9845861a02 it passes.

What was not run

  • macOS. Nothing was built or run on Darwin. The default-locale guard is Linux-only by design (elsewhere a load that reads the default locale is evaluated at its import); the compile cache has no platform branch. CI's macOS job is the only Darwin evidence.
  • pi on this tree. pi was not built on the train. The three start-up numbers at the top are the allowlist owner's, measured on the pi run tree with its two compiler files applied; the compile cache's and the lazy initializers' pi measurements (in their owners' notes) are from that tree too and are not quoted here. pi's 107-case suite was not run.
  • Test262. Not run. Three members change island code only, the deferred report's run-time side is off in the suites, and the lazy initializers defer initializers of modules other than the entry; the lazy initializers' owner did not run it either.
  • A second libnode pack. The bridge is unchanged (ABI 19); the island suites ran against the pack assembled from this tree.
  • Android, iOS and musl runtime packs were not built (the compile cache uses fstatvfs and clock_gettime).

🤖 Generated with Claude Code

cramforce and others added 26 commits November 7, 2026 08:50
…sland

  import { registry, bash, python } from "plugin-pkg";   // an island package

  registry.register("bash", bash);

  const languages = { bash, python };
  for (const [name, language] of Object.entries(languages)) {
    registry.register(name, language);
  }

Each call above was lowered natively, and the first one started Node while
the program's modules were still initializing, whatever the build had
deferred. Their whole effect is on the island's state: no native code
receives a value from them, and none of their operands is native. The
island evaluates them itself now, each in a generated island module with
no export, loaded where the statement stands by the deferred load of a
static island import. Node starts at the program's first use of an island
value, and every deferred module evaluates first, in the order the loads
were registered: the program's.

A statement is closed when it is one call or `new` on island values with
closed operands (the rule of closed constants). A `for…of` is closed when
its bindings are `const` (a name, or an array pattern of names), its
iterable is closed, and its body is calls with closed operands;
Object.entries, Object.keys and Object.values of a closed value are closed.
A table is a constant of the island when it is a top-level `const`, not
exported, whose initializer is an object or array literal of closed parts,
and every identifier of its file spelled like it sits in a statement the
island evaluates: no native code can then read it or change it, and its
statement lowers to its generated module's load alone. The statements that
name a table are closed only if the table is the island's and the reverse,
so each is decided with the table taken as the island's and those
decisions are not kept. A shorthand member that names an island import is
closed like the member written out.

What keeps a statement where the program has it: everything that keeps a
constant there (the probe performs none of its effects and reads none of
its inputs; a statement that prints, throws, sets a timer or changes what
an earlier module's snapshot answers evaluates in the island where it
stands), and two things more. A statement that read the clock or random
numbers: a constant that fails when it finally evaluates raises its error
where its value is used, and a statement has no value, so its later
evaluation has to be the probe's. A statement that leaves a rejected
promise: Node reports it when the turn that made it ends; the probe
records such a promise as an effect of any load.

The comparison behind "a later island module's load changes its exports"
now covers what a snapshot answers of a function besides its identity
(its name, its length, whether instanceof against it is the ordinary
prototype walk).

The modules of a group that shares files carry each other's reads and
effects (loadReads). Every generated module shares the package's file with
the package's own import, so one statement that prints made that import
eager, and every other statement of the program with it: Node would have
started at the import instead of at the statement. A generated module is
loaded once, where the program has it, after the imports of the packages
it names, so what it read or did is its own body's: it carries the reads
and effects of the loads whose files it loads and of the generated modules
it imports, and gives its own to the generated modules that import it,
never to the packages.

tests/corpus/9340 to 9353 run under Node and natively with the same
output; tests/harness/node-island-closed-init.test.ts reads the build's
decisions and the boot trace for each (Node never starts while nothing
uses the registry; the first use finds every registration, in order; a
statement with effects, a native operand, a function of the program, a
table the program reads and a `let` binding stay as they were).
8750's `install();` and 8939's `registry.add(…)` are closed statements
now, and their tests say so.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The decisions in progress and each file's identifiers by text were kept in
module-level WeakMaps keyed by the lowerer and the source file. Compiler
sources keep caches on the object that owns their lifetime
(compiler-weak-collections.test.ts): the compiler compiled by itself does
not depend on which of its values can be weak keys. Both are fields of the
lowerer now, named in its list of collections a unit's journal leaves out.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The test that named a module by its package directory went with the rule
it pinned when these commits moved onto the train's shared-reads groups.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ey make

A module's top-level code runs before `main`, whether or not the program
then uses what it built. A new backend pass, deferClosedInitializers,
defers a group of module initializers when moving it cannot be observed:

- closed: everything the initializers can reach (their statements, the
  functions they call, the closures they create) only computes on the
  heap: no output, no input, no scheduling, no dynamic-tier operation;
- cannot throw: computeMayThrow, asked of exactly that code, so an error
  is never raised at the first use instead of at the import;
- owns what it touches: every module global that code names is guarded
  everywhere in the program, and none is made by a module that stays
  (constants of a primitive type excepted).

Initializers that share a global form one group behind one flag. Each
member keeps its place in the module order: the importer runs the
member's own imports there and notes that the place was reached. The
first guard that fires runs the members reached so far, in order; after
that a member runs at its place, as it always did. So a member never runs
before its place, and after it only across code that touched nothing of
the group's.

Guards stand before the first statement of a function that names one of
the group's globals, outside any loop and inside the branch that reads.
A group the start-up code certainly uses, or one too small to matter,
stays as it is, and with it the bytes of the programs that have only
such initializers.

Measured (Intel Xeon 2.90GHz): the new workload startup-unused-tables
2.33 ms to 1.31 ms per launch with two of its four modules deferred (the
other two can throw and stay); the same program using every table 2.34
and 2.38 ms; 300 million reads of a module table through an accessor of
another module 458 ms before and 443 ms after.

Left where they are: the entry module, modules that can be loaded by
import() or that await, import cycles, initializers with dynamic-tier
operations or any operation that can throw, bindings with a temporal
dead zone. Library builds and worker programs are not touched.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
A generated module stopped giving its reads and effects to the packages
whose files it loads, on the premise that those files evaluated in an
earlier load. The premise does not hold in three cases: the probe reaches
the generated module before the package's import (an import the order walk
does not reach is probed after the others), the package is not a candidate
at all ("evaluate": "eager"), or only a call of the generated module
reaches the file. The file's reads are then in the generated module's
record and the package's is empty, and the package, no longer in a group
with the generated module, deferred with a snapshot that could hold a
value computed from a private environment variable, with no guard.

A file belongs to the load that lists it first, in the probe's order. A
generated module still takes one way from the loads that evaluated the
files it got from them. A file that a generated module's load evaluated
first is a shared file like any other: every other load of it joins the
generated module's group, both ways, as before these commits.

Three tests beside the maintainer's: a package file reads a private
variable and the statement's module loads it, with the package first, the
generated module first, and the package's import where the walk does not
reach. In all three neither defers, both reasons name the variable and the
value is nowhere in the module. The last two failed before this commit:
the package deferred.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… loaded from

The pass that runs closed module initializers at the first use of what they
make only placed modules of the entry's static import tree. A module that
`import()` can load, or a static dependency of one, stayed where it was: on
a large program that is nearly every module.

Every call of a deferred initializer now goes to the module's place: an
importer's header, the helper of an `import()`, a `require()` in a function.
The order a group runs its members in is the order their places were reached
at run time, kept as a list through one link slot per member, instead of a
position in the static import tree.

A module the program can load again after its evaluation threw has the
error-recording form. A deferred module's own statements cannot throw, so
the only error it can record is one of its imports: its place records that
error where the initializer did and throws it again at every later load, and
the module's statements never run, as before.

Start-up code is now the roots' static import tree only: a module that only
`import()` loads may never run. Guards stand inside a `try` body like inside
a branch, since such a module's whole top level is one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
SCRIPTC_INITIALIZERS_TRACE=defer at build time prints one line per module
initializer, in the form of the trace of deferred island loads:
`[initializers] defer <file>: runs at the first use of what it makes`, or
`[initializers] eager <file>: <the first reason found>`. Until now the
verdicts were only reachable by calling the pass from a script.

The variable reports on the backend, so it joins the names the frontend's
output does not depend on: a build from a lowered program may set it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Found with a table read through another module's one-line accessor, three
hundred million times: 614 ms on main, 607 ms with the first slice of the
pass, 1,054 ms with the list of places. The run of a group now holds the
loops that walk its list. Inlined into a guard, those loops sat inside the
caller's loop, which was then no longer optimized as an innermost loop; not
inlined, the store of the group's flag was hidden behind a call that could
write anything, so the loop tested the flag and loaded the table again in
every turn.

Two changes, measured together at 586 ms against main's 589 ms:

- The run is emitted `noinline cold`, by its name, beside the other
  generated slow paths.
- A guard is `if (!active) { run(); active = true; }`. The store after the
  call changes nothing for the program (the run sets the flag first) and
  tells the optimizer the flag is set on every path out of the guard, so a
  loop around an inlined accessor tests it once, ahead of the loop.

No workload of the bench set had this shape, so the set did not show the
cost. `module-table-reads` does: a hundred million reads through the
accessor and as many in the table module's own loop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…order-inputs

The recorder's rule for a load's ambient inputs is written on the tree the startup-2 train will have: the startup-1 train head plus the closed-statement commits, merged as the same commit objects.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Differential plus llvm-differential on an 8-vCPU Sandbox: 3.0 s plain,
10.5 s sanitized.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A program with a Node island compiled every island module again at every
start. Node can keep V8's bytecode for the modules it loads in a directory
and load it at the next start (module.enableCompileCache); a program now has
such a cache by default, so every launch that starts Node starts it faster.
A program that loads the `typescript` package in an island starts in 142 ms
instead of 287 ms; one whose island never starts does nothing new.

Where. Beside the island's extracted module tree and named by it:
<cache home>/scriptc/node-islands/<tree name>.compile-cache. The function
that decided where the tree is installed (the first half of ni_cached_root)
is now ni_islands_dir, used for both. A rebuilt program has another tree,
another path for every module and another directory.

Trust. Node checks nothing about the directory: it creates what is missing
with mode 0777 and loads any entry whose CRC-32 sums agree, and an entry is
code. The runtime creates the directory 0700 inside the islands directory,
opens it without following a link and uses it only when it is a directory of
the process's user that no one else can write. No cache for the $TMPDIR copy
of the tree, when the real and effective user or group differ, or while the
file system has no free space (Node leaves an empty temporary file behind
for every entry it fails to write). NODE_COMPILE_CACHE names the user's own
directory, which Node enables by itself; NODE_DISABLE_COMPILE_CACHE turns
the cache off. A refusal says nothing: the cache changes speed, not
behaviour. SCRIPTC_NODE_ISLANDS_TRACE=compile-cache prints the decision.

How. The directory reaches Node as a second global in front of the
bootstrap, which calls enableCompileCache before any module of the program
loads: no entry into JavaScript that did not happen before, and the
process's environment stays as it was.

When it is written. Node writes new entries when its environment ends: on
a normal return and on process.exit() called by island code. process.exit()
from native code ends the process with Node running, so the island unit
wraps the events unit's exit hook when Node starts with a cache and writes
the entries there, after the 'exit' listeners and the program's output
(0.008 ms when there is nothing new). The bootstrap also writes them once,
a second after the start, from a timer that keeps no program alive, for a
program that only ever ends by a signal.

Tests: tests/harness/node-island-compile-cache.test.ts pins where the cache
is, which launches write it, and each way the directory can be unusable or
not the user's own. The tree tests pick trees by name now that each has a
sibling directory.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ferently

A deferred island load whose evaluation read the machine or the moment kept
the build's answer in the executable whenever the load recorder did not list
the source: the time zone, the default locale, a date string without a zone,
performance.now(), a member only a terminal's stream has. And a load that
read a clock the recorder did list was deferred without constants and
evaluated when Node started, after the program's own later readings.

The recorder was a list of sources it knew to be dangerous. It is now a list
of what it knows to be safe. Every member of process, of the global object,
of the standard streams and of the listed core modules has a rule or is on
an allowlist; a builtin module on no list is not classified when a load asks
for it; an ES module's import of a builtin's member by name is the read
(Node copies the value into the namespace once, so the second importer of
process.pid was recorded as reading nothing). What is not classified keeps
the load eager, and the trace names the source and the place that read it.

The classes: deterministic (the language, what the libnode pack fixes, the
package's own files); guarded (an environment variable, also behind
os.homedir(), os.tmpdir(), util.debuglog(), the default locale on Linux and
a time zone that TZ names; whether a standard stream is a terminal, also
behind tty.isatty() and the members only a terminal's stream has); an input
(the arguments, the working directory, the user, the host, the time zone of
a machine whose TZ is unset, a terminal's size); a reading no run repeats (a
clock, a random number, the process id, a meter). A load that read an input
or such a reading evaluates where it is imported. That replaces the rule
that a load which read the clock snapshots without constants: read late, a
clock comes after the program's own readings; a snapshot without constants
still answers which members are objects from the build's reading; and a
load probed after it can hold, as a constant, what the first left in a
global. "evaluate": "lazy" still defers such a load, and its snapshot now
holds no constants for any unguarded read (it kept the build machine's host
name and time zone).

The recorder's Date gave its instances an empty prototype, so every load
that made a Date threw in the probe and stayed eager for that. Dates work,
and a Date made from a timestamp or a zoned string is deterministic.

A string of a snapshot that names a directory above the module tree, as
module.paths lists them, is a path of the build machine like one under it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… for

The docs sentence about the `typescript` package in an island carried the
numbers of a run that shared its machine; these are from a quiet one, two
runs of 61 launches per cell taken in turn (Intel Xeon 2.90 GHz, 8 vCPU):
205 ms without the cache, 102 ms with it, and a first start of 244 ms
instead of 218 ms.

The bootstrap's comment claimed that the write a second after the start
delays no program's first output. A program whose first output comes later
than that can wait for it once, on the launch that fills the cache; the
comment now says what the second is for. Only the comment changes, and with
it the bootstrap text the island unit embeds.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The recorder's text is a compiler source, and the guard that keeps weak
collections out of the compiler's own sources reads it as one
(compiler-weak-collections.test.ts). The map lives as long as the probe
process, which ends with the probe.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ry run

The deferred-refusal report writes to stderr the first time a JavaScript
statement that does not compile runs, so that a handler that catches its
Error cannot hide a compiler gap. For one kind of site that turns a program
that matches Node into one that does not: `try { optional() } catch {}`, the
way a program asks for something that may not be there. The program's own
handler deals with the failure, its output is the same on both paths, and
the report added two lines to stderr at every start (found on a command-line
tool whose entry file begins with such a call).

A deferred refusal that sits in the `try` block of a `try`/`catch` of the
same function is now named by the build and not by the binary:

- The build prints warning SC6012 at each such statement, with the refused
  construct. Its text says what follows from it: the statement throws when
  it runs, so the `catch` clause runs where Node's would not, and the
  program's output can differ from Node's from there on.
- The binary writes nothing for such a site. SCRIPTC_DEFERRED_REPORT=all
  reports these sites at run time too; SCRIPTC_DEFERRED_REPORT=0 still
  silences every site.
- Every other reached refusal is reported as before: a site in a function
  the `try` block calls or declares, in a `catch` or `finally` block, or in
  a `try` that has only a `finally`. The handler that swallows those is not
  the statement's own.

"Its own try" is decided where the source `try` statement is lowered
(lowerTry marks the deferred-diagnostic fences of the block when the
statement has a `catch` clause), so the try/catch nodes the compiler writes
itself do not count, and functions declared in the block are lowered apart
from it. The mark is a field of the runtimeFence node; the backend picks
scr_deferred_fence_guarded for a marked fence, and the warnings are read
from the lowered module beside the divergence scan, so a replayed unit
reports the same sites.

A silenced site no longer takes the report's one-line heading with it: the
heading goes to the first site that is reported.

Tests: tests/harness/deferred-fence-report.test.ts (a guarded site whose
stdout and stderr equal Node's; a guarded site whose catch takes another
path, named by the build and not by the binary; the lexical cases, guarded
through loops and an inner try/finally, not guarded in a try/finally, a
catch block, a finally block, a called function and a function written in
the block; both settings of the variable), ir/deferred-fence.test.ts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…rder

Node's own Headers, Request and Response mark themselves uncloneable when
they are made, through the public worker_threads.markAsUncloneable that
undici takes when it loads. Whether the recorder saw that call depended on
whether undici had loaded before the recorder wrapped the core modules
(describing a lazily defined global such as MessageEvent loads it), so a
load that made a Headers was eager with one order and deferred with the
other. A mark on an object the caller holds reads nothing and does nothing
to the machine: the three functions are on the recorder's allowlist, and a
load that makes these objects is deterministic by rule.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…it on corpus programs

Three corpus programs hold a refused statement inside its own try whose
refusal coincides with Node throwing (a guarded require of a module that is
not there through a run-time createRequire base): they match Node byte for
byte and are named by the warning. Its text claimed that the catch runs
where Node's would not; it now says that the catch runs also where the
statement would have completed under Node. The whole-corpus coverage test,
which treats every divergence warning on a corpus program as a false
positive, leaves this one out for the same reason.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Members: the load recorder as an allowlist (9400903, b135ce7,
aff4136) and, under it, instant-start-2's island-closed statements
and loops (54e051a, 3c2dd49, d906f7f, a505b9a).

Conflict: docs/content/docs/limitations.mdx (the include list): main's
list plus this branch's bullet.

A merge without a conflict that did not type-check: the train's
builtin-patch rule (c589650) calls traceClosed with the signature
it had before the closed statements gave it the lowerer; the call now
passes the lowerer.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…iltin module

The start-up train's rule (c589650) keeps a closed island constant
where it is written when a module of the program patches a builtin
module: Node would read the generated module through the patch. The
island-closed statements and loops of this train are generated modules
too, and the rule stood only in the function that decides a constant's
initializer, so `mark("x");` after `installPatch();` was read through
the patched fs.readFileSync.

One function, readThroughPatch, now answers for a constant, an
expression statement and a loop. A table is the island's only through a
statement that is, so it stays native with them.

Test: tests/harness/node-island.test.ts, "statements, a loop and a
constant after a builtin patch are evaluated where they are written".

Co-Authored-By: Claude Code <noreply@anthropic.com>
Node's module compile cache as the default for a program's Node island.

Conflicts, with islands-1 (#3377) and startup-1 (#3350) in the island
bootstrap:
- scr_node_island.c, ni_bootstrap: main's process state (argv, exiting)
  first, then the compile cache's directory; the buffer counts both.
  ni_node_ending is set right after the stop job, before main's
  scr_timers_teardown.
- scr_node_island_host.cjs: the cache is enabled first, then main's
  process state is applied.
- scr_node_island_host.h: regenerated by scripts/node-island-host.mjs.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A deferred refusal inside the program's own try block is named at build
time (SC6012) and not reported at run time.

Conflict: packages/compiler/src/diagnostics/codes.ts (the code table):
main's SC6008 and this branch's SC6012, both kept.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A module initializer that only computes runs at the first use of what it
makes, wherever the module is loaded from. No conflict.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The compile cache of this train keeps `<digest>.compile-cache` beside a
program's island module tree, so the cache home's node-islands directory
holds two entries per program. The load plan's test (#3236) counted the
entries; it now takes the tree by its digest name, as
tests/harness/node-island.test.ts does.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 8, 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 Nov 8, 2026 4:24am UTC

@vercel-internal-security-reviewer

Copy link
Copy Markdown

Security review details

@ctate
ctate merged commit 75f91cd into main Nov 8, 2026
84 checks passed
@ctate
ctate deleted the train/startup-2 branch November 8, 2026 07:07
@cramforce

Copy link
Copy Markdown
Contributor Author

Landed through #3395 (the landing rollup, merge commit 3dddae7): this train's head 0bd2727 is merged there as it was, with three fixes to the load recorder made in the rollup after review.

yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 9, 2026
…'s code

The build's load recorder classifies a locale-sensitive call before the
call runs. To do that it read the arguments itself: `options.timeZone`,
the locales through `supportedLocalesOf`, the `lastModified` of a File's
options. A getter, a proxy trap or a conversion of the load's own then
ran once in the recorder and once in the call. A load whose getter
counts its reads, or answers differently the second time, computed
another result in the build than under Node, and since the recorder had
found the call deterministic, that result went into the executable as a
constant: `new Intl.DateTimeFormat("en-US", { get timeZone() { … } })`
answered the getter's second zone, with exit status 0.

The recorder now looks at an argument only where the engine answers
without running program code: a data property, own or inherited, of an
object that is not a proxy; a string, an Intl.Locale (through its own
slot) or a plain array of those as locales; `supportedLocalesOf` as it
was before any load; util.types.isDate in place of instanceof. An
argument it cannot read that way is an input: the load evaluates where
it is imported, and the build trace names it ("reads an options
argument that only the load's own code can answer").

Found in review of vercel-labs#3392.

Tests: corpus 9817-node-island-load-reads-argument-accessors (three
island packages: an options getter, a proxy, a tag converted from an
object, an element getter, an array-like with a length getter, a File
option getter), which printed `zone: UTC 2` where Node prints
`zone: Asia/Tokyo 1`; a unit test in probe-recorder.test.ts with seven
such shapes compared with Node and four plain shapes that stay
deterministic.

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 0bd2727c Deployed Nov 8, 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