diff --git a/src/extensions.ts b/src/extensions.ts index 80c20c2..0722e68 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -1,13 +1,20 @@ // JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction. // Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals. -import { newQuickJSWASMModuleFromVariant, QuickJSContext, QuickJSHandle } from "quickjs-emscripten-core"; +import { + newQuickJSWASMModuleFromVariant, + QuickJSContext, + QuickJSHandle, +} from "quickjs-emscripten-core"; import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync"; import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine"; interface ExtensionApi { setVariable(name: string, value: number | ExtensionValue): void; addUnit(spec: ExtensionUnitSpec): void; - addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void; + addFunction( + spec: { id: string; phrases: string }, + fn: (values: ExtensionValue[]) => ExtensionValue | number, + ): void; } export function makeApi(engine: SumEngine): ExtensionApi { @@ -29,14 +36,17 @@ const LOAD_TIMEOUT_MS = 1000; // every keystroke for every line that uses them — this budget must stay small. const CALL_TIMEOUT_MS = 50; -const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); +const now = () => + typeof performance !== "undefined" ? performance.now() : Date.now(); function toExtValue(v: unknown, what: string): ExtensionValue | number { if (typeof v === "number" && Number.isFinite(v)) return v; if (v && typeof v === "object") { const o = v as { double?: unknown; unitId?: unknown }; if (typeof o.double === "number" && Number.isFinite(o.double)) { - return typeof o.unitId === "string" ? { double: o.double, unitId: o.unitId } : { double: o.double }; + return typeof o.unitId === "string" + ? { double: o.double, unitId: o.unitId } + : { double: o.double }; } } throw new Error(`${what}: expected a number or { double, unitId? }`); @@ -44,7 +54,11 @@ function toExtValue(v: unknown, what: string): ExtensionValue | number { function message(e: unknown): string { if (e instanceof Error) return e.message; - if (e && typeof e === "object" && typeof (e as { message?: unknown }).message === "string") { + if ( + e && + typeof e === "object" && + typeof (e as { message?: unknown }).message === "string" + ) { return (e as { message: string }).message; } return String(e); @@ -53,7 +67,10 @@ function message(e: unknown): string { // The sandbox has no console/setTimeout/fetch: an extension touching those gets a // ReferenceError inside QuickJS, which surfaces as a normal per-script failure. // Never rejects — boot() runs unawaited, so a broken sandbox must not stop the app. -export async function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): Promise { +export async function runExtensions( + engine: SumEngine, + scripts: { name: string; code: string }[], +): Promise { if (scripts.length === 0) return; try { await load(engine, scripts); @@ -62,56 +79,52 @@ export async function runExtensions(engine: SumEngine, scripts: { name: string; } } -async function load(engine: SumEngine, scripts: { name: string; code: string }[]): Promise { - const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant); - const vm: QuickJSContext = mod.newContext(); - vm.runtime.setMemoryLimit(MEMORY_LIMIT); - vm.runtime.setMaxStackSize(STACK_SIZE); +type RunWithTimeout = (timeoutMs: number, fn: () => T) => T; - // Armed before every entry into the sandbox and disarmed after, so a hung script - // at load time and a hung function call on keystroke N are both bounded. - let deadline = Infinity; - vm.runtime.setInterruptHandler(() => now() > deadline); - - const api = makeApi(engine); - - const callGuest = (id: string, fn: QuickJSHandle, values: ExtensionValue[]): ExtensionValue | number => { - // extension-sample.js convention: the callback takes one array of values. - const arr = vm.newArray(); - try { - values.forEach((v, i) => { - const o = vm.newObject(); - const d = vm.newNumber(v.double); - vm.setProp(o, "double", d); - d.dispose(); - if (v.unitId) { - const u = vm.newString(v.unitId); - vm.setProp(o, "unitId", u); - u.dispose(); - } - vm.setProp(arr, i, o); - o.dispose(); - }); - let res; - deadline = now() + CALL_TIMEOUT_MS; - try { - res = vm.callFunction(fn, vm.undefined, [arr]); - } finally { - deadline = Infinity; +function callGuest( + vm: QuickJSContext, + id: string, + fn: QuickJSHandle, + values: ExtensionValue[], + runWithTimeout: RunWithTimeout, +): ExtensionValue | number { + // extension-sample.js convention: the callback takes one array of values. + const arr = vm.newArray(); + try { + values.forEach((v, i) => { + const o = vm.newObject(); + const d = vm.newNumber(v.double); + vm.setProp(o, "double", d); + d.dispose(); + if (v.unitId) { + const u = vm.newString(v.unitId); + vm.setProp(o, "unitId", u); + u.dispose(); } - if (res.error) { - const err = vm.dump(res.error); - res.error.dispose(); - throw new Error(`${id}: ${message(err)}`); - } - const out = vm.dump(res.value); - res.value.dispose(); - return toExtValue(out, id); - } finally { - arr.dispose(); + vm.setProp(arr, i, o); + o.dispose(); + }); + const res = runWithTimeout(CALL_TIMEOUT_MS, () => + vm.callFunction(fn, vm.undefined, [arr]), + ); + if (res.error) { + const err = vm.dump(res.error); + res.error.dispose(); + throw new Error(`${id}: ${message(err)}`); } - }; + const out = vm.dump(res.value); + res.value.dispose(); + return toExtValue(out, id); + } finally { + arr.dispose(); + } +} +function exposeApi( + vm: QuickJSContext, + api: ExtensionApi, + runWithTimeout: RunWithTimeout, +) { const numi = vm.newObject(); const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => { const fn = vm.newFunction(name, (...args) => { @@ -127,13 +140,19 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[] define("setVariable", (nameHandle, valueHandle) => { const name = vm.dump(nameHandle); - if (typeof name !== "string" || !name) throw new Error("expects a variable name"); + if (typeof name !== "string" || !name) + throw new Error("expects a variable name"); api.setVariable(name, toExtValue(vm.dump(valueHandle), "value")); }); define("addUnit", (specHandle) => { const spec = vm.dump(specHandle); - if (!spec || typeof spec.id !== "string" || typeof spec.baseUnitId !== "string" || typeof spec.ratio !== "number") { + if ( + !spec || + typeof spec.id !== "string" || + typeof spec.baseUnitId !== "string" || + typeof spec.ratio !== "number" + ) { throw new Error("expects { id, phrases, baseUnitId, ratio }"); } api.addUnit({ @@ -147,28 +166,33 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[] define("addFunction", (specHandle, fnHandle) => { const spec = vm.dump(specHandle); - if (!spec || typeof spec.id !== "string") throw new Error("expects { id, phrases }"); - if (!fnHandle || vm.typeof(fnHandle) !== "function") throw new Error("expects a function"); + if (!spec || typeof spec.id !== "string") + throw new Error("expects { id, phrases }"); + if (!fnHandle || vm.typeof(fnHandle) !== "function") + throw new Error("expects a function"); // outlives this callback, so it needs its own reference; the context is never disposed const guestFn = fnHandle.dup(); const id = spec.id; - api.addFunction({ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id }, (values) => - callGuest(id, guestFn, values), + api.addFunction( + { id, phrases: typeof spec.phrases === "string" ? spec.phrases : id }, + (values) => callGuest(vm, id, guestFn, values, runWithTimeout), ); }); vm.setProp(vm.global, "numi", numi); numi.dispose(); +} +function evaluateScripts( + vm: QuickJSContext, + scripts: { name: string; code: string }[], + runWithTimeout: RunWithTimeout, +) { for (const s of scripts) { try { - let res; - deadline = now() + LOAD_TIMEOUT_MS; - try { - res = vm.evalCode(s.code, s.name); - } finally { - deadline = Infinity; - } + const res = runWithTimeout(LOAD_TIMEOUT_MS, () => + vm.evalCode(s.code, s.name), + ); if (res.error) { const err = vm.dump(res.error); res.error.dispose(); @@ -180,3 +204,30 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[] } } } + +async function load( + engine: SumEngine, + scripts: { name: string; code: string }[], +): Promise { + const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant); + const vm: QuickJSContext = mod.newContext(); + vm.runtime.setMemoryLimit(MEMORY_LIMIT); + vm.runtime.setMaxStackSize(STACK_SIZE); + + // Armed before every entry into the sandbox and disarmed after, so a hung script + // at load time and a hung function call on keystroke N are both bounded. + let deadline = Infinity; + vm.runtime.setInterruptHandler(() => now() > deadline); + + const runWithTimeout: RunWithTimeout = (timeoutMs, fn) => { + deadline = now() + timeoutMs; + try { + return fn(); + } finally { + deadline = Infinity; + } + }; + + exposeApi(vm, makeApi(engine), runWithTimeout); + evaluateScripts(vm, scripts, runWithTimeout); +}