…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>
Restacked 2026-11-07. #3124 now merges #3029 (through
3787c75b9) and builds itsJSON.stringifypart on #3029's walk, so the merge order is #3029, then #3124, then this PR. This branch merges #3124's new head2ba11f120and the reviewer's matrix commit35384b2c0; neither merge had a conflict outsidetests/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 head2ba11f120(Intel Xeon 2.50 and 2.90 GHz):--gate-corpus=all: PASS. 5247 identical, 105 both-fail (island programs; no libnode pack on either side), 0 differing, 0runtime-onlyrows; the self-hosted compiler builds itself.2963-child-fork-dispatch/main.tsin 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).native-1-commandsandnative-1-rebuildpass; sanitizedcommands,rebuild-emitandrebuild-frontendpass.node-islandandisland-valuecorpus 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-islandsand 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
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: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.takewas"undefined"while"take" in vwastrue.The table now comes from the class the object was constructed as, looked up when the island first reads a method of the object:
%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, throughmainPreludelike the generator members).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.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 nownodeIsland.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
anyvalue already does. Sizes are below.2. A method call with a spread argument on a class instance
#3124 gives
recv.m(args)on an island-typed value two arms: an island receiver takes the bridge, a native receiver takes thedynInvokenode, which the class dispatch pass rewrites for class instances.dynInvokehas 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 ananyto adynKeyGetwith a literal key, which the dispatch pass answers with the method value, and adynCallthat carries the receiver andspreads(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 samescr_dyn_invokethatdynInvokeends 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 ananythrows the runtime's named error), andtable.set(...entry)on a Map typed by an island alias works today.Evidence
tests/corpus/8779-node-island-class-crossing-typed-first/and8780-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.Gates
Base for every comparison: #3124's head
649e742c5. Sandboxes: Intel Xeon 2.50 GHz (focused tests) and 2.90 GHz (pi).--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, 0runtime-onlyrows, 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.packages/runtime/test/regex.test.tstimed 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-1-commandsandnative-1-rebuildjobs 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.packages/runtime/test/node-island-host.test.ts, the dispatch pass's unit tests,pnpm lint,pnpm format:check,pnpm test:ts7 --baselines-only.8885-node-island-native-receiver-calls+123,808 B (+12.0 %) and the new8778+178,296 B (+17.6 %, against the base compiler building the same source). Programs without island values are byte-identical (the gate).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.SC1090: values of abstract method declarationsat the crossing, because the table was built from the static class.8783did 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.
typeof v.pick === "undefined"), silently: the tables hold the class's non-generic methods.Object.prototypemembers (typeof v.toString === "undefined").dynInvokewould fix that and replace the predicate above.🤖 Generated with Claude Code