Repository navigation
Description
Attach (recommended) or Link to PDF file
Any PDF larger than 2 × rangeChunkSize, served with range support. The repro below uses the repository test file compressed.tracemonkey-pldi-09.pdf.
Web browser and its version
Chrome 154.0.8037.98
Operating system and its version
Windows 11 Pro 10.0.26300
PDF.js version
6.4.299 (also seen with 6.3.289; the code is unchanged on master 6c71c0b)
Is the bug present in the latest PDF.js version?
Yes
Is a browser extension
No
Steps to reproduce the problem
- Put
server.mjsandindex.html(below) in one folder, then runnpm pack pdfjs-dist@6.4.299 && tar -xzf pdfjs-dist-6.4.299.tgzand downloadcompressed.tracemonkey-pldi-09.pdfthere. node server.mjs ./package/build ./compressed.tracemonkey-pldi-09.pdf(serves the PDF withAccept-Ranges: bytes/206, each response delayed by 300 ms).- Open http://127.0.0.1:8765/ in Chrome and wait ~5 s. The result is printed on the page; the console shows
Uncaught (in promise) AbortError.
The page uses the plain API: getDocument({ url, disableStream: true }), renders page 1, then calls loadingTask.destroy() (what a viewer does when the user closes it). To hit the race deterministically it wraps Worker#addEventListener so that destroy() runs right before the main thread handles a queued GetRangeReader message. Messages are not reordered or dropped; this is the same as the user closing the viewer while that message is still waiting in the task queue.
What is the expected behavior?
destroy() cancels the in-flight range request silently, with no unhandled promise rejection.
What went wrong?
An unhandled rejection AbortError: signal is aborted without reason with the stack at PDFFetchStreamRangeReader.cancel. The page result (3 of 3 runs):
{"pdfjsVersion":"6.4.299","disableAutoFetch":false,"trapFired":true,"rejections":["AbortError: signal is aborted without reason"]}We first saw it in production error monitoring (Rollbar with captureUnhandledRejections) when users close a PDF viewer.
Why it happens:
- On
GetRangeReaderthe main thread creates aPDFFetchStreamRangeReader, whose constructor startsfetchand forwards a failure to.catch(this._readCapability.reject)(fetch_stream.js#L173). - The only consumer of
_readCapability.promiseisread()(#L177).read()is called only fromsink.onPull(api.js#L2673), i.e. after the worker receivedSTART_COMPLETEand sentPULL. - If
Terminatereaches the worker beforeSTART_COMPLETE(destroy()was called whileGetRangeReaderwas queued on the main thread), the worker cancels the stream before pulling. Thensink.onCancel→rangeReader.cancel()(api.js#L2692-L2693) →this._abortController.abort()(fetch_stream.js#L186) →fetchrejects →_readCapabilityis rejected with no handler. - With the default
disableAutoFetch: false, after the visible pages are rendered the worker keeps requesting the rest of the file chunk by chunk, so aGetRangeReadermessage is often in flight while the document is open. A busy main thread makes the window wider.
Suggested fix, checked locally on the 6.4.299 build with the same repro (0 of 3 runs produce the rejection, the trap still fires):
fetchUrl(url, headers, withCredentials, this._abortController)
.then(response => { /* unchanged */ })
.catch(this._readCapability.reject);
// Cancelled before the first read(): nobody awaits the capability.
this._readCapability.promise.catch(() => {});I only checked the fetch range reader; other readers that resolve a capability awaited only in read() may have the same pattern.
Workaround: disableAutoFetch: true (together with disableStream: true) stops the background chunk requests after rendering (same repro with ?disableAutoFetch=1: no rejection). Closing during an on-demand chunk request can still hit the window.
Link to a viewer
Not a viewer issue; the plain API repro is below.
Additional context
index.html
<!doctype html>
<meta charset="utf-8">
<canvas id="canvas"></canvas>
<pre id="out">running…</pre>
<script type="module">
import * as pdfjsLib from "/build/pdf.mjs";
pdfjsLib.GlobalWorkerOptions.workerSrc = "/build/pdf.worker.mjs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const rejections = [];
window.addEventListener("unhandledrejection", (e) => {
rejections.push(`${e.reason?.name}: ${e.reason?.message}`);
});
// Instrumentation (only to hit the race deterministically): call loadingTask.destroy() right
// before the main thread processes a queued "GetRangeReader" message from the worker -- what
// happens when the user closes the viewer while that message is still in the task queue.
let loadingTask = null;
let trapArmed = false;
let trapFired = false;
const NativeWorker = window.Worker;
window.Worker = class extends NativeWorker {
addEventListener(type, listener, options) {
if (type !== "message" || typeof listener !== "function") {
return super.addEventListener(type, listener, options);
}
return super.addEventListener(type, (event) => {
if (trapArmed && event.data?.action === "GetRangeReader") {
trapArmed = false;
trapFired = true;
loadingTask.destroy();
}
listener.call(this, event);
}, options);
}
};
async function renderFirstPage(params) {
loadingTask = pdfjsLib.getDocument(params);
const doc = await loadingTask.promise;
const page = await doc.getPage(1);
const viewport = page.getViewport({ scale: 1 });
const canvas = document.getElementById("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvas, canvasContext: canvas.getContext("2d"), viewport }).promise;
}
const disableAutoFetch = new URLSearchParams(location.search).get("disableAutoFetch") === "1";
// First page is shown; with the default disableAutoFetch: false the worker keeps requesting
// the remaining chunks of the file one by one.
await renderFirstPage({ url: "/test.pdf", disableStream: true, disableAutoFetch });
trapArmed = true;
await sleep(3000);
if (!trapFired) await loadingTask.destroy();
await sleep(1500);
window.__result = { pdfjsVersion: pdfjsLib.version, disableAutoFetch, trapFired, rejections };
document.getElementById("out").textContent = JSON.stringify(window.__result, null, 2);
</script>server.mjs
// Minimal repro server: serves pdfjs-dist build, index.html and a PDF with HTTP Range support.
// Every PDF response is delayed by 300 ms so that a range request is usually in flight.
// Usage: node server.mjs <path-to-pdfjs-dist/build> <path-to.pdf> [port]
import http from 'node:http';
import { readFileSync, existsSync } from 'node:fs';
import { join, normalize, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const [buildDir, pdfPath, port = '8765'] = process.argv.slice(2);
const pdf = readFileSync(pdfPath);
const here = dirname(fileURLToPath(import.meta.url));
const types = { mjs: 'text/javascript', js: 'text/javascript', html: 'text/html; charset=utf-8', map: 'application/json' };
http
.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
if (url.pathname === '/test.pdf') {
setTimeout(() => {
const range = req.headers.range;
const m = typeof range === 'string' ? /^bytes=(\d+)-(\d*)$/.exec(range) : null;
if (m) {
const start = Number(m[1]);
const end = m[2] ? Math.min(Number(m[2]), pdf.length - 1) : pdf.length - 1;
res.writeHead(206, {
'Content-Type': 'application/pdf',
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${start}-${end}/${pdf.length}`,
'Content-Length': String(end - start + 1),
});
res.end(pdf.subarray(start, end + 1));
return;
}
res.writeHead(200, { 'Content-Type': 'application/pdf', 'Accept-Ranges': 'bytes', 'Content-Length': String(pdf.length) });
res.end(pdf);
}, 300);
return;
}
const file =
url.pathname === '/' ? join(here, 'index.html')
: url.pathname.startsWith('/build/') ? join(buildDir, normalize(url.pathname.slice('/build/'.length)))
: null;
if (!file || file.includes('..') || !existsSync(file)) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { 'Content-Type': types[file.split('.').pop()] ?? 'application/octet-stream' });
res.end(readFileSync(file));
})
.listen(Number(port), '127.0.0.1', () => console.log(`http://127.0.0.1:${port}/`));Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity