Skip to content

Give class instances their own class's methods in Node islands, and call them with spread arguments - #3198

Merged
ctate merged 9 commits into
fix/island-runtime-bugsfrom
fix/island-class-receivers
Nov 7, 2026
Merged

ctate merged 9 commits into
fix/island-runtime-bugsfrom
fix/island-class-receivers

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Restacked 2026-11-07. #3124 now merges #3029 (through 3787c75b9) and builds its JSON.stringify part on #3029's walk, so the merge order is #3029, then #3124, then this PR. This branch merges #3124's new head 2ba11f120 and the reviewer's matrix commit 35384b2c0; neither merge had a conflict outside tests/harness/island-value-matrix/expected.json, which both sides rewrote. The table kept is #3124's, and regenerating it on this tree changes nothing (3515 entries, identical file), so this PR's commits add no entry to it and remove none.

Re-proved on the merged tree, head 0f6e4bb63, against #3124's head 2ba11f120 (Intel Xeon 2.50 and 2.90 GHz):

  • Zero-cost gate, --gate-corpus=all: PASS. 5247 identical, 105 both-fail (island programs; no libnode pack on either side), 0 differing, 0 runtime-only rows; the self-hosted compiler builds itself.
  • Lanes with a libnode pack: plain 22021 passed, 0 failed; sanitized 18790 passed, 1 failed. The failure is 2963-child-fork-dispatch/main.ts in the dev sanitized LLVM-differential shard (stdout one byte short; no island in that program), and it passes 3 of 3 alone, sanitized, on this tree. One plain job exited 1 without a failed test (packages/runtime/test/regex.test.ts, the 60 s setup-hook timeout other rounds hit).
  • Both native bootstraps on the merged tree: plain native-1-commands and native-1-rebuild pass; sanitized commands, rebuild-emit and rebuild-frontend pass.
  • Island tests: every node-island and island-value corpus program in the differential and LLVM-differential suites, plain 182/182 and sanitized 182/182; the eight island suites and the value matrix 344/344 (matrix 239/239); ir, node-islands and host-header unit tests 874/874; lint, format, ts7 baselines.

The sections below describe the change as first proved; their gate numbers are against the earlier base.

Stacked on #3124 (fix/island-runtime-bugs): this PR's base is that branch, and its code commit builds on the two-arm island method call #3124 adds. Two valid programs with a native class instance held in a binding typed by a Node island's interface failed at run time. Both now match Node. Programs without island values are unchanged.

1. A class instance crossed into an island without its methods

import { useSink, type Sink } from "pkg";          // useSink = (s) => s.take(2) + s.take(3)
class Counter implements Sink { sum = 0; take(n: number) { this.sum += n; return this.sum; } }
class Doubling extends Counter { override take(n: number) { return super.take(n * 2); } }

const typed: Sink = new Counter();
useSink(typed);                 // Node 7. Before: TypeError "s.take is not a function", thrown in the island
useSink(new Counter());         // 7
useSink(typed);                 // worked from here on: the class had crossed directly one line up

const base: Counter = new Doubling();
useSink(base);                  // Node 14. Before: 7, silently

An island reads a class instance's prototype methods from a table (name to unbound method value). The table was registered by the crossing expression: an expression whose static type is a class was wrapped with nodeIsland.classMethods(value, builder), and the runtime kept the first table it was given for each capsule type key. Three consequences, one cause:

  • An instance that reached the island any other way (a binding typed by the island's interface, unknown, a member of a record, an array or a Map, the result of a program callback) had no methods unless its class had crossed directly earlier. Some of these were silent: typeof v.take was "undefined" while "take" in v was true.
  • The table was the static class's. A subclass instance held as its base class ran the base class's method in the island and lacked the methods only the subclass declares. This one is a silent wrong answer on main.
  • The export table keeps an object's first capsule, so the static type an object first crossed under decided its methods for the rest of the run.

The table now comes from the class the object was constructed as, looked up when the island first reads a method of the object:

  • Compiler. A program that holds island values has one function, %island.classMethods(value). For each class a checked-dynamic value can hold (the classes the class dispatch pass already finds boxed, with their constructed subclasses) it tests the capsule's key and then the instance's class, most derived first, and answers that class's table. A table is built on first use and kept, so a method is one function value for every instance of its class. The function is maintained by the dispatch pass's fixed point (island-class-tables.ts) and registered once by %main (nodeIsland.classTables, through mainPrelude like the generator members).
  • Runtime (scr_node_island_export.c, an island-only unit). A read of a prototype method asks the function once per exported object and keeps the answer with the export.
  • Deleted. nodeIsland.classMethods, the wrapping at crossings and the runtime's per-key table. The shared-module class registration keeps only its constructor helper (nodeIsland.classMethodsOf(value, builder, ctor) is now nodeIsland.classConstructor(value, ctor)).

I considered registering from the declared type at the crossing (the classes assignable to the island's interface). It fixes the first line above and nothing else: unknown, members and callback results still depend on what crossed before, and the subclass answer stays wrong.

Cost: an island program now carries a table for every boxed class, where before only classes that crossed directly had one. That makes those classes' public methods reachable, as one computed read of an any value already does. Sizes are below.

2. A method call with a spread argument on a class instance

const sink: Sink = new Counter();
const one: [number] = [5];
sink.take(...one);              // Node 5. Before: Uncaught TypeError: take is not a function

#3124 gives recv.m(args) on an island-typed value two arms: an island receiver takes the bridge, a native receiver takes the dynInvoke node, which the class dispatch pass rewrites for class instances. dynInvoke has a fixed argument list, so a call with a spread kept the runtime's call by name, and the runtime has no table of class methods.

The ordinary tier lowers value.m(...args) on an any to a dynKeyGet with a literal key, which the dispatch pass answers with the method value, and a dynCall that carries the receiver and spreads (lowerDynDispatchMethodCall). The spread form's native arm is now that, with the member read before the arguments as JavaScript orders it.

It applies to the receivers whose members compiled code dispatches: a typed reference, which is a class instance or a generator (nodeIsland.isCapsule, one island-only predicate). Every other native receiver keeps the runtime's call by name. That is not a second mechanism: it is the same scr_dyn_invoke that dynInvoke ends in for those receivers, and it takes a list of any length. The ordinary read cannot serve them all, because a native Map or Set has no method values in the ordinary tier (m.set(...entry) on an any throws the runtime's named error), and table.set(...entry) on a Map typed by an island alias works today.

Evidence

  • tests/corpus/8779-node-island-class-crossing-typed-first/ and 8780-node-island-class-crossing-direct-first/: the same program with the two crossings in either order; both print the same answers. On the base, 8779 throws inside the island.
  • tests/corpus/8783-node-island-class-crossing-paths/: a parameter typed by the interface, unknown, a record literal and binding, an array, a Map, a class-typed field, a callback's result, a value the island kept; a subclass under its base class's type, under the island's type and under an abstract base; one object under two static types in either order; method identity; the kinds of member an island can name (async, callback parameter, optional parameter, overloads, an accessor; not statics or #private).
  • tests/corpus/8778-node-island-class-receiver-spread/: a class instance, a subclass and a rest parameter with spreads before, between and after plain arguments; one call site serving a class instance, an island object and a closure member; the member read before the arguments run and an own property hiding the method; an array and a Map by name; a native generator stepped with a spread; Node's TypeError text for a member that is not a function.
  • Order baselines and corpus cost records for the four.

Gates

Base for every comparison: #3124's head 649e742c5. Sandboxes: Intel Xeon 2.50 GHz (focused tests) and 2.90 GHz (pi).

  • Zero-cost gate (--gate-corpus=all), two rounds, both PASS. 5183 identical, 98 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. Compile and run times, first round at the code commit and second at this PR's head with four fixture timing runs: bench geomean -0.08 % (95 % CI -1.99 %, +1.31 %) and -1.10 % (-2.03 %, +0.36 %); self +1.09 % (-1.05 %, +1.67 %) and +1.38 % (-0.69 %, +1.84 %); fixtures +2.43 % (-0.18 %, +4.99 %) and -0.22 % (-3.14 %, +2.85 %). Bench and self are within tolerance; the gate calls the fixture interval inconclusive both times, and it straddles zero on both sides of it.
  • Lanes, with a libnode pack (island tests run): plain 21538 passed, 0 failed; sanitized 18311 passed, 0 failed. One plain job exited 1 without a failed test, which makes the round's own verdict line FAIL: packages/runtime/test/regex.test.ts timed out in its 60 s setup hook. It passes alone on this tree (2 of 2, twice), and this PR does not touch the regex unit.
  • Native bootstraps. Plain: the lanes' native-1-commands and native-1-rebuild jobs pass, and the gate's self program reports "candidate compiler source builds with the candidate". Sanitized: the round's three bootstrap phase jobs (commands, rebuild-emit, rebuild-frontend) all pass.
  • Focused, at this PR's head: the four new programs in the plain and sanitized differential and LLVM-differential suites (8 of 8 each), all 84 island corpus programs, the eight island suites (105 of 105), packages/runtime/test/node-island-host.test.ts, the dispatch pass's unit tests, pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only.
  • Sizes of the island corpus programs (release, linux-x64, 99 programs built with both compilers): 84 are 8 bytes smaller, 13 grow by 176 B to 5.5 KB (at most +0.57 %: the function and the tables of their boxed classes), and the two with a spread call on an island-typed value grow because that call's native arm is the dynamic tier's member read: 8885-node-island-native-receiver-calls +123,808 B (+12.0 %) and the new 8778 +178,296 B (+17.6 %, against the base compiler building the same source). Programs without island values are byte-identical (the gate).
  • pi (e770f5efe, run-pi.sh build plain, 32 vCPU Intel Xeon 2.90 GHz, each build alone in the same sandbox): 62 diagnostics on the base and 62 on this branch, the same 62 lines (SC2020 22, SC1090 19, SC2003 7, SC8010 5, others 9). 1314.0 s and 9.58 GB peak before, 1239.8 s and 9.74 GB after: no visible cost from the tables.
  • A refusal that is gone. A value typed as an abstract class crossing into an island was SC1090: values of abstract method declarations at the crossing, because the table was built from the static class. 8783 did not compile on the base for that reason; it now compiles and the island calls the concrete class's methods.

The lanes ran at adb760a0d (the code commit); the two commits after it add one corpus case and one unit test and change no compiler or runtime source.

Not in this PR

Each is the same before and after, and is in the fleet's follow-up notes with a repro.

  • A generic method of a class instance is absent in the island (typeof v.pick === "undefined"), silently: the tables hold the class's non-generic methods.
  • A class instance in an island has no inherited Object.prototype members (typeof v.toString === "undefined").
  • A generator a class method returns is not iterable in the island.
  • In the ordinary tier, a native Map or Set receiver with a spread argument throws at run time. An argument pack on dynInvoke would fix that and replace the predicate above.

🤖 Generated with Claude Code

cramforce and others added 3 commits November 6, 2026 20:45
…all them with spread arguments

Two failures of a native class instance held in a binding typed by a Node
island's interface.

A class instance crossing into an island without its methods

An island reads a class instance's prototype methods from a table. The
table was registered by the crossing expression: an expression whose
static type is a class was wrapped with nodeIsland.classMethods, and the
runtime kept the first table it was given for each capsule type key. So:

- An instance that reached the island any other way (a binding typed by
  the island's interface, `unknown`, a member of a record, an array or a
  Map, the result of a program callback) had no methods unless its class
  had crossed directly earlier in the run. The island threw "s.take is not
  a function", or silently saw `typeof s.take === "undefined"`.
- The table was the static class's. A subclass instance held as its base
  class ran the base class's method in the island (7 where Node prints 14)
  and lacked the methods only the subclass declares.
- The export table keeps an object's first capsule, so the static type an
  object first crossed under decided its methods for the rest of the run.

The table now comes from the class the object was constructed as, and is
looked up when the island first reads a method of the object. A program
that holds island values has one function, %island.classMethods, from a
capsule to that table: it tests the capsule's key and then the instance's
class, most derived first, over every class a checked-dynamic value can
hold (the classes the class dispatch pass found boxed, with their
constructed subclasses). %main registers it once (nodeIsland.classTables)
and the export keeps the answer. Nothing is registered at crossings:
nodeIsland.classMethods and the per-key table are gone, and the
shared-module class registration keeps only its constructor helper
(nodeIsland.classConstructor).

A method call with a spread argument on a class instance

`sink.take(...values)` threw "take is not a function". The native arm of
an island method call is the dynInvoke node, which has a fixed argument
list, so a call with a spread kept the runtime's call by name, and the
runtime has no table of class methods. The spread form now lowers its
native arm the way the ordinary tier lowers such a call: the member read
(a dynKeyGet with a literal key, which the class dispatch pass answers
with the method value) before the arguments, and a dynCall that spreads
the argument pack. That serves the receivers whose members compiled code
dispatches (nodeIsland.isCapsule: a class instance, a generator). Other
native receivers keep the runtime's call by name, which takes a list of
any length and is what dynInvoke ends in for them; the ordinary tier has
no method values for a native Map or Set.

Programs without island values are unchanged: the function and its
registration exist only when the IR holds an island operation.

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

A generator is the other kind of native value whose members compiled code
dispatches. Typed by an island's interface and stepped with
`stepper.next(...steps)`, it takes the same native arm as a class
instance: the member read and a call that spreads the argument pack.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The class dispatch pass builds the island method tables only for a program
whose IR holds an operation on a Node island value. The calls that copy
JSON both ways (nodeIsland.callSync, nodeIsland.call) do not count: no
value crosses by reference through them.

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

vercel Bot commented Nov 7, 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 7, 2026 11:12am UTC

Brings in #3124's new head: the fix for the bot's JSON thread and the spread call (eb7546b), main merged in at 4fc60de, and the same refusal for a call without a spread (47e4b09).

Three conflicts, all where main's island crossing changes (#3005, #3093, which #3124 now contains) meet this branch's class tables:

- `toIsland` (lower-node-island.ts): main's `crossByReference` wrapped a class instance with `withClassMethods`, which this branch deletes (an instance's methods come from its own class's table when the island first reads one). Kept main's structure, with `crossByReference` only boxing the value.
- `scr_ni_host_export_has` (scr_node_island_export.c): main's new branch for an inherited name calls `ni_class_method`, whose signature this branch changed. It now passes the export id and clears an exception the resolver left, like the branch beside it.
- `scr_node_island.h`: the two declarations side by side (`scr_node_island_calls` with its new arguments, `scr_node_island_is_capsule`).

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

Copy link
Copy Markdown
Contributor Author

Merged #3124's new head into this branch (9973d74): #3124 now contains main at 4fc60de and the fixes for its two review threads (eb7546b, 47e4b09). Three conflicts, all where main's island crossing changes (#3005, #3093) meet this branch's class tables: toIsland keeps main's crossByReference without withClassMethods (deleted here), scr_ni_host_export_has passes the export id to ni_class_method in main's new inherited-name branch, and the two declarations in scr_node_island.h sit side by side. Ran on the merged tree in a Sandbox with a libnode pack: the 180 node-island cases of differential.test.ts and llvm-differential.test.ts (this PR's four programs among them), the four programs plus 8885 and 8886 sanitized, tests/harness/node-island.test.ts (35), dispatch-sites.test.ts, the ir and node-islands unit tests (851), pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only. Not rerun on the merged tree: the zero-cost gate and the bootstraps of this PR.

cramforce and others added 2 commits November 6, 2026 23:56
#3124 now merges #3029 and builds on its JSON.stringify walk for island
values, and has current main. This merge had no textual conflicts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Brings the island value matrix recorded for #3124's tree.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 2 commits November 7, 2026 02:30
#3124 merged #3029's review fixes (3787c75) and recorded the island
value matrix for its tree.

Co-Authored-By: Claude Code <noreply@anthropic.com>
35384b2 recorded the island value matrix for this branch before
#3124 took #3029. Both sides rewrote the table; this merge keeps
#3124's, and the next commit records it for this tree.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce
cramforce requested a review from ctate November 7, 2026 13:09
@ctate
ctate merged commit a554971 into fix/island-runtime-bugs Nov 7, 2026
76 checks passed
@ctate
ctate deleted the fix/island-class-receivers branch November 7, 2026 15:01

This branch was successfully deployed

1 active deployment
Preview — 0f6e4bb6 Deployed Nov 7, 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