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/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/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); 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/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_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). */ 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/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" 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"; 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(); 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"); 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).