// 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 / 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);