open-standards/wai/wai-int-module/browser/run-browser.mjs
Transaction Science cb453c3d50
Some checks failed
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 6s
wai-int-module WAISM module of record / does this change touch it (push) Successful in 11s
wai-int-module WAISM module of record / the module of record in Chromium (push) Successful in 18s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 1m56s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 5m15s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 2m17s
wai-int-module WAISM module of record / linux · both checkers, wasmtime (Cranelift, Winch), Wasmi and Node (push) Successful in 7m36s
wai-int-module WAISM module of record / linux-arm64 · both checkers, wasmtime (Cranelift, Winch), Wasmi and Node (push) Successful in 6m59s
wai-int-module: the browser run ends when the page goes silent, with the browser's last words
On f5bb6842a the module of record in Chromium sat silent for 30 minutes after the browser's
--version line, and the job's timeout ended it with nothing said; the same commit's scheduled run,
on the same runner, passed in 3 seconds, and the run as CI makes it (the job's container, its
first process tail, each step through docker exec, /dev/shm 64 MB) passes in 6 to 7 seconds on
that host, so the stall is not the code's and was not reproduced. run-browser.mjs waited for the
overall bound (the limit for every case of every module, 20 hours here) and printed nothing
before it. The page holds each case to the limit and then sends it as timed out, so a page that
asks for nothing and sends nothing for longer than the limit and a minute has stopped: the run
now ends there, exit 1, saying how many results came back, with the browser's stderr.

Checked in the job's image on the Linux x86_64 runner's host: every case of both modules of
record, 120 of 120 each, one run digest; and a planted page that never starts its job, with
--limit 5, ended by the watchdog at 67 s, its stderr printed.

Changelog: none
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:20:50 -05:00

172 lines
8 KiB
JavaScript

// run-browser.mjs: the module of record in a browser's engine, as a page runs it.
//
// node run-browser.mjs --browser PATH [--external DIR] [--only PREFIX] [--limit SECONDS]
// CORPUS MODULE...
//
// It serves page.html, worker.mjs, ../sink.mjs, the modules and the cases from a local
// server, and starts the browser at PATH headless on the page. The page decodes every case
// through each module in a worker of its own (waism §5; §6 checks 11 to 13), each case under
// the wall-clock limit (waism §7; default 300 seconds), and sends each output back as bytes.
// This script, not the page, holds every output to the corpus, and prints for each module the
// same line as the native host (../host/) and run-node.mjs: its module, engine, the cases it
// held and its run digest. Every run digest must be the same.
//
// PATH is any Chromium-family browser that takes --headless=new. No package is installed.
//
// Exit status 0 when every case of every run is the corpus's and every run digest is the
// same; 1 otherwise, a browser that does not start or finish included, and a page that sends
// nothing for longer than the limit (and a minute): each case is held to the limit by the page,
// which then sends the case as timed out, so a page silent that long has stopped; 2 for a usage
// or input error.
import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { arch } from 'node:process';
import { fileURLToPath } from 'node:url';
import { judge, loadCases, sha256 } from '../cases.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
function usage(why) {
if (why) console.error(`run-browser.mjs: ${why}`);
console.error('usage: node run-browser.mjs --browser PATH [--external DIR] [--only PREFIX] [--limit SECONDS] CORPUS MODULE...');
process.exit(2);
}
const argv = process.argv.slice(2);
let browser = null, external = null, only = null, limit = 300;
const rest = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--browser') browser = argv[++i] ?? usage();
else if (a === '--external') external = argv[++i] ?? usage();
else if (a === '--only') only = argv[++i] ?? usage();
else if (a === '--limit') limit = Number(argv[++i] ?? usage());
else if (a.startsWith('--')) usage(`no option ${a}`);
else rest.push(a);
}
if (!browser || rest.length < 2 || !(limit > 0)) usage();
let cases;
try {
cases = loadCases(rest[0], { external, only });
} catch (e) {
console.error(`run-browser.mjs: ${e.message}`);
process.exit(2);
}
const modules = rest.slice(1).map((p) => ({ path: p, bytes: readFileSync(p) }));
const results = modules.map(() => new Array(cases.length));
const job = JSON.stringify({
modules: modules.length,
limit,
cases: cases.map((c) => ({ name: c.name, cap: c.cap, payload: Buffer.from(c.payload).toString('base64'), set: Buffer.from(c.set).toString('base64') })),
});
const files = {
'/': ['text/html', readFileSync(join(HERE, 'page.html'))],
'/worker.mjs': ['text/javascript', readFileSync(join(HERE, 'worker.mjs'))],
'/sink.mjs': ['text/javascript', readFileSync(join(HERE, '..', 'sink.mjs'))],
'/job': ['application/json', Buffer.from(job)],
};
modules.forEach((m, i) => { files[`/module/${i}`] = ['application/wasm', m.bytes]; });
let finished;
const done = new Promise((r) => { finished = r; });
// When the page last asked for a file or sent a result, and how many results came back.
let progress = Date.now();
let received = 0;
const server = createServer((req, res) => {
progress = Date.now();
if (req.method === 'GET' && files[req.url]) {
const [type, body] = files[req.url];
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
res.end(body);
return;
}
if (req.method === 'POST' && (req.url === '/result' || req.url === '/done')) {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
let m;
try { m = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { res.writeHead(400); res.end(); return; }
if (req.url === '/result') {
const r = m.r.output !== undefined ? { output: new Uint8Array(Buffer.from(m.r.output, 'base64')) } : m.r;
if (results[m.module] && m.case >= 0 && m.case < cases.length) results[m.module][m.case] = r;
received++;
} else {
finished(m);
}
res.writeHead(204);
res.end();
});
return;
}
res.writeHead(404);
res.end();
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${server.address().port}/`;
const profile = mkdtempSync(join(tmpdir(), 'wai-int-module-browser-'));
const child = spawn(browser, ['--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run', '--no-default-browser-check', `--user-data-dir=${profile}`, url], { stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
const exited = new Promise((r) => child.on('exit', (code, sig) => r({ exit: `the browser exited (${code ?? sig}) before the page finished` })));
child.on('error', (e) => finished({ error: `the browser did not start: ${e.message}` }));
const overall = (limit * cases.length * modules.length + 120) * 1000;
const timeout = new Promise((r) => setTimeout(() => r({ error: 'the page did not finish' }), overall).unref());
// A page that sends nothing for longer than a case may take has stopped: the run ends there, with
// the browser's last words, rather than at the overall bound or the job's own timeout with none
// (CI, 2026-11-08: one run in Chromium sat silent for 30 minutes; the same commit's next run, on
// the same runner, took 3 seconds).
const quiet = limit + 60;
const stalled = new Promise((r) => {
const t = setInterval(() => {
if (Date.now() - progress <= quiet * 1000) return;
clearInterval(t);
r({ error: `the page sent nothing for ${quiet} s, ${received} of ${cases.length * modules.length} results back: the page, its worker or the browser stopped` });
}, 1000);
t.unref();
});
const end = await Promise.race([done, exited, timeout, stalled]);
// The browser is ended, and its processes have let go of the profile before it is removed.
child.kill('SIGKILL');
await Promise.race([exited, new Promise((r) => setTimeout(r, 10000))]);
server.close();
try {
rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 });
} catch (e) {
console.error(`run-browser.mjs: the browser's profile ${profile} was not removed: ${e.message}`);
}
if (end.error || end.exit) {
console.log(`run-browser.mjs: ${end.error || end.exit}`);
if (stderr) console.log(stderr);
process.exit(1);
}
// The engine's version, from the user agent's token of the form <name>/<version> whose name
// holds "Chrom".
// The user agent may give a reduced version; the browser's own --version gives it whole.
const token = end.userAgent.match(/[A-Za-z]*Chrom[A-Za-z]*\/([0-9.]+)/);
const whole = (spawnSync(browser, ['--version'], { encoding: 'utf8' }).stdout || '').match(/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/);
const engine = `chromium@${whole ? whole[0] : token ? token[1] : 'unknown'}`;
const digests = new Map();
let failed = false;
modules.forEach((m, i) => {
const rs = results[i].map((r) => r ?? { sink: 'module_result_invalid', detail: 'no result came back' });
const { wrong, report, digest } = judge(cases, rs);
for (const line of report) console.log(line);
console.log(`${m.path} (SHA-256 ${sha256(m.bytes)}) on ${engine}, ${arch}: ${cases.length - wrong} of ${cases.length} cases the corpus's; run digest ${digest}`);
failed ||= wrong > 0;
digests.set(m.path, digest);
});
const distinct = new Set(digests.values());
if (distinct.size > 1) {
console.log(`run-browser.mjs: the runs differ: ${distinct.size} run digests`);
failed = true;
} else {
console.log(`run-browser.mjs: ${digests.size} runs in ${end.userAgent}, one run digest ${[...distinct][0]}`);
}
process.exit(failed ? 1 : 0);