#3124

Follow the value in island method calls and serialize island objects in JSON.stringify

Opencramforcewants to merge intofrom

Stacked on #3029 (ctate/islands-value-island-a1a34a): this branch merges that PR's head and builds its JSON.stringify part on #3029's walk, so it must merge after #3029. Until then the diff here shows #3029's commit as well; review that one there.

Two valid programs compiled and then threw at the Node island boundary. Both now match Node. Both fixes are island-only: the lowering runs only in a program with islands, and the runtime code is in island units or under SCR_NODE_ISLANDS in the existing node-island build of scr_json.c.

1. A method call on a native value typed by an island's declarations

import { send, type Input } from "pkg";          // type Input = Array<InputItem>, declared by the island
function convert<T extends string>(model: T, n: number): string {
  const messages: Input = [];                     // a native array: the island's alias only annotates it
  for (let i = 0; i < n; i++) messages.push({ type: "out", call_id: model + i, output: "o" + i });
  return send(messages);
}

Before: Uncaught TypeError: scriptc: a call needs a Node island value, exit 1. Node prints the JSON.

The island lowering claims recv.m(args) whenever the receiver's declared type is island-declared, wherever the value lives. Every other operation follows the value at run time (scr_node_island_get, _set, _invoke, _apply and _construct take the checked-dynamic path for a non-handle). A call did not, in two cases:

  • An argument that runs code. JavaScript reads the member before the arguments, so the call lowers to nodeIsland.get and nodeIsland.applyTo, and scr_node_island_apply_to assumed the held member was a native closure or an island function. A builtin member of a native value (push, a Map's set) has no function value, so the read answered something not callable and the call reached the bridge. Nothing here is specific to generics or to push: any member, any native receiver, any argument that is not inert.
  • A native class instance, with any arguments. const sink: Sink = new Counter(); sink.take(5) threw take is not a function: the runtime's by-name call has no table of class methods, which only the class dispatch pass knows.

islandMethodCall lowers the call in two arms:

  • An island receiver takes the bridge exactly as before (nodeIsland.invoke, or the member read first and nodeIsland.applyTo after the arguments).
  • A native receiver takes the ordinary checked-dynamic method call, the dynInvoke node the rest of the compiler lowers value.m(args) to. So class methods, generator steps and iterator helpers are dispatched by the passes that rewrite that node, and the native arm is the same code a value typed any gets.

The arguments cross once, into hidden locals both arms read. A member that is a function value (a closure on a native object, an island function a native object holds) is still called as it was read (nodeIsland.calls). islandCaughtRead already routes a caught value with the same nodeIsland.isHandle test. A call that spreads an argument has no fixed argument list for dynInvoke and keeps the runtime's routing; nodeIsland.applyTo now carries the member name, so a native receiver's builtin member is called by name there too.

2. JSON.stringify reaching an island object: three things on top of #3029's walk

#3029 already serializes an island value inside the stringify walk (the object's kind from an is_array slot in ScrNiDynOps, keys and members through scr_dyn_obj_keys and scr_dyn_obj_read, an array's length read in Node, toJSON with the real key, omitted functions and symbols, cycles by the handle's referent). This PR used to carry a second walk for the same thing. It no longer does: the branch merges #3029 (through 3787c75b9), deletes its own walk (scr_json_island_write, its is_array slot, its member-read and toJSON branches, its symbol test and its entry in the compact walker) and keeps only what #3029's walk does not do.

I ran this PR's 37 JSON.stringify cases against #3029 merged with main, compiled and under Node 24.22.1. 28 match as they are. Three behaviours account for the other 9, and each is a small addition under SCR_NODE_ISLANDS on top of that walk:

casesprogramNode#3029 + mainhere
9 to 14JSON.stringify({ a: "x", n: 1, label: "l" }, islandFn) and (…, islandArray), on island and native values, with boxed String/Number keys{"a":"X","n":1,"label":"L"}, {"label":"l","n":1}the replacer is ignored: {"a":"x","n":1,"label":"l"} (the same on main, silently)matches
19, 20an island object holding new Number(1.5), new String("s"), new Boolean(true), JSON.rawJSON("12345678901234567890")1.5, "s", true, 12345678901234567890{}, {"0":"s"}, {}, {"rawJSON":"…"}matches
32a cycle through two instances of an island class Link… object with constructor 'Link'… object with constructor 'Object'matches
  • Replacers. scr_json_stringify_value tested the native kinds of the replacer only, so an island function or an island array was dropped. It is honoured now, for island and native values, and an island property list reads boxed String and Number keys with their ToString (the review fix already on this branch).
  • Internal slots. One host helper, scriptc:json, reached through a json_query slot, answers the JSON text of a boxed primitive or a raw JSON object; the walk asks before it enters an island object that is not an array.
  • Constructor names. The walk marks the entry it pushes for an island object, and the circular-structure TypeError asks the same helper for its constructor's name.

Zero cost: every line this PR adds to scr_json.c is under SCR_NODE_ISLANDS, the unit's node-island build, so against #3029 the default object is unchanged.

Evidence

  • tests/corpus/8885-node-island-native-receiver-calls/: the reported push; other array members with calls, operators and spreads as arguments; a native Map; a native object with a closure member; a class instance and a subclass through a binding and a parameter, with inert arguments and arguments that run code; the same calls on island objects; Node's TypeError for a member that is not a function, after the arguments ran.
  • tests/corpus/8886-node-island-json-stringify/: handle roots (object, any, array, index-like keys); gaps; native and island replacers; native and island property lists; a Date's toJSON; omitted undefined and function members; a getter; boxed primitives, a symbol and a function inside an array; a raw JSON object; toJSON keys as a root, a record member and an array element; handles inside a native record, array and dictionary; a native value held by an island object; four circular shapes; util.format's %j.
  • Order baselines and corpus cost records for both.
  • tests/harness/island-value-matrix/expected.json is regenerated for this tree. Merging #3029 with main changes how most kinds of value cross, and the matrix main gained after #3029 branched pins the old answers: 107 of its 239 programs fail on #3029 merged with main alone, and the same 107 here before the regeneration. Against main the table goes from 4213 entries to 3515. All but two of those changes are #3029's: #3029 merged with main regenerates to 3517 (857 entries gone, 161 new, 171 with another status). This PR's part is two entries gone and none new (map-obj.p, n2i2n/N.read.getByKey, with and without the island callee: threw, now matches Node). When #3029 records its own table this file will conflict and is regenerated again.

Since the merge with main (adb045cfe, main 326aef394)

  • The matrix table is recorded again on the merged tree: 3,515 entries become 3,499 (the twelve x.constructor reads main compiles since #3165 and four identity facts of an island function that #3234 fixes now match Node; none new). The conformance list is regenerated on top of main's #3293: 85 entries become 67, none new.
  • scr_json_stringify_value: main's #3234 takes an island function replacer, this PR's block the island array alone.
  • Gate against main 50bcfe1b3 + #3029 3787c75b9, --gate-corpus=all: PASS, 5,295 identical, 109 both-fail, 0 differing, 0 runtime-only. Island suites and matrix 354/354 plain and sanitized, island corpus 234/234 plain and sanitized, conformance lane 413 passed, the 37-case table 37/37. Details and what was not re-run are in the merge comment below.
  • Against plain main this branch's tree differs in 244 programs and has 3,301 runtime-only rows; all of them are #3029's (the round above is this PR's own delta).
  • Later (head 9cdb212dd, main 23f1b176f): #3198 is merged into this branch (its own delta: gate PASS, 5,295 identical), and main was merged three more times; the matrix table is recorded again (3,499 entries, 43 status changes that main's #3335 also made, none new). Island suites and matrix 374/374 and island corpus 264/264 on that head. See the second merge comment.

Gates

Proved against #3029 merged with main (4775581e3 = main 716cb8626 + #3029 3787c75b9), so the comparison is this PR's own delta. Head 2ba11f120; the gate ran on 70d98a984, which differs from the head only in the regenerated matrix table. Sandboxes: Intel Xeon 2.50 and 2.90 GHz.

  • Zero-cost gate, --gate-corpus=all: PASS. 5247 identical, 103 both-fail (the island programs: the gate runs without a libnode pack, on both sides), 0 differing, 0 runtime-only rows, over the benchmarks, the whole corpus, the fixtures and the self-hosted compiler ("candidate compiler source builds with the candidate"). Bench geomean +0.21 % (95 % CI −0.94 %, +1.01 %). The runtime pack differs only in the node-island builds of scr_json.c and in scr_node_island.c / scr_node_island_handle.c. An earlier round on the first merge (b92abbd93 against main + #3029 2d07592af) gave the same counts.
  • Island tests, with a libnode pack built from this tree's bridge: every node-island and island-value corpus program in the differential and LLVM-differential suites, plain 174/174 and sanitized 174/174; the eight island suites and the value matrix, 344/344 (matrix 239/239 pinned); the ir, node-islands and host-header unit tests, 874/874; pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only.
  • The 37-case JSON.stringify table: 37/37 here, 28/37 on #3029 merged with main.
  • Earlier on this branch, before the restack (the compiler sources of the two-arm call are unchanged since): the full lanes and both native bootstraps at 5de1aa70b, the gate and both bootstraps again for the review fixes at 47e4b09e7, and a pi before/after pair with identical diagnostics (62 and 62). The stacked #3198 reruns the bootstraps on this tree plus its own commits.
  • Size of the two-arm call lowering, six island corpus programs built with and without it (release, linux-x64): four unchanged, 8810-node-island-sdk-request-values +88 B, 6834-node-island-package-callbacks +2,496 B (+0.27 %), 6848-node-island-handle-ops +23,800 B (+2.6 %). Programs without islands are byte-identical (the gate).

Still failing, found on the way (not in this PR)

  • An island value typed as a library class or a container of program records, used natively (doc.when.getTime() for a Date member, first(listOf<Rec>())), compiles and throws expected Date at $, got Object at run time: fromIsland's last-resort checked cast. #3029 changes parts of this; the matrix table shows where.
  • Members of an island class that extends Promise are lost, because every V8 promise crosses as a scriptc promise.
  • A class instance held in a binding typed by the island's interface, crossing into the island or called with a spread argument: fixed by #3198, stacked on this PR.

🤖 Generated with Claude Code

Activity

34
vercel[bot]6d ago

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

ProjectDeploymentActionsUpdated
scriptc ReadyPreview, v0Nov 7, 2026 4:39pm UTC
vercel[bot]reviewed6d ago
vercel[bot]reviewed6d ago
cramforce5d ago

Merged main into this branch, three times as main moved while the proof ran: 25914c21f (main 50bcfe1b3), ba2e57376 (97d372266, the conformance list) and adb045cfe (326aef394, the matrix re-pin), with one commit of its own, 90109f149 (the conformance list for this tree). Head: adb045cfe.

What conflicted.

  • ScrNiDynOps (scr_node_island.h, its initializer in scr_node_island_handle.c): main's #3234 appended function_identity and function_of_identity, this branch json_query. All three kept, main's first.
  • tests/harness/island-value-matrix/expected.json, twice (#3234's cells, then #3318's re-pin): not resolved by hand. The table is recorded again on the merged tree: 3,515 entries become 3,499. The 16 that are gone now match Node: the twelve x.constructor reads (main's #3165, which #3318 has now also removed from main's table) and four identity facts of an island function (fn-rec.p and fn-this.p, #3234). None is new and none changed status. The table also already has #3318's other change (async-iterable.p without the S.make lines).

What did not conflict and needed a decision. #3234 made an island function a replacer function in scr_json_stringify_value; this branch had made an island function or an island array one, a few lines below. After the merge the branch's function arm could not be reached. Main's block now takes the function and the branch's block the island array alone: one test per case, main's kept. The 37-case JSON.stringify table is 37 of 37 on the merged head, and 8886-node-island-json-stringify passes plain and sanitized.

Island conformance list. Main refreshed its list (#3293) while this was in proof. This tree moves entries of its own (most of them through #3029's conversions and this PR's JSON.stringify), so the list is regenerated on top of main's: 85 entries become 67, 18 programs now match Node, 6 fail at a later point than before, none is new. One of the 18 is main's own: packages/semver > island:semver is stale on main since semver runs in an island by default, so main's lane is red on that cell.

Re-run on the merged head (Linux x64 Sandboxes, Intel Xeon 2.90 GHz, libnode pack built from this tree's bridge; the compiler and runtime sources are those of 25914c21f, the later commits change three test lists):

  • Zero-cost gate, --gate-corpus=all, against main 50bcfe1b3 merged with #3029 (3787c75b9), as the description's gate was: PASS, 5,295 identical, 109 failing on both sides, none differ, no runtime-only row. Compile time: benchmarks +0.35% [−1.12%, +1.48%], the compiler's own build +0.87% [−0.54%, +1.75%], fixtures +3.06% [+1.20%, +4.92%] (the gate calls it inconclusive; the description's round had +2.42% [+0.28%, +4.54%] for the same row).
  • Island unit tests: 186 passed. The island suites and the value matrix: 354 passed, plain and sanitized (matrix 239 of 239 against the recorded table). Island diagnostics: 7 passed.
  • Every island corpus program, differential and LLVM-differential: 234 passed plain, 234 sanitized.
  • Island conformance lane against the regenerated list: 413 passed, none failed.
  • pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only, the generated island host header and IR codec checks: clean.

Not run on this head: the full lanes and the native bootstraps (the two-arm call lowering is untouched by the merge; both ran before the restack, as the description says).

What #3029 still needs (not touched here; this branch contains its head 3787c75b9):

  • It merges with main without a textual conflict, but against plain main it is not zero-cost. Measured on this branch's tree, whose own delta over main + #3029 is zero (the round above), against main 50bcfe1b3: 244 programs differ (243 corpus programs and bench/json-unions; IR identical, LLVM differs in the sc_dm_* checked-conversion helpers, executables +0.02% to +0.79%) and 3,301 are runtime-only (the default object of scr_json.c: scr_dyn_freeze_data, scr_dyn_key_set, scr_dyn_call and the functions around them; 0 to 3,824 bytes, median 384). Those rows need an allow list and an exception entry, or a way to keep the default object as it is.
  • Its island value matrix and its conformance list have to be recorded again once it takes main; the tables here are the ones for #3029 plus this PR, so they cannot be copied.
  • #3135, if it merges first, removes the island build of scr_json.c; the island blocks #3029 and this PR add under SCR_NODE_ISLANDS there (seven on this branch) then have to call through the installed table, and runtime-pack-matrix.test.ts names the unit until they do.
cramforce5d ago

Main moved under this branch four more times after my comment above, and the maintainer merged #3198 into it (a55497183). Head now: 9cdb212dd, merged with main 23f1b176f. This is my last push here; I am not following main's matrix re-pins further until #3029 lands.

What the three later merges resolved.

  • b0d0a9c89 (main 47f7dbec9). lower-node-island.ts: #3254 dropped the import of stripTypeWrappers next to the import this branch added; the branch's import stays, the removed one goes (the branch calls no removed peel helper). scr_node_island_export.c: both sides added fields to an export slot, main the twin state of #3271, this branch (through #3198) a class instance's method table; both kept, each side's initialization and release intact.
  • 5b65065a1 (main 8411368da). The conformance list: main's #3331 removes the stale semver entry; the list recorded for this tree already lacked it and is kept.
  • 9cdb212dd (main 23f1b176f). The value matrix: main's #3335 changed 43 lines (a read of a missing member now throws where it was wrong). Recorded again on the merged tree: 3,499 entries, the same 43 facts change status here, none new, none gone.

Re-run (Linux x64 Sandboxes, Intel Xeon 2.90 GHz, libnode pack from this tree's bridge, ABI 13):

  • On 9cdb212dd itself: island unit tests 226 passed; the island suites and the value matrix 374 passed (matrix 239 of 239 against the table recorded on this tree); island diagnostics 7; island corpus, differential and LLVM-differential, 264 passed. pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only, generated header and codec checks: clean.
  • Sanitized, on 5b65065a1 (one merge of main earlier; the two trees differ by five main commits and the 43 table lines): island suites and matrix 374 passed, island corpus 264 passed. The conformance lane last ran to the end on b0d0a9c89 (two merges of main earlier): regenerated there, the list did not change, and 413 passed against it. A run on 5b65065a1 was cut off by the Sandbox's time limit and has no result.
  • The peel-helper guard and runtime-pack-matrix.test.ts pass on b0d0a9c89; on main 82475b871 and later the guard fails for member-call-receivers.ts#unwrap, which is main's.
  • #3198's own delta, measured before it was merged in (its tree against this branch's adb045cfe), zero-cost gate --gate-corpus=all: PASS, 5,295 identical, 111 failing on both sides, none differ, no runtime-only row. Island suites 354 and island corpus 242 passed on that tree, plain and sanitized; matrix and conformance regenerated there: unchanged.

Not run on this head: the zero-cost gate (it ran on ba2e57376 against main + #3029: PASS, 5,295 identical, and on #3198's delta, above; the three later merges resolve an import line, slot fields of an island-only unit and two test lists), the sanitized suites on the last merge, the full lanes and the native bootstraps.

What #3029 still needs is unchanged from the comment above: an answer for its 244 differing programs and 3,301 runtime-only rows against plain main, and its own matrix and conformance tables once it takes main.