Repository navigation
Train islands 1: errors, promises, process events and process.stdout.write keep Node's behaviour across the Node island boundary - #3377
Conversation
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>
…) 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>
|
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).
Verdict: real, in one reachable form, and fixed in this train (
🤖 Generated with Claude Code |
The gate's 3,709 runtime-only rows, by causeZero-cost gate, 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 Two shared objects change, nothing else:
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 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 ( 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 ( Recorded in the fleet's 🤖 Generated with Claude Code |
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>
|
The checks on the pushed head
Not repeated on the head: the sanitized island suites and the gate (both ran on the tree one merge of main earlier, 🤖 Generated with Claude Code |
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>
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>
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>
…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>
…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>
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>
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.writeand process exit across the boundary.One answer gets worse, and it is known before the merge. After this train,
structuredCloneof a program error that went native code, island, native code is wrong in untyped code: forconst back = lib.passThrough(err); const c = structuredClone(back);, the text(c === back ? "same" : "fresh") + ":" + JSON.stringify(c)isfresh:{"%error":true,"message":"plain"}where Node givesfresh:{}, and where main givesfresh:{}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.clonein 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 nativestructuredClone(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.
process.argv, thenables are awaited as Node awaits them, work queued after'exit'does not run; 40 lifecycle corpus programs.process.stdout.writefor native code and Node islands (a replacement installed by either side is seen by both).uncaughtException,unhandledRejection,rejectionHandledandwarning, and oneprocess.exitCode, across a Node island.fix/node-start-mid-turn(no pull request of its own; one signed commit): when Node is started from inside a native promise job, a rejection the program catches later in the same turn is no longer reported as unhandled (the process exited 1 where Node exits 0), and the hosted loop decides unhandled rejections at the end of Node's turn. Ten corpus programs (9690to9699). On this train its 351-program family goes from 210 to 337 matching Node with no cell lost.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.
scr_promise_mark_island_mirror,scr_promise_subscribe_fn: it also covers typed views of a promise and settles inside V8's reaction job) and removes One registry for process events and one process.exitCode across a Node island #3166's copy on purpose (island_source,scr_promise_island_fn,SCR_PROMISE_REACTED, the one-bytestate). One registry for process events and one process.exitCode across a Node island #3166's guard for a released handle is kept in the subscriber. One registry for process events and one process.exitCode across a Node island #3166'snode-island-events-*programs pass on the one mechanism.fix/node-start-mid-turn). With One registry for process events and one process.exitCode across a Node island #3166 a rejection nobody observed is handed to Node's tracking, which decides at the end of the turn; for a rejected main module Node ends the program at once, and a tick queued before the throw ran and printed. The checkpoint now decides the main module's rejection first. Pinned by9699-node-island-start-module-rejects, which failed on the train before the fix.scr_json.ctest the installed table instead of#ifdef SCR_NODE_ISLANDS. Reach Node islands through the installed table from four shared units #3135 builds that unit once, so the paths Node islands: fix error properties, process.argv, thenable awaits and work after 'exit'; pin lifecycle behaviour with 40 corpus programs #3123 (error_get,error_has) and Node islands: promises, errors and thenables keep Node's behaviour across the boundary #3190/Reach the members of an island Promise subclass from untyped code #3309 (promise_get,promise_has,promise_set) kept under the define would have been compiled into no program, with no conflict to say so. They are behind the table now, once each. Each is proven to run by switching it off alone: a named corpus program then stops matching Node (error_get,error_has:node-island-lifecycle-error-properties;promise_get,promise_has:node-island-crossing-promise-subclass;promise_set:node-island-crossing-request-promise).scr_events.c. Main's Build scr_events.c once per flavor #2801 builds that unit once; Node islands: fix error properties, process.argv, thenable awaits and work after 'exit'; pin lifecycle behaviour with 40 corpus programs #3123's announcement of the exit sequence to island code was still under the define after a merge without a conflict. It goes through main's emit hook now.ScrNiDynOpskeeps main's offsets. Node islands: fix error properties, process.argv, thenable awaits and work after 'exit'; pin lifecycle behaviour with 40 corpus programs #3123 replacederror_constructorby two entries in the middle of the table, which would have movedinspectandreport_uncaught; since Reach Node islands through the installed table from four shared units #3135 the units every program links read those by offset.error_gethaserror_constructor's place and the new entries are at the end:error_has,promise_get,promise_has,promise_set.message,nameandcausekeep the attributes the program defined (review finding on Give a program error's V8 Error the program error's own properties, with one value each #3289, see the comment below). The export listed them with fixed attributes. Fixed in the train and pinned bynode-island-crossing-error-integrity.export_opanswers the error's own properties before the twin operations); One registry for process events and one process.exitCode across a Node island #3166 with the twins (the helper's exported name); Share process.stdout.write between native code and Node islands #3175 with Node islands: fix error properties, process.argv, thenable awaits and work after 'exit'; pin lifecycle behaviour with 40 corpus programs #3123 (the shared output streams stop after the exit microtasks drained); the two island exit-code reads say they may throw (main's builtin guard).The island lists
Both regenerated by their update modes on this tree, with #3367's lists as the base.
structuredCloneobservations of the first paragraph, and 13 build-time refusals (N.mapkey5,N.read.keys/.json/.ctor6,N.read.str2). 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.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 theMapkey it was stored under (rec,rec-nested,rec-opt). They are not caused by this train.process.argv, which also lets the two commander programs match Node;process.exitCodeis 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 to50696d31ed(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:packages/compilerandpackages/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.tests/harness/*island*.test.tsexcept 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.differentialandllvm-differential, every island program): plain 470 of 470, sanitized 470 of 470. Island diagnostics 10 passed.worker.shareEnv(Support the Worker env option and worker_threads.SHARE_ENV #3023, merged ata7a0fd1734) is in neither throw list on main.On the train's tree before main's later merges (
68d82b6840, train + main558850c513), 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.cwas 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,a6d763a807against maina7a0fd1734: 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%).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) andscr_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.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 ofcall,applyandbind), so main's compiler builds it differently.Not run
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 ona6d763a807.🤖 Generated with Claude Code