From 6d837f816decd08b1cb26ef4b705c8f5b7a6d515 Mon Sep 17 00:00:00 2001 From: Nikita S <259122746+exo-nikita@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:06:51 +0000 Subject: [PATCH 1/3] chore: add quickjs-wasi as an optional dep --- package.json | 1 + pnpm-lock.yaml | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/package.json b/package.json index 5f7ef65..fc70469 100644 --- a/package.json +++ b/package.json @@ -191,6 +191,7 @@ "playwright-core": "^1.58.2", "pretty-format": "^30.2.0", "puppeteer-core": "^24.37.2", + "quickjs-wasi": "^3.6.2", "tsx": "^4.21.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bbf492..e4b5764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: puppeteer-core: specifier: ^24.37.2 version: 24.37.2 + quickjs-wasi: + specifier: ^3.6.2 + version: 3.6.2 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3408,6 +3411,9 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==, tarball: https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz} engines: {node: '>=10'} + quickjs-wasi@3.6.2: + resolution: {integrity: sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==, tarball: https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==, tarball: https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz} @@ -8251,6 +8257,9 @@ snapshots: quick-lru@5.1.1: {} + quickjs-wasi@3.6.2: + optional: true + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 From c7568d1d09b89198105dc555381b28770a2bacf1 Mon Sep 17 00:00:00 2001 From: Nikita S <259122746+exo-nikita@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:06:51 +0000 Subject: [PATCH 2/3] feat: add quickjs-wasi:bundle engine Runs bundled tests on QuickJS (quickjs-ng) compiled to WebAssembly via quickjs-wasi, hosted on Node.js, so no native binary is needed. bin/quickjs-wasi.js provides what barebone engines are expected to have, mimicking `qjs --std`: print(), setTimeout/setInterval (with arguments), clearTimeout/clearInterval, gc(), catchable stack overflows, and unhandled rejections reported after the microtask queue is drained. EXODUS_TEST_PLATFORM is 'quickjs' (same engine, like shermes -> hermes), EXODUS_TEST_ENGINE is 'quickjs-wasi:bundle'. --- .github/workflows/checks.yaml | 17 +++ README.md | 2 + bin/find-binary.js | 2 + bin/index.js | 12 ++- bin/quickjs-wasi.js | 168 +++++++++++++++++++++++++++++ package.json | 2 + tests/engines/quickjs-wasi.test.js | 43 ++++++++ 7 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 bin/quickjs-wasi.js create mode 100644 tests/engines/quickjs-wasi.test.js diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index ad79af2..e138773 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -237,6 +237,23 @@ jobs: - run: pnpm i --frozen-lockfile - run: node --run test:workerd + quickjs-wasi: + runs-on: ubuntu-22.04 + name: 'Test on quickjs-wasi' + timeout-minutes: 10 + steps: + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda + with: + run_install: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version-file: '.nvmrc' + cache: pnpm + registry-url: 'https://registry.npmjs.org' + - run: pnpm i --frozen-lockfile + - run: EXODUS_TEST_DROP_NETWORK=1 node --run test:quickjs-wasi + bun: runs-on: ubuntu-22.04 name: 'Test on Bun' diff --git a/README.md b/README.md index adab6d7..0a745eb 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ Use `--engine` (or `EXODUS_TEST_ENGINE=`) to specify one of: - `hermes:bundle` — [Hermes](https://hermesengine.dev) (React Native JavaScript engine) - `spidermonkey:bundle` — [SpiderMonkey](https://spidermonkey.dev/) (Firefox/Gecko JavaScript engine) - `quickjs:bundle` — [QuickJS](https://github.com/quickjs-ng/quickjs) + - `quickjs-wasi:bundle` — [QuickJS](https://github.com/quickjs-ng/quickjs) compiled to WebAssembly, runs on Node.js + (via [`quickjs-wasi`](https://github.com/vercel-labs/quickjs-wasi), installed as an optional dependency) - `xs:bundle` — [Moddable XS](https://github.com/Moddable-OpenSource/moddable) - `graaljs:bundle` — [GraalJS](https://github.com/oracle/graaljs) - `escargot:bundle` — [Escargot](https://github.com/Samsung/escargot) diff --git a/bin/find-binary.js b/bin/find-binary.js index 49abc89..e4b3f60 100644 --- a/bin/find-binary.js +++ b/bin/find-binary.js @@ -69,6 +69,8 @@ function findBinaryOnce(name) { return require('electron') case 'workerd': return require.resolve('workerd/bin/workerd') + case 'quickjs-wasi': + return require.resolve('quickjs-wasi/quickjs.wasm') // not an executable, see bin/quickjs-wasi.js case 'porffor': return require.resolve('porffor/porf') case 'jerryscript': diff --git a/bin/index.js b/bin/index.js index c746e85..9de9163 100755 --- a/bin/index.js +++ b/bin/index.js @@ -50,6 +50,7 @@ const ENGINES = new Map( 'duktape:bundle': { binary: 'duk', ...bareboneOpts }, 'engine262:bundle': { binary: 'engine262', ...bareboneOpts }, 'quickjs:bundle': { binary: 'quickjs', binaryArgs: ['--std'], ...bareboneOpts }, + 'quickjs-wasi:bundle': { binary: 'quickjs-wasi', ...bareboneOpts }, 'xs:bundle': { binary: 'xs', ...bareboneOpts }, 'graaljs:bundle': { binary: 'graaljs', ...bareboneOpts }, 'escargot:bundle': { binary: 'escargot', ...bareboneOpts }, @@ -71,7 +72,7 @@ const ENGINES = new Map( 'msedge:playwright': { binary: 'msedge', browsers: 'playwright', ...bundleOpts }, }) ) -const bareOk = ['v8', 'd8', 'spidermonkey', 'quickjs', 'xs', 'hermes', 'shermes'] +const bareOk = ['v8', 'd8', 'spidermonkey', 'quickjs', 'quickjs-wasi', 'xs', 'hermes', 'shermes'] const bareNotrack = ['jsc', 'escargot', 'boa', 'graaljs', 'jerryscript', 'engine262'] const bareIncomplete = ['ladybird-js', 'nova', 'duktape'] @@ -292,7 +293,8 @@ Object.assign(options, engineOptions) options.platform = options.binary // binary can be overriden by c8 or electron const isBrowserLike = options.browsers || options.electron || options.html setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bundle', 'node:test', 'node:pure' -setEnv('EXODUS_TEST_PLATFORM', options.binary === 'shermes' ? 'hermes' : options.binary) // e.g. 'hermes', 'node' +const platformAliases = { __proto__: null, shermes: 'hermes', 'quickjs-wasi': 'quickjs' } // same engine +setEnv('EXODUS_TEST_PLATFORM', platformAliases[options.binary] ?? options.binary) // e.g. 'hermes', 'node' setEnv('EXODUS_TEST_TIMEOUT', options.testTimeout) setEnv('EXODUS_TEST_DEVTOOLS', options.devtools ? '1' : '') setEnv('EXODUS_TEST_IS_BROWSER', isBrowserLike ? '1' : '') @@ -662,6 +664,12 @@ async function launch(binary, args, opts = {}, buffering = false) { return browsers.run(runner, args, { binary, devtools, dropNetwork, timeout, throttle }) } + if (options.platform === 'quickjs-wasi') { + // quickjs.wasm is not an executable, it is run on Node.js by our host script + args = [fileURLToPath(import.meta.resolve('./quickjs-wasi.js')), ...args] + binary = process.execPath + } + const barebones = [...bareOk, ...bareNotrack, ...bareIncomplete] const bins = ['node', 'bun', 'deno', 'electron', 'workerd', 'servo', 'servoshell', 'jerry', 'duk'] bins.push(...barebones) diff --git a/bin/quickjs-wasi.js b/bin/quickjs-wasi.js new file mode 100644 index 0000000..0e224a2 --- /dev/null +++ b/bin/quickjs-wasi.js @@ -0,0 +1,168 @@ +// Host for the quickjs-wasi:bundle engine: QuickJS (quickjs-ng) compiled to WebAssembly, run on Node.js +// Provides what barebone engines are expected to have: print(), timers and gc(), mimicking `qjs --std` + +import { readFile } from 'node:fs/promises' +import { QuickJS, JSException, MAX_STACK_SIZE } from 'quickjs-wasi' + +const abort = (message) => { + console.error(message) + process.exit(1) +} + +if (process.argv[1] !== import.meta.filename) abort('Unexpected launcher script') +const files = process.argv.slice(2) +if (files.length === 0) abort('No files to run') + +const [wasm, ...sources] = await Promise.all([ + readFile(new URL(import.meta.resolve('quickjs-wasi/quickjs.wasm'))), + ...files.map((file) => readFile(file, 'utf8')), +]) + +// Unhandled rejections are checked after the microtask queue is drained, like in qjs +const rejections = new Map() // promise identity => [promise, reason] handles, kept alive while tracked +const onUnhandledRejection = (promise, reason, isHandled) => { + const key = promise.identity + if (isHandled) { + for (const handle of rejections.get(key) ?? []) handle.dispose() + rejections.delete(key) + } else { + rejections.set(key, [promise.dup(), reason.dup()]) + } +} + +// Compiled synchronously, async compilation was observed to delay the first timers by tens of ms +const module = new WebAssembly.Module(wasm) +// maxStackSize makes deep recursion a catchable RangeError instead of exhausting the WebAssembly stack +const vm = await QuickJS.create({ + wasm: module, + maxStackSize: MAX_STACK_SIZE, + onUnhandledRejection, +}) + +// Timers are stored in the guest, the host only tracks numeric ids +const prelude = `(function (write, startTimer, stopTimer, gc) { + 'use strict' + const { String, Number, Map, Error } = globalThis + const timers = new Map() + let lastId = 0 + const schedule = (callback, delay, args, repeat) => { + if (typeof callback !== 'function') throw new TypeError('The "callback" argument must be of type function') + const id = ++lastId + const ms = Number(delay) + timers.set(id, { callback, args, ms, repeat }) + startTimer(id, ms) + return id + } + + const clear = (id) => { + const key = Number(id) + if (timers.delete(key)) stopTimer(key) + } + + globalThis.print = (...args) => write(args.map((arg) => String(arg)).join(' ')) + globalThis.gc = () => gc() + globalThis.setTimeout = (callback, delay, ...args) => schedule(callback, delay, args, false) + globalThis.setInterval = (callback, delay, ...args) => schedule(callback, delay, args, true) + globalThis.clearTimeout = (id) => clear(id) + globalThis.clearInterval = (id) => clear(id) + + // Returns [callback, ...args] for the host to call, so that no extra frames end up in stack traces + const take = (id) => { + const timer = timers.get(id) + if (!timer) return + if (timer.repeat) { + startTimer(id, timer.ms) + } else { + timers.delete(id) + } + + return [timer.callback, ...timer.args] + } + + // Same format as js_std_dump_error1() in quickjs-libc + const isError = Error.isError ?? ((value) => value instanceof Error) + const describe = (value) => { + try { + const text = String(value) + if (!isError(value) || value.stack === undefined) return text + return text + '\\n' + String(value.stack) + } catch { + return '[exception]' + } + } + + return { take, describe } +})` + +const timers = new Map() // id => Node.js timeout +const hostFunction = (name, fn) => vm.newFunction(name, (...args) => fn(...args) ?? vm.undefined) +const write = hostFunction('write', (text) => void process.stdout.write(`${text.toString()}\n`)) +const gc = hostFunction('gc', () => vm.runGC()) +const stopTimer = hostFunction('stopTimer', (idHandle) => { + const id = idHandle.toNumber() + clearTimeout(timers.get(id)) + timers.delete(id) +}) +const startTimer = hostFunction('startTimer', (idHandle, delayHandle) => { + const id = idHandle.toNumber() + const delay = delayHandle.toNumber() + const ms = delay >= 1 && delay <= 2 ** 31 - 1 ? delay : 1 // same as Node.js does, minus the warnings + const timeout = setTimeout(() => { + timers.delete(id) + task(() => runTimer(id)) + }, ms) + timers.set(id, timeout) +}) + +function runTimer(id) { + const entry = vm.newNumber(id).consume((arg) => vm.callFunction(take, vm.undefined, arg)) + if (entry.isUndefined) return entry.dispose() // cleared + // The entry handle keeps the callback alive while it runs, even if it clears its own timer + const [callback, ...args] = Array.from({ length: entry.length }, (_, i) => entry.getProp(`${i}`)) + try { + vm.callFunction(callback, vm.undefined, ...args).dispose() + } finally { + for (const handle of [entry, callback, ...args]) handle.dispose() + } +} + +const api = vm.evalCode(prelude, '').consume((fn) => { + return vm.callFunction(fn, vm.undefined, write, startTimer, stopTimer, gc) +}) +const take = api.getProp('take') +const describe = api.getProp('describe') +for (const handle of [api, write, startTimer, stopTimer, gc]) handle.dispose() + +let crashed = false +const crash = (message) => { + crashed = true + process.exitCode = 1 + for (const timeout of timers.values()) clearTimeout(timeout) + timers.clear() + process.stderr.write(`${message}\n`) +} + +const format = (handle) => { + try { + return vm.callFunction(describe, vm.undefined, handle).consume((x) => x.toString()) + } catch { + return '[exception]' + } +} + +// Run a macrotask, then drain microtasks and check for unhandled rejections +function task(fn) { + if (crashed) return + try { + fn() + vm.executePendingJobs() + } catch (err) { + return crash(err instanceof JSException ? format(err.handle) : err?.stack ?? err) + } + + if (rejections.size === 0) return + const reasons = [...rejections.values()].map(([, reason]) => format(reason)) + crash(reasons.map((reason) => `Possibly unhandled promise rejection: ${reason}`).join('\n')) +} + +for (const [i, file] of files.entries()) task(() => vm.evalCode(sources[i], file).dispose()) diff --git a/package.json b/package.json index fc70469..919329a 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "bin/electron.preload.cjs", "bin/find-binary.js", "bin/inband.js", + "bin/quickjs-wasi.js", "bin/reporter.js", "loader/babel.cjs", "loader/deno-import-map.json", @@ -164,6 +165,7 @@ "test:servo": "EXODUS_TEST_ENGINE=servo:bundle npm run test:_bundle --", "test:engine262": "EXODUS_TEST_ENGINE=engine262:bundle npm run test:_bundle --", "test:quickjs": "EXODUS_TEST_ENGINE=quickjs:bundle npm run test:_bundle --", + "test:quickjs-wasi": "EXODUS_TEST_ENGINE=quickjs-wasi:bundle npm run test:_bundle --", "test:xs": "EXODUS_TEST_ENGINE=xs:bundle npm run test:_bundle --", "test:graaljs": "EXODUS_TEST_ENGINE=graaljs:bundle npm run test:_bundle --", "test:escargot": "EXODUS_TEST_ENGINE=escargot:bundle npm run test:_bundle --", diff --git a/tests/engines/quickjs-wasi.test.js b/tests/engines/quickjs-wasi.test.js new file mode 100644 index 0000000..d510fd7 --- /dev/null +++ b/tests/engines/quickjs-wasi.test.js @@ -0,0 +1,43 @@ +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' + +describe( + 'quickjs-wasi', + { + skip: process.env.EXODUS_TEST_ENGINE !== 'quickjs-wasi:bundle', + }, + () => { + test('Are we QuickJS', () => { + assert.equal(process.env.EXODUS_TEST_PLATFORM, 'quickjs') + assert.equal(typeof globalThis.gc, 'function') + assert.equal(globalThis.gc(), undefined) + }) + + test('setTimeout passes arguments', async () => { + const args = await new Promise((resolve) => setTimeout((...a) => resolve(a), 0, 1, 'x')) + assert.deepEqual(args, [1, 'x']) + }) + + test('clearTimeout and clearInterval', async () => { + let count = 0 + const cleared = setTimeout(() => count++, 0) + clearTimeout(cleared) + clearTimeout(cleared) + clearTimeout() + await new Promise((resolve) => { + const interval = setInterval(() => { + if (++count < 3) return + clearInterval(interval) // from inside the callback + setTimeout(resolve, 20) + }, 1) + }) + assert.equal(count, 3) + }) + + test('Deep recursion is a catchable RangeError', () => { + const depth = (n) => (n === 0 ? 0 : 1 + depth(n - 1)) + assert.throws(() => depth(1e6), RangeError) + assert.equal(depth(1000), 1000) + }) + } +) From 6d1e7f894da37b3b713e921cf22e8d980bfbd389 Mon Sep 17 00:00:00 2001 From: Nikita S <259122746+exo-nikita@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:00:14 +0000 Subject: [PATCH 3/3] refactor: simplify quickjs-wasi host --- bin/find-binary.js | 2 +- bin/index.js | 1 - bin/quickjs-wasi.js | 164 ++++++++++++++++++-------------------------- 3 files changed, 66 insertions(+), 101 deletions(-) diff --git a/bin/find-binary.js b/bin/find-binary.js index e4b3f60..6867953 100644 --- a/bin/find-binary.js +++ b/bin/find-binary.js @@ -70,7 +70,7 @@ function findBinaryOnce(name) { case 'workerd': return require.resolve('workerd/bin/workerd') case 'quickjs-wasi': - return require.resolve('quickjs-wasi/quickjs.wasm') // not an executable, see bin/quickjs-wasi.js + return require.resolve('quickjs-wasi/quickjs.wasm') case 'porffor': return require.resolve('porffor/porf') case 'jerryscript': diff --git a/bin/index.js b/bin/index.js index 9de9163..8149e92 100755 --- a/bin/index.js +++ b/bin/index.js @@ -665,7 +665,6 @@ async function launch(binary, args, opts = {}, buffering = false) { } if (options.platform === 'quickjs-wasi') { - // quickjs.wasm is not an executable, it is run on Node.js by our host script args = [fileURLToPath(import.meta.resolve('./quickjs-wasi.js')), ...args] binary = process.execPath } diff --git a/bin/quickjs-wasi.js b/bin/quickjs-wasi.js index 0e224a2..ddbf6e6 100644 --- a/bin/quickjs-wasi.js +++ b/bin/quickjs-wasi.js @@ -1,25 +1,15 @@ // Host for the quickjs-wasi:bundle engine: QuickJS (quickjs-ng) compiled to WebAssembly, run on Node.js // Provides what barebone engines are expected to have: print(), timers and gc(), mimicking `qjs --std` -import { readFile } from 'node:fs/promises' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { QuickJS, JSException, MAX_STACK_SIZE } from 'quickjs-wasi' -const abort = (message) => { - console.error(message) - process.exit(1) -} - -if (process.argv[1] !== import.meta.filename) abort('Unexpected launcher script') -const files = process.argv.slice(2) -if (files.length === 0) abort('No files to run') - -const [wasm, ...sources] = await Promise.all([ - readFile(new URL(import.meta.resolve('quickjs-wasi/quickjs.wasm'))), - ...files.map((file) => readFile(file, 'utf8')), -]) +const [, script, file, ...extra] = process.argv +assert(script === import.meta.filename && file && extra.length === 0, 'Unexpected launcher script') -// Unhandled rejections are checked after the microtask queue is drained, like in qjs -const rejections = new Map() // promise identity => [promise, reason] handles, kept alive while tracked +// Reported after the microtask queue is drained, like in qjs +const rejections = new Map() // promise identity => [promise, reason], kept alive while tracked const onUnhandledRejection = (promise, reason, isHandled) => { const key = promise.identity if (isHandled) { @@ -31,126 +21,102 @@ const onUnhandledRejection = (promise, reason, isHandled) => { } // Compiled synchronously, async compilation was observed to delay the first timers by tens of ms -const module = new WebAssembly.Module(wasm) +const wasm = new WebAssembly.Module( + readFileSync(new URL(import.meta.resolve('quickjs-wasi/quickjs.wasm'))) +) // maxStackSize makes deep recursion a catchable RangeError instead of exhausting the WebAssembly stack -const vm = await QuickJS.create({ - wasm: module, - maxStackSize: MAX_STACK_SIZE, - onUnhandledRejection, +const vm = await QuickJS.create({ wasm, maxStackSize: MAX_STACK_SIZE, onUnhandledRejection }) + +const hostFunction = (name, fn) => + vm.newFunction(name, (...args) => { + fn(...args.map((arg) => vm.dump(arg))) + return vm.undefined + }) + +const timers = new Map() // id => Node.js timeout +const write = hostFunction('write', (text) => process.stdout.write(`${text}\n`)) +const gc = hostFunction('gc', () => vm.runGC()) +const stopTimer = hostFunction('stopTimer', (id) => { + clearTimeout(timers.get(id)) + timers.delete(id) +}) +const startTimer = hostFunction('startTimer', (id, delay) => { + const ms = delay >= 1 && delay <= 2 ** 31 - 1 ? delay : 1 // same as Node.js does, minus the warnings + const fire = () => { + timers.delete(id) + task(() => runTimer(id)) + } + + timers.set(id, setTimeout(fire, ms)) }) // Timers are stored in the guest, the host only tracks numeric ids +// take(id) returns the bound callback, called by the host so that stack traces have no extra frames const prelude = `(function (write, startTimer, stopTimer, gc) { 'use strict' - const { String, Number, Map, Error } = globalThis + const { apply } = Reflect + const { bind } = Function.prototype const timers = new Map() let lastId = 0 const schedule = (callback, delay, args, repeat) => { - if (typeof callback !== 'function') throw new TypeError('The "callback" argument must be of type function') const id = ++lastId const ms = Number(delay) - timers.set(id, { callback, args, ms, repeat }) + timers.set(id, { callback: apply(bind, callback, [undefined, ...args]), ms, repeat }) startTimer(id, ms) return id } - const clear = (id) => { + globalThis.print = (...args) => write(args.map(String).join(' ')) + globalThis.gc = gc + globalThis.setTimeout = (callback, delay, ...args) => schedule(callback, delay, args, false) + globalThis.setInterval = (callback, delay, ...args) => schedule(callback, delay, args, true) + globalThis.clearTimeout = globalThis.clearInterval = (id) => { const key = Number(id) if (timers.delete(key)) stopTimer(key) } - globalThis.print = (...args) => write(args.map((arg) => String(arg)).join(' ')) - globalThis.gc = () => gc() - globalThis.setTimeout = (callback, delay, ...args) => schedule(callback, delay, args, false) - globalThis.setInterval = (callback, delay, ...args) => schedule(callback, delay, args, true) - globalThis.clearTimeout = (id) => clear(id) - globalThis.clearInterval = (id) => clear(id) - - // Returns [callback, ...args] for the host to call, so that no extra frames end up in stack traces - const take = (id) => { + return (id) => { const timer = timers.get(id) - if (!timer) return - if (timer.repeat) { - startTimer(id, timer.ms) - } else { - timers.delete(id) - } - - return [timer.callback, ...timer.args] + if (timer?.repeat) startTimer(id, timer.ms) + else timers.delete(id) + return timer?.callback } - - // Same format as js_std_dump_error1() in quickjs-libc - const isError = Error.isError ?? ((value) => value instanceof Error) - const describe = (value) => { - try { - const text = String(value) - if (!isError(value) || value.stack === undefined) return text - return text + '\\n' + String(value.stack) - } catch { - return '[exception]' - } - } - - return { take, describe } })` -const timers = new Map() // id => Node.js timeout -const hostFunction = (name, fn) => vm.newFunction(name, (...args) => fn(...args) ?? vm.undefined) -const write = hostFunction('write', (text) => void process.stdout.write(`${text.toString()}\n`)) -const gc = hostFunction('gc', () => vm.runGC()) -const stopTimer = hostFunction('stopTimer', (idHandle) => { - const id = idHandle.toNumber() - clearTimeout(timers.get(id)) - timers.delete(id) -}) -const startTimer = hostFunction('startTimer', (idHandle, delayHandle) => { - const id = idHandle.toNumber() - const delay = delayHandle.toNumber() - const ms = delay >= 1 && delay <= 2 ** 31 - 1 ? delay : 1 // same as Node.js does, minus the warnings - const timeout = setTimeout(() => { - timers.delete(id) - task(() => runTimer(id)) - }, ms) - timers.set(id, timeout) +const take = vm.evalCode(prelude, '').consume((fn) => { + return vm.callFunction(fn, vm.undefined, write, startTimer, stopTimer, gc) }) function runTimer(id) { - const entry = vm.newNumber(id).consume((arg) => vm.callFunction(take, vm.undefined, arg)) - if (entry.isUndefined) return entry.dispose() // cleared - // The entry handle keeps the callback alive while it runs, even if it clears its own timer - const [callback, ...args] = Array.from({ length: entry.length }, (_, i) => entry.getProp(`${i}`)) + const callback = vm.newNumber(id).consume((arg) => vm.callFunction(take, vm.undefined, arg)) + // This handle keeps the callback alive while it runs, even if it clears its own timer + callback.consume(() => { + if (!callback.isUndefined) vm.callFunction(callback, vm.undefined).dispose() + }) +} + +// Same output as js_std_dump_error1() in quickjs-libc +const describe = vm.evalCode( + `(value) => String(value) + (Error.isError(value) && value.stack !== undefined ? '\\n' + value.stack : '')`, + '' +) +const format = (value) => { try { - vm.callFunction(callback, vm.undefined, ...args).dispose() - } finally { - for (const handle of [entry, callback, ...args]) handle.dispose() + return vm.callFunction(describe, vm.undefined, value).consume((text) => text.toString()) + } catch { + return '[exception]' } } -const api = vm.evalCode(prelude, '').consume((fn) => { - return vm.callFunction(fn, vm.undefined, write, startTimer, stopTimer, gc) -}) -const take = api.getProp('take') -const describe = api.getProp('describe') -for (const handle of [api, write, startTimer, stopTimer, gc]) handle.dispose() - let crashed = false const crash = (message) => { crashed = true process.exitCode = 1 for (const timeout of timers.values()) clearTimeout(timeout) - timers.clear() process.stderr.write(`${message}\n`) } -const format = (handle) => { - try { - return vm.callFunction(describe, vm.undefined, handle).consume((x) => x.toString()) - } catch { - return '[exception]' - } -} - -// Run a macrotask, then drain microtasks and check for unhandled rejections +// Run a macrotask, then drain microtasks and report unhandled rejections function task(fn) { if (crashed) return try { @@ -165,4 +131,4 @@ function task(fn) { crash(reasons.map((reason) => `Possibly unhandled promise rejection: ${reason}`).join('\n')) } -for (const [i, file] of files.entries()) task(() => vm.evalCode(sources[i], file).dispose()) +task(() => vm.evalCode(readFileSync(file, 'utf8'), file).dispose())