#3283

Follow the value in member reads and writes on Node island types

Opencramforcewants to merge intofrom

A value typed by a Node island package's declarations is lowered as an island value, by its declared type. The value may still be native: an island alias annotating a native Map, Set, typed array or array, an island interface a native class implements, a string the island module exports, a parameter that receives both origins. Member reads and writes on such a value compiled and then threw. They now follow the value, the way #3124 makes method calls follow it.

import { VERSION, type Table, type Bytes, type Gauge } from "pkg"; // pkg runs in a Node island
// export type Table = Map<string, number>; export type Bytes = Uint8Array;
// export interface Gauge { level: number }

const table: Table = new Map([["a", 1]]);
table.size;            // TypeError: expected number at $, got undefined          Node: 1
const bytes: Bytes = new Uint8Array([4, 5, 6]);
bytes.length;          // TypeError: expected number at $, got undefined          Node: 3
bytes[1];              // the same                                                Node: 5

class Dial implements Gauge { get level() { … } set level(v) { … } }
const dial: Gauge = new Dial();
dial.level = 3;        // TypeError: Cannot assign to read only property 'level'  Node: runs the setter

VERSION.length;        // TypeError: Reflect.get called on non-object             Node: 5

Cause

lowerIslandExpr claims recv.k, recv[k] and their writes whenever the receiver's declared type is island-declared (isForeignType), and emits nodeIsland.get / getKey / set / setKey. The runtime entry points test for an island handle and send any other value to scr_dyn_reflect_get and scr_dyn_key_set. Those know stored properties only. A Map's size, a typed array's length and elements, a class's accessors and a string's length are not stored properties: the ordinary checked-dynamic read (dynKeyGet) and write (dyn.keySet) answer them, because the class dispatch pass rewrites those nodes, and the island lowering did not emit those nodes.

Change

One file, lower-node-island.ts. A member read, a member write and a compound member assignment on an island-typed value lower in two arms:

  • an island handle takes the bridge, exactly as before;
  • any other value takes the node the rest of the compiler lowers value.k and value.k = v to (dynKeyGet, dyn.keySet, dyn.keySetComputed), so it is the same code a value typed any gets, including Node's Cannot read properties of undefined (reading 'k') for a missing receiver.

The receiver, a computed key and the assigned value are evaluated once, in JavaScript's order, into hidden locals both arms read. A member of a CommonJS namespace binding answered from a snapshot is unchanged. The runtime is unchanged.

This is the read and write half of the rule #3124 states for method calls ("the native arm is the same code a value typed any gets"). The two PRs touch different branches of lowerIslandExpr: a trial merge of #3124's head (eb7546b79) and of #3198's head (475ce4a72) into this branch is clean, and the merged tree runs the whole family below.

Evidence

tests/corpus/8738-node-island-native-member-reads/: size of a Map and a Set, length, byteLength and elements of a typed array, element and length writes and compound writes, the same through a record member, an optional chain, a computed key and a destructuring; a class's getter and setter through a binding and through a parameter that also receives an island object and a literal; one function measuring native and island-made values; a string export's length; missing members; Node's TypeError for a missing receiver. On the base the program ends at its first line with Uncaught TypeError: expected number at $, got undefined.

Two pinned tables change with it (second commit):

  • tests/harness/island-value-matrix/expected.json loses six entries, N.read.size and N.read@isl.size of map.p, map-obj.p and set.p: v.size on a native Map or Set that came back from an island was pinned as a throw and now matches Node.
  • The coverage reports of the two island fixtures (tests/harness/__snapshots__/coverage-node-islands-*.txt) count more statements "in dynamic tier": 6 of 6 where it was 3, and 15 of 16 where it was 11. The statements that read or write a member of an island-typed value operate on a value typed only at run time; their native arm is now the checked-dynamic read or write in the IR, where the report sees it, instead of a runtime entry point. The build is unchanged, --tier=static-only refuses nothing new, and no build warning appears. #3124's calls will count the same way (dynInvoke).

What it costs

  • Programs without islands: nothing. lowerIslandExpr returns before any of this when the program has no island (the gate below).
  • Programs with islands: a handle test and a branch per member read or write, and the dynamic member dispatch when the program had none. Release executables of ten island corpus programs, same base, before and after: three unchanged, six +11,712 to +50,064 B (1.5 % to 5.6 %), 8926-node-island-value-observations +116,904 B (12.4 %).
  • pi (e770f5efe), measured on the pi run tree (snapshot snap_OHKyfEFPZ1TqFwpVRAeYiMevj237, the 26-island forced build, 32 vCPU Intel Xeon @ 2.90GHz, one after the other on one machine): executable 182,819,064 B before and 183,828,848 B after (+0.55 %); frontend 472.2 s before and 446.2 s after, build 659.1 s and 622.3 s (no visible cost); the 106-case run-time suite gives the same verdict for every case (75 match, 5 after normalization, 26 differ). pi has 660 member reads and 102 member writes whose receiver is an island value only by its declared type.

Gates

Validation round (bin/validate --skip=bench,t262 --gate-corpus=all) at 01dc0145c (the lowering commit) against e8c933e5d, then a lanes round at 378609430; Sandboxes on Intel Xeon:

  • Zero-cost gate: PASS. 5247 identical, 103 both-fail (the island programs: the gate runs without a libnode pack on both sides), 0 differing, 0 runtime-only, over the benchmarks, the whole corpus (--gate-corpus=all), the fixtures and the self-hosted compiler ("candidate compiler source builds with the candidate"). Bench geomean +0.91 % (95 % CI −0.66 %, +1.89 %), within tolerance; self +1.08 % (−0.72 %, +2.86 %) and fixtures −0.86 % (−4.03 %, +2.22 %), both intervals straddle zero.
  • Native bootstraps, same round: plain (native-1-commands, native-1-rebuild) and sanitized (native-1-commands, native-1-rebuild-emit, native-1-rebuild-frontend) all pass.
  • Lanes, with a libnode pack (island tests run), on this head (378609430): plain 22040 passed, 0 failed. Sanitized 18804 passed, 1 failed: tests/harness/three.test.ts > published three.js 'nodes' … on 'native' timed out at its 20-minute limit; it has no island and passed in the first round on the same compiler. The first round's lanes (before the second commit) failed only on the two pinned tables above, plus two cases that pass when run again on this tree: a beforeAll clang compile in packages/runtime/test/regex.test.ts that hit its 60 s hook limit, and packages/cli/test/executable-cache.test.ts in the sanitized lane (7/7 alone).
  • Focused, on this head: the new program in the plain and sanitized differential and LLVM-differential suites; all island corpus programs (218 cases) and the eight island suites (105/105), plain; pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only, the order-parity suite (122/122).

The family around it

Where this came from: pi's OpenAI path died with scriptc: a call needs a Node island value (a push with a computed argument onto a native array typed by the SDK's alias). That call is #3124's. Running the family of "a native value in an island-typed slot" on #3198's head showed the reads and writes above still failing, which is this PR. With #3124, #3198 and this PR merged onto main, every case of the family matches Node except these, none made worse here:

  • "size" in table answers false for a Map held as a dynamic value, with or without islands (silent).
  • xs["push"](v) on a dynamic array is "not a function", with or without islands.
  • [...names.entries()] on a native array typed by an island alias loses the iterator's values.
  • A method of a string an island module exports (VERSION.toUpperCase()) is "not a function" even with #3124's two-arm call.
  • A type parameter instantiated with string, stored into a literal that crosses, is refused by name (SC8010).

🤖 Generated with Claude Code

Activity

3
vercel[bot]6d ago

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
scriptc ReadyPreview, v0Nov 7, 2026 9:43am UTC