Skip to content
1 change: 1 addition & 0 deletions docs/src/app/limitations/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 23 additions & 7 deletions packages/compiler/src/backend/emission/emit-walkers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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; }`);
Expand All @@ -1790,16 +1797,25 @@ 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]));`);
d.push(` }`);
}
// 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;`);
Expand Down
36 changes: 31 additions & 5 deletions packages/compiler/src/backend/llvm/dyn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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`);
Expand All @@ -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");
Expand All @@ -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();
Expand Down Expand Up @@ -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") {
Expand Down
32 changes: 25 additions & 7 deletions packages/compiler/src/backend/llvm/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions packages/compiler/src/frontend/lowering/lower-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 38 additions & 7 deletions packages/runtime-rust/src/island_web.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]() {
Expand Down
32 changes: 24 additions & 8 deletions packages/runtime/src/scr_async_dyn.c
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
22 changes: 22 additions & 0 deletions packages/runtime/src/scr_island.c
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime/src/scr_runtime.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading