Improve reftest runner performance

- Replace base64/JSON POST image submission with binary WebSocket frames,
  avoiding base64 overhead and per-request HTTP costs; quit is also sent
  over the same WS channel to guarantee ordering
- Prefetch the next task's PDF in the worker while the current task is
  still rendering
- Use `getImageData` instead of `toBlob` for partial-test baseline
  comparison (synchronous, no encoding); only encode to PNG in master mode
- Disable bounce tracking protection in Firefox to prevent EBUSY errors
  from Puppeteer's profile cleanup on Windows
This commit is contained in:
Calixte Denizet 2026-04-15 15:45:12 +02:00
parent 7c5f7876e9
commit b6fac76429
3 changed files with 319 additions and 190 deletions

View File

@ -480,6 +480,20 @@ class Rasterize {
* @property {HTMLDivElement} end - Container for a completion message.
*/
function buffersEqual(a, b) {
if (a.byteLength !== b.byteLength) {
return false;
}
const v1 = new Uint8Array(a);
const v2 = new Uint8Array(b);
for (let i = 0; i < v1.length; i++) {
if (v1[i] !== v2[i]) {
return false;
}
}
return true;
}
class Driver {
/**
* @param {DriverOptions} options
@ -509,6 +523,11 @@ class Driver {
this.sessionIndex = parseInt(params.get("sessionindex") || "0", 10);
this.sessionCount = parseInt(params.get("sessioncount") || "1", 10);
// Open a persistent WebSocket connection to the server for binary result
// submission.
this.ws = new WebSocket(`ws://${location.host}`);
this.ws.binaryType = "arraybuffer";
// Create a working canvas
this.canvas = document.createElement("canvas");
}
@ -538,6 +557,11 @@ class Driver {
// When gathering the stats the numbers seem to be more reliable
// if the browser is given more time to start.
setTimeout(async () => {
if (this.ws.readyState !== WebSocket.OPEN) {
await new Promise(resolve => {
this.ws.addEventListener("open", resolve, { once: true });
});
}
const response = await fetch(this.manifestFile);
if (!response.ok) {
throw new Error(response.statusText);
@ -622,6 +646,8 @@ class Driver {
const prevFile = md5FileMap.get(task.md5);
if (prevFile) {
if (task.file !== prevFile) {
task._prefetchedLoadingTask?.destroy();
task._prefetchedLoadingTask = null;
this._nextPage(
task,
`The "${task.file}" file is identical to the previously used "${prevFile}" file.`
@ -659,6 +685,10 @@ class Driver {
this._log(` Loading file "${task.file}"\n`);
// Start fetching and parsing the next task's PDF in the worker
// now, so it overlaps with the current task's load and render time.
this._prefetchNextTask();
try {
let xfaStyleElement = null;
if (task.enableXfa) {
@ -670,28 +700,13 @@ class Driver {
.getElementsByTagName("head")[0]
.append(xfaStyleElement);
}
const isOffscreenCanvasSupported =
task.isOffscreenCanvasSupported === false ? false : undefined;
const disableFontFace = task.disableFontFace === true;
const documentOptions = {
url: new URL(task.file, window.location),
password: task.password,
cMapUrl: CMAP_URL,
iccUrl: ICC_URL,
standardFontDataUrl: STANDARD_FONT_DATA_URL,
wasmUrl: WASM_URL,
disableAutoFetch: !task.enableAutoFetch,
pdfBug: true,
useSystemFonts: task.useSystemFonts,
useWasm: task.useWasm,
useWorkerFetch: task.useWorkerFetch,
enableXfa: task.enableXfa,
isOffscreenCanvasSupported,
...this._getDocumentOptions(task),
styleElement: xfaStyleElement,
disableFontFace,
};
const loadingTask = getDocument(documentOptions);
const loadingTask =
task._prefetchedLoadingTask ?? getDocument(documentOptions);
task._prefetchedLoadingTask = null;
let promise = loadingTask.promise;
if (!this.masterMode && task.type === "extract") {
@ -849,6 +864,44 @@ class Driver {
});
}
_getDocumentOptions(task) {
return {
url: new URL(task.file, window.location),
password: task.password,
cMapUrl: CMAP_URL,
iccUrl: ICC_URL,
standardFontDataUrl: STANDARD_FONT_DATA_URL,
wasmUrl: WASM_URL,
disableAutoFetch: !task.enableAutoFetch,
pdfBug: true,
useSystemFonts: task.useSystemFonts,
useWasm: task.useWasm,
useWorkerFetch: task.useWorkerFetch,
enableXfa: task.enableXfa,
isOffscreenCanvasSupported:
task.isOffscreenCanvasSupported === false ? false : undefined,
disableFontFace: task.disableFontFace === true,
};
}
_prefetchNextTask() {
const nextIdx = this.currentTask + 1;
if (nextIdx >= this.manifest.length) {
return;
}
const task = this.manifest[nextIdx];
// Skip tasks that do not load a PDF or that need DOM setup (XFA style
// element injection) to happen synchronously before getDocument.
if (
task.type === "skip-because-failing" ||
task.type === "other" ||
task.enableXfa
) {
return;
}
task._prefetchedLoadingTask = getDocument(this._getDocumentOptions(task));
}
_cleanup() {
// Clear out all the stylesheets since a new one is created for each font.
while (document.styleSheets.length > 0) {
@ -896,14 +949,15 @@ class Driver {
let ctx;
if (!task.pdfDoc) {
const dataUrl = this.canvas.toDataURL("image/png");
this._sendResult(dataUrl, task, failure).then(() => {
this._log(
"done" + (failure ? " (failed !: " + failure + ")" : "") + "\n"
);
this.currentTask++;
this._nextTask();
});
new Promise(r => {
this.canvas.toBlob(r, "image/png");
})
.then(blob => this._sendResult(blob, task, failure))
.then(() => {
this._log(`done${failure ? ` (failed !: ${failure})` : ""}\n`);
this.currentTask++;
this._nextTask();
});
return;
}
@ -1177,7 +1231,21 @@ class Driver {
};
clearOutsidePartial();
const baseline = ctx.canvas.toDataURL("image/png");
// Capture pixel data synchronously for the comparison;
// only encode to PNG in master mode where the server needs
// to save it as the reference image.
const baselinePixels = ctx.getImageData(
0,
0,
ctx.canvas.width,
ctx.canvas.height
);
const baselineBlob =
this.masterMode && !task.knownPartialMismatch
? await new Promise(r => {
ctx.canvas.toBlob(r, "image/png");
})
: null;
this._clearCanvas();
const recordedBBoxes = page.recordedBBoxes;
@ -1223,7 +1291,8 @@ class Driver {
// one pixel of a very slightly different shade), so we
// avoid compating them to the non-optimized version and
// instead use the optimized version also for makeref.
task.knownPartialMismatch ? null : baseline
task.knownPartialMismatch ? null : baselinePixels,
baselineBlob
);
return;
}
@ -1268,32 +1337,31 @@ class Driver {
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
_snapshot(task, failure, baselineDataUrl = null) {
async _snapshot(task, failure, baselinePixels = null, baselineBlob = null) {
this._log("Snapshotting... ");
const dataUrl = this.canvas.toDataURL("image/png");
if (baselineDataUrl && baselineDataUrl !== dataUrl) {
failure ||= "Optimized rendering differs from full rendering.";
}
this._sendResult(dataUrl, task, failure, baselineDataUrl).then(() => {
this._log(
"done" + (failure ? " (failed !: " + failure + ")" : "") + "\n"
);
task.pageNum++;
this._nextPage(task);
const snapshotBlob = await new Promise(r => {
this.canvas.toBlob(r, "image/png");
});
if (baselinePixels) {
const snapPixels = this.canvas
.getContext("2d")
.getImageData(0, 0, this.canvas.width, this.canvas.height);
if (!buffersEqual(baselinePixels.data.buffer, snapPixels.data.buffer)) {
failure ||= "Optimized rendering differs from full rendering.";
}
}
await this._sendResult(snapshotBlob, task, failure, baselineBlob);
this._log(`done${failure ? ` (failed !: ${failure})` : ""}\n`);
task.pageNum++;
this._nextPage(task);
}
_quit() {
this._log("Done !");
this.end.textContent = "Tests finished. Close this window!";
// Send the quit request
fetch(`/tellMeToQuit?browser=${escape(this.browser)}`, {
method: "POST",
});
// Send quit over the same WebSocket channel so the server processes it
// only after all preceding result frames have been handled.
this.ws.send(JSON.stringify({ type: "quit", browser: this.browser }));
}
_info(message) {
@ -1331,8 +1399,11 @@ class Driver {
}
}
_sendResult(snapshot, task, failure, baselineSnapshot = null) {
const result = JSON.stringify({
async _sendResult(snapshotBlob, task, failure, baselineBlob = null) {
// Build a binary WebSocket frame:
// [4 bytes BE: meta_len][meta JSON][4 bytes BE: snapshot_len]
// [snapshot PNG][baseline PNG]
const meta = JSON.stringify({
browser: this.browser,
id: task.id,
numPages: task.pdfDoc ? task.lastPage || task.pdfDoc.numPages : 0,
@ -1342,14 +1413,34 @@ class Driver {
file: task.file,
round: task.round,
page: task.pageMapping?.[task.pageNum] ?? task.pageNum,
snapshot,
baselineSnapshot,
stats: task.stats.times,
viewportWidth: task.viewportWidth,
viewportHeight: task.viewportHeight,
outputScale: task.outputScale,
});
return this._send("/submit_task_results", result);
const metaBytes = new TextEncoder().encode(meta);
const snapshotBytes = new Uint8Array(await snapshotBlob.arrayBuffer());
const baselineBytes = baselineBlob
? new Uint8Array(await baselineBlob.arrayBuffer())
: null;
const totalLen =
4 +
metaBytes.length +
4 +
snapshotBytes.length +
(baselineBytes?.length ?? 0);
const buf = new ArrayBuffer(totalLen);
const view = new DataView(buf);
const bytes = new Uint8Array(buf);
view.setUint32(0, metaBytes.length);
bytes.set(metaBytes, 4);
view.setUint32(4 + metaBytes.length, snapshotBytes.length);
bytes.set(snapshotBytes, 8 + metaBytes.length);
if (baselineBytes) {
bytes.set(baselineBytes, 8 + metaBytes.length + snapshotBytes.length);
}
this.ws.send(buf);
}
_send(url, message) {
@ -1358,26 +1449,20 @@ class Driver {
fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: message,
})
.then(response => {
// Retry until successful.
if (!response.ok || response.status !== 200) {
throw new Error(response.statusText);
}
this.inFlightRequests--;
resolve();
})
.catch(reason => {
console.warn(`Driver._send failed (${url}):`, reason);
this.inFlightRequests--;
resolve();
this._send(url, message);
});

View File

@ -290,6 +290,33 @@ async function startRefTest(masterMode, showRefImages) {
startTime = Date.now();
startServer();
server.hooks.POST.push(refTestPostHandler);
server.hooks.WS.push(ws => {
let pendingOps = 0;
let pendingQuit = null;
ws.on("message", (data, isBinary) => {
if (isBinary) {
pendingOps++;
handleWsBinaryResult(data).finally(() => {
if (--pendingOps === 0 && pendingQuit) {
pendingQuit();
pendingQuit = null;
}
});
} else {
const msg = JSON.parse(data.toString());
if (msg.type === "quit") {
const session = getSession(msg.browser);
monitorBrowserTimeout(session, null);
const doQuit = () => closeSession(session.name);
if (pendingOps === 0) {
doQuit();
} else {
pendingQuit = doQuit;
}
}
}
});
});
onAllSessionsClosed = finalize;
await startBrowsers({
@ -400,68 +427,94 @@ function getSessionManifest(manifest, sessionIndex, sessionCount) {
return manifest.slice(start, end);
}
function checkEq(task, results, session, masterMode) {
var taskId = task.id;
async function checkEq(task, results, session, masterMode) {
const taskId = task.id;
const browserType = session.browserType ?? session.name;
var refSnapshotDir = path.join(refsDir, os.platform(), browserType, taskId);
var testSnapshotDir = path.join(
const refSnapshotDir = path.join(refsDir, os.platform(), browserType, taskId);
const testSnapshotDir = path.join(
testResultDir,
os.platform(),
browserType,
taskId
);
const tmpSnapshotDir = masterMode
? path.join(refsTmpDir, os.platform(), browserType, taskId)
: null;
var pageResults = results[0];
var taskType = task.type;
var numEqNoSnapshot = 0;
var numEqFailures = 0;
for (var page = 0; page < pageResults.length; page++) {
if (!pageResults[page]) {
const pageResults = results[0];
const taskType = task.type;
let numEqNoSnapshot = 0;
let numEqFailures = 0;
// Read all reference PNGs in parallel, skipping pages with no valid snapshot.
const refSnapshots = await Promise.all(
pageResults.map((pageResult, page) => {
if (!pageResult || !(pageResult.snapshot instanceof Buffer)) {
return null;
}
return fs.promises
.readFile(path.join(refSnapshotDir, `${page + 1}.png`))
.catch(err => {
if (err.code === "ENOENT") {
return null;
}
throw err;
});
})
);
// Compare all pages (in-memory) and collect all I/O writes to fire together.
const writePromises = [];
const logEntries = [];
let testDirCreated = false;
let tmpDirCreated = false;
for (let page = 0; page < pageResults.length; page++) {
const pageResult = pageResults[page];
if (!pageResult) {
continue;
}
const pageResult = pageResults[page];
let testSnapshot = pageResult.snapshot;
if (testSnapshot?.startsWith("data:image/png;base64,")) {
testSnapshot = Buffer.from(testSnapshot.substring(22), "base64");
} else {
const testSnapshot = pageResult.snapshot;
if (!(testSnapshot instanceof Buffer)) {
console.error("Valid snapshot was not found.");
continue;
}
let unoptimizedSnapshot = pageResult.baselineSnapshot;
if (unoptimizedSnapshot?.startsWith("data:image/png;base64,")) {
unoptimizedSnapshot = Buffer.from(
unoptimizedSnapshot.substring(22),
"base64"
);
}
const unoptimizedSnapshot = pageResult.baselineSnapshot ?? null;
const refSnapshot = refSnapshots[page];
var refSnapshot = null;
var eq = false;
var refPath = path.join(refSnapshotDir, `${page + 1}.png`);
if (!fs.existsSync(refPath)) {
let eq = false;
if (!refSnapshot) {
numEqNoSnapshot++;
if (!masterMode) {
console.log(`WARNING: no reference snapshot ${refPath}`);
console.log(
`WARNING: no reference snapshot ${path.join(refSnapshotDir, `${page + 1}.png`)}`
);
}
} else {
refSnapshot = fs.readFileSync(refPath);
eq =
stripPrivatePngChunks(refSnapshot).toString("hex") ===
stripPrivatePngChunks(testSnapshot).toString("hex");
Buffer.compare(
stripPrivatePngChunks(refSnapshot),
stripPrivatePngChunks(testSnapshot)
) === 0;
if (!eq) {
console.log(
`TEST-UNEXPECTED-FAIL | ${taskType} ${taskId} | in ${session.name} | rendering of page ${page + 1} != reference rendering`
);
ensureDirSync(testSnapshotDir);
if (!testDirCreated) {
ensureDirSync(testSnapshotDir);
testDirCreated = true;
}
const testPng = path.join(testSnapshotDir, `${page + 1}.png`);
const refPng = path.join(testSnapshotDir, `${page + 1}_ref.png`);
fs.writeFileSync(testPng, testSnapshot);
fs.writeFileSync(refPng, refSnapshot);
writePromises.push(
fs.promises.writeFile(testPng, testSnapshot),
fs.promises.writeFile(refPng, refSnapshot)
);
// This no longer follows the format of Mozilla reftest output.
const viewportString = `(${pageResult.viewportWidth}x${pageResult.viewportHeight}x${pageResult.outputScale})`;
fs.appendFileSync(
eqLog,
logEntries.push(
`REFTEST TEST-UNEXPECTED-FAIL | ${session.name}-${taskId}-page${page + 1} | image comparison (==)\n` +
`REFTEST IMAGE 1 (TEST)${viewportString}: ${testPng}\n` +
`REFTEST IMAGE 2 (REFERENCE)${viewportString}: ${refPng}\n`
@ -470,20 +523,24 @@ function checkEq(task, results, session, masterMode) {
}
}
if (masterMode && (!refSnapshot || !eq)) {
var tmpSnapshotDir = path.join(
refsTmpDir,
os.platform(),
browserType,
taskId
);
ensureDirSync(tmpSnapshotDir);
fs.writeFileSync(
path.join(tmpSnapshotDir, `${page + 1}.png`),
unoptimizedSnapshot ?? testSnapshot
if (!tmpDirCreated) {
ensureDirSync(tmpSnapshotDir);
tmpDirCreated = true;
}
writePromises.push(
fs.promises.writeFile(
path.join(tmpSnapshotDir, `${page + 1}.png`),
unoptimizedSnapshot ?? testSnapshot
)
);
}
}
if (logEntries.length) {
writePromises.push(fs.promises.appendFile(eqLog, logEntries.join("")));
}
await Promise.all(writePromises);
session.numEqNoSnapshot += numEqNoSnapshot;
if (numEqFailures > 0) {
session.numEqFailures += numEqFailures;
@ -506,7 +563,7 @@ function checkFBF(task, results, session, masterMode) {
if (!r0Page) {
continue;
}
if (r0Page.snapshot !== r1Page.snapshot) {
if (Buffer.compare(r0Page.snapshot, r1Page.snapshot) !== 0) {
// The FBF tests fail intermittently in Firefox and Google Chrome when run
// on the bots, ignoring `makeref` failures for now; see
// - https://github.com/mozilla/pdf.js/pull/12368
@ -543,7 +600,7 @@ function checkLoad(task, results, browser) {
console.log(`TEST-PASS | load test ${task.id} | in ${browser}`);
}
function checkRefTestResults(browser, id, results) {
async function checkRefTestResults(browser, id, results) {
var failed = false;
var session = getSession(browser);
var task = session.tasks[id];
@ -605,7 +662,7 @@ function checkRefTestResults(browser, id, results) {
case "text":
case "highlight":
case "extract":
checkEq(task, results, session, session.masterMode);
await checkEq(task, results, session, session.masterMode);
break;
case "fbf":
checkFBF(task, results, session, session.masterMode);
@ -624,16 +681,61 @@ function checkRefTestResults(browser, id, results) {
});
}
function refTestPostHandler(parsedUrl, req, res) {
var pathname = parsedUrl.pathname;
if (
pathname !== "/tellMeToQuit" &&
pathname !== "/info" &&
pathname !== "/submit_task_results"
) {
return false;
async function handleWsBinaryResult(data) {
// Binary frame layout:
// [4 bytes BE: meta_len][meta JSON][4 bytes BE: snapshot_len]
// [snapshot PNG][baseline PNG (rest)]
const metaLen = data.readUInt32BE(0);
const meta = JSON.parse(data.subarray(4, 4 + metaLen).toString("utf8"));
const snapshotLen = data.readUInt32BE(4 + metaLen);
const snapshotOffset = 8 + metaLen;
const snapshot = data.subarray(snapshotOffset, snapshotOffset + snapshotLen);
const baseline =
data.length > snapshotOffset + snapshotLen
? data.subarray(snapshotOffset + snapshotLen)
: null;
const { browser, id, round, page, failure, lastPageNum, numberOfTasks } =
meta;
const session = getSession(browser);
monitorBrowserTimeout(session, handleSessionTimeout);
const taskResults = session.taskResults[id];
if (!taskResults[round]) {
taskResults[round] = [];
}
if (taskResults[round][page - 1]) {
console.error(
`Results for ${browser}:${id}:${round}:${page - 1} were already submitted`
);
// TODO abort testing here?
}
taskResults[round][page - 1] = {
failure,
snapshot,
baselineSnapshot: baseline,
viewportWidth: meta.viewportWidth,
viewportHeight: meta.viewportHeight,
outputScale: meta.outputScale,
};
if (stats) {
stats.push({ browser, pdf: id, page: page - 1, round, stats: meta.stats });
}
const lastTaskResults = taskResults.at(-1);
const isDone =
lastTaskResults?.[lastPageNum - 1] ||
lastTaskResults?.filter(result => !!result).length === numberOfTasks;
if (isDone) {
await checkRefTestResults(browser, id, taskResults);
session.remaining--;
}
}
function refTestPostHandler(parsedUrl, req, res) {
if (parsedUrl.pathname !== "/info") {
return false;
}
var body = "";
req.on("data", function (data) {
body += data;
@ -641,80 +743,7 @@ function refTestPostHandler(parsedUrl, req, res) {
req.on("end", function () {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end();
var session;
if (pathname === "/tellMeToQuit") {
session = getSession(parsedUrl.searchParams.get("browser"));
monitorBrowserTimeout(session, null);
closeSession(session.name);
return;
}
var data = JSON.parse(body);
if (pathname === "/info") {
console.log(data.message);
return;
}
var browser = data.browser;
var round = data.round;
var id = data.id;
var page = data.page - 1;
var failure = data.failure;
var snapshot = data.snapshot;
var baselineSnapshot = data.baselineSnapshot;
var lastPageNum = data.lastPageNum;
var numberOfTasks = data.numberOfTasks;
session = getSession(browser);
monitorBrowserTimeout(session, handleSessionTimeout);
var taskResults = session.taskResults[id];
if (!taskResults[round]) {
taskResults[round] = [];
}
if (taskResults[round][page]) {
console.error(
"Results for " +
browser +
":" +
id +
":" +
round +
":" +
page +
" were already submitted"
);
// TODO abort testing here?
}
taskResults[round][page] = {
failure,
snapshot,
baselineSnapshot,
viewportWidth: data.viewportWidth,
viewportHeight: data.viewportHeight,
outputScale: data.outputScale,
};
if (stats) {
stats.push({
browser,
pdf: id,
page,
round,
stats: data.stats,
});
}
const lastTaskResults = taskResults.at(-1);
const isDone =
lastTaskResults?.[lastPageNum - 1] ||
lastTaskResults?.filter(result => !!result).length === numberOfTasks;
if (isDone) {
checkRefTestResults(browser, id, taskResults);
session.remaining--;
}
console.log(JSON.parse(body).message);
});
return true;
}
@ -911,6 +940,10 @@ async function startBrowser({
// Disable AI/ML functionality.
"browser.ai.control.default": "blocked",
"privacy.baselineFingerprintingProtection": false,
// Disable bounce tracking protection to avoid creating a SQLite database
// file that Firefox keeps locked briefly after shutdown, causing EBUSY
// errors in Puppeteer's profile cleanup on Windows.
"privacy.bounceTrackingProtection.mode": 0,
...extraPrefsFirefox,
};
}

View File

@ -22,6 +22,7 @@ import fsPromises from "fs/promises";
import http from "http";
import path from "path";
import { pathToFileURL } from "url";
import { WebSocketServer } from "ws";
const MIME_TYPES = {
".css": "text/css",
@ -49,6 +50,7 @@ class WebServer {
this.host = host || "localhost";
this.port = port || 0;
this.server = null;
this.wss = null;
this.verbose = false;
this.cacheExpirationTime = cacheExpirationTime || 0;
this.disableRangeRequests = false;
@ -56,6 +58,7 @@ class WebServer {
this.hooks = {
GET: [crossOriginHandler, redirectHandler],
POST: [],
WS: [],
};
}
@ -63,10 +66,18 @@ class WebServer {
this.#ensureNonZeroPort();
this.server = http.createServer(this.#handler.bind(this));
this.server.listen(this.port, this.host, callback);
this.wss = new WebSocketServer({ server: this.server });
this.wss.on("connection", ws => {
for (const handler of this.hooks.WS) {
handler(ws);
}
});
console.log(`Server running at http://${this.host}:${this.port}/`);
}
stop(callback) {
this.wss.close();
this.wss = null;
this.server.close(callback);
this.server = null;
}