Skip to content

Node islands: promises, errors and thenables keep Node's behaviour across the boundary - #3190

Closed
cramforce wants to merge 14 commits into
test/island-stress-lifecyclefrom
fix/island-promises-errors
Closed

cramforce wants to merge 14 commits into
test/island-stress-lifecyclefrom
fix/island-promises-errors

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #3123 (test/island-stress-lifecycle, merged in at f96f851); the diff against that branch is this PR. It includes one commit by the agent working on top of it (e496a03, the V8 Error of a native error is freed once the program and the island drop the error). The regenerated island value matrix is commit 12870c1.

Three ways promises and errors went wrong when they crossed between native code and a Node island, found by #3123's stress suite and decided there (pi-decisions, island promises = A, native errors in island code = A), plus one found since (members of an island Promise subclass).

What changes

An island promise that only island code handles is no longer reported as unhandled, and awaiting one takes Node's number of turns. try { await lib.all([lib.failing(), lib.other()]) } catch { … } printed the caught error and then Unhandled promise rejection, exit 1. The program's mirror of an island promise subscribed to the V8 promise when it crossed, so every island promise the program had seen had a native reaction, and the runtime reported the mirror. Now the mirror subscribes when native code first reacts to it, settles inside V8's reaction job, and runs the native reactions in that job (no microtask of their own). A typed conversion of a mirror (const p: Promise<number> = lib.f()) waits with it. V8 decides whether an island promise is unhandled: one island code handles is not reported, one nobody handles ends the program through Node's report as before.

A native error keeps its identity, class and fields through island frames. After lib.run(() => { throw original }), e === original was false, e instanceof ValidationError false and e.status undefined, and island code that caught a native error saw none of its fields, cause or errors. A native error now crosses by reference: one V8 Error per native error (an export; SCR_NV_ERROR carries the export id, bridge ABI 12), which is the native error again when it crosses back. Island code reads its own enumerable fields (copied onto the V8 Error at each crossing, where Object.keys, JSON.stringify and util.inspect look) and everything else (cause, an AggregateError's errors) through the export. The message, name, stack and code the V8 Error holds are brought up to date at each crossing too, so island code that keeps a native error sees what native code changed on it. A native AggregateError is V8's AggregateError.

An island object with a then method is a thenable to every resolution. #3123 fixed await. Promise.resolve(v), a combinator's entry, an async function's return v, an executor's resolve(v) and a reaction's result still answered the object. The Node island build of scr_async_dyn.c reads then in V8 and the runtime's own thenable job calls it; await uses the same job now (one mechanism instead of V8's adoption for one form). That also answers the review thread on #3123 at scr_node_island_handle.c:1224 (then read twice during adoption): the resolution reads then once and calls what it read, pinned by node-island-crossing-then-read-once (a getter that answers a function on the first read only: Node and the program both print the function of the first read 1). An executor that resolved its promise with an island thenable has resolved it: its later resolve, reject or throw changes nothing.

Members an island class adds to Promise reach the island object. class Request<T> extends Promise<T> { url; describe() } (an SDK's request promise) crossed as a plain promise: a read typed any answered undefined, a typed read threw, a method call threw. Reads, writes and calls through the package's declared types, and dynamic reads and in, now go to the island object; then, catch and finally stay the program's promise.

Where the code is

Island-only: scr_node_island_handle.c, scr_node_island_export.c, scr_node_island.c, the bridge, and #ifdef SCR_NODE_ISLANDS blocks in scr_async.c, scr_json.c and scr_async_dyn.c. scr_async_dyn.c gets Node island builds beside its existing ones (the pack matrix test covers the selection); every other program keeps the objects it had. ScrPromise keeps its size: the lazy flag takes padding, and a pending lazy view keeps its source in the payload words. The compiler changes are the bridge ABI pin, one library function (nodeIsland.promiseResolve) and four lines in the existing island-thenable pass, which only runs for programs with islands.

Not in this PR (each with a repro, in the fleet's follow-up list)

  • Several native reactions on one island promise run at the first one's position: p.then(n1); lib.then(p, i2); p.then(n3) runs n1, n3, i2 (Node: n1, i2, n3). One native reaction, or native reactions with no island reaction between them, keep Node's order.
  • Island code writing a field of a native error does not reach the native error.
  • Methods and constructor of a program error class are not visible to island code (typeof e.retryable is undefined); fields, cause, errors, instanceof are right. This is the remaining difference in the stress programs r02 and d02.
  • A dynamic method call or write on a member of an island Promise subclass in untyped code is the TypeError it was.
  • In a program without islands, a thenable held in a dynamic value is still not adopted by Promise.resolve, an executor's resolve, an async return or a reaction's result in JavaScript source (on main; not island-specific).

Proof

Linux x64 Sandboxes (Intel Xeon 2.90 GHz), Node 24.22.1, a libnode pack built from this branch's bridge (ABI 12). Head def1687, base f96f851.

  • Full round on the head (zero-cost gate with --gate-corpus=all, lanes, native bootstraps): gate PASS, 5,242 programs identical, 142 both-fail (the Node island programs, which the gate builds without a pack on both sides), 0 differing rows, 0 runtime-only rows; bench geomean +0.36% [95% CI -0.57%, +1.10%], self-compile -0.76% [-1.75%, +0.97%], fixtures -0.25% [-1.44%, +0.95%]. Lanes: plain 22,110 passed, 0 failed; sanitized 18,875 passed, 0 failed (90 jobs), including the plain and the sanitized native bootstrap lanes.
  • Stress driver (Node islands: fix error properties, process.argv, thenable awaits and work after 'exit'; pin lifecycle behaviour with 40 corpus programs #3123's differential driver: each program compiled once, run under Node and natively, 3 runs per side plain and 2 sanitized). Of Node islands: fix error properties, process.argv, thenable awaits and work after 'exit'; pin lifecycle behaviour with 40 corpus programs #3123's 76 programs, 43 matched on its head and 46 match here (b03-await-ticks, r03-await-island-thenable, r11-await-island-promise-tick), and no program that matched stopped matching. The other repros of these bugs are down to the remainders listed above: b06-combinators no longer ends in an unhandled rejection and differs in one line (instanceof AggregateError of an island error, on Node islands: fix error properties, process.argv, thenable awaits and work after 'exit'; pin lifecycle behaviour with 40 corpus programs #3123's list); r02-native-error-through-island differs in one line and d02-native-throws in six of 70 (methods and constructor of a program error class). With the programs written for this PR, 62 of 95 match, plain and sanitized.
  • New corpus programs. 14 tests/corpus/node-island-crossing-* (JavaScript and TypeScript, three with a typed island package): await order across the boundary at each level; combinators run by each side over promises of both sides; rejections handled by one side only; an unhandled island rejection (three exit-1 programs); error identity, class, fields, cause, errors, stack and later changes after one and two round trips in both directions; every thenable form with a then that resolves, rejects, throws, resolves then throws, has a throwing getter, never settles or is not callable, and an executor's first resolution; then read once; the Promise subclass members; a lock taken from an island package and released in finally. All node-island corpus programs pass differential.test.ts and llvm-differential.test.ts, plain and SCRIPTC_SAN=1 (276 cases per flavor). The sanitized lane is the leak audit: it caught a typed view of an island promise that is never awaited keeping the mirror, fixed in 97be309.
  • Island value matrix (main's Pin how values cross a Node island boundary, and fix seven observations that differed from Node #3161), regenerated in 12870c1 and reviewed cell by cell: 57 pinned differences are gone and 26 cells list fewer; the 32 new cells are all in sections that used to end in exit before they were reached, and are named refusals, island-write cells that already existed for the other direction, or the native tier's own answers. Four cells change kind, n2i2n/U.clone: unsupported -> wrong in the untyped error kinds: structuredClone of a native error after a round trip is now the native tier's own answer for an Error, which the control rows pin as wrong with no island. No cell went from a match to a difference.
  • The self-build. A first version of the pass change replaced an IR node in place through a Record<string, unknown> view. The gate did not see it; the native bootstrap lanes did, as time limits: in the compiler compiled by itself every IR node type became a dynamic record, the LLVM input doubled (2.06 GB against 1.03 GB) and the bootstrap's seed build took 1,926 s against the base's 767 s (one 32 vCPU machine, each build alone). def1687 uses the typed IR rewrite instead: 751 s, 1.03 GB, the base's profile match (81.9% against 82.0%).
  • pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only: clean. pnpm manifest leaves surface-manifest.json unchanged.

🤖 Generated with Claude Code

cramforce and others added 2 commits November 6, 2026 20:45
…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>
…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>
@vercel

vercel Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Nov 7, 2026 2:52pm UTC

Comment thread packages/runtime/src/scr_async.c Resolved
cramforce and others added 2 commits November 6, 2026 22:30
…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>
@cramforce
cramforce marked this pull request as draft November 7, 2026 06:34
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>
…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>
cramforce and others added 3 commits November 7, 2026 00:28
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>
Awaiting an island promise that is an instance of a Promise subclass whose
constructor does not take an executor (the request promise of generated
API clients, which also starts its request in an overridden `then`)
rejected with `TypeError: Promise resolve or reject function is not
callable`. The bridge took every V8 promise as itself and called the
intrinsic `then` on it, which constructs the result through the species
constructor, here the subclass, with an executor it ignores; the
subclass's own `then` never ran.

`await` takes a promise as itself only when its `constructor` is the
intrinsic Promise. Anything else is resolved like a thenable: its own
`then` runs from a promise job. The bridge now follows that rule.

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

The island operations table takes main's object_tag before this branch's
promise_get and promise_has.

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
#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>
cramforce added a commit that referenced this pull request Nov 7, 2026
…te through the dynamic set

#3190's merged head changes an error's message, name and code natively
after island code kept the error (node-island-crossing-error-identity),
and the island read the old message and name. A native error's view holds
those three as of the last time it was built or written through; typed
code writes the error's own slots. The export now answers message, name
and cause, and whether each is an own property, from the error itself, for
every native error and not only for a class instance, and leaves an
accessor of the view (a message computed on demand) to the view.

An island write went through Reflect.set followed by a commit of the
whole view, which would have overwritten a slot typed code had written
since the view was built. It now goes through the dynamic property write,
which stores name, message, stack, cause and code in the slots itself.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
…he error properties work

#3190's branch took main through #3123. The merge is clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The matrix pins every observation that is not a match with Node. With a
native error crossing by reference, 57 pinned differences are gone (its
identity in island code and after a round trip, cause, an AggregateError's
errors, code, and the programs that used to exit in the round-trip
section) and 26 cells list fewer differences.

32 cells are new, all in sections that used to end in `exit` before they
were reached (the round trip of a typed error, an island async iterable);
they are named refusals, the island-write cells that already exist for the
other direction, and the native tier's own answers. Four cells change kind:
`structuredClone` of an untyped native error after a round trip was
`unsupported` for the island view it used to come back as and is now the
native tier's own answer for an Error, which the control rows pin as
`wrong` without any island.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The island-thenable pass replaced an intrinsic node in place by deleting
its keys through a `Record<string, unknown>` view and assigning the new
node's. In the compiler compiled by itself that view made every IR node
type a dynamic record: the native compiler's LLVM input doubled (2.06 GB
against 1.03 GB), 18,000 more of its functions lost their profile, and
the bootstrap's seed build took 1,926 s against the base's 767 s on one
32 vCPU machine, past the test's 1,800 s limit. The zero-cost gate does
not see it; the native bootstrap lanes did, as timeouts.

The pass now uses the typed rewrite (transformStmtList) on the functions
that have such a promise and leaves every other body alone. Same machine:
751 s, 1.03 GB, the base's profile match.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ite) into fix/island-promise-subclass

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce
cramforce marked this pull request as ready for review November 7, 2026 12:16
@cramforce

Copy link
Copy Markdown
Contributor Author

Superseded by #3377 (landing train islands-1), which carries this branch's commits merged with the other island boundary changes and current main, validated as one tree. Review of this change continues there; if #3377 does not land, this can be reopened.

🤖 Generated with Claude Code

@cramforce cramforce closed this Nov 8, 2026
espetro pushed a commit to espetro/scriptc that referenced this pull request Nov 8, 2026
…3309) into train/islands-1

ScrNiDynOps: main's function_identity and function_of_identity stay first;
promise_get, promise_has and promise_set follow at the end. The bridge ABI
stays at main's number here; the train bumps it once at the end.

Co-Authored-By: Claude Code <noreply@anthropic.com>
espetro pushed a commit to espetro/scriptc that referenced this pull request Nov 8, 2026
…lands-1

ScrNiDynOps: inspect and report_uncaught follow promise_get, promise_has and
promise_set at the end. scr_json.c builds once since vercel-labs#3135, so the five
island blocks that vercel-labs#3123, vercel-labs#3190 and main's Promise-subclass change keep under
SCR_NODE_ISLANDS would be compiled into no program: they lose the #ifdef and
test the installed table, as vercel-labs#3135 did for the blocks it met. vercel-labs#3123 replaced
error_constructor by error_get, so vercel-labs#3135's rewrite of that call is dropped.

Co-Authored-By: Claude Code <noreply@anthropic.com>
espetro pushed a commit to espetro/scriptc that referenced this pull request Nov 8, 2026
vercel-labs#3166 and vercel-labs#3190 each made an island promise's mirror subscribe to its V8
promise at the first native reaction. The train keeps one mechanism, vercel-labs#3190's
(scr_promise_mark_island_mirror, scr_promise_subscribe_fn, which also covers
typed views and settles inside V8's reaction job), and drops vercel-labs#3166's copy of
it (island_source, scr_promise_island_fn, SCR_PROMISE_REACTED); the subscriber
keeps vercel-labs#3166's guard for a released handle. The new library functions and the
events unit are added after main's and the other members' entries.

Co-Authored-By: Claude Code <noreply@anthropic.com>
espetro pushed a commit to espetro/scriptc that referenced this pull request Nov 8, 2026
vercel-labs#3123 (emit and stop), vercel-labs#3190 (an error's export id) and vercel-labs#3289 (an error's
class kind, export id and own properties) each changed the bridge and took
a number of their own (11, 12, 13) below or at main's 13. The train carries
all three under one number, so a libnode pack built from any other tree is
refused instead of half-working.

Co-Authored-By: Claude Code <noreply@anthropic.com>
espetro pushed a commit to espetro/scriptc that referenced this pull request Nov 8, 2026
The self-compile budget judged the compiler's own compile by its lowering
passes, its heap and its time. What that compile emits was not judged, and
it is the exact number: vercel-labs#2878 added 6.4% of the compiler's functions (9,464,
and 40% of its dynamic-tier functions) with 428 lines, because a memo made
the Lowerer a dynamic value, and vercel-labs#3190 doubled the compiler's LLVM the same
way. Neither showed in any gate row.

The gate already builds the candidate's compiler source and the baseline's
with the candidate compiler. It now compares the two LLVM texts: bytes and
functions may grow 1% beyond the growth of the source, and the functions
of the dynamic tier 5% (and 100). Across 102 pairs of commits of main from
2026-10-26 to 2026-11-07 the functions grew beyond the source by 0.51% at
the 95th percentile and 0.79% at most, except for vercel-labs#2878. A change that
means to grow them sets outputGrowthAllowance with a reason.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 9, 2026
…ercel-labs#3047)

The base is main 5b64e49 with train/islands-1 (712aeab) merged in
(689813c). Two files conflicted by text, scr_node_island_handle.c and
scr_node_island_export.c. The rest of this merge is by meaning: every
pinned lookup and island entry point the base added since this branch's
merge base was written for one thread and for lookups that return a
borrowed pointer, and this branch makes a lookup return +1 under the pin
lock and routes island operations from other threads.

Pinned lookups the base added, each now releasing the lookup's reference:
nh_promise_subscribe, the pinned-error identity check of nh_from_node,
the NH_PIN_NATIVE_ERROR lookups of nh_error_of and nh_to_node, nh_error_get,
nh_error_has, scr_ni_error_link (two lookups used as tests) and
nh_promise_object. nh_pinned_obj retains a NH_PIN_NATIVE_ERROR object like
the other kinds: it answered that kind without a reference, which its
caller then released.

One definition of nh_error_info: the read runs on Node's stack on the
island's thread for every caller (main's vercel-labs#3253), and a request from
another thread copies the bridge's strings where they are read (this
branch); the island's thread reads them in place, as on main.

nh_promise_box keeps the base's lazy subscription (vercel-labs#3190) and loses this
branch's eager one. A mirror stays the island thread's object: an island
promise is refused crossing into another thread before it is boxed.

Entry points the base added, on a thread other than the island's:
- a twin adoption (vercel-labs#3271) makes a native object and its export, which is
  the island thread's: the island object stays a handle there;
- a native error crosses by reference since vercel-labs#3190, as an export: refused
  by name like the thread's other native objects
  (ERR_SCRIPTC_ISLAND_WORKER_VALUE) instead of minting an export of
  another thread's object;
- the thenable adoption (vercel-labs#3190) answered "not a thenable" off the main
  thread, which fulfilled a promise with the thenable itself: the read of
  `then` is routed like every read and the thenable's job rejects by name;
- nh_error_get converts a routed answer and releases it (nh_routed_done).

Export table: a twin class's attach and detach run outside the table's
lock (they are a class's own code), and scr_ni_exports_release_all keeps
both sides' work.

The bridge is untouched by this branch, so the tree keeps the train's
ABI 19.

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

This branch was successfully deployed

1 active deployment
Preview — ac4e4537 Deployed Nov 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants