Skip to content

Train islands 1: errors, promises, process events and process.stdout.write keep Node's behaviour across the Node island boundary - #3377

Merged
ctate merged 74 commits into
mainfrom
train/islands-1
Nov 8, 2026
Merged

ctate merged 74 commits into
mainfrom
train/islands-1

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

One rollup of five reviewed pull requests and one branch about the Node island boundary (landing train islands-1), merged together and validated as one tree. After it, a program that mixes native code and Node islands keeps Node's behaviour for errors, promises, process events, process.stdout.write and process exit across the boundary.

One answer gets worse, and it is known before the merge. After this train, structuredClone of a program error that went native code, island, native code is wrong in untyped code: for const back = lib.passThrough(err); const c = structuredClone(back);, the text (c === back ? "same" : "fresh") + ":" + JSON.stringify(c) is fresh:{"%error":true,"message":"plain"} where Node gives fresh:{}, and where main gives fresh:{} too. The cause is not in the crossing: structuredClone(err) of a plain native error is already wrong with no island in the program (main's own control cell pins it, error.control.- U.clone = wrong). On main the round trip gave back a copy the island made, and that copy clones correctly; with this train the round trip gives back the program's own error (which is what makes 185 other observations right), so it reaches the native bug. Nine matrix cells show it (n2i2n/U.clone in the error cells): four go from a match to wrong, five were not observable on main because its program ended at the round trip. The fix of native structuredClone(Error) has an owner and removes all ten cells, the control included.

What lands

Each member's own description and proof is in its pull request; one line each here. The member branches are merged, not rebased, so their signed commits are in this branch.

Three former members landed on main by themselves while the train was being validated and are in it through main: #3135, #3253, #3072. The train also contains the head of #3367 (the re-pin of the island lists, merged since) and #3369.

Not on this train: #3047 (island operations from worker threads). It changes who owns what the pinned-object lookups return and routes every island entry point from other threads; the members above add lookups and entry points written to the old convention, so its owner restacks it on this tree with a thread test.

What the train changed beyond the members

These are the places where two members, or a member and main, met. No member's own proof covers them; the train's validation does.

The island lists

Both regenerated by their update modes on this tree, with #3367's lists as the base.

  • Value matrix: 208 observations leave the table (185 were wrong, 14 threw, 4 were unsupported, 5 ended the program), all in the error cells. 22 are new, all behind the round trip of an error: the 9 structuredClone observations of the first paragraph, and 13 build-time refusals (N.mapkey 5, N.read.keys / .json / .ctor 6, N.read.str 2). All 13 are in five typed cells where main's program ended at the round trip itself (n2i2n/S.back = exit), so nothing behind it was observed on main. Each refusal is what the same read does on a native error that never crossed: main's compiler refuses the control forms with the same diagnostics (SC2020 "collection slot conversion requiring an Error snapshot or promise adapter", SC1090 "string conversions of unions with object arms"), and the control cells now make those observations too.
  • Control observations added (N.mapkey, and the wide reads and the collection key on a function's wide result): 20 new entries, all in control cells. Fifteen are refusals. Five are wrong answers the native tier has by itself, seen for the first time: a record that went through a function's wide result is not found as the Map key it was stored under (rec, rec-nested, rec-opt). They are not caused by this train.
  • Conformance list: 69 to 64. Five leave (an unreferenced island timer no longer fires; island code reads process.argv, which also lets the two commander programs match Node; process.exitCode is one value). Five keep failing with another text, because Node now reports the unhandled rejection or the uncaught error. None is new.

Validation

All in Vercel Sandboxes (32 vCPU, Intel Xeon 2.50 GHz), Node 24.22.1, with a libnode pack built from the tree under test (bridge ABI 19). Main moved about 120 pull requests under the train during the day, so there is one full round on the train's tree early, and after every merge of main the list that the merge can touch. Details per tree: the fleet's trains/islands-1/VALIDATION.md.

On the train merged with main a7a0fd1734 (a6d763a807). The pushed head adds to that tree only main up to 50696d31ed (three more pull requests, no conflict but the conformance list, where the train's regenerated list is kept) and the conformance list's regeneration; the build, the guards and the conformance lane are being repeated on the head beside CI, and I will add their result as a comment:

  • Build (tsc in packages/compiler and packages/cli), runtime pack, LLVM helper: pass. Generated-file checks (node-island-host.mjs, IR codec, IR fields): pass. pnpm lint: 0 warnings, 0 errors (runtime refusal sites: 390, 0 not in the baseline). pnpm format:check: clean. pnpm test:ts7 --baselines-only: complete.
  • Island unit tests 238 passed. Island harness suites (every tests/harness/*island*.test.ts except the conformance lane, the value matrix in check mode among them): plain 382 of 382, sanitized 382 of 382. In the first plain run one test failed once, node-island-gc.test.ts "AbortSignal twins are collected with their requests" (208,483 freed exports where the test wants more than 260,000 by the time it looks: it depends on when V8 schedules a collection); alone it passed 3 of 3 and the whole set passed on the second run. It is main's test and the train does not touch twins.
  • Island corpus (differential and llvm-differential, every island program): plain 470 of 470, sanitized 470 of 470. Island diagnostics 10 passed.
  • Island conformance lane: 417 passed, 70 skipped, 0 failed, with the list regenerated on this tree. Main's runtime refusals name their diagnostic code since Name a diagnostic code on every runtime refusal #3137 and twelve entries of main's list had gone stale with it (main re-pinned in Fix main again: the refusal baseline, the island conformance list, and two builtins without a throw decision #3373 meanwhile; the train's list equals main's except the train's own ten entries).
  • Guard tests (builtin attributes, runtime unit references, pack matrix with "a unit that tests SCR_NODE_ISLANDS has a build that defines it" and "the installed island table has every entry the shared units call through it", runtime profile, runtime features, executable cache, feature tables): 140 passed, 1 failed, and that one is main's: worker.shareEnv (Support the Worker env option and worker_threads.SHARE_ENV #3023, merged at a7a0fd1734) is in neither throw list on main.

On the train's tree before main's later merges (68d82b6840, train + main 558850c513), one full round: PASS. Lanes plain 22,888 passed and sanitized 19,625 passed, 0 failed, over 92 jobs with both native bootstraps; Test262 (Object, Reflect, JSON, Proxy, Function) 0 regressed; bench-runtime 66 workloads.

Each of the five island paths of scr_json.c was switched off alone in one runtime build; each time a named corpus program stopped matching Node (table in VALIDATION.md).

Gate rows and exceptions

Zero-cost gate, --gate-corpus=all, a6d763a807 against main a7a0fd1734: 5,726 programs; 1,790 identical, 229 fail to build on both sides (the island programs: the gate has no libnode pack), 3,709 runtime-only, 1 differs. bench-runtime: 69 workloads, geomean -0.1%, 63 neutral, 2 faster, 1 slower (channel-messages +1.9% [+0.1%, +4.4%]; the same workload read -4.1% and neutral in the day's earlier rounds of this train). Gate timing within tolerance (bench -0.33%, self +0.48%, fixtures -1.79%).

  • All 37 fully typed programs, every typed benchmark among them, are byte-identical. No program gains a dynamic-tier symbol or a symbol of the island runtime (the gate's two-tier check: 0 programs).
  • The 3,709 runtime-only rows (3,685 corpus, 24 dynamic benchmarks) are the programs of the dynamic tier: IR and LLVM identical on every one. The only shared runtime objects that change are scr_json.o (the five island paths now test the installed table: scr_dyn_key_set, scr_dyn_obj_read_receiver, scr_dyn_promise_key_get, scr_dyn_in) and scr_events.o (scr_run_exit_listeners, +64 bytes, in 131 of those programs: the exit announcement through main's hook; scr_process_stdio_write_dyn, +163 bytes, in Share process.stdout.write between native code and Node islands #3175's four programs). Executables grow 320 to 1,064 bytes, median 448, at most 0.61% (one whole-unit build 5,920). This is a one-time cost of the same kind as Reach Node islands through the installed table from four shared units #3135's and is recorded with it in the fleet's gate exceptions.
  • The one differing program is corpus/8786-stdout-write-bind-stream-slots, a program this train adds (Share process.stdout.write between native code and Node islands #3175): it pins the lowering Share process.stdout.write between native code and Node islands #3175 changes (a process stream as the receiver of call, apply and bind), so main's compiler builds it differently.

Not run

  • The full lanes, the native bootstraps and Test262 were not repeated after the later merges of main; they ran on 68d82b6840. What entered after that: main, fix/node-start-mid-turn (its own full round on main: gate with no differing program, lanes 22,686 and 19,424 with 0 failed), and the train's fixes named above. The island suites, the island corpus and the gate were repeated on a6d763a807.
  • Darwin. The docs gate (no docs change). pi.

🤖 Generated with Claude Code

cramforce and others added 30 commits November 6, 2026 18:50
Properties of an error thrown by island code. The native view of an island
error held its name, message, stack and string code; every other read
(status, errno, cause, errors, a method of its class) answered undefined and
`in` answered false, with `constructor` as the one key read from the V8
error. That special case is now the rule: a key the view does not hold is
read from the V8 error and `in` asks it (ScrNiDynOps.error_get and error_has
replace error_constructor). Errors among the results are native views.

process.argv in island code. Node started with ["node"], so an argument
parser running as an island saw no arguments. The runtime hands the command
line to the bootstrap in process.argv's native shape and the host script
assigns it.

await of an island thenable. In a program with Node islands the operand of
every dynamic await and PromiseResolve passes through
nodeIsland.adoptThenable: V8 adopts an island object with a callable then, a
throwing then getter rejects, any other value is itself. Programs without
islands are untouched.

Work after the 'exit' listeners. An island 'exit' listener is a plain call
now, as Node's own emission is, so process.exit() in island code no longer
drains promise jobs after the listeners. The bridge stops Node before the
loop pass that closes the hosted loop's handles, so timers and immediates an
'exit' listener armed no longer run. The runtime tells island code when the
exit sequence begins (process._exiting), also when Node starts during it,
drops its own ticks while exiting, and at a natural end drains the
microtasks once after the last listener.

The bridge ABI goes to 11: the runtime relies on what emit and stop now do.

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

Each program has a native main.mjs and an island lib.cjs and runs under
Node and as a compiled binary with identical stdout, stderr and exit status:
a module that throws at load; first island use inside 'exit',
uncaughtException, unhandledRejection and signal listeners; ticks,
microtasks, timers and immediates queued alternately by both sides from the
main body and from inside each kind of callback; await chains that cross the
boundary at every level; native callbacks re-entering the island 300 levels
deep with a throw at each depth; every kind of value island code throws or
rejects with; process.exit from either side with work pending on the other;
SIGINT and SIGTERM listeners on both sides; what keeps the process alive;
environment, working directory and umask as one state; interleaved and
large output; shutdown with awaits, handles and threads pending.

Co-Authored-By: Claude Code <noreply@anthropic.com>
An island 'exit' listener that throws ends the exit sequence as it does
under Node: the listeners after it do not run, a natural end exits with
status 1, and a process.exit(3) keeps status 3.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ross the boundary

An island promise that only island code handles was reported as an
unhandled rejection by the runtime, and awaiting one took a microtask turn
more than in Node. The program's mirror of an island promise subscribed to
the V8 promise when it crossed. It now subscribes when native code first
reacts to it, settles inside V8's reaction job and runs the native
reactions there; a typed conversion of a mirror waits with it. V8 decides
whether an island promise is unhandled.

A native error thrown through island frames came back as another object,
without its class and fields, and island code saw none of its fields. A
native error now crosses by reference: one V8 Error per native error (an
export, bridge ABI 12), which is the native error again when it crosses
back. Island code reads its own enumerable fields (copied at each
crossing) and everything else (cause, an AggregateError's errors) through
the export.

An island object with a then method was adopted only by await. The Node
island build of the dynamic promise unit reads then in V8, so every
resolution adopts it: Promise.resolve, combinator entries, an async
function's return, an executor's resolve, a reaction's result.

Members an island class adds to Promise (an SDK's request promise) were
lost on the program's promise: typed reads, writes and calls through the
package's types, and dynamic reads, now reach the island object.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Both replace the error_constructor operation of the island's dynamic
operations table: #3123 with error_get and error_has, #3161 by adding
object_tag after it. The merged table has error_get, error_has and
object_tag.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…) into the value matrix base

The island's dynamic operations table gains object_tag from #3161 and
promise_get and promise_has from the promise work at the same place; the
merged table has all three.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…e island

Once Node is loaded, Node's process emitter is the registry for
uncaughtException, its monitor, unhandledRejection, rejectionHandled and
warning. The program's native listeners are registered on it as the
functions they are (those installed earlier move over at start, in
order), native errors go through process._fatalException, native
rejections nobody observed are handed to Node's own tracking as their V8
promises, and native warnings are emitted on process. A native error the
runtime raises is one V8 error while native code holds it, so a native
listener receives the error that was thrown.

The runtime promise of an island promise subscribes to the V8 promise
when native code first reacts to it, not when it crosses, so a promise
that only passes through native code is not handled by passing and V8
alone decides whether an island rejection is unhandled.

process.exitCode is Node's property once Node is loaded: a post-pass
routes the program's reads and writes through the island unit in
programs with islands.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…corpus programs

Each program is a native main.mjs and an island lib.cjs, run under Node
and as a compiled binary: listener order across the two sides with once,
off, prependListener and listenerCount; listeners installed before Node
started; what a listener receives; no listener and a throwing listener
from either side; setUncaughtExceptionCaptureCallback; what runs after a
handled error from a native timer, immediate, tick and microtask;
unhandledRejection and rejectionHandled for each combination of promise
side, holder and handler; warnings; beforeExit re-arming the loop from
both sides; process.exitCode read and written by each side, then
process.exit() with and without an argument from each side.

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

A conversion of an island promise that nothing reacted to keeps the
promise it converts in its payload words. The free path cleared the
payload before the source was released, so the mirror leaked (the
sanitized lane caught it in node-island-crossing-combinators-typed).
Every free and every settlement now drops the lazy state first, which
also keeps a view that something else settles from treating its payload
as a source. The value a V8 reaction settles a mirror with converts
before the settlement's jobs are collected.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A program that replaces process.stdout.write (an output guard, a logger, a
test runner) and also has Node islands was refused with SC8006 when the
islands were automatic, and built with the island's output silently bypassing
the replacement when they were explicit @scriptc/node calls: the island's Node
has its own process.stdout over the same descriptor.

The program's write slot is now the only one. In a program that has islands and
holds a process output stream as a value, the island's process.stdout.write and
process.stderr.write are accessors over the program's slots:

- A function the program holds in the slot (the stream's own write method or a
  replacement) reads in the island as one island function per program
  function. Calling it runs the program's function with the program's stream
  as its receiver and the arguments as passed: console's (string, handler)
  pair, byte chunks, an encoding, a callback.
- A function island code assigns is stored as a native function that calls it
  in Node with the island's stream as its receiver, so the program's console
  lines and writes reach it, and it reads back in the island as itself.
- Either side can restore the method the other saved, or stack its own
  replacement on the other's.

The three operations are native functions handed to a bootstrap helper through
the existing export channel, so the bridge ABI and the libnode pack are
unchanged. They live in a new unit, scr_node_island_stdio.c, and the accessor
in scr_node_island_stdio.cjs, which the SCR_NODE_ISLAND_STDIO build of
scr_node_island.c appends to its bootstrap and calls when Node has started.
Both are selected by the nodeIsland feature together with the new stdioObject
feature: the IR has a process.stdio or process.stdioSlot lib call. Those are the
only ways to the stream object, and a replacement is a store into it, so the
condition is exact for every spelling of a replacement, including an alias or a
computed key the source scan cannot see. Every other program with islands links
the objects it linked before.

The SC8006 refusal for this combination, its site bookkeeping in the lowerer and
its diagnostic builder are removed.

In the island the accessor is an own property that cannot be reconfigured, so
Object.defineProperty(process.stdout, "write", ...) and delete fail with a
TypeError instead of leaving island code with a write the program never sees.

Corpus 8895 (native replacement, island and package output, stderr), 8896
(island replacement, restore and stacking from either side, errors, async),
8897 (output right before exit) and 8898 (a JavaScript replacement through an
alias) are differential against Node with a libnode pack.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
A value of the process stream type becomes the stream object when it is boxed
(the backend calls scr_process_stdio directly), which is a third way to the
object beside the two lib calls. The stdioObject feature now also follows the
type, so a TypeScript program that hands process.stdout to an untyped helper
which replaces write shares the slot with its islands. Corpus 8890.

Co-Authored-By: Claude Code <noreply@anthropic.com>
process.stdout.write(chunk) with a chunk whose type is known only at run time
(an any value, a string | Uint8Array union) wrote to the descriptor without
looking at the stream's write slot. Its lowering assumes nothing replaced the
method when the source scan finds no assignment, and that is not something the
call site can know: a replacement through an untyped alias is invisible to the
scan, and so is one a Node island installs now that islands share the slot.
In both cases the chunk bypassed the replacement silently.

scr_process_stdio_write_dyn now calls a replaced write with the chunk and the
stream as receiver, as its one-string twin scr_stdio_write_str_dyn already did,
and keeps the direct write otherwise.

Corpus 8777 pins the alias case without islands, 8896 gains the island case,
and 8775 pins that an island-installed replacement keeps its receiver in the
'exit' listeners of both sides when the loop drains.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`tests/corpus/node-island-lifecycle-streams/main.mjs` called `resolve()` with no argument inside `new Promise((resolve) => …)`. In a JavaScript file tsgo reports that call (SC0001, "Expected 1 argument, but got 0") while tsc-ts compiles it, so the checker-identity job failed on this program alone: "1 of 5258 programs differ between tsgo and tsc-ts". `resolve(undefined)` is the same program for Node and compiles with both checkers.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ronise what a V8 Error holds

Two review findings on #3190, both reproduced.

An executor that resolved its promise with an island thenable left its
resolving functions active: a later reject, resolve or throw in the
executor settled the promise first (Node: the first resolution stands).
The promise is now marked resolved until the thenable's job settles it,
and the executor's resolve, reject and throw leave a resolved promise
alone.

The V8 Error of a native error kept the message, name, stack and code it
was made with. Island code that held the error read the old values after
native code changed them. They are now brought up to date at every
crossing, like the enumerable fields.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… an explicit value)

Co-Authored-By: Claude Code <noreply@anthropic.com>
…tore noDeprecation

Three review findings on the one registry for process events:

- A native promise that crossed before it rejected sent its reason
  without the error link, so a native unhandledRejection listener got a
  view of a copy. The reason is linked when the V8 promise is rejected.
- A native warning's code and detail did not reach Node's report after
  Node loaded: the V8 error a warning crosses as carries neither. They
  come beside it.
- Emitting a native deprecation while process.noDeprecation was set in
  native code left Node's own flag set for good. It is set for that emit
  only.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ocess-events

Co-Authored-By: Claude Code <noreply@anthropic.com>
The review thread on #3123 (then read twice during adoption): a getter
that answers a function on its first read only. The resolution reads it
once and calls what it read.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Under checked-dynamic stream slots a process stream that flows into a dynamic
slot becomes the Writable such a slot holds. The receiver argument of
Function.prototype.call, apply and bind on a checked-dynamic function is such
a slot, so process.stdout.write.bind(process.stdout) bound the stream's own
write method to that Writable, and calling it stopped with "Native process
stream method receiver has no lowering [SC2020]". A program reaches this as
soon as one of its stream slots also holds a JavaScript object, with or
without Node islands.

The receiver argument of those three calls now boxes a process stream as the
stream object itself.

Corpus 8786 pins it without islands (bound, .call and .apply with completion
callbacks, beside a JavaScript sink in a stream slot), and 8785 pins the kept
original write beside an island: bound once, bound at the call, .call and
.apply, from a promise chain, while the island writes through the
replacement.

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

When Node is first loaded inside a native listener, the move of the
native listeners onto Node's process waits for that dispatch to end. The
dispatch can end with an exception pending (what the listener threw, or
the error nobody handled), and the registrations cleared it: the failure
disappeared instead of reaching the uncaughtException listeners. The
exception is set aside for the registrations and pending again
afterwards. A deferred move also puts the native listeners ahead of the
ones island code registered meanwhile, since they are older.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…the error

A program error crosses into a Node island by reference: the export table
holds the error's view and the bridge keeps one V8 Error for it. The
runtime's error-view cache also holds the view and the error, until nothing
else does. The cross-heap analysis counted the cache's reference to the view
as a holder in the program, so it reported every exported error as a root:
V8 never collected the V8 Error, the export table never released the view,
and the cache never swept the pair. Every error that crossed stayed alive
for the rest of the run.

The analysis now explains the cache's two references for an exported error
view, and keeps the export a root only while something else in the program
holds the error.

Peak resident memory for 100,000 iterations of one pattern, before: an
error passed as an argument 361 MB, thrown through an island call 437 to
489 MB, as a cause 734 MB, against 108 MB for a callback that crosses no
error. tests/fixtures/node-island/gc-native-errors.mjs sends four errors
per iteration; the test asserts that 300,000 iterations stay within a
factor of 1.5 (plus 32 MiB) of 100,000 and that the collector frees the
exports.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…rogram drops it

Two faults in how the program holds an error island code threw.

A crash. The runtime builds the program's view of a thrown island error from
the error's name, message, stack and code, which the bridge reads by running
JavaScript (any of them may be an accessor). It made that call directly,
after the operation that threw had returned to the program's stack, so V8
ran on a stack it was not entered on. A collection that started there
failed the stack scan's check (`Check failed: segment.start >=
segment.top`, exit 133). A loop that catches 10,000 island errors dies.
The read now runs on Node's stack like every other operation, for a caught
error and for the report of an uncaught one.

A leak. A caught island error is pinned to its island handle, and once the
program reads it as a dynamic value the runtime's error-view cache holds
the error and its view until nothing else does. The pin sweep released a
pin only at reference count 1, which the cache's reference prevented, and
the cache drops a pair only when nothing else holds the error, which the
pin prevented. 100,000 caught errors peaked at 336 MB against 108 MB for
the same loop without them. The sweep now takes an error held only by the
table and by an otherwise unreferenced cached view for unheld.

tests/corpus/node-island-error-catch-loop pins the loop against Node, and
node-island-gc.test.ts asserts that 300,000 caught errors stay within a
factor of 1.5 (plus 32 MiB) of 100,000.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…the error

A program error crosses into a Node island by reference: the export table
holds the error's view and the bridge keeps one V8 Error for it. The
runtime's error-view cache also holds the view and the error, until nothing
else does. The cross-heap analysis counted the cache's reference to the view
as a holder in the program, so it reported every exported error as a root:
V8 never collected the V8 Error, the export table never released the view,
and the cache never swept the pair. Every error that crossed stayed alive
for the rest of the run.

The analysis now explains the cache's two references for an exported error
view, and keeps the export a root only while something else in the program
holds the error.

Peak resident memory for 100,000 iterations of one pattern, before: an
error passed as an argument 361 MB, thrown through an island call 437 to
489 MB, as a cause 734 MB, against 108 MB for a callback that crosses no
error. tests/fixtures/node-island/gc-native-errors.mjs sends four errors
per iteration; the test asserts that 300,000 iterations stay within a
factor of 1.5 (plus 32 MiB) of 100,000 and that the collector frees the
exports.

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
Two conflicts, both in the table the island handle unit installs
(ScrNiDynOps in scr_node_island.h and its initializer in
scr_node_island_handle.c). This branch replaces the error_constructor
slot with error_get and error_has; main added object_tag after
error_constructor. The merged table has error_get, error_has and
object_tag, and no error_constructor: scr_json.c reads an island
error's constructor through error_get on this branch.

The bridge ABI stays 11 (main is 10).

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ith one value each

A program error that crosses into a Node island is one V8 Error per error
(the export table's identity). Until now that V8 Error was built from the
error's class, name and message, took a copy of the error's enumerable
fields at each crossing, and had an interceptor object in its prototype
chain for everything else. So an error made without a message had an own
`message`, `cause` and `errors` were not own, `Object.getPrototypeOf(e)`
was never `Error.prototype`, and what island code wrote on the error
(`err.code = ...`, `err.handled = true`) never reached the program.

Each own property of the native error is now an own property of the V8
Error whose value lives in the native error: a V8 native data property,
which JavaScript sees as a plain data property with the native property's
attributes and whose reads and writes go to the runtime's export. Both
sides read and write one value. Which properties exist is reconciled when
the error crosses in either direction and when the runtime's call into the
island returns: a property one side added or deleted since is added or
deleted on the other.

- The bridge constructs the intrinsic class with no argument and installs
  the properties the runtime lists (SCR_NODE_EXPORT_OWN: own names in
  order, with attributes), so key order is the program's.
- A native error that is not an instance of a program class keeps its
  intrinsic prototype; the interceptor object stays for instances of
  program classes, where the class's prototype would be.
- `stack` is an accessor pair over the native error's stack, formatted
  when read instead of at every crossing.
- DOMException crosses as Node's DOMException.
- `instanceof` of a native error against a program class answers by the
  compiled hierarchy, so a subclass instance is an instance of its base.
- A class instance's message, name and cause are read from the error
  itself: its view is a snapshot that typed writes do not update.
- An island error the program already holds as an error is that error
  again when island code hands it back.

Bridge ABI 12 to 13: the error value carries its class kind, export id and
whether it is a class instance, and export_op gains SCR_NODE_EXPORT_OWN.

Corpus: node-island-error-properties, -class, -writes and -paths, each in
JavaScript and TypeScript source. The value matrix's table is regenerated:
against the tree this is stacked on, 24 entries leave it and 12 shrink.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…then the catch loop

The fixture caught each island error and read only `message`, which the
program's view of the error holds, so the error never entered the
error-view cache and the fixture did not exercise the leak. It now reads a
property the view does not hold (`e.index`). On main with only the stack
fix, 100,000 iterations peak at 451 MB in 9.5 s and 300,000 at 940 MB in
62 s; with the sweep fix, 105 MB in 2.4 s and 135 MB in 7.2 s. The test
asserts on the island objects still imported at exit (one per unreleased
error: 30,001 after 30,000 iterations before, 49 after), which holds in
the sanitized lane too, where resident memory says nothing.

The corpus loop runs 30,000 iterations instead of 10,000: on main it then
dies in four runs of five instead of two of three.

Co-Authored-By: Claude Code <noreply@anthropic.com>
#3190 gained a re-synchronisation of the name, message, stack and code a
V8 Error holds at every crossing. Here those are properties whose values
live in the native error, so there is nothing to bring up to date: the arm
keeps this branch's form. #3190 also took this branch's collector fix by
cherry-pick; the test file keeps both branches' tests.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A method call or a write on a member an island class adds to Promise (a
request promise's asResponse() and withResponse()) threw in untyped code:
`r.asResponse is not a function`, `Cannot create property`. The Node
island builds of the invoke unit and of the dynamic write ask the island
object, as reads and the typed entry points already do. then, catch and
finally stay the program's promise's own.

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

Copy link
Copy Markdown
Contributor Author

Answer to a review finding the bot left in a review body on #3289 (a member of this train; review bodies cannot be resolved, so it is answered here).

packages/runtime/src/scr_node_island_export.c:480-486: a native error sealed or frozen before its first crossing exposes message, name and cause as configurable, writable properties in the island, because their exported attributes are hard-coded instead of read from their descriptors.

Verdict: real, in one reachable form, and fixed in this train (8abeb9b26f).

  • The hard-coded attributes were there: the export listed message, name and cause as writable and configurable whatever the program had defined.
  • A frozen or sealed native error cannot be made today: Object.freeze of an error, Object.seal and Object.preventExtensions are refused (SC2020, at build time in TypeScript and when the statement runs in JavaScript), so the finding's own example does not reach the code.
  • Object.defineProperty does: after Object.defineProperty(err, "message", { value: "first", writable: false, enumerable: false, configurable: false }) the program's own descriptor was right (message:---), island code saw message:w-c, and err.message = "written" in a module that is not strict threw TypeError: Cannot assign to read only property 'message' where Node drops the write.
  • Fix: the writable and configurable bits now come from the descriptor the error's view holds (ni_slot_defined_bits); the error's slot still says whether the property exists and whether it is enumerable.
  • Pinned by tests/corpus/node-island-crossing-error-integrity, against Node, plain and sanitized, in the differential and LLVM-differential suites: descriptors on both sides, a write in a module that is not strict and in one that is, redefinition, delete, for a plain error, one with message and cause locked, and one with a read-only but configurable message. The error cells of the island value matrix did not move (15 of 15 in check mode).
  • One case is left open and is not in the program: redefining from island code a message that is read-only but still configurable throws, where one realm allows it (V8 calls the native data property's setter for the redefinition). Natively, with no island, the redefinition works. Tracked for Give a program error's V8 Error the program error's own properties, with one value each #3289's owner.

🤖 Generated with Claude Code

@cramforce

Copy link
Copy Markdown
Contributor Author

The gate's 3,709 runtime-only rows, by cause

Zero-cost gate, --gate-corpus=all, this train (a6d763a807) against main a7a0fd1734: 5,726 programs, 1,790 identical, 229 failing on both sides (island programs; the gate has no libnode pack), 3,709 runtime-only, 1 differs (corpus/8786-stdout-write-bind-stream-slots, a program #3175 adds for the lowering it changes). IR and LLVM are identical on all 3,709.

No typed-only program is among the rows. All 37 programs the gate classes as fully typed (every typed benchmark among them) are byte-identical, and no program gains a symbol of the dynamic tier or of the island runtime. The 3,709 are the programs that link the dynamic object core of scr_json.c: 3,685 corpus programs and 24 benchmarks, all of which the gate classes as dynamic-tier.

Two shared objects change, nothing else: scr_json.o (default, object-model and worker builds) and scr_events.o.

Function (unit)ProgramsGrowthMemberWhat was added
scr_dyn_key_set (scr_json.c)3,709+213 bytes in 3,543; 144 to 198 in 165; 1,665 in one whole-unit build#3190 / #3309a write to a member of a promise asks the table's promise_set (an island Promise subclass has members of its own)
scr_dyn_obj_read_receiver (scr_json.c)3,708+111 (142 in 128, 113 in 37)#3123a property an error's view does not hold asks error_get (an island error keeps its own properties and class)
scr_dyn_promise_key_get (scr_json.c)2,056+89#3190 / #3309a member a promise does not have asks promise_get
scr_dyn_in (scr_json.c)1,690+234 in 1,526; 181 to 251 in 163; 1,545 in one#3123 and #3190 / #3309in on an error's view asks error_has, on a promise promise_has
scr_run_exit_listeners (scr_events.c)131 (all among the above)+64#3123the exit sequence is announced to island code before the first listener, through the emit hook main installed in #2801
scr_process_stdio_write_dyn (scr_events.c)4+163#3175a run-time-typed chunk written to a process stream tests the write slot

Per executable: 320 to 1,064 bytes, median 448, at most 0.61% (the one whole-unit build 5,920). By what a program links: 1,365 programs link the first two functions and grow 320 bytes; 654 also link the third, 448; 287 link the first, second and fourth, 808; 1,402 link all four, 936.

Why this is not confined to programs that have an island. The island behaviour itself is: all five paths are calls through ScrNiDynOps, which is NULL in a program without the island units, and the island code they reach lives in the island units. What is in the always-linked unit is the question "is this value island-backed?" at four generic sites: a keyed write, a property read that missed, in, and a promise's member read. The values at those sites (the view of an error, a promise) exist in every dynamic program, and only at run time is it known whether island code made them, so the site cannot be compiled out by a link feature: #3135 removed the island build of scr_json.c for exactly that reason (one build per unit, island paths behind the table), and under that rule these five were compiled into no program at all until the train moved them. The alternative, an island build of the unit again, is what #3135 and #3371 moved away from.

What of it could still leave the always-linked unit. Not the test, but most of what surrounds it. Each site today holds the table test, the argument set-up and, for the two error paths, the lookup of the error behind a view (scr_errdyn_find, which is no longer inlined and is a symbol of its own in 1,689 programs). #3135 measured the same shape on scr_dyn_new_func: with the island path inline a site cost 41 to 74 bytes, as a call to one out-of-line function 10 to 38. Done the same way here (one cold function per path, the error lookup inside it), my estimate is that 250 to 500 of the 320 to 936 bytes per program go away. That is an estimate from #3135's per-site numbers, not a measurement; it is written down as the first cleanup of the next island train, which should replace it by the measured number. It changes the same 3,709 executables once more, which is why I did not do it inside this train after its round.

Run time (bench-runtime, same round, 69 workloads, 16 runs over 4 layouts): geomean -0.1%; 63 neutral, 2 faster, 1 slower, 3 not compared. Of the 28 workloads whose executable changes, the geomean is -0.5%: 25 neutral, 2 faster (dynamic-methods -1.5% [-2.3%, -0.2%], dynamic-properties -3.1% [-8.0%, -0.1%]), 1 slower: channel-messages +1.9% [+0.1%, +4.4%]. The same workload read -4.1% (faster), neutral and neutral in this train's three earlier rounds today, with the same four functions changed each time, so I read it as noise around zero and not as a cost; it is named here so that it is not lost. Gate compile timing: bench -0.33% [-1.54%, +0.81%], self +0.48% [+0.02%, +1.19%], fixtures -1.79% [-2.33%, -1.24%], all within tolerance.

Recorded in the fleet's GATE-EXCEPTIONS.md as one row for this train. Run: validate/runs/20261107T224601-train-islands-1-gate.

🤖 Generated with Claude Code

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings

  1. packages/runtime/src/scr_async_dyn.c:1271-1273 — When a warning listener is registered after Node starts, native MaxListenersExceededWarning events lose their emitter, type, and count properties.

    Fix on Vercel

@ctate
ctate merged commit 3dda5d9 into main Nov 8, 2026
42 of 43 checks passed
@ctate
ctate deleted the train/islands-1 branch November 8, 2026 00:12
cramforce added a commit that referenced this pull request Nov 8, 2026
No conflict. Main now holds train islands-1 (#3377), which regenerated the
island value matrix; the table is regenerated on this merge in the next
commit.

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

Copy link
Copy Markdown
Contributor Author

The checks on the pushed head e1a0935bf9 that the description said were being repeated (the sandbox that first ran them expired before it reported). All in one 32-vCPU Sandbox, libnode pack built from the head (bridge ABI 19):

  • Build (tsc in packages/compiler and packages/cli), runtime pack; generated-file checks; pnpm lint (0 warnings, 0 errors); pnpm format:check; pnpm test:ts7 --baselines-only: all pass.
  • Guard tests (builtin attributes, runtime unit references, pack matrix, runtime profile, runtime features, executable cache, feature tables): 137 of 137. The one failure named in the description (worker.shareEnv) was main's and is gone with main 50696d31ed.
  • Island conformance lane, check mode: 417 passed, 70 skipped, 0 failed.
  • Island unit tests 238; island harness suites (the value matrix in check mode among them) 382 of 382; island diagnostics 12; island corpus, differential and LLVM differential: 470 of 470.

Not repeated on the head: the sanitized island suites and the gate (both ran on the tree one merge of main earlier, a6d763a807).

🤖 Generated with Claude Code

cramforce added a commit that referenced this pull request Nov 8, 2026
Main brings train islands-1 (#3377). Seven conflicts, all in the runtime feature tables (executable-features.ts, native-link-info-core.ts, native/contracts.ts, runtime-pack-core.ts, executable-cache.ts, index.ts, sanitizer.test.ts): main adds stdioObject where the train adds streamPipeEvents; both kept, main's first. Regenerating the refusal baseline and the compatibility files on the merged tree changes nothing.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 8, 2026
Main now holds train islands-1 (#3377).

Conflict, frontend/lowering/lowerer.ts: islands-1 removed the lowerer's
stdioPatchSites field and what reported it (a patched process.stdout.write
now works beside a Node island); #3241's three built-in census fields and
#3221's loweredFunctions sat beside those lines. The field and its two
mentions are gone, the train's lines stay.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 8, 2026
A closed island constant (#3264) is a module the build writes, which
Node loads when the program reaches the declaration. Main's #3377 lets a
program replace `process.stdout.write` beside a module of its own that
patches a builtin (`fs.readFileSync`). With both, the constant's module
was loaded after the patch, Node's loader read the generated file
through it, and the program ended with `ReferenceError: patched is not
defined` where main prints its lines
(tests/harness/node-island.test.ts, "replacing process.stdout.write
beside an island builds, and the island's write is the program's").

When a module of the program is in the island because it patches a
builtin module, no constant of the program is closed: every initializer
is evaluated where it is written, as on main. The pre-scan's marker is
carried on the island module record for that.

Not covered: a package that patches the loader's inputs at run time
without the pre-scan seeing it. A generated module is still read through
Node's public loader.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 8, 2026
…or in an island

Regenerated by the lane's update mode; one line moves. With
@hono/zod-validator forced into an island the program ended with
`Error: Malformed JSON in request body`; since main's #3377 (errors and
promises keep Node's behaviour across the island boundary) it gets past
that and stops at the next gap, a refusal in natively compiled hono:
`'Headers.[Symbol.iterator] in a static build' … has no scriptc lowering
yet [SC2020]`. Exit 1 against Node's 0 before and after. On main the
entry is still `build SC8006`: main cannot build the program, this
branch can (#3312).

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 9, 2026
…slands-2 train

vercel-labs#3284 was proven on main 50bcfe1; since then the islands-1 train (vercel-labs#3377)
landed in the same units. What the merge decides:

- Bridge ABI 20, the number reserved for this train (main is 19).
- ScrNiDynOps keeps main's entries in main's order; set and has_own follow
  promise_set.
- The pin table is vercel-labs#3284's (hash index, one pin per ref, young pins) and
  carries main's native-error pins as a sixth kind. A pin is dead by
  vercel-labs#3284's count, and by main's rule for an error whose only other holder
  is its view in the error-view cache. A sweep of the young pins leaves
  native-error pins to a full sweep, as it leaves promises and errors.
  The cross-heap collector is handed promise, error and native-error pins.
- Both sides had added nh_error_info (the read on Node's stack): main's is
  kept.
- nh_from_node: main's pinned-error identity and twin adoption, then the
  promise, the symbol, the handle.
- scr_ni_host_export_call ends its read of the arguments, then sweeps the
  pins; export_set writes a symbol key first, then main's native-error
  write, then the reflect write.
- The host script has main's twin section and vercel-labs#3284's pair helpers in one
  islandHelpers; the embedded header is regenerated by its script.
- vercel-labs#3284's three symbol-key paths of scr_json.c were under
  SCR_NODE_ISLANDS, which no build of that unit defines since vercel-labs#3135: they
  test the island handle and call the table, as main's other handle sites
  do.
- The value matrix and the conformance list are main's here; they are
  regenerated on the tree in a later commit.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 9, 2026
Node's module compile cache as the default for a program's Node island.

Conflicts, with islands-1 (vercel-labs#3377) and startup-1 (vercel-labs#3350) in the island
bootstrap:
- scr_node_island.c, ni_bootstrap: main's process state (argv, exiting)
  first, then the compile cache's directory; the buffer counts both.
  ni_node_ending is set right after the stop job, before main's
  scr_timers_teardown.
- scr_node_island_host.cjs: the cache is enabled first, then main's
  process state is applied.
- scr_node_island_host.h: regenerated by scripts/node-island-host.mjs.

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

This branch was successfully deployed

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