From f9bcb68bd1840e1656195c2cce0328595fa0d7c3 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 21:43:26 -0300 Subject: [PATCH 1/7] fix: pack the island rest binding on the dyn-boxed call path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An island-rest signature SPELLS its trailing engine-array parameter, so its params list already carries that jsval slot AND the type is marked rest. The dyn-boxed call thunk read both literally: it filled every param positionally — handing the trailing slot the first surplus ARGUMENT where the closure expects the pack — and then appended an extra dyn rest array the callee has no parameter for. So `const f = (...args) => args.length; f(1, 2)` in a --dynamic .js program threw "expected number, got undefined": a module-level arrow is a dyn global, so the call routes through this thunk rather than the direct path, and `args` was bound to the number 1. Every rest-forwarding and engine-value-through-rest idiom failed the same way (2568's very first call, 2590's Object.create prototype). The thunk now fills only the LEADING params positionally and builds the trailing slot with scr_jsval_rest_from_dyn — the surplus dyn arguments marshalled into one fresh engine array, the same pack the direct call builds inline (jsOp arrLit) and isl_hostfn_invoke builds for a closure entering the island as a host function. Fixed in the C and LLVM emitters; the Rust backend already sliced the trailing slot off correctly. 2859 pins the ABI minimally on all three lanes. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- .../src/backend/emission/emit-walkers.ts | 30 ++++++++++++---- packages/compiler/src/backend/llvm/dyn.ts | 36 ++++++++++++++++--- packages/runtime/src/scr_island.c | 22 ++++++++++++ packages/runtime/src/scr_runtime.h | 6 ++++ tests/corpus/2859-island-rest-boxed-call.js | 31 ++++++++++++++++ 5 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 tests/corpus/2859-island-rest-boxed-call.js diff --git a/packages/compiler/src/backend/emission/emit-walkers.ts b/packages/compiler/src/backend/emission/emit-walkers.ts index 95ef55e12..bc088e7ff 100644 --- a/packages/compiler/src/backend/emission/emit-walkers.ts +++ b/packages/compiler/src/backend/emission/emit-walkers.ts @@ -1755,9 +1755,16 @@ export function jsonWriteHelper(E: CEmitter, t: IrType): string { const sig = `static ScrDyn *${name}(ScrClosure *c, ScrDyn *const *args, size_t argc)`; E.walkerProtos.push(`${sig}; /* dyn call thunk for ${key} */`); const d: string[] = [`${sig} { /* dyn call thunk for ${key} */`]; - if (t.params.length === 0) d.push(` (void)args;`); + // An ISLAND-REST signature (restAbi jsval) SPELLS its trailing + // engine-array param, so only the LEADING params fill positionally — + // the last slot IS the pack and there is no extra dyn rest argument. + // Filling it from args[params.length - 1] instead would hand the + // closure the first surplus ARGUMENT where it expects the array. + const islandRest = t.rest === true && t.restAbi === "jsval"; + const fixed = islandRest ? t.params.slice(0, -1) : t.params; + if (fixed.length === 0 && !t.rest) d.push(` (void)args;`); d.push(` (void)argc;`); - t.params.forEach((p, i) => { + fixed.forEach((p, i) => { // JS arity: a missing argument IS the undefined dyn value; the // param's own check decides whether that flies (dyn params take // anything; a number param throws the catchable TypeError). @@ -1772,14 +1779,14 @@ export function jsonWriteHelper(E: CEmitter, t: IrType): string { // functions cross through the host shim; a kind with no crossing // throws the catchable TypeError (NULL + pending). d.push(` a${i} = scr_jsval_from_dyn(ad);`); - const undo = t.params + const undo = fixed .slice(0, i) .flatMap((q, j) => (isRefCounted(q) ? [`${releaseCallC(q, `a${j}`)};`] : [])); d.push(` if (!a${i}) { ${undo.join(" ")}${undo.length > 0 ? " " : ""}return NULL; }`); } else { d.push(` ScrDynPath pp = { NULL, NULL, ${i} };`); d.push(` a${i} = ${E.dynCheckHelper(p)}(ad, &pp);`); - const undo = t.params + const undo = fixed .slice(0, i) .flatMap((q, j) => (isRefCounted(q) ? [`${releaseCallC(q, `a${j}`)};`] : [])); d.push(` if (scr_exc_pending()) { ${undo.join(" ")}${undo.length > 0 ? " " : ""}return NULL; }`); @@ -1790,7 +1797,15 @@ export function jsonWriteHelper(E: CEmitter, t: IrType): string { // param carries the call's arguments from index params.length on — // the mustCall wrapper's `arguments`, a JS `...args`. Built fresh per // call (+1, moved into the callee like every param). - if (t.rest) { + if (islandRest) { + // The trailing jsval slot: the surplus dyn arguments marshalled into + // one fresh ENGINE array (+1, moved into the callee) — the same pack + // the direct call builds inline and the host-call adapter builds for + // a closure entering the island. + const undo = fixed.flatMap((q, j) => (isRefCounted(q) ? [`${releaseCallC(q, `a${j}`)};`] : [])); + d.push(` ScrJsval *rest = scr_jsval_rest_from_dyn(args, ${fixed.length}, argc);`); + d.push(` if (!rest) { ${undo.join(" ")}${undo.length > 0 ? " " : ""}return NULL; }`); + } else if (t.rest) { d.push(` ScrDyn *rest = scr_dyn_new_arr();`); d.push(` for (size_t ri = ${t.params.length}; ri < argc; ri++) {`); d.push(` scr_dyn_arr_push(rest, scr_dyn_retain((ScrDyn *)args[ri]));`); @@ -1798,8 +1813,9 @@ export function jsonWriteHelper(E: CEmitter, t: IrType): string { } // The closure CONSUMES its params (+1 each moved in — exactly what the // builders above returned). - const castParams = ["ScrClosure *", ...t.params.map((p) => cType(p).trim()), ...(t.rest ? ["ScrDyn *"] : [])].join(", "); - const call = `((${cType(t.ret).trim()} (*)(${castParams}))c->fn)(${["c", ...t.params.map((_, i) => `a${i}`), ...(t.rest ? ["rest"] : [])].join(", ")})`; + const restCType = islandRest ? cType(t.params[t.params.length - 1]!).trim() : "ScrDyn *"; + const castParams = ["ScrClosure *", ...fixed.map((p) => cType(p).trim()), ...(t.rest ? [restCType] : [])].join(", "); + const call = `((${cType(t.ret).trim()} (*)(${castParams}))c->fn)(${["c", ...fixed.map((_, i) => `a${i}`), ...(t.rest ? ["rest"] : [])].join(", ")})`; if (t.ret.kind === "void") { d.push(` ${call};`); d.push(` if (scr_exc_pending()) return NULL;`); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index bb4a5b50b..d71d6623c 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -2843,7 +2843,12 @@ export class LlDyn { const host = this.host; const B = new BlockBuilder(); const argNames: string[] = []; - t.params.forEach((p, i) => { + // An ISLAND-REST signature (restAbi jsval) SPELLS its trailing + // engine-array param, so only the LEADING params fill positionally — + // the last slot IS the pack and there is no extra dyn rest argument. + const islandRest = t.rest === true && t.restAbi === "jsval"; + const fixed = islandRest ? t.params.slice(0, -1) : t.params; + fixed.forEach((p, i) => { // JS arity: a missing argument IS the undefined dyn value. const adSlot = B.slot(); B.entryAllocas.push(`${adSlot} = alloca ptr`); @@ -2883,7 +2888,7 @@ export class LlDyn { const lOk = B.newLabel("dfk.jo"); B.condBr(isNull, lFail, lOk); B.startBlock(lFail); - t.params.slice(0, i).forEach((q, j) => { + fixed.slice(0, i).forEach((q, j) => { if (isRefCounted(q)) B.line(`call void ${releaseSym(host, q)}(ptr ${argNames[j]})`); }); B.terminate(`ret ptr null`); @@ -2904,7 +2909,7 @@ export class LlDyn { const a = B.tmp(); B.line(`${a} = call ${this.valTy(p)} @${this.dynCheckHelper(p)}(ptr ${ad}, ptr ${pathSlot})`); this.pendingBail(B, "dfk", () => { - t.params.slice(0, i).forEach((q, j) => { + fixed.slice(0, i).forEach((q, j) => { if (isRefCounted(q)) B.line(`call void ${releaseSym(host, q)}(ptr ${argNames[j]})`); }); }, "ptr null"); @@ -2914,7 +2919,28 @@ export class LlDyn { // VARIADIC (rest-marked) signatures: one extra trailing dyn-array // param carries the call's arguments from index params.length on. let rest: string | null = null; - if (t.rest) { + if (islandRest) { + // The trailing jsval slot: the surplus dyn arguments marshalled into + // one fresh ENGINE array (+1, moved into the callee) — the same pack + // the direct call builds inline and the host-call adapter builds for + // a closure entering the island. + host.declare(`declare ptr @scr_jsval_rest_from_dyn(ptr, ${host.sizeType}, ${host.sizeType})`); + rest = B.tmp(); + B.line( + `${rest} = call ptr @scr_jsval_rest_from_dyn(ptr %args, ${host.sizeType} ${fixed.length}, ${host.sizeType} %argc)`, + ); + const isNull = B.tmp(); + B.line(`${isNull} = icmp eq ptr ${rest}, null`); + const lFail = B.newLabel("dfk.rf"); + const lOk = B.newLabel("dfk.ro"); + B.condBr(isNull, lFail, lOk); + B.startBlock(lFail); + fixed.forEach((q, j) => { + if (isRefCounted(q)) B.line(`call void ${releaseSym(host, q)}(ptr ${argNames[j]})`); + }); + B.terminate(`ret ptr null`); + B.startBlock(lOk); + } else if (t.rest) { host.declare(`declare ptr @scr_dyn_new_arr()`); host.declare(`declare void @scr_dyn_arr_push(ptr, ptr)`); rest = B.tmp(); @@ -2953,7 +2979,7 @@ export class LlDyn { const retTy = t.ret.kind === "void" ? "void" : this.valTy(t.ret); const callArgs = [ `ptr %c`, - ...t.params.map((p, i) => `${this.valTy(p)} ${argNames[i]}`), + ...fixed.map((p, i) => `${this.valTy(p)} ${argNames[i]}`), ...(rest !== null ? [`ptr ${rest}`] : []), ].join(", "); if (t.ret.kind === "void") { diff --git a/packages/runtime/src/scr_island.c b/packages/runtime/src/scr_island.c index 546fdab92..46c2371e0 100644 --- a/packages/runtime/src/scr_island.c +++ b/packages/runtime/src/scr_island.c @@ -2340,6 +2340,28 @@ ScrJsval *scr_jsval_arr_lit(int n, ScrJsval **elems) { return isl_cell_new(a); } +/* The ISLAND-REST pack a DYN-BOXED closure's call thunk hands its + * trailing jsval slot: the surplus dyn arguments (index `from` on) + * marshalled into one fresh ENGINE array, so the closure's `...args` + * binding is the engine's own array on this path too — the same shape + * isl_hostfn_invoke builds for the host-call path and the direct call + * builds inline from an arrLit. NULL with the exception pending when an + * argument has no crossing (scr_jsval_from_dyn's refusal). */ +ScrJsval *scr_jsval_rest_from_dyn(ScrDyn *const *args, size_t from, size_t argc) { + isl_entry(); + JSValue a = JS_NewArray(isl_ctx); + for (size_t i = from; i < argc; i++) { + ScrJsval *cell = scr_jsval_from_dyn(args[i]); + if (!cell) { + JS_FreeValue(isl_ctx, a); + return NULL; + } + JS_SetPropertyUint32(isl_ctx, a, (uint32_t)(i - from), JS_DupValue(isl_ctx, cell->v)); + scr_jsval_release(cell); + } + return isl_cell_new(a); +} + /* ── the module system (embedded npm code) ──────────────────────────── * The engine's module loader and a CommonJS require shim, both resolving * exclusively from the emitted tables (isl_mods/isl_edges — no filesystem). diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index ad70d56e4..3d7384542 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -4678,6 +4678,12 @@ ScrJsval *scr_jsval_tpl_strings(int n, ScrJsval **kv); ScrJsval *scr_jsval_obj_spread(ScrJsval *obj, ScrJsval *src); ScrJsval *scr_jsval_arr_lit(int n, ScrJsval **elems); +/* The ISLAND-REST pack a dyn-boxed closure's call thunk hands its trailing + * jsval slot: the surplus dyn arguments (index `from` on) marshalled into + * one fresh ENGINE array (+1). NULL + pending when an argument has no + * crossing. */ +ScrJsval *scr_jsval_rest_from_dyn(ScrDyn *const *args, size_t from, size_t argc); + /* Marshal out (island → static): validated, STRICT extraction — a * non-number refuses to exit as number (no coercion), throwing a * catchable path-less TypeError like the dynCheck walkers'. Composite diff --git a/tests/corpus/2859-island-rest-boxed-call.js b/tests/corpus/2859-island-rest-boxed-call.js new file mode 100644 index 000000000..2ff780bda --- /dev/null +++ b/tests/corpus/2859-island-rest-boxed-call.js @@ -0,0 +1,31 @@ +// @dynamic +// A dyn-BOXED island-rest closure called through the dyn boundary. The +// `...args` binding must be the ENGINE's own array on every path that +// reaches the closure — the direct call, a closure entering the island as +// a host function, and (this program) the boxed call thunk. +// +// A module-level `const f = (...args) =>` in a .js program is stored as a +// dyn global, so `f(1, 2)` routes through that thunk. The thunk used to +// fill the signature's trailing jsval slot POSITIONALLY, handing the +// closure the first surplus argument where it expects the pack — so +// `args.length` read a number's missing property — and then passed an +// extra dyn array the callee has no parameter for. +"use strict"; + +const rest = (...args) => `${args.length}:${args.join(",")}`; +console.log(rest()); +console.log(rest(1)); +console.log(rest(1, 2, 3)); + +// Leading declared params keep filling positionally; the pack is the tail +// only, and a short call pads the declared slots with undefined. +const lead = (a, b, ...args) => `${a}|${b}|${args.length}:${args.join(",")}`; +console.log(lead(1, 2)); +console.log(lead(1, 2, 3, 4)); +console.log(lead(1)); + +// A composite surplus argument crosses the boundary as an engine value. +const first = (...args) => args[0]; +console.log(JSON.stringify(first({ x: 1 }))); + +console.log("done"); From 3deb9d069353f60dd0bd76b6182f1531610e3733 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 20:07:08 -0300 Subject: [PATCH 2/7] fix: emit island optional chains that answer a static union in LLVM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LLVM emitter's jsval-receiver optChain arm handled only a void body and an engine-valued result, and threw an InternalCompilerError for anything else. A chain step that lands back in the STATIC world — `flatValue(text, key)?.trim()`, a package's optional string through an island handle — answers `string | undefined`, so 2716 could not compile at all on the LLVM lane. Give the arm the same two shapes the C emitter already has: an engine result takes the engine's undefined cell, a union result takes that union's interned undefined arm. An unmodelled result kind now raises LlvmUnsupportedError (a backend-coverage fence) rather than claiming an emitter bug. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- packages/compiler/src/backend/llvm/emitter.ts | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 150532440..8483d7eff 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5818,18 +5818,36 @@ class LlEmitter { B.startBlock(lj); return { name: "", type: e.type }; } - if (e.type.kind !== "jsval") throw new InternalCompilerError("llvm emitter bug: jsval optChain result kind"); + // The result is the ENGINE's undefined only when the chain itself + // answers an engine value. A step that lands back in the static + // world (`handle?.trim()` over a package's optional string) is a + // UNION, and its unit path is that union's interned undefined + // arm — the C emitter's two-arm shape. + if (e.type.kind !== "jsval" && e.type.kind !== "union") { + throw new LlvmUnsupportedError(`optChainResult:${e.type.kind}`, e.loc); + } + const jty = this.llType(e.type); const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ptr`); + B.entryAllocas.push(`${slot} = alloca ${jty}`); const lu = B.newLabel("ocj.u"); const lb = B.newLabel("ocj.b"); const lj = B.newLabel("ocj.j"); B.condBr(isN, lu, lb); B.startBlock(lu); - this.declare(`declare ptr @scr_jsval_undefined()`); - const un = B.tmp(); - B.line(`${un} = call ptr @scr_jsval_undefined()`); - B.line(`store ptr ${un}, ptr ${slot}`); + if (e.type.kind === "jsval") { + this.declare(`declare ptr @scr_jsval_undefined()`); + const un = B.tmp(); + B.line(`${un} = call ptr @scr_jsval_undefined()`); + B.line(`store ptr ${un}, ptr ${slot}`); + } else { + const undefTag = undefinedArmTag(e.type, this.unionsById); + if (undefTag < 0) { + throw new InternalCompilerError( + "llvm emitter bug: jsval optChain result lacks its undefined arm", + ); + } + B.line(`store ptr ${this.unitInstanceRef(e.type.unionId, undefTag)}, ptr ${slot}`); + } B.br(lj); B.startBlock(lb); const rr = B.tmp(); @@ -5841,7 +5859,7 @@ class LlEmitter { B.br(lj); B.startBlock(lj); const t = B.tmp(); - B.line(`${t} = load ptr, ptr ${slot}`); + B.line(`${t} = load ${jty}, ptr ${slot}`); return this.own({ name: t, type: e.type }); } if (e.receiver.type.kind !== "union") throw new LlvmUnsupportedError(`optChain:${e.receiver.type.kind}`, e.loc); From 78253cc58dd2b583fd0b1ded154c964d7dc75e35 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 20:10:42 -0300 Subject: [PATCH 3/7] fix: await a dyn finally callback's promise and let it replace the outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.finally` over a checked-dynamic promise dropped its callback's result outright — a documented shortcut ("a finally callback returning a promise would delay adoption; that refinement waits for a use"). Two things followed in 2210: - A cleanup promise that REJECTED never reached the chain. The rejection escaped the reaction fiber entirely and the binary died reporting an unhandled rejection, where JS replaces the source settlement with it — so `.finally(() => cleanupFails()).catch(...)` never fired. - Not awaiting a cleanup that FULFILLS also settled the chain too early, so `finally kept 7` overtook a longer chain's `finally ran`. The ordering was a symptom of the same missing await, and falls out with it. The reaction now walks a promise result the way the .then arm already does: a cleanup rejection rejects dst (dropping the source's caught record), a fulfillment is discarded and the source settlement passes through, and a non-thenable result behaves exactly as before. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- packages/runtime/src/scr_async_dyn.c | 32 +++++++++++++++++++++------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 3e392afb9..d000bd812 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -358,15 +358,31 @@ static void scr_dyn_then_entry(ScrFiber *self, void *ap) { /* The handler threw: dst rejects with that. */ scr_promise_reject_pending(a->dst); } else if (a->onfin != NULL) { - /* finally: the callback's value is dropped and the source - * settlement passes through (JS — a finally callback returning a - * promise would delay adoption; that refinement waits for a use). */ + /* finally: a callback returning a PROMISE delays the settlement — + * JS awaits it before the chain continues, and its REJECTION + * REPLACES the source outcome (a source rejection included, whose + * caught record is then dropped). A cleanup FULFILLMENT is + * discarded and the source settlement passes through, which is also + * the non-thenable case. */ + bool replaced = false; + while (r != NULL && r->kind == SCR_DYN_PROMISE) { + ScrDyn *inner = scr_await_dyn(r->v.promise); + scr_dyn_release(r); + r = inner; /* NULL with the cleanup rejection re-thrown */ + if (scr_exc_pending()) { + scr_promise_reject_pending(a->dst); + replaced = true; + break; + } + } scr_dyn_release(r); - if (rejected) { - scr_rethrow(c); - scr_promise_reject_pending(a->dst); - } else { - scr_promise_fulfill_ref(a->dst, scr_dyn_retain(v), scr_dyn_retain_v, scr_dyn_release_v, NULL); + if (!replaced) { + if (rejected) { + scr_rethrow(c); + scr_promise_reject_pending(a->dst); + } else { + scr_promise_fulfill_ref(a->dst, scr_dyn_retain(v), scr_dyn_retain_v, scr_dyn_release_v, NULL); + } } } else { /* Adopt dyn-promise results (JS's resolve walk). */ From d15d4cbd12f62b969129e127beb198ef5a8769d3 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 20:33:07 -0300 Subject: [PATCH 4/7] fix: match Node in the island's URLSearchParams and base64 globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent divergences in the island's own web globals, all read by corpus 1120 through __island_eval against Node's real implementations. Astral percent-encoding. formDecode walked the raw query by UTF-16 CODE UNIT and handed each one to TextEncoder, so a non-BMP character arrived as a lone high surrogate then a lone low surrogate and became two U+FFFD before ever reaching toString() — `new URLSearchParams('x=')` serialized as %EF%BF%BD%EF%BF%BD instead of %F0%9F%98%80. The literal branch now pairs a high surrogate with its low surrogate; a genuinely lone surrogate still replaces. (The sequence-init path was already correct, which is why only the parsed spelling failed.) Pair-iteration liveness. entries/keys/values were generators over this._pairs and forEach iterated a slice() — both snapshots. WebIDL pair iteration is LIVE: it holds the object plus a positional index and re-reads the current list each step, so appending from a forEach callback re-enters for the new tail, a mid-iteration delete skips forward over the hole, and a mid-iteration sort can re-yield a pair that moved past the cursor. All four of 1120's mutation ladders were wrong, forEach included. btoa/atob rejections. The prelude's own invalidChar built a plain Error and stamped .name, leaving .code undefined and `instanceof DOMException` false where Node answers InvalidCharacterError with the legacy code 5. It now throws the DOMException the same prelude already defines, matching the static tier's scr_btoa/scr_atob. The Rust island twin (island_web.js) already carried the surrogate pairing and the DOMException; it gets the liveness fix so both islands stay behaviourally identical. No vendored file is touched. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- packages/runtime-rust/src/island_web.js | 45 ++++++++++++++++--- packages/runtime/src/scr_web.c | 58 ++++++++++++++++++++----- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/packages/runtime-rust/src/island_web.js b/packages/runtime-rust/src/island_web.js index 7897a0791..c98b4916d 100644 --- a/packages/runtime-rust/src/island_web.js +++ b/packages/runtime-rust/src/island_web.js @@ -332,20 +332,51 @@ .join("&"); } + /* WebIDL pair-iterable iteration is LIVE: forEach and the + * entries/keys/values iterators hold the params object plus a + * positional index and re-read the CURRENT list on every step — they + * do NOT snapshot. So a callback that appends is re-entered for the + * new tail, a delete() mid-iteration makes the iterator skip forward + * over the hole, and a sort() mid-iteration can re-yield a pair that + * moved past the cursor. Oracle-pinned by corpus 1120 lines 32-35 + * against Node, and the exact twin of scr_web.c's copy. */ forEach(callback, thisArg) { - for (const [key, value] of this._pairs) callback.call(thisArg, value, key, this); + for (let index = 0; index < this._pairs.length; index += 1) { + const [key, value] = this._pairs[index]; + callback.call(thisArg, value, key, this); + } + } + + _iterate(kind) { + const params = this; + let index = 0; + const iterator = { + next() { + if (index >= params._pairs.length) return { value: undefined, done: true }; + const [key, value] = params._pairs[index]; + index += 1; + return { + value: kind === "key" ? key : kind === "value" ? value : [key, value], + done: false, + }; + }, + [Symbol.iterator]() { + return iterator; + }, + }; + return iterator; } - *entries() { - for (const [key, value] of this._pairs) yield [key, value]; + entries() { + return this._iterate("key+value"); } - *keys() { - for (const [key] of this._pairs) yield key; + keys() { + return this._iterate("key"); } - *values() { - for (const [, value] of this._pairs) yield value; + values() { + return this._iterate("value"); } [Symbol.iterator]() { diff --git a/packages/runtime/src/scr_web.c b/packages/runtime/src/scr_web.c index 519ca143c..835181741 100644 --- a/packages/runtime/src/scr_web.c +++ b/packages/runtime/src/scr_web.c @@ -651,7 +651,16 @@ static const char web_prelude[] = " bytes.push(parseInt(s.slice(i + 1, i + 3), 16));\n" " i += 2;\n" " } else {\n" - " const enc = new TextEncoder().encode(ch);\n" + /* A literal (unescaped) char goes through utf-8. s[i] is a CODE UNIT, + * so an astral character would hand TextEncoder a lone high surrogate + * and come back as U+FFFD; pair it with its low surrogate first so + * 'x=' serializes back as %F0%9F%98%80 like Node. A genuinely + * lone surrogate still falls through to the U+FFFD replacement. */ + " const hi = s.charCodeAt(i);\n" + " const lo = i + 1 < s.length ? s.charCodeAt(i + 1) : 0;\n" + " let unit = ch;\n" + " if (hi >= 0xd800 && hi <= 0xdbff && lo >= 0xdc00 && lo <= 0xdfff) { unit = s.slice(i, i + 2); i++; }\n" + " const enc = new TextEncoder().encode(unit);\n" " for (let j = 0; j < enc.length; j++) bytes.push(enc[j]);\n" " }\n" " }\n" @@ -740,21 +749,50 @@ static const char web_prelude[] = " toString() {\n" " return this._pairs.map(([k, v]) => formEncode(k) + '=' + formEncode(v)).join('&');\n" " }\n" + /* WebIDL pair-iterable iteration is LIVE: forEach and the + * entries/keys/values iterators hold the params object plus a + * positional index and re-read the CURRENT list on every step — they + * do NOT snapshot. So a callback that appends is re-entered for the + * new tail, a delete() mid-iteration makes the iterator skip forward + * over the hole, and a sort() mid-iteration can re-yield a pair that + * moved past the cursor. Oracle-pinned by corpus 1120 lines 32-35 + * against Node (a snapshot answers 'a1|b2' where Node answers + * 'a1|b2|c3'), so keep the index-based reads. */ " forEach(fn, thisArg) {\n" - " for (const [k, v] of this._pairs.slice()) fn.call(thisArg, v, k, this);\n" + " for (let i = 0; i < this._pairs.length; i++) {\n" + " const [k, v] = this._pairs[i];\n" + " fn.call(thisArg, v, k, this);\n" + " }\n" + " }\n" + " _iterate(kind) {\n" + " const params = this;\n" + " let i = 0;\n" + " const it = {\n" + " next() {\n" + " if (i >= params._pairs.length) return { value: undefined, done: true };\n" + " const [k, v] = params._pairs[i++];\n" + " return { value: kind === 'key' ? k : kind === 'value' ? v : [k, v], done: false };\n" + " },\n" + " [Symbol.iterator]() { return it; },\n" + " };\n" + " return it;\n" " }\n" - " *entries() { for (const [k, v] of this._pairs) yield [k, v]; }\n" - " *keys() { for (const [k] of this._pairs) yield k; }\n" - " *values() { for (const [, v] of this._pairs) yield v; }\n" + " entries() { return this._iterate('key+value'); }\n" + " keys() { return this._iterate('key'); }\n" + " values() { return this._iterate('value'); }\n" " [Symbol.iterator]() { return this.entries(); }\n" " }\n" "\n" " const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';\n" - " const invalidChar = (op) => {\n" - " const e = new Error(\"Invalid character\");\n" - " e.name = 'InvalidCharacterError';\n" - " return e;\n" - " };\n" + /* btoa/atob reject with a DOMException, not a plain Error: Node hands + * back InvalidCharacterError with the legacy .code 5, and corpus 1120 + * reads error.constructor.name / .code / instanceof DOMException. The + * DOMException class is declared further down this same prelude scope; + * it is initialized long before any user code can call btoa/atob, so + * the forward reference is safe. The static (non-island) tier's + * scr_btoa/scr_atob already throw the same shape — keep both tiers + * observably identical. */ + " const invalidChar = (op) => new DOMException('Invalid character', 'InvalidCharacterError');\n" " const btoa = (data) => {\n" " const s = String(data);\n" " let out = '';\n" From d89a77936145c542a5444d9b90b3b10ff0b61fe4 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 19:44:50 -0300 Subject: [PATCH 5/7] test: pin the island receiver rejection by error type, not V8 wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2084 printed the message of a TypeError thrown INSIDE the island engine. quickjs-ng words its engine-internal errors its own way ("not a number" where V8 writes "Number.prototype.toFixed requires that 'this' be a Number"), so that text can never match the Node oracle. It is also not ours to align: the vendored engine is an unmodified upstream snapshot by policy, and its prebuilt libqjs.a is cached by upstream commit, so a local edit to quickjs.c would not even key the cache correctly. Pin the error TYPE instead, which is the actual contract — the receiver rules reject identically on both sides. Documented in the dynamic-tier limits: match on error type, not message text. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- docs/src/app/limitations/page.mdx | 1 + tests/corpus/2084-destructuring-primitive-sources.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index 8307f8b3d..24291c1f1 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -80,6 +80,7 @@ const who = process.argv.length > 2 ? process.argv[2] : "world"; ## Dynamic-tier limits - **The island is quickjs-ng, not V8** — correct, but slower for CPU-bound dependency code. The win is startup, size, memory, deployment shape. +- **Engine-thrown error *messages* are quickjs-ng's, not V8's** — the error type and the throw itself match (a bad `Number.prototype.toFixed` receiver is a `TypeError` either way), but the wording is the engine's: `not a number` where V8 writes `Number.prototype.toFixed requires that 'this' be a Number`. The vendored engine is an unmodified upstream snapshot and its prebuilt archive is cached by upstream commit, so this text is deliberately not patched. Match on error type, not message text. - **The island's Node builtins are shims** — reimplementations, reported per-builtin in the coverage report, not the real modules. - **Island microtask interleaving**: static fibers drain first, then the engine's jobs at loop quiescence — a static `await` racing a package promise resolves in a documented, deterministic order that can differ from Node's interleaving. - **Top-level `await` in embedded ESM packages** is not supported yet. It does compile in your program's own ESM graph and in npm packages compiled through `--npm-static`; the remaining limit is package code running inside the `--dynamic` island. diff --git a/tests/corpus/2084-destructuring-primitive-sources.ts b/tests/corpus/2084-destructuring-primitive-sources.ts index a20b14c06..2b18be59d 100644 --- a/tests/corpus/2084-destructuring-primitive-sources.ts +++ b/tests/corpus/2084-destructuring-primitive-sources.ts @@ -6,6 +6,14 @@ // members are the engine's own (an unbound prototype method behaves // exactly like Node's, .call receiver rules included); a static build // reports the SC2010 dynamic-family choice. +// +// The receiver rejection below pins the error TYPE, not its message: the +// throw comes from inside the island engine, and quickjs-ng's wording for +// an engine-internal TypeError is its own, not V8's ("not a number" where +// V8 says "Number.prototype.toFixed requires that 'this' be a Number"). +// The vendored engine is an unmodified upstream snapshot by policy, and +// its prebuilt archive is cached by upstream commit, so that text is not +// ours to align — see packages/runtime/vendor/README.md. { let { toString } = 1; console.log(`${toString.call(9)}`); } { const { toString: toStringRadix } = 1; console.log(`${toStringRadix.call(15, 16)}`); } { @@ -14,7 +22,7 @@ try { toFixed.call("2.5", 1); } catch (error) { - console.log((error as Error).message); + console.log((error as Error).name); } } const { length } = "abc"; From 31b5940b1820e365c9c8ce1be667a921d41b8403 Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 21:46:18 -0300 Subject: [PATCH 6/7] test: pin the differential oracle to the compat matrix primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three differential lanes still resolved their oracle with nodeOracleExecutable(), which follows the HOST. node-matrix.ts landed the distinction they need and says so in its own docstring: a census follows the host, but a SEMANTIC oracle pins to the primary, because a compiled binary reproduces one Node's observable behavior and cannot reproduce two. On a Node 26 host that mismatch turned six corpus programs red for reasons that say nothing about the compiler: - 1746 and 2813 — read() with no size stopped concatenating the internal buffer in 26.0.0 (nodejs/node#60441, semver-major), and the async iterator inherits it. - 1640 — v26 validates `position` even when the read window is empty; v24 short-circuited first. - 2631 — builtinModules.length moved from 72 to 66. - 2599 — v26 dropped stream_base's 'buffer' encoding special case. - 1967 — v26 removed --experimental-transform-types, so the harness fell back to the tsc hook, which ELIDES an import= alias of an uninstantiated namespace where the native transform emitted `var P = T` and threw. All six pass unchanged against the primary. Pinning keeps their assertions intact rather than deleting the version-dependent half of each one, and SCRIPTC_NODE_ORACLE still overrides — which is how you go looking for these divergences deliberately instead of tripping over them. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- tests/harness/differential.test.ts | 16 +++++++++++++--- tests/harness/llvm-differential.test.ts | 9 ++++++--- tests/harness/rust-differential.test.ts | 9 ++++++--- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index 6a6cff5b8..4df88fe6b 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -16,8 +16,9 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; import ts5 from "typescript"; -import { compile } from "@scriptc/compiler"; -import { nodeOracleExecutable, nodeTransformTypesArgs, oracleCacheKeyBase } from "./oracle-environment.js"; +import { NODE_COMPAT_MATRIX, compile } from "@scriptc/compiler"; +import { nodeTransformTypesArgs, oracleCacheKeyBase } from "./oracle-environment.js"; +import { primaryOracleExecutable } from "./node-matrix.js"; import { shardSelect, shardSuffix } from "./shard.js"; import { DRIVER_FIXTURES } from "./driver-fixtures.js"; @@ -25,7 +26,16 @@ const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); const corpusDir = join(repoRoot, "tests/corpus"); const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); -const oracleExecutable = nodeOracleExecutable(); +// This is a SEMANTIC oracle, so it pins to the compat matrix's primary +// rather than following the host (node-matrix.ts): a compiled binary +// reproduces ONE Node's observable behavior and cannot reproduce two, so +// a corpus program compared against whichever major happens to be running +// reds on things that say nothing about the compiler — v26 reworded +// errors, dropped read()'s buffer concatenation (nodejs/node#60441), moved +// builtinModules' length, and removed the native type-transform this +// harness feeds @transform-types programs through. SCRIPTC_NODE_ORACLE +// still overrides, which is how you go LOOKING for those divergences. +const oracleExecutable = primaryOracleExecutable(NODE_COMPAT_MATRIX); // Flat single-file tests plus directory tests (/main. as the // entry with sibling modules). JavaScript entries (.js/.mjs/.cjs) are diff --git a/tests/harness/llvm-differential.test.ts b/tests/harness/llvm-differential.test.ts index 8e76c4d2c..18fe21387 100644 --- a/tests/harness/llvm-differential.test.ts +++ b/tests/harness/llvm-differential.test.ts @@ -23,16 +23,19 @@ import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { afterAll, describe, expect, test } from "vitest"; import ts5 from "typescript"; -import { compile } from "@scriptc/compiler"; +import { NODE_COMPAT_MATRIX, compile } from "@scriptc/compiler"; import { shardSelect, shardSuffix } from "./shard.js"; import { DRIVER_FIXTURES } from "./driver-fixtures.js"; -import { nodeOracleExecutable, nodeTransformTypesArgs } from "./oracle-environment.js"; +import { nodeTransformTypesArgs } from "./oracle-environment.js"; +import { primaryOracleExecutable } from "./node-matrix.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); const corpusDir = join(repoRoot, "tests/corpus"); const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); -const oracleExecutable = nodeOracleExecutable(); +// The SEMANTIC oracle pins to the compat matrix's primary, not the host — +// see the note in differential.test.ts. +const oracleExecutable = primaryOracleExecutable(NODE_COMPAT_MATRIX); // Same corpus, same SCRIPTC_TEST_SHARD slice as differential.test.ts (the // two files split identically, so a shard's compile cache serves both lanes). diff --git a/tests/harness/rust-differential.test.ts b/tests/harness/rust-differential.test.ts index b2fab94f5..31707e5ab 100644 --- a/tests/harness/rust-differential.test.ts +++ b/tests/harness/rust-differential.test.ts @@ -23,16 +23,19 @@ import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { afterAll, describe, expect, test } from "vitest"; import ts5 from "typescript"; -import { compile } from "@scriptc/compiler"; +import { NODE_COMPAT_MATRIX, compile } from "@scriptc/compiler"; import { shardSelect, shardSuffix } from "./shard.js"; import { DRIVER_FIXTURES } from "./driver-fixtures.js"; -import { nodeOracleExecutable, nodeTransformTypesArgs } from "./oracle-environment.js"; +import { nodeTransformTypesArgs } from "./oracle-environment.js"; +import { primaryOracleExecutable } from "./node-matrix.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); const corpusDir = join(repoRoot, "tests/corpus"); const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); -const oracleExecutable = nodeOracleExecutable(); +// The SEMANTIC oracle pins to the compat matrix's primary, not the host — +// see the note in differential.test.ts. +const oracleExecutable = primaryOracleExecutable(NODE_COMPAT_MATRIX); // Same corpus, same SCRIPTC_TEST_SHARD slice as differential.test.ts (the // three lanes split identically, so a shard's oracle work serves them all). From 427733b09182cfdc34d592d8274a9b807c51f4aa Mon Sep 17 00:00:00 2001 From: filipeforattini Date: Tue, 1 Sep 2026 21:47:31 -0300 Subject: [PATCH 7/7] fix: raise ERR_UNKNOWN_ENCODING for unknown socket write encodings socket.write(string, encoding) lowered only three shapes: the literal 'buffer' (Node's stream_base special case), the utf8 spellings, and a Buffer chunk that ignores the encoding. Every other literal encoding fell through to the "write with 2 arguments" fence, so a program Node answers with a plain TypeError failed to compile at all. An encoding Node does not know is its synchronous ERR_UNKNOWN_ENCODING, raised before anything is written. Known but not-yet-lowered spellings ('hex', 'base64', ...) keep the fence rather than silently writing the wrong bytes. 2599 gains a rung for it beside the existing 'buffer' one, which stays. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A --- .../compiler/src/frontend/lowering/lower-server.ts | 13 +++++++++++++ tests/corpus/2599-stream-arg-ladders.cjs | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index f24bea012..3af93bb84 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -26,6 +26,7 @@ import { TLS_SERVER_DOCUMENTED_OPTIONS, } from "./surfaces.js"; import { conditionalSpreadOf } from "./lower-exprs.js"; +import { knownBufEncoding } from "./lower-containers.js"; import { boolLit, numLit, strLit, varRef } from "../../ir/build.js"; const NARROW_DATA_HINT = @@ -1441,6 +1442,18 @@ function lowerNetSocketMethodCall(L: Lowerer, call: ts.CallExpression, const fn: IrLibFn = data2.type.kind === "string" ? "net.sockWrite" : "net.sockWriteBytes"; return { kind: "libCall", fn, args: [receiver2, data2], type: VOID, loc }; } + // A spelling Node does not know is its synchronous + // ERR_UNKNOWN_ENCODING TypeError, raised before anything is + // written. Only string chunks reach here: a Buffer chunk ignored + // the encoding in the passthrough above, exactly like Node. Known + // but not-yet-lowered spellings ('hex', 'base64', ...) keep the + // fence below rather than silently writing the wrong bytes. + if (chunkT.kind === "string" && knownBufEncoding(encT.value) === undefined) { + L.lowerExpr(args[0]!); // evaluation order (effect-free in practice) + return nodeThrowExpr( + 1, "ERR_UNKNOWN_ENCODING", `Unknown encoding: ${encT.value}`, VOID, loc, + ); + } } } const maxArgs = name === "write" ? 1 : 1; diff --git a/tests/corpus/2599-stream-arg-ladders.cjs b/tests/corpus/2599-stream-arg-ladders.cjs index 6297d203f..ef4281849 100644 --- a/tests/corpus/2599-stream-arg-ladders.cjs +++ b/tests/corpus/2599-stream-arg-ladders.cjs @@ -3,7 +3,8 @@ // watcher never registers), socket write's two-argument encoding form // implements Node's stream_base typecheck — write(string, 'buffer') is // the synchronous "Second argument must be a buffer" TypeError on an -// established socket, utf8 spellings are the plain write — and +// established socket, an encoding Node does not know at all is its +// ERR_UNKNOWN_ENCODING, utf8 spellings are the plain write — and // Readable.toWeb's `type` option answers Node's one-of ladder before any // web-stream machinery. 'use strict'; @@ -29,6 +30,7 @@ finished(streamObj, () => console.log('finished fired')); const server = net.createServer((sock) => sock.destroy()).listen(0, () => { const client = net.connect(server.address().port, () => { show(() => { client.write('broken', 'buffer'); }); + show(() => { client.write('broken', 'bogus-encoding'); }); client.write('fine', 'utf8'); client.destroy(); server.close();