Skip to content

[Bug]: Unhandled AbortError from PDFFetchStreamRangeReader when the loading task is destroyed before the first read() #22171

Closed

Description

@olegius88

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

  1. Put server.mjs and index.html (below) in one folder, then run npm pack pdfjs-dist@6.4.299 && tar -xzf pdfjs-dist-6.4.299.tgz and download compressed.tracemonkey-pldi-09.pdf there.
  2. node server.mjs ./package/build ./compressed.tracemonkey-pldi-09.pdf (serves the PDF with Accept-Ranges: bytes/206, each response delayed by 300 ms).
  3. 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 GetRangeReader the main thread creates a PDFFetchStreamRangeReader, whose constructor starts fetch and forwards a failure to .catch(this._readCapability.reject) (fetch_stream.js#L173).
  • The only consumer of _readCapability.promise is read() (#L177). read() is called only from sink.onPull (api.js#L2673), i.e. after the worker received START_COMPLETE and sent PULL.
  • If Terminate reaches the worker before START_COMPLETE (destroy() was called while GetRangeReader was queued on the main thread), the worker cancels the stream before pulling. Then sink.onCancel → rangeReader.cancel() (api.js#L2692-L2693) → this._abortController.abort() (fetch_stream.js#L186) → fetch rejects → _readCapability is 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 a GetRangeReader message 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

No one assigned

    Labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions