Repository navigation
Landing rollup: lang 2, startup 2, islands 3 - #3395
Conversation
… 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>
…#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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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>
There was a problem hiding this comment.
Additional findings
-
packages/compiler/src/node-islands/probe-recorder.ts:959-960— AllowlistingcreateECDHandgetDiffieHellmanas pure lets loads that generate private keys embed the build's keys as constants reused across executions. -
packages/compiler/src/node-islands/probe-recorder.ts:852-861— A custom iterator on the File parts array can removelastModifiedbefore construction reads it, causing the recorder to embed a build-time timestamp as a deterministic constant.
|
Answer to the review bot's finding in its review of
Right, and silent. Reproduced on
|
…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>
|
Answer to the review bot's two findings in its review of
Reproduced on
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 Tests: |
|
The round started before the first push, on
|
|
The paired measurement of the natively built compiler announced above: main
|
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>
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
5b32646f38): language correctness off the hot path. Its description has the members and their evidence.0bd2727c9e): what a compiled program's Node island does at start-up: the load recorder as an allowlist, island-closed statements and loops, Node's compile cache for the island, build-time notes for a refused statement inside its owntry, lazy module initializers. Its description has the members, the evidence, and the one start-up number that moves with the project setting that restores it.69393c2f3f; supersedes Keep byte views and symbols one identity across a Node island boundary #3284): typed arrays, Buffers, DataViews, ArrayBuffers and symbols keep one identity across the Node island boundary, which removes the build-time refusal of aunique symbolhanded to island code. It rides here without a PR of its own; its description is at the end of this text.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) andb1de4ff7d9(the findings on this PR about the recorder's list of pure builtins and about the parts ofnew File()).options.timeZone, the locales throughsupportedLocalesOf, thelastModifiedof 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.timeZonebefore the call gets to it (and the conversion of a compared value can empty the list of locale tags beforelocaleComparereads it). The call then takes the machine's zone or default locale while the recorder had seen a named one.9817-node-island-load-reads-argument-accessors, five island packages). First finding: Node and a build with the packages marked"evaluate": "eager"printzone: 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 printszone: UTC 2,time: 12:00:00 AM 2,number: 1,234.5 2,order: -1 2,file: 10 2. Second finding: built with noTZand no locale variables and run underTZ=America/St_Johns LC_ALL=sv_SE.UTF-8, Node printsremoved: America/St_Johns America/St_Johns 1; the build with only the first fix exits 0 and printsremoved: UTC UTC -1.crypto.createECDHandcrypto.getDiffieHellmanwere 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 themcrypto.createSign(a signature draws a nonce for most key types), the classesECDH,DiffieHellman,DiffieHellmanGroupandSign, and the async id counters ofasync_hooks;util.parseArgs()andmodule.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 fromBuffer.allocUnsafethat a load reads before writing it.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 ofnew File()are strings or buffers;supportedLocalesOfis the one captured before any load, andutil.types.isDatestands in forinstanceof. 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)).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/harness/node-island-load-reads.test.tsbuilds it under one machine setting and runs it under another, and runs it twice to see two different generated keys; unit tests inprobe-recorder.test.tswith 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
packages/compiler/src/frontend/lowering/divergences.ts(lang-2 against main's Train islands 2: structuredClone clones an Error as an Error, a warning where an island object is copied, leaf records an island held are released, and dynamic programs get back part of what islands 1 cost #3382). Report width copies from the lowered program and test that no warning means Node's behaviour #3221 reports width copies from the lowered program and deleted the variable-declaration branch of this file's syntax walk; Train islands 2: structuredClone clones an Error as an Error, a warning where an island object is copied, leaf records an island held are released, and dynamic programs get back part of what islands 1 cost #3382 added one line to that branch, the list of annotated declarations a program with islands checks (SC6007). The branch stays with main's line only. Main's SC6007 test ("a Node island value converted to a native type reports as a copy") passes on the result.docs/content/docs/coverage.mdx(startup-2 against main): main'sSC6007line and startup-2'sSC6012line, both kept.packages/compiler/src/frontend/lowering/lowerer.ts(startup-2 against lang-2): the width-copy imports and the deferred-fence imports, both kept.tests/harness/coverage.test.ts, "every corpus program is 100% static" (startup-2 against lang-2): lang-2 letsSC6001stand on a corpus program, startup-2SC6012; the test lets both stand, each for its own stated reason.packages/compiler/src/ir/codec.generated.ts: generated; regenerated from the mergedir.ts(below).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 byscripts/generate-ir-codec.mjsfrom the mergedir.ts(lang-2 and startup-2 both change the IR). The only generated file whose regenerated text differs from the merged text.ir/fields.generated.ts, the island host headerscr_node_island_host.h(scripts/node-island-host.mjs), the runtime refusal baselinepackages/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).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 theisland:chunk-buffersway ofcorpus/7748-npm-any-bytes-receiverleave 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.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 (maincca23de1eamerged 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.pnpm buildsteps): pass.generate-ir-codec.mjs --check,generate-ir-fields.mjs --check,node-island-host.mjs --check,gen-builtin-object-members.mjs --checkpass;pnpm manifestandpnpm node-compat --localchange nothing;pnpm node-compat:checkpasses.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.packages/compiler/srcandpackages/cli/testexcept 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 underpackages/compiler/testand the guard tests undertests/harness.tests/harness/coverage.test.tswithout its whole-corpus case (the lanes run it sharded): 75 passed.copy-soundness,dyncheck,diagnostics,errors,deferred-fence-report: 606 passed.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).NEXT_DIST_DIR=.next-check pnpm checkindocs/): passes, 25 of 25 route tests.6da2be6a1b(perf-3 and--definelanded 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 trainislands-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 symbolconstant, aSymbol.forsymbol, 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).What lands
fix/island-views-symbols, headcfc71de2ed, 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.ScrNiDynOpskeeps main's offsets: main's 26 entries in main's order, thensetandhas_own.nh_error_info, the read of an island error on Node's stack): main's is kept.nh_from_nodekeeps main's pinned-error identity and twin adoption before the symbol;export_setwrites 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.scr_json.cwere 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 off8748-node-island-symbolsstops matching Node; with the delete off every corpus program still matched, so the delete had no proof:8748now deletes a program symbol's key from an island object and asks both sides (with the path off the delete silently does nothing).SCR_JS_OBJECT_MODEL, and the island handle unit has no build with that define (runtime-define-sites.test.tsnames 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 ofscr_bytes.cin both of its builds). Pinned by a new test innode-island-handles.test.ts(fixturemovable-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 prints8,8and0for the three, which is what the branch did.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 aWeakSet; 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 threwInvalid 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.
u8221,sym188,arraybuffer67,dataview64,buffer36,f32,f64andi3230 each,holder23,big6420. 30 are new, none of them wrong: 22 in thearraybuffercells (structuredClonethrows,JSON.stringifyandfor…inare unsupported), which is what the kind's control cell without an island answers, and 8 in thesymcells (JSON.stringifyof a typed symbol is the build-time SC1090 the control cell has); they were hidden behind the refusal and the crash. Threebig64observations go from wrong to unsupported.corpus/7531-buffer-constructor-dependency(island:legacy-buf) and theisland:chunk-buffersway ofcorpus/7748-npm-any-bytes-receivermatch 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 (
c13d28ad69against #3382's tree with main952d9fb2be;bin/validate --gate-corpus=all --gate-complete, 53 minutes, 97 lane jobs):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.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 readrecord-untyped-writes+6.6% [+0.5%, +11.1%]; that executable does differ (it linksscr_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-symbolsfails. Symbol-key probe off:8748fails. Symbol-key delete off:8748fails on its new line (before that line existed: nothing failed). Symbol weak keys off:8754-node-island-weak-keysfails. Mutable-buffer check off: the new test ofnode-island-handles.test.tsfails, 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.tsalone, 3 of 3.The refusal a large TypeScript program meets most often at the island boundary, as a neutral program (a
unique symbolconstant, an inferredconstsymbol, aSymbol.forsymbol andSymbol.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):bridgeAbi20), 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.node-islands), withnode-island-handles.test.tsandnode-island-weak-keys.test.ts: 504 passed.differentialandllvm-differential, every island program): 586 of 586. Sanitized: harness 424 of 424, corpus 586 of 586.Gate rows and exceptions
Against #3382's head (
0f4df1865e, main851b59b90f),--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.)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, andscr_weak_map_invokeandscr_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.corpus/8748-node-island-symbols/main.ts, which this branch adds: main's compiler refuses it at build time (SC8010, theunique symbolrefusal 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
🤖 Generated with Claude Code