diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index ad79af23..e138773a 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 adab6d72..0a745ebc 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 49abc896..68679536 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') case 'porffor': return require.resolve('porffor/porf') case 'jerryscript': diff --git a/bin/index.js b/bin/index.js index c746e852..8149e922 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,11 @@ async function launch(binary, args, opts = {}, buffering = false) { return browsers.run(runner, args, { binary, devtools, dropNetwork, timeout, throttle }) } + if (options.platform === 'quickjs-wasi') { + 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 00000000..ddbf6e63 --- /dev/null +++ b/bin/quickjs-wasi.js @@ -0,0 +1,134 @@ +// 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 assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { QuickJS, JSException, MAX_STACK_SIZE } from 'quickjs-wasi' + +const [, script, file, ...extra] = process.argv +assert(script === import.meta.filename && file && extra.length === 0, 'Unexpected launcher script') + +// 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) { + 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 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, 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 { apply } = Reflect + const { bind } = Function.prototype + const timers = new Map() + let lastId = 0 + const schedule = (callback, delay, args, repeat) => { + const id = ++lastId + const ms = Number(delay) + timers.set(id, { callback: apply(bind, callback, [undefined, ...args]), ms, repeat }) + startTimer(id, ms) + return 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) + } + + return (id) => { + const timer = timers.get(id) + if (timer?.repeat) startTimer(id, timer.ms) + else timers.delete(id) + return timer?.callback + } +})` + +const take = vm.evalCode(prelude, '').consume((fn) => { + return vm.callFunction(fn, vm.undefined, write, startTimer, stopTimer, gc) +}) + +function runTimer(id) { + 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 { + return vm.callFunction(describe, vm.undefined, value).consume((text) => text.toString()) + } catch { + return '[exception]' + } +} + +let crashed = false +const crash = (message) => { + crashed = true + process.exitCode = 1 + for (const timeout of timers.values()) clearTimeout(timeout) + process.stderr.write(`${message}\n`) +} + +// Run a macrotask, then drain microtasks and report 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')) +} + +task(() => vm.evalCode(readFileSync(file, 'utf8'), file).dispose()) diff --git a/package.json b/package.json index 5f7ef650..919329a0 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 --", @@ -191,6 +193,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 2bbf4926..e4b5764e 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 diff --git a/tests/engines/quickjs-wasi.test.js b/tests/engines/quickjs-wasi.test.js new file mode 100644 index 00000000..d510fd73 --- /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) + }) + } +)