blob: 638aea23be3058a77973eddc8a668d59c6dec59f [file]
// Drives the harness the way a web host loads a file: import, await the
// script runner's prepare(), then start scripts and run frames.
// node run.mjs <harness.mjs> <file.riv> [--sync] [--early] [--settle]
// [--decoder-throws] [--fault <method>] [--deep] [--vms <count>]
// [--scroll] [--lacks simd|relaxed|bulk,...] [--until <log line>]
// [--audio <blocks>] [--pump-audio] [--lane-twin] [--nrdp-mock]
// [--hard-blocks <n>] [--bounded]
// [--while-pending] [--nrdp-fault <start|silent|fetch>] [--nrdp-stall]
// [--forget-modules] [--worklet-throws] [--page-boundary]
// [--seam <address>] [--first <file.riv>] [--error-sink]
// [--hash <blocks>] [--no-isolation] [--job-threads <n>]
// [--parallel-parts <n>] [--parallel-safe] [--call-budget <ms>]
// [--expect <log line>]... [--expect-line <log line>]...
// --no-isolation runs as a page without cross origin isolation would, which
// a build with threads refuses to load.
// --job-threads sets the job pool's threads. --parallel-parts sets the
// runner's parallelParts, the parts a parallel call gets on a build with
// threads. --parallel-safe runs every parallel call one part at a time.
// --call-budget sets how long a call into the file's scripts may run, 0 for
// no limit.
// --early starts scripts once before prepare() settles, as a host that does
// not wait for it would. --deep runs the frames with little stack left.
// --bounded starts only modules the page can bound, as an editor does.
// --vms is how many script vms must run, else one. --scroll sends one scroll
// over the artboard after the frames. --until keeps running a frame every
// few milliseconds until a line holds the text, for script on other lanes;
// given more than once, until each text shows.
// --while-pending runs them only while script work is pending, as a host
// that settles does.
// --audio then renders that many blocks offline through each audio processor
// and runs frames until every expected line shows. --pump-audio renders a block
// with every one of those frames instead. --lane-twin gives processors the
// shared memory natives an audio worklet uses, Atomics on librive's heap.
// --hard-blocks sets the audio hard limit, 0 for none, for real time runs a
// loaded machine must not drop a processor in.
// --nrdp-mock stands in NRDP's audio API (nrdp_mock.mjs), so processors run
// in real time through the runner's NRDP output and its worklet bundle.
// --nrdp-fault gives the mock that fault. --nrdp-stall freezes the mocked
// audio thread once a line "stall ring <id>" shows, as if it froze holding
// that ring's lock. --forget-modules then starts and stops processors of
// more modules than the page keeps, and reports which it forgot.
// --hash logs a hash of the first that many blocks the mock renders, as
// browser_audio.mjs does on a page.
// --worklet-throws stands in a browser whose worklet node refuses librive's
// memory.
// --settle stands in for the browser's image decoder and lets its results
// land before the file is released. --decoder-throws settles with a decoder
// that throws as librive starts it. --fault makes librive's first call to the
// named script runner method throw, a JS error raised under librive frames.
// --lacks stands in for an engine without those features, which node always
// has: it refuses any module whose code uses their opcodes. --page-boundary
// starts the scripts and services them once through the runner, as the JS
// runtime does at the end of a frame, before the harness runs its own frames.
// --seam round trips a string through librive's module memory seam at that
// address after the frames. --first loads, starts and releases that file on
// the same page before the one under test. --error-sink gives the files an
// error sink, which prefixes its lines with "error sink: ". --expect wants a
// line holding the text, --expect-line one that is exactly the text, once for
// each time it is given.
// Preloaded with node --import ./worker_compiles.mjs, the run also says how
// many modules the worker threads compiled.
import { readFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { installNrdpMock } from './nrdp_mock.mjs';
import {
kChunkShift,
kChunkSlots,
kHeaderLock,
kHoldLock,
kHoldToken,
kIdBit,
kIdSlotShift,
kLaneFirstToken,
kSlotBlock,
kSlotSize,
kTableChunks,
kTableLanes,
} from '../../src/wasm/web/rive_shared_layout.mjs';
const [harnessPath, rivPath, ...rest] = process.argv.slice(2);
const sync = rest.includes('--sync');
const decoderThrows = rest.includes('--decoder-throws');
const settle = rest.includes('--settle') || decoderThrows;
const valueOf = (flag) => rest.find((arg, i) => rest[i - 1] === flag);
const expected = rest.filter((arg, i) => rest[i - 1] === '--expect');
const exactLines = rest.filter((arg, i) => rest[i - 1] === '--expect-line');
const fault = valueOf('--fault');
const early = rest.includes('--early');
const deep = rest.includes('--deep');
const vms = valueOf('--vms');
const scroll = rest.includes('--scroll');
const untils = rest.filter((arg, i) => rest[i - 1] === '--until');
const audio = valueOf('--audio');
const pumpAudio = rest.includes('--pump-audio');
const whilePending = rest.includes('--while-pending');
const lacks = valueOf('--lacks')?.split(',');
const hardBlocks = valueOf('--hard-blocks');
const pageBoundary = rest.includes('--page-boundary');
const seam = valueOf('--seam');
const bounded = rest.includes('--bounded');
const first = valueOf('--first');
const errorSink = rest.includes('--error-sink');
// The relaxed opcodes are 0xfd then 0x80 to 0x93 and 0x02, and memory.copy
// and memory.fill 0xfc then 0x0a and 0x0b; a stray byte match only refuses
// more. Only the code section counts, as an engine never validates the lane
// modules custom sections carry.
function usesSimd(bytes, relaxed) {
const code = sections(bytes, 8).find((section) => section.id === 10);
if (code === undefined) {
return false;
}
for (let i = code.body; i + 2 < code.end; i++) {
if (bytes[i] !== 0xfd) {
continue;
}
if (!relaxed ||
(bytes[i + 1] >= 0x80 && bytes[i + 1] <= 0x93 && bytes[i + 2] === 0x02)) {
return true;
}
}
return false;
}
function usesBulkMemory(bytes) {
const code = sections(bytes, 8).find((section) => section.id === 10);
if (code === undefined) {
return false;
}
for (let i = code.body; i + 1 < code.end; i++) {
if (bytes[i] === 0xfc && (bytes[i + 1] === 0x0a || bytes[i + 1] === 0x0b)) {
return true;
}
}
return false;
}
// The feature the engine lacks that the module uses, or null.
function refusal(bytes) {
if (lacks.includes('bulk') && usesBulkMemory(bytes)) {
return 'bulk memory';
}
if (lacks.includes('simd') && usesSimd(bytes, false)) {
return 'SIMD';
}
if (lacks.includes('relaxed') && usesSimd(bytes, true)) {
return 'relaxed SIMD';
}
return null;
}
function leb(bytes, at) {
let value = 0;
let shift = 0;
let byte;
do {
byte = bytes[at++];
value |= (byte & 0x7f) << shift;
shift += 7;
} while (byte & 0x80);
return [value >>> 0, at];
}
// The sections of a module whose first one starts at first.
function sections(bytes, first) {
const out = [];
for (let at = first; at < bytes.length; ) {
const [size, body] = leb(bytes, at + 1);
out.push({ id: bytes[at], body, end: body + size });
at = body + size;
}
return out;
}
if (settle && !decoderThrows) {
// Accepts bytes that start with a PNG signature byte as a 2x1 image whose
// first pixel is straight red at half alpha.
globalThis.createImageBitmap = async (blob) => {
const bytes = new Uint8Array(await blob.arrayBuffer());
if (bytes[0] !== 0x89) {
throw new Error('not an image');
}
return { width: 2, height: 1 };
};
globalThis.OffscreenCanvas = class {
getContext() {
return {
drawImage() {},
getImageData: () => ({
data: new Uint8ClampedArray([200, 0, 0, 128, 0, 0, 255, 255]),
}),
};
}
};
}
if (decoderThrows) {
globalThis.createImageBitmap = () => {
throw new Error('decoder threw');
};
}
const nrdpFault = valueOf('--nrdp-fault') ?? null;
const nrdpStall = rest.includes('--nrdp-stall');
const forgetModules = rest.includes('--forget-modules');
const nrdpMock = rest.includes('--nrdp-mock') || nrdpFault !== null || nrdpStall;
let mock = null;
if (nrdpMock) {
const web = resolve(dirname(fileURLToPath(import.meta.url)), '../../src/wasm/web');
mock = installNrdpMock((url) => readFileSync(resolve(web, url), 'utf8'), nrdpFault);
}
// As a page without cross origin isolation, which has no shared memory.
if (rest.includes('--no-isolation')) {
globalThis.crossOriginIsolated = false;
}
const workletThrows = rest.includes('--worklet-throws');
if (workletThrows) {
globalThis.crossOriginIsolated = true;
// The page's window, where the runner waits for a gesture.
const page = new EventTarget();
globalThis.addEventListener = page.addEventListener.bind(page);
globalThis.removeEventListener = page.removeEventListener.bind(page);
globalThis.AudioContext = class {
constructor() {
this.sampleRate = 48000;
this.state = 'suspended';
this.destination = {};
this.audioWorklet = { addModule: async () => {} };
}
addEventListener() {}
close() {}
};
globalThis.AudioWorkletNode = class {
constructor() {
throw new DOMException('memory could not be cloned', 'DataCloneError');
}
};
}
const lines = [];
const hash = valueOf('--hash');
if (hash !== undefined) {
mock.tap(Number(hash), (sums, value) => {
const [left, right] = sums.map((sum) => sum.toFixed(2));
const hex = value.toString(16).padStart(8, '0');
lines.push(`worklet rendered ${hash} blocks sums ${left} ${right} hash ${hex}`);
});
}
const createHarness = (await import(pathToFileURL(resolve(harnessPath)))).default;
const Module = await createHarness({
print: (text) => lines.push(text),
printErr: (text) => lines.push(text),
...(lacks === undefined ? {} : {
riveScriptingValidate: (bytes) =>
refusal(bytes) === null && WebAssembly.validate(bytes),
}),
riveScriptingLaneTwin: rest.includes('--lane-twin'),
}).catch((error) => {
// A build with threads refuses a page that cannot share memory, which
// passes only a run that expects it.
const line = `harness refused: ${error.message}`;
const missing = expected.filter((want) => !line.includes(want));
const wanted = (want) => exactLines.filter((other) => other === want).length;
missing.push(...exactLines.filter((want) => want !== line || wanted(want) > 1));
const passed = missing.length === 0 && expected.length + exactLines.length > 0;
console[passed ? 'log' : 'error'](line);
process.exit(passed ? 0 : 1);
});
if (nrdpMock) {
Module.riveScripting.audioWorkletUrl = 'rive_audio_worklet_nrdp.js';
}
if (workletThrows) {
Module.riveScripting.audioWorkletUrl = 'rive_audio_worklet.mjs';
}
// The engine refuses to compile them too, once librive itself is running.
if (lacks !== undefined) {
const refuse = (bytes) => {
const missing = refusal(new Uint8Array(bytes));
if (missing !== null) {
throw new WebAssembly.CompileError(`this engine lacks ${missing}`);
}
};
const { Module: WasmModule, compile } = WebAssembly;
WebAssembly.Module = class extends WasmModule {
constructor(bytes) {
refuse(bytes);
super(bytes);
}
};
WebAssembly.compile = async (bytes) => {
refuse(bytes);
return compile(bytes);
};
}
// Counts the script modules the runner tries to instantiate.
let instantiations = 0;
const { Instance, instantiate } = WebAssembly;
WebAssembly.Instance = class extends Instance {
constructor(...args) {
instantiations++;
super(...args);
}
};
WebAssembly.instantiate = (...args) => {
instantiations++;
return instantiate(...args);
};
if (fault !== undefined) {
const runner = Module.riveScripting;
const original = runner[fault];
runner[fault] = () => {
runner[fault] = original;
throw new Error('host fault injected');
};
}
if (bounded) {
Module._harness_require_bounds();
}
if (hardBlocks !== undefined) {
Module._harness_audio_hard_blocks(Number(hardBlocks));
}
if (errorSink) {
Module._harness_error_sink();
}
const jobThreads = valueOf('--job-threads');
if (jobThreads !== undefined) {
Module._harness_job_threads(Number(jobThreads));
}
if (rest.includes('--parallel-safe')) {
Module._harness_parallel_safe();
}
Module.riveScripting.parallelParts = valueOf('--parallel-parts');
const callBudget = valueOf('--call-budget');
if (callBudget !== undefined) {
Module._harness_call_budget(Number(callBudget));
}
function importRiv(path) {
const bytes = readFileSync(path);
const ptr = Module._malloc(bytes.length);
Module.HEAPU8.set(bytes, ptr);
const imported = Module._harness_import(ptr, bytes.length);
Module._free(ptr);
if (!imported) {
console.error('FAIL: import failed\n' + lines.join('\n'));
process.exit(1);
}
return bytes;
}
if (first !== undefined) {
importRiv(first);
if (!sync) {
await Module.riveScripting.prepare();
}
Module._harness_run(0);
Module._harness_release();
}
// Only the file under test counts.
instantiations = 0;
const riv = importRiv(rivPath);
if (audio !== undefined || pumpAudio) {
Module._harness_render_audio_offline();
}
// Skipping prepare exercises the inline instantiate fallback.
if (early) {
const preparing = Module.riveScripting.prepare();
Module._harness_run(0);
await preparing;
} else if (!sync) {
const stack = Module._harness_stack();
await Module.riveScripting.prepare();
const kept = Module._harness_stack() === stack;
lines.push(`librive stack ${kept ? 'kept' : 'leaked'} across prepare`);
}
if (scroll) {
Module._harness_scroll();
}
if (pageBoundary) {
Module._harness_run(0);
// The module exports librive calls while the runner services the VMs.
const runner = Module.riveScripting;
const call = runner.call;
const called = [];
runner.call = (vm, name, ...args) => {
const end = Module.HEAPU8.indexOf(0, name);
called.push(new TextDecoder().decode(Module.HEAPU8.subarray(name, end)));
return call(vm, name, ...args);
};
const warnings = runner.frameBoundary();
runner.call = call;
lines.push(`page boundary called ${called.join(' ')}`);
lines.push(`page boundary warned ${JSON.stringify(warnings)}`);
}
const running = deep
? (Module._harness_run(0), Module._harness_run_low_on_stack(5, 16384))
: Module._harness_run(5);
if (seam !== undefined) {
Module._harness_seam(Number(seam));
}
// The heap words of the ring the stall lane names, once frozen.
let stall = null;
const freezeOnStall = () => {
const ring = lines.map((line) => /^stall ring (\d+)$/.exec(line)).find(Boolean);
const holds = mock.options !== null ? new Int32Array(mock.options.counter) : null;
if (stall !== null || ring === undefined || holds === null) {
return;
}
const token = Atomics.load(holds, kHoldToken);
const table = mock.options.table >>> 0;
const u32 = new Uint32Array(mock.options.memory.buffer);
const index = (Number(ring[1]) & ~kIdBit) >>> kIdSlotShift;
const chunk = u32[(table + kTableChunks + (index >>> kChunkShift) * 4) >>> 2];
const slot = chunk + (index & (kChunkSlots - 1)) * kSlotSize;
const lockAt = u32[(slot + kSlotBlock) >>> 2] + kHeaderLock;
Atomics.store(u32, lockAt >>> 2, token);
Atomics.store(holds, kHoldLock, lockAt);
mock.freeze();
stall = { u32, lockAt, laneAt: ((table + kTableLanes) >>> 2) + token - kLaneFirstToken };
};
// A deadline only a stuck lane misses, so a loaded machine only slows a run.
const runUntil = async (done) => {
const deadline = Date.now() + 30000;
while (!done() && Date.now() < deadline) {
await new Promise((settle) => setTimeout(settle, 5));
if (nrdpStall) {
freezeOnStall();
}
Module._harness_frames(1);
if (pumpAudio) {
Module._harness_audio_pump(1);
}
}
};
if (untils.length > 0) {
await runUntil(() =>
untils.every((until) => lines.some((line) => line.includes(until)))
);
}
if (whilePending) {
await runUntil(() => !Module._harness_pending());
}
if (pumpAudio) {
await runUntil(() =>
expected.every((want) => lines.some((line) => line.includes(want)))
);
}
if (audio !== undefined) {
// A processor still in its init counts as pending, and each one's state
// goes in the lines.
await runUntil(() => !Module._harness_pending());
Module._harness_audio(Number(audio));
await runUntil(() =>
expected.every((want) => lines.some((line) => line.includes(want)))
);
}
if (stall !== null) {
const yes = (ok) => (ok ? 'yes' : 'no');
lines.push(`stall thread terminated ${yes(mock.terminated)}`);
lines.push(`stall lock freed ${yes(Atomics.load(stall.u32, stall.lockAt >>> 2) === 0)}`);
lines.push(`stall lane freed ${yes(Atomics.load(stall.u32, stall.laneAt) === 0)}`);
}
if (forgetModules) {
const runner = Module.riveScripting;
const bytes = laneSection(riv, 'rive.audio');
const format = { sampleRate: 48000, maxFrames: 128, channels: 2 };
const start = (moduleId) =>
runner.audioStart(0, moduleId, () => bytes, 0, new Uint8Array(0), format, 4).id;
for (let i = 0; i < 10; i++) {
runner.audioStop(start(9000 + i));
}
const forgot = mock.sent.filter((message) => message.kind === 'forget');
lines.push('forgot modules ' + forgot.map((message) => message.moduleId).join(' '));
runner.audioStop(start(9000));
const resent = mock.sent.filter((message) => message.kind === 'start' && message.moduleId === 9000);
lines.push(`forgotten module resent ${resent.at(-1).bytes !== null ? 'yes' : 'no'}`);
}
lines.push(`pending async work ${Module._harness_pending() ? 'yes' : 'no'}`);
// Set by worker_compiles.mjs. A count only means something once workers
// made instances, which shows the probe reached them.
const workerCompiles = globalThis.riveWorkerCompiles;
if (workerCompiles !== undefined && Atomics.load(workerCompiles, 1) > 0) {
lines.push(`worker threads compiled ${Atomics.load(workerCompiles, 0)} modules`);
}
if (settle) {
for (let turn = 0; turn < 5; turn++) {
await new Promise((done) => setTimeout(done, 10));
}
}
Module._harness_release();
lines.push(`script modules instantiated: ${instantiations}`);
lines.push(`parallel team ${JSON.stringify(Module.riveScripting.parallelState())}`);
// A custom section of the riv's script module.
function laneSection(bytes, name) {
for (const section of sections(bytes, bytes.indexOf(Buffer.from('\0asm')) + 8)) {
const [length, start] = leb(bytes, section.body);
if (section.id === 0 && bytes.toString('utf8', start, start + length) === name) {
return bytes.subarray(start + length, section.end);
}
}
return null;
}
const missing = expected.filter((want) => !lines.some((line) => line.includes(want)));
const times = (list, want) => list.filter((item) => item === want).length;
missing.push(...exactLines.filter((want) => times(lines, want) < times(exactLines, want)));
if ((vms === undefined ? running < 1 : running !== Number(vms)) ||
missing.length > 0) {
console.error(`FAIL: ${running} script vms running, missing log lines: ${JSON.stringify(missing)}`);
console.error(lines.join('\n'));
process.exit(1);
}
console.log(`web scripting ok (${sync ? 'inline' : 'prepared'}): ${running} vm, ${lines.length} log lines`);
for (const line of lines) {
console.log(' | ' + line);
}
// A mocked audio thread and its watchdog would keep node running.
process.exit(0);