#3319

Read an island member unconverted where the use answers for every value

Opencramforcewants to merge intofrom

Stacked on #3219 (fix/island-view-members, head 7f1236f42): this branch is that head plus seven commits. The PR is opened against that branch so that the diff shows this change alone. The commits of this PR are 0d234aa02 and the six before it, from 06478006e.

The problem

A member that an island package declares T | null is often absent in the object the island hands over: an SDK types its usage counters number | null, and a proxy in front of the provider leaves them out. Under Node the read is undefined, and a defensive program handles it:

output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0;
if (event.usage.input_tokens != null) output.usage.input = event.usage.input_tokens;

Natively the read converted to number | null before anything was done with it and threw expected number | null at $, got undefined. The first real model response of a natively compiled pi ended there.

The rule

Decided in DECISIONS.md, "Absent member declared nullable at an unverified boundary", option (c).

A use that answers for every JavaScript value takes the island's value as it is, so the island's answer decides, as in Node:

  • truthiness: the condition of if, while, do, for and ?:, the operand of !, the left operand of ||;
  • the left operand of ??;
  • == and != against null or undefined, === and !== against those or a string, number or boolean literal;
  • the operand of typeof, the receiver of ?., a template literal's substitution, a value nobody reads.

A position whose value is the enclosing expression's value takes that expression's answer: the right operand of ||, ?? and a comma, both operands of &&, parentheses, and as, satisfies and !.

Everything else needs a typed value and converts as before: a binding, an argument, a field write, a return, arithmetic, a branch of ?:, && used as a value. Behind a guard the read converts to the narrowed type (number after != null). No value outside its type enters a typed slot, and nothing is added for typed code.

read || d and read ?? d are the one total use whose value can go on: a truthy or non-nullish read is the expression's result. That result converts as the read would have, to the expression's type: an absent or null member takes the default, a member of another type still fails, a promise is adopted, and an island array of primitives is the copy it has always been (list() ?? []). Only those conversions can be made up for on the result. A read whose type crosses by a handle rule, a refusal or a cast (a Date, a Map, an array of records) and whose value can go on from || or ?? to a typed use is converted, or refused, at the read exactly as before; in a pure test it is free of its type like every other value.

What changed

  • total-uses.ts: the list, as one syntactic predicate totalUse. It is the one list for every value that may not be what its type says; a partial record view's read of a property nothing wrote (#3255) is the same question and should ask it when that read rule changes, and #3252's onlyTested is a subset of it (whichever merges second drops the copy).
  • fromIsland returns the island's value unconverted when totalUse holds. This replaces #3161's islandUnitComparison list and its rawIslandRead switch.
  • A member read of a program type that receives island values (a live view) takes the same path. ClassProtocols.receivesIslandValues answers which types those are from the marking that already exists; class-backed protocols share the representation but are not marked.
  • unconvertedComparison (the former islandUnitComparison, extended from null/undefined to the literal list and to view reads) and unconvertedDefault (the conversion of ||/?? results, by convertedIslandValue: fromIsland's conversions without its refusals). defaultedOnward in total-uses.ts says when a total use can still hand the value on.

All of it is behind lowerer.nodeIslands !== null: a program without islands lowers as before. The position is asked before any type (it is syntax), and receivesIslandValues keeps its answers by type, because both run for every member read of a program with islands.

One number moves in the coverage report: a comparison with a literal on an island value (if (input.length === 0)) now runs on the island's value, so that statement counts in the dynamic tier (coverage-node-islands-fenced-sdk-values: 11 to 12 of 16 statements).

Three forms, one corpus program

tests/corpus/9587-node-island-nullable-member-reads (382 lines of output, identical to Node, plain and sanitized) reads absent, null, zero and present members through every use in the list on:

  • the island's own type (usage(): Usage declared by the package);
  • a program type that views the island's object (make<Usage>());
  • parsed data asserted to the island's type (JSON.parse(text) as T in a generic function at the package's Event type), which is how pi reads provider events.

tests/harness/node-island.test.ts pins the uses that must still throw: a typed and an inferred binding, an argument, a field write, && as a value and a branch of ?: over an absent member (expected number | null at $, got undefined), and a member of another type behind ||, ?? and a guard (expected number at $, got string).

Not covered, by the decision: parsed data asserted to a type the program declares (JSON.parse(text) as Entry). That is a copy into a native record at the assertion, not a view, and still throws there with the member's path.

Island value matrix

Regenerated (239 programs, 1,030 cells). Five cells change, all in arr-rec.p, observation v.find((r) => r.a === 2)?.b on an island array of program records; no cell that matched Node changes.

## arr-rec.p
    clo-i2n/N.read.find : refused:SC8010 -> throws
    i2n/N.read.find : refused:SC8010 -> throws
    i2n/N.read@isl.find : refused:SC8010 -> throws
    n2i2n/N.read.find : refused:SC8010 -> None
    n2i2n/N.read@isl.find : refused:SC8010 -> None

The result of find is the receiver of ?., so it is no longer converted and no longer refused. For the program's own array coming back from the island the cell now matches Node. For an island-made array the program now reaches the callback, whose argument check throws expected object at $[0], got Object from a Node island: the same TypeError the same program already pins for v.map((r) => r.a) (read.as, throws). That check is the callback crossing, which this PR does not touch.

Proof

All of it in Vercel Sandboxes, on the branch's head 0d234aa02 with a libnode pack built from the branch.

  • Island corpus (differential.test.ts, llvm-differential.test.ts, every @requires-libnode program): 220 of 220 plain, 220 of 220 sanitized (8 vCPU, Intel Xeon @ 2.50GHz).
  • Island harness suites (the eight tests/harness/node-island*.test.ts): 107 of 107 plain, 107 of 107 sanitized.
  • Island value matrix, pinned: 239 of 239 programs plain and sanitized.
  • Diagnostics suite 303 of 303; pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only and node scripts/checker-identity.mjs --filter 9587 clean.
  • Zero-cost gate (bin/validate … --gate-corpus=all, against c3e73cbfd, #3219's base): PASS: 5241 identical, 102 both-fail, none differ, no runtime-only row. The 102 are the island programs, which build on neither side because the gate has no libnode pack. Bench geomean -0.41% [95% CI -1.52%, +0.52%], self -0.82% [-1.40%, +0.31%], fixtures -1.50% [-1.72%, -1.27%], all within tolerance.
  • Both lanes: plain 21,996 passed, sanitized 18,764 passed and 0 failed. Two plain shards failed on load-dependent tests that this change does not reach and that pass alone on the same tree: packages/runtime/test/regex.test.ts (its 60 s beforeAll clang build timed out; the known one) and packages/compiler/src/backend/native-toolchain.test.ts "TLS builds promote staged vendor archives before bounded LRU eviction". Rerun alone on the same tree: regex.test.ts 2 of 2, and the TLS test passes (the driver lists both as known flakes, #3299).
  • Native bootstraps (tests/harness/self-hosting-native-driver.test.ts): plain and sanitized (SCRIPTC_SAN=1) both pass, in the same round (62 minutes in all).
  • pi (e770f5efe), lowering diagnostics on the fleet's integration tree with #3219 and this change applied, one-round build: 16 before, 16 after, the same 16 sites. Build time there, one machine (32 vCPU, Intel Xeon @ 2.90GHz): 349 s and 352 s without, 306 s and 373 s with (312 s and 308 s one commit earlier). Measured on the integration tree, not on this branch.
  • pi at run time (measured on the pi run tree, snapshot snap_OzYds92NXW0llMbwF4dEUow3QGaj, with this change ported): a probe that parses a message_start without cache counters and a message_delta with only output_tokens at the SDK's real event type, read as pi reads them, goes from five expected number | null at $, got undefined to identical with Node; pi built without its scratch edit for the omitted counters runs the sparse-usage session identically to Node, and its offline suite is unchanged (83 of 107 byte for byte, 5 more after normalization).

Three earlier full rounds ran on earlier heads of this branch; they are why the rule has its two refinements. The first version handed every read on unconverted, and the round passed, because nothing in the corpus read an island array through ?? or called a method after ?. on an island string. Both work on main and both broke (list() ?? [] threw at run time; label?.split(",").join("-") stopped compiling). They were found by merging this branch with #3252 and by a probe of every syntactic form around a read (n12-forms, 71 lines, in the results directory), and the corpus program now pins them.

Notes for CI and for landing: main's matrix has five stale cells until #3298, which this branch's base predates. Since #3234 this branch conflicts with main in lower-node-island.ts (imports and one line where both add a hook); I merge main and resolve it as soon as #3219 lands, in the same push as any review fixes.

🤖 Generated with Claude Code