Skip to content

Landing rollup: lang 2, startup 2, islands 3 - #3395

Merged
ctate merged 141 commits into
mainfrom
train/landing-1
Nov 8, 2026
Merged

ctate merged 141 commits into
mainfrom
train/landing-1

Conversation

@cramforce

@cramforce cramforce commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

One branch that lands three validated trains together: it merges the heads of #3368 (train lang-2), #3392 (train startup-2) and the restack of #3284 (train islands-3, which had no PR of its own yet) with current main. Each of the three was validated on its own tree; main's last island train (#3382) then conflicted with all of them, mostly in generated files and registries, so they are resolved once here instead of one re-merge and one CI run each. The members' commits are merged as they are. The branch has no behaviour of its own except one fix made in it, described below.

What it merges

When this merges, #3368 and #3392 show as merged.

A change made in the rollup: the load recorder reads a call's arguments without running the load's code

Findings of the review bot on the load recorder of #3392 (probe-recorder.ts), all right, each reproduced on this tree and fixed here with tests: b325e364c7 (the finding on #3392), fed1d5a7db (the finding on this PR about the first fix) and b1de4ff7d9 (the findings on this PR about the recorder's list of pure builtins and about the parts of new File()).

  • What was wrong, first. The build's load recorder classifies a locale-sensitive call before the call runs, and 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, so a load whose getter counts its reads, or answers differently the second time, computed another result in the build than under Node; and because the recorder had found the call deterministic, that result went into the executable as a constant.
  • What was still wrong after the first fix. Reading a member as plain data is not enough: the call reads other members first, and a getter among them, or a member whose conversion to a string runs the load's code, can remove timeZone before the call gets to it (and the conversion of a compared value can empty the list of locale tags before localeCompare reads it). The call then takes the machine's zone or default locale while the recorder had seen a named one.
  • Measured (the new corpus program 9817-node-island-load-reads-argument-accessors, five island packages). First finding: Node and a build with the packages marked "evaluate": "eager" print zone: Asia/Tokyo 1, time: 9:00:00 AM 1, number: 1.234,5 1, order: 1 1, file: 5 1; the deferred build without the fix exits 0 and prints zone: UTC 2, time: 12:00:00 AM 2, number: 1,234.5 2, order: -1 2, file: 10 2. Second finding: built with no TZ and no locale variables and run under TZ=America/St_Johns LC_ALL=sv_SE.UTF-8, Node prints removed: America/St_Johns America/St_Johns 1; the build with only the first fix exits 0 and prints removed: UTC UTC -1.
  • A private key of the build in the executable. crypto.createECDH and crypto.getDiffieHellman were on the recorder's list of functions that compute from their arguments. Their objects generate a private key, so a package that generates a key pair when it loads was deferred with the BUILD's keys as constants: measured, two runs of the executable print the same public key (the build's), where Node prints a fresh one each run. They are off the list (a reading no run repeats, so the load evaluates where it is imported), and with them crypto.createSign (a signature draws a nonce for most key types), the classes ECDH, DiffieHellman, DiffieHellmanGroup and Sign, and the async id counters of async_hooks; util.parseArgs() and module.getCompileCacheDir(), which read the command line and the cache directory through Node's own code, are inputs now. Every entry of the recorder's lists was reviewed with one question (can its answer depend on anything but its arguments and the pack?); one stated limit stays: memory from Buffer.allocUnsafe that a load reads before writing it.
  • The rule now. The recorder looks at an argument only where the engine answers without running program code, and counts an argument as readable only when nothing on it can run while the call reads it: an options object is not a proxy and has, on itself and its prototypes, no accessor and no member that holds an object or a function (the built-in prototypes' own members as they were before any load excepted); locales are a string, an Intl.Locale (through its own slot) or a plain array of those; the receiver and the compared value of the String methods are primitives; the parts and the name of new File() are strings or buffers; supportedLocalesOf is the one captured before any load, and util.types.isDate stands in for instanceof. Anything else is an input: the load evaluates where it is imported, and the build trace names it (eager …: its load reads an options argument that only the load's own code can answer (an accessor, a proxy or a conversion)).
  • What it costs. A load that passes such an argument is evaluated where it is imported (Node starts there) instead of waiting. Counted on the island corpus, for each of the three fixes: all 311 corpus programs that need a libnode pack, built without and with it: the only loads whose decision changes are the new program's own; 309 executables are byte-identical each time, and the one other that differs (9814-node-island-load-reads-run) differs between any two builds of it (one of its snapshots holds a random draw of the build: a known open point of 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's member, not this change). The rule is conservative: an options object that carries a method, a nested object or an accessor, or a class instance as options, makes its load eager even when the call would never reach that member. Not measured on pi: whether one of its package loads passes such an argument is not known; if one does, that load starts Node at its import, and "evaluate": "lazy" for the package is the setting that states the argument's code may run when Node starts.
  • Tests. The corpus program (differential, LLVM differential and sanitized differential pass; checker identity passes); tests/harness/node-island-load-reads.test.ts builds it under one machine setting and runs it under another, and runs it twice to see two different generated keys; unit tests in probe-recorder.test.ts with fourteen argument shapes (seven compared with what Node computes), four plain shapes that stay deterministic, and the key, signature, command-line and cache-directory sources. Each new test fails without its fix.

Conflicts and how each was resolved

Regenerated files, and the list entries that moved

Every generated file was regenerated by its script on the combined tree, in a Sandbox; none was merged by hand.

  • packages/compiler/src/ir/codec.generated.ts: regenerated by scripts/generate-ir-codec.mjs from the merged ir.ts (lang-2 and startup-2 both change the IR). The only generated file whose regenerated text differs from the merged text.
  • Unchanged by regeneration (the merged text is what the script writes): ir/fields.generated.ts, the island host header scr_node_island_host.h (scripts/node-island-host.mjs), the runtime refusal baseline packages/runtime/refusal-sites.json (scripts/runtime-refusals.mjs --write: 390 sites, every one with a diagnostic code, none missing from the baseline), builtin-object-members.generated.ts, the surface manifest and the Node compatibility ledger (pnpm manifest, pnpm node-compat --local, pnpm node-compat:check).
  • Island conformance list (tests/harness/island-conformance/known-failures.json): the lane's update mode over the whole lane (8 shards) on the combined tree changes no line. Against main the list moves by islands-3's two entries, which come with its merge: corpus/7531-buffer-constructor-dependency (island:legacy-buf) and the island:chunk-buffers way of corpus/7748-npm-any-bytes-receiver leave the list, because both programs match Node once byte views are paired. lang-2 and startup-2 move none, and the new corpus program of the recorder fix needs none.
  • Island value matrix (tests/harness/island-value-matrix/expected.json): the update mode on the combined tree changes no line (239 cells pass). Against main it moves by islands-3's change (its description below has the counts: observations that were wrong on main and are right or refused now).

Pre-checks on the combined tree (Vercel Sandbox, 32 vCPU), before the push

On b325e364c7, the first commit pushed (main cca23de1ea merged with the three heads, the regenerated codec, the first recorder fix). The second push, fed1d5a7db, adds the second recorder fix (probe-recorder.ts, its unit test, the load-reads harness, the corpus program); on it again: type check, lint, format, baselines, the codec check, the recorder's unit tests and the island harness (29 files, 524 tests), the conformance lane (update mode changes no line; 434 passed), the corpus program in the three differential suites, checker identity.

  • Build and type check (CI's pnpm build steps): pass.
  • Generated-file checks: generate-ir-codec.mjs --check, generate-ir-fields.mjs --check, node-island-host.mjs --check, gen-builtin-object-members.mjs --check pass; pnpm manifest and pnpm node-compat --local change nothing; pnpm node-compat:check passes.
  • pnpm lint (with the runtime refusal baseline: 390 sites, none unlisted): 0 warnings, 0 errors. pnpm format:check: clean. pnpm test:ts7 --baselines-only: complete.
  • Unit and guard tests: 372 test files, 4,488 tests, 0 failed: every test file under packages/compiler/src and packages/cli/test except four slow toolchain and pack builders the lanes run (runtime-pack.test.ts, wasi-runtime-pack.test.ts, backend/llvm/common.test.ts, backend/native-toolchain.test.ts), the members' unit files under packages/compiler/test and the guard tests under tests/harness.
  • tests/harness/coverage.test.ts without its whole-corpus case (the lanes run it sharded): 75 passed. copy-soundness, dyncheck, diagnostics, errors, deferred-fence-report: 606 passed.
  • Island harness (the fifteen node-island* files): 220 passed. Island value matrix: 239 of 239 cells. Island conformance lane, check mode, 8 shards: 434 passed, 68 skipped, 0 failed. The libnode pack was built from this tree (bridge ABI 20, which islands-3 brings).
  • The members' own corpus programs in both differential suites: 103 programs, 206 cases, 0 failed (lang-2's list, startup-2's and the recorder fix's programs, islands-3's symbol and weak-key programs and the two programs that leave the conformance list).
  • Checker identity for the new JavaScript corpus programs: passes.
  • Docs gate (NEXT_DIST_DIR=.next-check pnpm check in docs/): passes, 25 of 25 route tests.
  • A trial merge with main 6da2be6a1b (perf-3 and --define landed while this was checked; no conflict): builds, lint and format clean, and main's new guard on how the compiler hands the Lowerer over passes on the merged source with the other guard tests (24 files, 398 tests).

Still running when this was pushed

Each member ran both native bootstraps on its own tree; the combination had not. Started on b325e364c7 (the second push differs from it only in the load recorder, its tests and one corpus program), in their own Sandboxes: both lanes (plain and sanitized) with both native bootstraps, and the zero-cost gate's identity over the whole corpus with the union of the members' allow lists. Results follow in a comment. CI runs the lanes too; it runs the native bootstraps only for a change to bootstrap paths, which this is not.

Not in this branch

Train pi-1 (#3352) was planned as a fourth member and is not here: it returns as a branch of its own.

Train islands-3 (the restack of #3284): its own description

One reviewed pull request about the Node island boundary, restacked on main after train islands-1 (#3377) and validated as one tree (landing train islands-3). Supersedes #3284.

After it, a typed array, Buffer, DataView or ArrayBuffer and a symbol are one identity on both sides of the boundary: a program view that goes into an island and comes back is itself, island code sees one object for one program view, and a program symbol (a unique symbol constant, a Symbol.for symbol, a well-known symbol) is the same symbol in the island and when it comes back. The last part removes a build-time refusal: SC8010: a 'unique symbol' value (its native representation has no island export).

const kState: unique symbol = Symbol("state");
register(kState, "value");            // island code, a Map key      before: SC8010 at build time
lookup(kState);                       // Node: "value"               now: "value"
echo(kState) === kState;              // Node: true                  now: true
const u8 = new Uint8Array([1, 2, 3]);
keep(u8) === u8;                      // Node: true    main: false   now: true
Buffer.isBuffer(isl.bytes());         // island: a Uint8Array        Node: false   main: true   now: false

What lands

  • Supersedes Keep byte views and symbols one identity across a Node island boundary #3284 (fix/island-views-symbols, head cfc71de2ed, merged, not rebased, so its commits and the maintainer's review commit are in this branch): byte views and symbols keep one identity across a Node island boundary. Its description has the rules in full (the bytes are one memory; one view object and one ArrayBuffer per side; byteOffset, length and element kind are the view's own; Buffer or Uint8Array by the object's class; symbols by name, by key, or paired; symbol keys on objects of both sides) and its own proof on its base: gate with no differing program, lanes, the leak and plateau runs, the chunk throughput table.

What the train changed beyond the member

#3284 was proven on main 50bcfe1b39. Since then #3377 landed in the same units, and two of main's guard tests are newer than its base. These are the places where they met; no part of #3284's own proof covers them.

  • Bridge ABI 20 (main is 19; the branch said 14). A libnode pack built from another tree is refused.
  • ScrNiDynOps keeps main's offsets: main's 26 entries in main's order, then set and has_own.
  • The pin table. Keep byte views and symbols one identity across a Node island boundary #3284 rewrote it (a hash index, one pin per handle, pins made since the last entry looked at first); Train islands 1: errors, promises, process events and process.stdout.write keep Node's behaviour across the Node island boundary #3377 had added native-error pins and a rule for an error whose only other holder is its view in the error-view cache. The table is Keep byte views and symbols one identity across a Node island boundary #3284's and carries native-error pins as a sixth kind; a pin is dead by Keep byte views and symbols one identity across a Node island boundary #3284's count and by main's rule for that error; a sweep of the young pins leaves native-error pins to a full sweep, as it leaves promises and errors; the cross-heap collector is handed promise, error and native-error pins.
  • One helper where both sides had added the same one (nh_error_info, the read of an island error on Node's stack): main's is kept.
  • Smaller: nh_from_node keeps main's pinned-error identity and twin adoption before the symbol; export_set writes a symbol key first, then main's native-error write; the host script has main's twin section and Keep byte views and symbols one identity across a Node island boundary #3284's pair helpers in one table, and the embedded header is regenerated by its script.
  • Three symbol-key paths of scr_json.c were compiled into no program. Keep byte views and symbols one identity across a Node island boundary #3284 kept them under #ifdef SCR_NODE_ISLANDS; since Reach Node islands through the installed table from four shared units #3135 that unit builds once and no build defines it, so the merge built and a symbol-keyed write, probe or delete on an island object silently used the native path. They test the island handle and call the table now, as main's other handle sites do. Each is proven to run by switching it off alone in one runtime build: with the write or the probe off 8748-node-island-symbols stops matching Node; with the delete off every corpus program still matched, so the delete had no proof: 8748 now deletes a program symbol's key from an island object and asks both sides (with the path off the delete silently does nothing).
  • Found by main's guard: the refusal of a resizable or detached program ArrayBuffer was compiled into no program, on Keep byte views and symbols one identity across a Node island boundary #3284's own branch too. It was under SCR_JS_OBJECT_MODEL, and the island handle unit has no build with that define (runtime-define-sites.test.ts names the unit), so a JavaScript program with mutable buffers could hand an island a view over memory the program can move, shrink or detach. The handle unit now asks the bytes unit the program links (scr_bytes_storage_movable, at the end of scr_bytes.c in both of its builds). Pinned by a new test in node-island-handles.test.ts (fixture movable-buffers.mjs): a fixed buffer and its view cross; a resizable buffer, a view of one and a detached buffer are refused by name. With the check switched off the fixture prints 8, 8 and 0 for the three, which is what the branch did.
  • A program that matched Node stopped matching, and is fixed: a symbol as a weak-collection key. 8754-node-island-weak-keys (Keep weak-collection entries keyed by Node island objects inside the island #3325, on main) puts an island symbol into a WeakSet; on main the island's collection held it, as an island object. With symbols paired, that symbol is a native symbol, and the native weak collections do not hold symbols yet: the program threw Invalid value used in weak set. In a program that links the island units a symbol key now goes to the island's collection (V8 holds an unregistered symbol weakly and refuses a registered one, as Node does). Without islands nothing changes.

The island lists

Both regenerated by their update modes on this tree.

  • Value matrix: 4,423 pinned observations become 3,744. 709 leave the table, 512 of them wrong answers (the others: 88 refused, 69 threw, 22 unsupported, 10 ended the program, 8 crashed): u8 221, sym 188, arraybuffer 67, dataview 64, buffer 36, f32, f64 and i32 30 each, holder 23, big64 20. 30 are new, none of them wrong: 22 in the arraybuffer cells (structuredClone throws, JSON.stringify and for…in are unsupported), which is what the kind's control cell without an island answers, and 8 in the sym cells (JSON.stringify of a typed symbol is the build-time SC1090 the control cell has); they were hidden behind the refusal and the crash. Three big64 observations go from wrong to unsupported.
  • Conformance list: 59 programs with an entry become 58. corpus/7531-buffer-constructor-dependency (island:legacy-buf) and the island:chunk-buffers way of corpus/7748-npm-any-bytes-receiver match Node now. None is new and none changes.

Validation

All in Vercel Sandboxes, Node 24.22.1, libnode pack built from the tree under test (bridge ABI 20). The branch is stacked on train islands-2 (#3382), so every comparison below is against that train's head, which is main plus #3382.

Full round (c13d28ad69 against #3382's tree with main 952d9fb2be; bin/validate --gate-corpus=all --gate-complete, 53 minutes, 97 lane jobs):

  • Typecheck: pass.
  • Lanes: plain 23,835 passed, 0 failed; sanitized 20,451 passed, 1 failed. Both native bootstraps are among the lane jobs and passed (the plain one, and the sanitized one in its three phases). The one failure: sanitized, npm-static.test.ts "commander compiles statically and byte-matches Node", stopped at its 180 s limit; alone, sanitized, on the same tree it passes 3 of 3 (90 s for the first build, 17 s after). That program has no island.
  • Zero-cost gate: rows below. Compile time within tolerance: bench geomean +0.41% [-0.42%, +1.14%], self -0.92% [-1.75%, +0.85%], fixtures -0.17% [-1.06%, +0.71%]; the candidate compiler source builds with the candidate.
  • bench-runtime: 71 workloads, geomean +1.1%, 67 neutral, 1 slower: unicode-lexer +34.3% [+12.1%, +42.9%], whose executable is byte-identical to the base's (173,920 bytes on both sides), so that is one binary measured twice. An earlier gate round of the same code read record-untyped-writes +6.6% [+0.5%, +11.1%]; that executable does differ (it links scr_dyn_symbol_key_set, +64 bytes) though the program writes no symbol key, so it was measured alone, base against candidate, 31 runs over 4 layouts, three times: -1.2% [-2.2%, +0.6%], -0.6% [-1.2%, +0.2%], -4.8% [-18.3%, +6.8%], neutral each time; in the full round it is neutral too.

Mutation proof (one runtime build with a switch per path; the sandbox copy only): with nothing switched off the four programs pass. Symbol-key write off: 8748-node-island-symbols fails. Symbol-key probe off: 8748 fails. Symbol-key delete off: 8748 fails on its new line (before that line existed: nothing failed). Symbol weak keys off: 8754-node-island-weak-keys fails. Mutable-buffer check off: the new test of node-island-handles.test.ts fails, the three buffers cross.

Leaks. The handle leak fixture (handles-leak.mjs, 20,000 iterations; #3284 added a program view, a subarray and a symbol per iteration to it): 100 runs, 100 ended normally, 2 live handles at exit each time. node-island-gc.test.ts alone, 3 of 3.

The refusal a large TypeScript program meets most often at the island boundary, as a neutral program (a unique symbol constant, an inferred const symbol, a Symbol.for symbol and Symbol.asyncIterator, each handed to island code as a Map key and as a value that comes back): refused at build time on main with #3382 (SC8010: a 'unique symbol' value (its native representation has no island export)), matches Node on this branch.

On the pushed head's tree (the round's tree, #3382's last head with main 851b59b90f, the regenerated lists; 32 vCPU):

  • Build, runtime pack, libnode pack (bridgeAbi 20), generated-file checks (the embedded host header by its script), pnpm lint (0 warnings, 0 errors; runtime refusal sites 390, none outside the baseline), pnpm format:check, ts7 baselines: pass.
  • Guard and unit tests (pack matrix, runtime define sites, builtin attributes, runtime features, executable cache, unit journal, lowerer, peel guard, diagnostics, node-islands), with node-island-handles.test.ts and node-island-weak-keys.test.ts: 504 passed.
  • Island value matrix, full update: 239 of 239, byte-identical to the committed table. Island conformance lane, check mode, 8 shards: 434 passed, 0 failed.
  • Island harness suites, plain: 424 of 424. Island diagnostics 12. Island corpus, plain (differential and llvm-differential, every island program): 586 of 586. Sanitized: harness 424 of 424, corpus 586 of 586.
  • Gate identity: rows below.

Gate rows and exceptions

Against #3382's head (0f4df1865e, main 851b59b90f), --gate-corpus=all --gate-complete, on the pushed head's tree: 5,833 programs; 4,690 byte-identical, 291 fail to build on both sides (the island programs: the gate has no libnode pack), 851 runtime-only, 1 listed, none differing. (The full round, one merge of main earlier, counted 848.)

  • All 37 fully typed programs are byte-identical. No program gains a dynamic-tier or island symbol.
  • 851 runtime-only rows: IR and LLVM identical on every one; +0 to +384 bytes, median +128 (258 programs +128, 229 +64, 219 +192, 89 +256, 31 +320), at most +0.046%; 5 dynamic benchmarks among them. They are the programs that link one of five functions of scr_json.c, a unit every dynamic program is built from: scr_dyn_symbol_key_set (560 programs), scr_dyn_key_probe_computed (444), scr_dyn_key_delete_computed (99), where a symbol-keyed write, probe or delete on an island object now tests the island handle and calls the table, and scr_weak_map_invoke and scr_weak_set (289), where a symbol key goes to the island's collection. Keep byte views and symbols one identity across a Node island boundary #3284's own gate showed none of these because the three paths were under a define no build sets. Each site is the handle test and one call; the rest is in the island unit. Recorded as a one-time change in the fleet's gate exceptions.
  • One listed program, corpus/8748-node-island-symbols/main.ts, which this branch adds: main's compiler refuses it at build time (SC8010, the unique symbol refusal this change removes) and the branch's needs the libnode pack, which the gate does not have, so neither side has an executable to compare. The three other new island programs fail on both sides for the pack alone, like every island program.

Not run

  • Test262 (no island program is in it; the gate finds every program without the changed functions identical).
  • Darwin. pi. The docs gate (the branch changes no docs file).

🤖 Generated with Claude Code

cramforce and others added 30 commits November 6, 2026 22:25
… elements in array conversions with a TypeError

An array that can hold a hole or undefined, read by a for-of loop inside a
generic function, method or class, aborted with `scriptc: RangeError: array
index N is a hole` (or `is undefined`) where Node carries on or throws a
catchable error. The element-state analysis keeps its facts per element key,
a generic array has no key outside an instance, and the loop's decision was
stored once per syntax node, so every instance read with the proven getter.

- The analysis records, for each loop it judged present, the generic arrays
  the answer read, including those a slot that owns a copy was built from.
  When an instance lowers the loop, the same query runs with the instance's
  type-parameter bindings; a key that can hold a hole or undefined gets the
  checked read a concrete function already gets.
- At a call of a generic function, method or constructor, an argument key
  that can hold holes gives every array the call can reach, and its result,
  hole and undefined facts: the body may keep the holes under another
  element type or copy them as present undefined.
- The copying array conversion tests the element state when the source key
  can hold a missing element, and reads a dynamic source element as
  undefined. A target that cannot hold undefined throws a TypeError.

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

A generic function that maps an array held in a field of its argument
(`box: { items: T[] }`) still returned the holes under another element type
with no fact recorded. The generic-call rule now enters the fields of a
record or class instance one level deep where the callee's declared
parameter or result type mentions a type parameter, and follows map and set
values.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…he array-fact rules

A bare `v: T` argument gives the generic body no view of its fields, so the
generic-call rule no longer enters them: `publish(graph)` marked every array
field of the published object. A parameter typed by a type parameter whose
constraint is generic (`B extends Box<T>`) is still entered.

An implicitly typed JavaScript parameter is no longer a generic source for
the per-instance loop decision. Its instance lowers bindings the analysis
did not plan as optional, and a captured one has no lowering yet
(`7748-npm-any-bytes-receiver`). Those instances read as before.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…a generic array was read

The generic-call rule read the "some key can hold holes" fact at every call
site, so the first holed key made every call site of the program stale. It
now decides whether the callee is generic first, and computes the reachable
keys only when some key can hold holes. A receiver's fields are entered only
for methods of a generic class or interface.

`elementsPresent` and `loopSources` judge an array a second time only when
the first judgement read a generic array.

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

A generic function that only reads its array, or returns it, creates no
fact its caller does not already have: `first(xs)`, a visitor, a hole-punch
helper. The analysis now records the functions and classes whose code builds
an array from a generic array's elements or stores them into another array
(a spread, a copying method, a rest pattern, a push of a spread), directly
or by calling a generic function that does, and the rule fires only for
those callees and for callees with no body in the program.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…e two program facts

A function held in a parameter is not a generic callee: `visit(child)` inside
a generic visitor made the visitor a builder of arrays, through the path for
callees with no body, and with it every generic helper that takes a
callback. Records reached through a callback's signature and functions held
in fields are no longer entered when collecting the keys a call can reach.

A loop source that has no element key even where the loop lowers is judged
as the loop was.

The generic-call rule and the "elements present" answer for arrays built
from generic ones are registered in program-facts.ts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… means Node's behaviour

A value converted to another record, array or class layout is copied where
JavaScript keeps one object, so a write through one name is lost and `===`
is false. SC6001 came from a scan of the syntax tree that knew five
spellings and looked for a write through the same binding: of 56 probed
forms that differ from Node it named 7.

The conversions are now read off the functions the lowering produced
(frontend/lowering/width-copies.ts). Every call of a copy helper is one;
helpers that only route a value hand their copies to the site that calls
them. A conversion is reported when the converted value is aliasable (not
a literal, a spread, another copy, or a value that only has a member read)
and the whole program observes either layout: it writes a field or element,
compares values of the copy's type by identity, or hands either to code
the scan cannot follow. That is a statement about types, so no path of the
copy escapes it; it can name a conversion on a path the program does not
take. No emitted code changes.

A record something else can reach, converted to a class instance in a
program that writes either type, is refused (SC1090): nothing keeps that
value one object, and no corpus program or pi site has one.

tests/harness/copy-soundness.test.ts builds 291 generated cases and
asserts that a case with no SC60xx warning prints what Node prints. Its
baseline holds the aliasable conversions per kind, which may only go
down, and the one case known to differ silently (a promise payload
converted at the checked boundary).

The corpus sweep in coverage.test.ts keeps "no divergence warning on a
corpus program" for every code but SC6001, whose counts per program are
in corpus-width-copies.json (58 programs of 4,743 entries).

scriptc build renders at most 25 warnings and counts the rest by code.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The scan formatted the type of every field write, array write and
identity comparison it met, then kept the description only for the first
one under each key. A program writes the same fields many times, and the
compiler's own source formats large node unions. The description is now
built when the observation is recorded. The facts are unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Compiled code calls a built-in member's native entry point and never reads the member from its object. `Array.prototype.push = f` built without a diagnostic, in TypeScript and JavaScript sources, and `xs.push(1)` then ran the built-in: the binary printed `1,2,3 0` where Node prints `1,2,3 2`, both with exit status 0.

Existing members of built-in objects are now read-only. A write the build can see that replaces or deletes one is refused with SC1092: an assignment, a compound assignment, an update, `delete`, `Object.defineProperty`, `Object.defineProperties`, `Object.assign` or a `Reflect` call whose target is a built-in constructor, its prototype, a namespace object or the global object, through `globalThis`, a `const` alias, a literal key, a well-known symbol or the prototype of a value the source spells out. The refusal is a build error in JavaScript sources too, where a deferred fence could be caught and the built-in would keep running. A member the object inherits from another built-in counts, and so do `toJSON`, `then` and well-known symbol keys, which the runtime's own readers do not look up on a built-in's prototype object. Redefining a member with its own value and assigning `Error.stackTraceLimit` keep their lowering, and so does adding a member to `Array.prototype`, a constructor or a namespace object.

Adding a member to any other built-in prototype is refused with SC1093: only untyped arrays read a member from their prototype object, so `Map.prototype.added = 7` left `new Map().added` undefined where Node answers 7. Like other refusals it becomes a run-time fence in a JavaScript source.

"Existing" is a fact of the pinned Node release, generated by scripts/gen-builtin-object-members.mjs. Because an existing member cannot change, a feature test of one has one answer, so a polyfill behind `if (!Array.prototype.at)`, `typeof X.m !== "function"`, `X.m || (X.m = f)`, `X.m = X.m || f`, `X.m ??= f` or an early return lowers to nothing. The fold applies only where the code that never runs contains a refused write.

A write the build cannot see throws at run time: when a program reads a built-in prototype as a value (Array, String, Number, Boolean, Date, Map, Set, WeakMap, WeakSet, DataView), the object's members get the fenced bit the namespace objects already use. The bit is set at the value entry points, by a header helper that keeps no state outside the object, so a program that never holds a built-in prototype keeps its exact executable. Array.prototype has its own entry point for that (dyn.arrayPrototypeValue), because the method table also installs for programs that never hold the object.

Four corpus programs whose subject was replacing an existing member are removed and three are trimmed; their shapes are diagnostics snapshots now.

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

A function held at a type with fewer parameters than it has is a view: the
signature adapter that calls it with undefined for the parameters the type
lacks. A later conversion to a type with more parameters wrapped that view
in a second adapter, which dropped the arguments the first type lacks, so a
function with an optional parameter stored in a one-parameter slot and then
in a two-parameter slot never received the second argument. JavaScript has
one function object throughout.

When lowering ends, every adapter that makes a view of a function type is
known with the type of the function it forwards to. A widening adapter now
tests, per such view, whether its operand is that view (a comparison of the
closure's code) and builds its result from the function the view forwards
to, through the conversions the two adapters already are with the extra
parameters threaded through. Two IR expressions carry it: viewOf and
viewSource. A checked-dynamic callable held at a function type (a bound
function) is looked through the same way, boxing a view into a
checked-dynamic value boxes the function it stands for, and a function
asserted to a type with more parameters converts instead of erasing, so a
call through the assertion passes its extra arguments.

An extra argument the function's parameter cannot hold takes the stranded
TypeError when the wide view is invoked. Nothing changes where a function
is narrowed or called; a widening adapter changes only in a program that
holds a view of its source type that can be handed more.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…a narrower slot

JavaScript calls every array-method callback with the element, its index
and the array. The method's helper passes the parameters the callback's
type has, so a function with an index parameter that came through a slot
declaring only the element never received it: items.map(cb) with
cb: (x) => string holding format(x, index?).

Each shared array-method helper (the one callback values use; a literal
callback has its own) records what it was built from. When its callback
type has a view of a longer function, the helper tests for that view and
calls the same helper built for the full parameter list with the function
the view forwards to. A helper with tests leaves the registry pipeline
fusion reads, so a fused chain does not skip them.

A function whose parameters past the element cannot hold what the method
passes, handed over where the checker cannot object, was refused as a
value "that cannot be compiled yet"; the refusal now names the parameter
and the rewrite.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ir gets none

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

The run-time fence on prototype objects a program holds, the refusal of members added to built-in prototypes (SC1093), of protocol keys and of copies from a source the build cannot read move to a change of their own, stacked on this one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A typed array lost its identity at every crossing: `keep(u8) === u8` was
false after a view passed through an island and back, the island saw two
objects for one program view, an island subarray arrived with byteOffset 0
and a buffer of only its own range, and every island Uint8Array answered
Buffer.isBuffer true. `String(x)` of an island symbol held as `unknown`
was a segmentation fault: the symbol was an island handle whose typeof is
"symbol", and narrowed code read the handle as a native symbol.

Each of these values is now one object on each side, paired for as long as
the program holds its half. The pairing uses the tables that already pair
a runtime object with a V8 value: the bridge's import table and the handle
unit's pin table.

Byte views:
- Two levels, as in JavaScript. The owner of a storage is paired with one
  V8 ArrayBuffer, a view object with one V8 view. Into V8, the first
  crossing makes the ArrayBuffer over the whole storage and the view at its
  real offset, and the bridge binds them (host callbacks bound and bind);
  later crossings send the view's handle. Out of V8, the bridge sends the
  view's handle and, for a view the runtime does not hold, the
  ArrayBuffer's handle, offset and extent.
- A view's first crossing costs no table entry: until the next entry it is
  "young", kept alive by the bridge and held by the handle unit in lists
  of one order, and named by its position. The next entry pins the views
  the program still holds and lets the others go, which is what happens
  to a stream's chunks.
- A typed array that is its own storage binds once, as the view; its
  ArrayBuffer gets a handle only if V8 hands it back, and is then found by
  the store's memory.
- ArrayBuffers cross as themselves through the same pairing.
- Buffer.isBuffer follows the prototype chain to Buffer.prototype.
- BigInt64Array and BigUint64Array views stay island handles; they
  crossed as Uint8Arrays of eight times the length.
- The V8 ArrayBuffer over a program buffer carries a detach key, so island
  code that transfers or detaches it gets a TypeError: a compiled view
  cannot follow a detach.
- A pin is released when only the table, and for a storage the V8 backing
  stores over it, holds the object. Pins made since the last entry are
  looked at first, and the table is also swept when island code calls a
  program function. It has a hash index now.

Symbols:
- An island symbol is a native symbol paired with it: Symbol.for symbols
  by key, well-known ones by name, every other symbol one to one. A program
  with a Node island links the symbol unit.
- symbol-typed values cross (they were SC8010).
- Symbol-keyed reads, writes, `in` and delete on an island object go to
  Node. On a program object island code reaches the program's property
  under a symbol the program holds; a property under any other symbol
  stays on the representative.

Bridge ABI 14: the byte value carries the buffer's handle and extent, the
host gains bound, bind, holds and export_symbol, the bridge symbol_info
and young_flush.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Both sides added a check at the start of finishModule (the refusal of a
record-to-class copy the program can tell, and #3097's refusal of a
source field delete); both stay.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Two programs merged since this branch was cut hold a conversion the scan
reports: 63 programs, 251 warnings, of 4,804 entries.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…at typed slots

`const o = { files: [] }; const f: string[] = o.files; f.push("x")` left
`o.files` empty: the field was the unit-element array and every typed slot
its value reached received a fresh array from the empty-array lift, so two
names for one JavaScript array were two arrays. No diagnostic, exit 0.

An empty array has one identity, so its element representation is now
decided once, where it is written:

- A property `name: []` (also `[] as const`) takes the array type of its
  contextual type, or the one the typed slots its value reaches agree on.
  The search (empty-array-places.ts) follows the value out of the literal
  that holds it, through unannotated bindings and function results, every
  read of the property in the program, aliases, conditional and logical
  operands, spreads, assertions through `unknown`, callback results and
  generic calls, until a declared type names the element.
- The copying lift is removed (`emptyArr`, `%arr.empty`). The empty literal
  itself still becomes its destination's empty array; any other
  unit-element array at a typed slot is refused by name, SC2015, with the
  fix in the hint. That is what remains when one array is used at two
  element types or reaches a slot through a path the search does not follow.
- A binding, a declared function's result, a function-valued binding, an
  alias property and a spread copy that the checker types `never[]`
  because they read such an array take the array's type instead of never's
  f64 element array.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The per-program list broke for whoever merged second: three corpus
programs that carry SC6001 landed on main in the four hours this branch
was open, and each one fails the sweep until it is listed. The sweep
keeps asserting that a corpus program has no other divergence warning.
The conversions that copy and the warnings that were not needed are
counted by copy-soundness.test.ts, whose programs no other change adds
to.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…replayed verdict, and bound the warning's type text

Three findings on the width-copy scan:

- A copy-helper result counted as fresh, so a value that reaches a third
  layout through a second (`w` to N to M in one expression, or through a
  function that returns its converted parameter) was reported only for
  the first conversion. A write through M was then lost with no warning
  when the program wrote neither N nor W. A copy of an aliasable value is
  now as aliasable as the value. (Vercel bot review.)
- The launcher replays an unchanged program's coverage verdict without
  loading the compiler. It hid SC6001 for --no-copy-warning and then let
  --fail-on=divergences pass, where the full CLI refuses the pair. It now
  leaves that pair to the full CLI. (Vercel bot review.)
- The compiler's own source carries several thousand SC6001, each naming
  up to three types in full: the self-hosting tests that return a compile
  result from a child overflowed the child's 16 MiB stdout. The message
  cuts a type's text at 120 characters, and the child's buffer is 64 MiB.

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
A guard such as `effect() && !Array.prototype.at` is false whatever `effect()` answers, so the polyfill behind it is still dead, but `effect()` runs. The test was folded to a literal and the call was lost. A decided guard now lowers its test whenever an operand runs for its effects, and only the dead arm is dropped; a logical expression in statement position and an early-exit guard fold only when every operand that runs is a feature test.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ter at any depth

An untyped empty array in a generic function or class kept the array type
of the first instance when the slot's element only contained the type
parameter (`{ value: T }[]`), so later instances were refused. The answer
is no longer kept across instances for a literal inside a generic
declaration. Found by the Vercel bot on #3288.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The family gained the case the review asked for (a value that reaches a
third layout through a second): two more record conversions that copy
an aliasable value, 364 in all.

Co-Authored-By: Claude Code <noreply@anthropic.com>
lowerer.ts: main settles the x.constructor identity helpers in the two
reachability fixpoints where this branch processes function views. Both
steps are kept, identity first; the loop ends when neither changed.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A write or delete through `<T>process.env` changed a snapshot copy, not the environment: `isAmbientProcessEnv` looked through parentheses, `as`, `satisfies` and `!` but not the angle-bracket assertion. `require("node:path") satisfies T` was refused (SC1090) and `fork(path satisfies string)` was refused (SC2020) where the `as` spellings compile: the require and fork-target recognizers left out `satisfies`. The name in "x is not iterable" was lost behind `<T>x`.

Each of these wrappers leaves no trace at run time, so each site now looks through the same set as its `as` spelling.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`function strict(): undefined { return loose(); }` with `loose(): any` returning 5 printed `undefined`; Node prints 5. A result declared `undefined` has the void representation, so the value was dropped without a look, while a binding declared `undefined` checks it.

A `return` and a concise arrow body now test an untyped value in a function whose declared result is `undefined` and throw a TypeError unless it is undefined. Functions declared `void`, typed operands and async functions lower as before.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 6 commits November 7, 2026 19:44
…#3284

Co-Authored-By: Claude Code <noreply@anthropic.com>
Conflict: packages/compiler/src/frontend/lowering/divergences.ts. The
lang train moved the width-copy scans of variable declarations out of
this visitor (#3221); main (islands-2, #3382) added one line to the
same branch, the list of annotated declarations a program with islands
checks. The branch keeps main's line and nothing of the scans the lang
train moved.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Conflicts:
- docs/content/docs/coverage.mdx: main's SC6007 line and this train's
  SC6012 line, both kept.
- frontend/lowering/lowerer.ts: the lang train's width-copies imports
  and this train's deferred-fence imports, both kept.
- tests/harness/coverage.test.ts ("every corpus program is 100%
  static"): the lang train lets SC6001 stand on a corpus program, this
  train SC6012; the test lets both stand, each for its own reason.
- ir/codec.generated.ts: main's side here; regenerated from the merged
  ir.ts by scripts/generate-ir-codec.mjs in a later commit.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…#3284) into train/landing-1

Byte views and symbols keep one identity across the Node island
boundary. The head sits on #3382's head, so the merge brings only its
own diff. No conflict. (The branch's later head 6eb3826821 is its own
merge of main 3dc1f4a and adds nothing to this tree.)

Co-Authored-By: Claude Code <noreply@anthropic.com>
scripts/generate-ir-codec.mjs on the tree that holds lang-2, startup-2
and islands-3; the merge took main's side of the file.
generate-ir-fields.mjs, node-island-host.mjs, runtime-refusals.mjs
--write, the surface manifest and the node-compat ledger regenerate to
the merged files unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…'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 #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>
@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 7:00am UTC

@vercel-internal-security-reviewer

Copy link
Copy Markdown

Security review details

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings

  1. packages/compiler/src/node-islands/probe-recorder.ts:505-509 — An options getter that removes a data-valued timeZone before date formatting reads it makes the recorder embed a machine-dependent result without a time-zone guard.

    Fix on Vercel

…on it can run during the call

The load recorder reads a call's arguments where the engine answers
without running the load's code. That was not enough: it read
`options.timeZone` as plain data, but the call reads other members
first, and a getter among them, or a member whose conversion to a string
runs the load's code, can remove `timeZone` before the call gets to it.
The call then takes the machine's zone, while the recorder had found a
named zone and recorded the load as deterministic: built with no TZ and
run under TZ=America/St_Johns, the executable answered UTC where Node
answers America/St_Johns, with exit status 0. In the same way the
conversion of a compared value could empty the list of locale tags
before `localeCompare` read it.

An options argument is now readable only when no part of it can run
while the call reads it: not a proxy, and on it and its prototypes no
accessor and no member that holds an object or a function (members of
the built-in prototypes as they were before any load excepted). The
receiver and the compared value of the String methods must be
primitives, and the parts and the name of `new File()` strings or
buffers. Anything else is an input: the load evaluates where it is
imported.

Found in review of #3395.

Tests: corpus 9817 gets a package whose own code takes the zone and
the tag list away during the call, read before anything starts Node;
tests/harness/node-island-load-reads.test.ts builds it with no TZ and
no locale variables and runs it under another zone and locale; the unit
test in probe-recorder.test.ts gets seven such shapes.

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

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings

  1. packages/compiler/src/node-islands/probe-recorder.ts:959-960 — Allowlisting createECDH and getDiffieHellman as pure lets loads that generate private keys embed the build's keys as constants reused across executions.

    Fix on Vercel

  2. packages/compiler/src/node-islands/probe-recorder.ts:852-861 — A custom iterator on the File parts array can remove lastModified before construction reads it, causing the recorder to embed a build-time timestamp as a deterministic constant.

    Fix on Vercel

@cramforce

Copy link
Copy Markdown
Contributor Author

Answer to the review bot's finding in its review of b325e364c7 (a review body cannot be resolved, so it is answered here):

packages/compiler/src/node-islands/probe-recorder.ts:505-509 — An options getter that removes a data-valued timeZone before date formatting reads it makes the recorder embed a machine-dependent result without a time-zone guard.

Right, and silent. Reproduced on b325e364c7 and fixed in fed1d5a7db (pushed).

  • Reproduced. Corpus program 9817 got a fourth island package whose options name a zone as plain data and whose own code removes it while the call reads the members before it (a getter of hour12; a calendar value whose toString does it), read before anything starts Node. Built with no TZ and no locale variables, run under TZ=America/St_Johns LC_ALL=sv_SE.UTF-8: Node prints removed: America/St_Johns America/St_Johns 1; the executable of b325e364c7 exits 0 and prints removed: UTC UTC -1 (the load was recorded "deterministic"). The same package shows the same class in localeCompare: the compared value's conversion empties the list of locale tags before the call reads it.
  • Fixed. An options argument counts as readable only when nothing on it can run while the call reads it: not a proxy, and on it and its prototypes no accessor and no member that holds an object or a function. The receiver and the compared value of the String methods must be primitives; the parts and the name of new File() strings or buffers. Anything else is an input: the load evaluates where it is imported (eager …: its load reads an options argument that only the load's own code can answer). With it the program prints what Node prints under both settings.
  • Tests. tests/harness/node-island-load-reads.test.ts builds the program under one machine setting and runs it under another; probe-recorder.test.ts has seven more shapes (zone removed by a getter, by a conversion, through an inherited getter, in toLocaleTimeString; the tag list emptied by the compared value and by the receiver; a File option removed). Both fail on b325e364c7.
  • Cost. Over the 311 island corpus programs no load but the new program's changes its decision.

…builtins

The load recorder defers an island package's load when everything the
load reached is on a list of what follows from its arguments alone.
`crypto.createECDH` and `crypto.getDiffieHellman` were on that list. The
objects they make generate a private key, so a package that generates a
key pair when it loads was recorded as deterministic and the executable
carried the key pair the BUILD generated: every run of the program, on
every machine, answered with the same private key. Measured: two runs of
the executable printed the same public key, where Node prints a fresh one
each run.

Off the list now, so that such a load evaluates where it is imported:
- `crypto.createECDH`, `crypto.getDiffieHellman` and the classes `ECDH`,
  `DiffieHellman`, `DiffieHellmanGroup` (a private key, a prime);
- `crypto.createSign` and the class `Sign` (a signature draws a nonce for
  EC, DSA and RSA-PSS keys);
- `async_hooks.executionAsyncId`, `triggerAsyncId`,
  `executionAsyncResource` (counters and the current resource of this
  process).
Inputs now, because they answer through Node's own code where no wrapper
saw the read: `util.parseArgs()` (the command line) and
`module.getCompileCacheDir()` (the environment and the island's cache).
The parts of `new File()` count as plain only when the array has the
engine's own iterator, unchanged since before any load: a custom
iterator could remove `lastModified` before the constructor read it.

Every entry of the recorder's lists was reviewed with one question: can
its answer, or what the object it makes can answer, depend on anything
but its arguments and the pack. One limit stays as the recorder's header
states it: memory from `Buffer.allocUnsafe` that a load reads before it
writes it.

Found in review of #3395.

Tests: corpus 9817 gets a package that generates two key pairs when it
loads; tests/harness/node-island-load-reads.test.ts runs the executable
twice and expects two different keys; a unit test in
probe-recorder.test.ts pins the class of each source named above.

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

Copy link
Copy Markdown
Contributor Author

Answer to the review bot's two findings in its review of fed1d5a7db (review bodies cannot be resolved, so they are answered here). Both are right; both are fixed in b1de4ff7d9 (pushed).

  1. packages/compiler/src/node-islands/probe-recorder.ts:959-960 — Allowlisting createECDH and getDiffieHellman as pure lets loads that generate private keys embed the build's keys as constants reused across executions.

Reproduced on fed1d5a7db: corpus program 9817 got an island package that generates two key pairs when it loads (createECDH("prime256v1").generateKeys(), getDiffieHellman("modp14").generateKeys()), read before anything starts Node. The build recorded the load as deterministic, and two runs of the executable printed the same public key, the one the build generated; Node prints a fresh one each run. Fixed: both functions are off the list (a reading no run repeats: the load evaluates where it is imported), and with them what the same review of the whole list found: createSign and the class Sign (a signature draws a nonce for most key types), the classes ECDH, DiffieHellman, DiffieHellmanGroup, and the async id counters of async_hooks; util.parseArgs() and module.getCompileCacheDir() are inputs (they read the command line and the cache directory through Node's own code, where no wrapper saw it). With the fix the two runs print different keys.

  1. packages/compiler/src/node-islands/probe-recorder.ts:852-861 — A custom iterator on the File parts array can remove lastModified before construction reads it, causing the recorder to embed a build-time timestamp as a deterministic constant.

Fixed in the same commit: the parts count as plain only when the array has the engine's own iterator, unchanged since before any load (no own Symbol.iterator, Array.prototype as its prototype, the original Array.prototype[Symbol.iterator] and array-iterator next); otherwise the load is an input. A unit row pins it (a generator on the parts array that deletes lastModified).

Tests: tests/harness/node-island-load-reads.test.ts runs the executable twice and expects two different generated keys; probe-recorder.test.ts pins the class of each source named above; both fail on fed1d5a7db. Over the 311 island corpus programs no load but the new program's changes its decision. One limit stays as the recorder's header states it: memory from Buffer.allocUnsafe that a load reads before writing it (no byte of a buffer is ever captured as a constant; only a load that turns unwritten bytes into an exported string or number would carry them).

@cramforce

Copy link
Copy Markdown
Contributor Author

The round started before the first push, on b325e364c7 (Vercel Sandboxes; b1de4ff7d9, the head now, differs from it in the load recorder, its tests and one corpus program, which have their own evidence in the description and the two comments above):

  • Type check: passes.
  • Lanes: plain 24,365 passed, 0 failed; sanitized 20,973 passed, 0 failed (95 jobs).
  • Native bootstraps, both on the combined tree: pass. Plain: commands (1,394 s), rebuild (3,249 s). Sanitized: commands (1,744 s), rebuild-emit (3,186 s), rebuild-frontend (4,860 s).
  • Zero-cost gate, --gate-corpus=all, identity over 5,896 programs: 4,652 identical; 312 fail to build on both sides (they need a libnode pack; the gate has none); 177 allowed by the members' lists (lang-2's patterns and ids, startup-2's ids); 747 runtime-only rows, every one of which links only the five functions of islands-3's recorded exception (scr_dyn_symbol_key_set in 492, scr_dyn_key_probe_computed in 371, scr_weak_map_invoke and scr_weak_set in 244, scr_dyn_key_delete_computed in 72; IR and LLVM identical; +0 to +384 bytes, median +128; five dynamic benchmarks among them, no typed one); and one listed program that neither side can build here (8748-node-island-symbols: main refuses it at build time, this branch needs a pack). No row is unexplained. The gate's own verdict reads FAIL by the runtime-only rule, as it did in islands-3's own round.
  • Compile time, stated plainly because it is outside tolerance: the gate's self-compile (the compiler, run by Node, compiling its own source) is +4.5% [+3.2%, +6.9%] over main (four pairs; the source itself is 1.7% larger, the LLVM text 1.4%); benchmarks +0.8% [-1.0%, +2.4%] and fixtures +3.1% [-0.2%, +6.3%] are inconclusive. The bootstrap phases above also ran longer here than in the members' own rounds (the plain rebuild phase: "production CLI rebuilds itself" 1,138.8 s on a box whose seed build was about 1.5 times slower than usual; 522.8 s, 513.6 s and 719.0 s in the rounds of startup-2, islands-3 and lang-2). A paired measurement of the natively built compiler on main against lang-2 alone, in one Sandbox each way, is running; its numbers follow here.

@cramforce

Copy link
Copy Markdown
Contributor Author

The paired measurement of the natively built compiler announced above: main 851b59b90f against lang-2 alone (5b32646f38, which contains that main), the plain bootstrap's rebuild phase, each pair in one 32-vCPU Sandbox (Xeon 2.90 GHz), the two Sandboxes in opposite orders.

Sandbox A: main first Sandbox A: lang-2 second Sandbox B: lang-2 first Sandbox B: main second
seed build (compiler run by Node) 721.3 s 741.9 s 820.6 s 783.1 s
production CLI rebuilds itself 643.7 s 546.3 s 545.3 s 513.1 s
compare Node and native compiler 56.4 s 56.3 s 61.1 s 55.5 s
rebuilt compiler emits itself 437.1 s 437.7 s 449.4 s 402.0 s
  • The large difference between the members' separate rounds (719.0 s against about 520 s for "rebuilds itself") is not there: it was the box. Main alone ranges from 513.1 s to 643.7 s for that phase.
  • In both Sandboxes the second run is the faster one, so the order has to be averaged out. lang-2 against main, geometric mean of the two orders: "rebuilds itself" -5.0% (A: -15.1%, B: +6.3%), "emits itself" +5.8% (A: +0.1%, B: +11.8%), "compare" +4.8% (A: -0.2%, B: +10.1%), seed build +3.8%.
  • Two pairs do not resolve a few percent. Together with the gate's own four pairs (the compiler run by Node on its own source: +2.6% for lang-2 alone, +4.5% [+3.2%, +6.9%] for the whole rollup) the honest reading is a compile-time cost of a few percent that comes with lang-2, not tens of percent. It has an owner.

yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 9, 2026
Main now holds the landing rollup (vercel-labs#3395: train lang-2, startup-2,
islands-3), the load-cost train and perf-4.

Conflicts:
- executable-features.ts, native-link-info-core.ts, native/contracts.ts,
  executable-cache.ts, index.ts: main's moduleCompileCache and vercel-labs#3053's
  fnMemberProps were added at the same places; both rows stay, main's
  first.
- ir/builtin-never-throw.ts: main's two module.compileCache rows, then
  vercel-labs#2999's record.sourceHasKey.
- frontend/lowering/lower-modules.ts: vercel-labs#3053's store of a hoisted function
  member, then the rollup's closed-island block with its continue; both
  stay.
- ir/codec.generated.ts: regenerated on the merged sources
  (pnpm generate:ir-codec, layout 772ae1c67381f949).

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

This branch was successfully deployed

1 active deployment
Preview — b1de4ff7 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