Skip to content

[Bug]: In Node.js, rendering a JPEG 2000 image never finishes after another document opened later was destroyed #22131

Description

@JesperBellenbaum

Issue was created with AI, I verified it to my best knowledge - apologize if it turns out to be slop.

Attach (recommended) or Link to PDF file

Two tiny synthetic PDFs (912 and 580 bytes). Since they are generated, the script that writes them is included below (make-pdfs.mjs); the JPEG 2000 bytes are an 8×8 solid red image encoded with OpenJPEG 2.5.4.

Web browser and its version

None — Node.js v24.19.0, pdfjs-dist/legacy/build/pdf.mjs with @napi-rs/canvas 1.0.11

Operating system and its version

macOS 26.6.2 (arm64)

PDF.js version

pdfjs-dist 6.4.299 (also seen on 5.4.296)

Is the bug present in the latest PDF.js version?

Yes

Is a browser extension

No

Steps to reproduce the problem

  1. npm i pdfjs-dist@6.4.299 @napi-rs/canvas
  2. node make-pdfs.mjs
  3. node repro.mjs sequential → both pages render (jpx centre pixel is rgb(255, 0, 0)), exit 0
  4. node repro.mjs interleaved → plain renders, then jpx: page.render() has not settled after 5 s (hang), exit 1 (5 of 5 runs)

The interleaved case is: open jpx.pdf (await loadingTask.promise), then open plain.pdf, render it and destroy() it, then render page 1 of jpx.pdf. repro.mjs follows examples/node/pdf2png and sets cMapUrl, standardFontDataUrl and wasmUrl to the package's own folders.

make-pdfs.mjs
// Writes jpx.pdf (one page, an 8x8 red JPEG 2000 image scaled over the page) and
// plain.pdf (one page of Helvetica text). The JP2 bytes were produced with
// Pillow 12 / OpenJPEG 2.5.4: Image.new("RGB", (8, 8), (255, 0, 0)).save(f, "JPEG2000").
import fs from "fs";

const RED_JP2 = Buffer.from(
  "AAAADGpQICANCocKAAAAFGZ0eXBqcDIgAAAAAGpwMiAAAAAtanAyaAAAABZpaGRyAAAACAAAAAgAAwcHAAAAAAAPY29scgEAAAAAABAAAACkanAyY/9P/1EALwAAAAAACAAAAAgAAAAAAAAAAAAAAAgAAAAIAAAAAAAAAAAAAwcBAQcBAQcBAf9SAAwAAAABAAMEBAAB/1wADUBASEhQSEhQSEhQ/2QAJQABQ3JlYXRlZCBieSBPcGVuSlBFRyB2ZXJzaW9uIDIuNS40/5AACgAAAAAAIwAB/5PPtAQA34AIB9+ACAeAgICAgICAgID/2Q==",
  "base64"
);

function pdf(content, resources, extraObjects = []) {
  const objects = [
    "<< /Type /Catalog /Pages 2 0 R >>",
    "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
    `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources ${resources} /Contents 4 0 R >>`,
    `<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
    ...extraObjects,
  ];
  let out = "%PDF-1.7\n";
  const offsets = [];
  objects.forEach((o, i) => {
    offsets.push(Buffer.byteLength(out, "binary"));
    out += `${i + 1} 0 obj\n${o}\nendobj\n`;
  });
  const xref = Buffer.byteLength(out, "binary");
  out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
  for (const off of offsets) out += `${String(off).padStart(10, "0")} 00000 n \n`;
  out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
  return Buffer.from(out, "binary");
}

fs.writeFileSync(
  "jpx.pdf",
  pdf("q 200 0 0 200 0 0 cm /Im1 Do Q", "<< /XObject << /Im1 5 0 R >> >>", [
    `<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /JPXDecode /Length ${RED_JP2.length} >>\nstream\n${RED_JP2.toString("binary")}\nendstream`,
  ])
);
fs.writeFileSync(
  "plain.pdf",
  pdf("BT /F1 24 Tf 20 100 Td (Hello) Tj ET", "<< /Font << /F1 5 0 R >> >>", [
    "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
  ])
);
console.log("wrote jpx.pdf and plain.pdf");
repro.mjs
// Usage: node repro.mjs sequential | interleaved
// Modelled on examples/node/pdf2png, plus wasmUrl (needed for JPEG 2000).
import fs from "fs";
import { createRequire } from "module";
import path from "path";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";

const root = path.dirname(createRequire(import.meta.url).resolve("pdfjs-dist/package.json"));
const params = {
  cMapUrl: path.join(root, "cmaps/"),
  cMapPacked: true,
  standardFontDataUrl: path.join(root, "standard_fonts/"),
  wasmUrl: path.join(root, "wasm/"),
};
const open = (file) => getDocument({ ...params, data: new Uint8Array(fs.readFileSync(file)) });

async function render(name, loadingTask) {
  const doc = await loadingTask.promise;
  const page = await doc.getPage(1);
  const viewport = page.getViewport({ scale: 1 });
  const { canvas, context } = doc.canvasFactory.create(viewport.width, viewport.height);
  await page.render({ canvasContext: context, viewport }).promise;
  const [r, g, b] = context.getImageData(100, 100, 1, 1).data;
  console.log(`${name}: rendered, centre pixel rgb(${r}, ${g}, ${b})`);
  fs.writeFileSync(`${name}.png`, canvas.toBuffer("image/png"));
}

// Report a render that has not settled after 5 s, then exit.
async function withWatchdog(name, promise) {
  let timer;
  const watchdog = new Promise((resolve) => {
    timer = setTimeout(() => resolve("timeout"), 5000);
  });
  const result = await Promise.race([promise.then(() => "ok"), watchdog]);
  clearTimeout(timer);
  if (result === "timeout") {
    console.log(`${name}: page.render() has not settled after 5 s (hang)`);
    process.exit(1);
  }
}

const mode = process.argv[2];
if (mode === "sequential") {
  // One document at a time: open, render, destroy.
  const a = open("jpx.pdf");
  await withWatchdog("jpx", render("jpx", a));
  await a.destroy();
  const b = open("plain.pdf");
  await withWatchdog("plain", render("plain", b));
  await b.destroy();
} else if (mode === "interleaved") {
  // Two documents open; the one opened second is rendered and destroyed first.
  const a = open("jpx.pdf");
  await a.promise;
  const b = open("plain.pdf");
  await withWatchdog("plain", render("plain", b));
  await b.destroy();
  await withWatchdog("jpx", render("jpx", a));
  await a.destroy();
} else {
  console.log("Usage: node repro.mjs sequential | interleaved");
  process.exit(2);
}
console.log("done");

What is the expected behavior?

Page 1 of jpx.pdf renders (red centre pixel), as in the sequential run.

What went wrong?

page.render().promise for jpx.pdf never settles and the event loop goes idle — no error, no warning.

Link to a viewer

Not applicable (Node.js only).

Additional context

What we found while tracing it (please correct us if we misread the code):

  • In Node.js, PDFWorker uses the fake worker, so all documents share one instance of the worker modules and their statics.
  • With useWorkerFetch: false (the Node.js case), WasmImage.setOptions (src/core/wasm_image.js, called from src/core/pdf_manager.js per document) stores that document's MessageHandler in the static WasmImage.#handler — i.e. always the handler of the most recently opened document.
  • WasmImage#instantiateWasm fetches openjpeg.wasm via WasmImage.#handler.sendWithPromise("FetchBinaryData", …). If the document owning that handler has already been destroyed, its LoopbackPort has no listeners, the message is dropped and the promise never settles; the Promise.race in _getModule never resolves, so the JPX decode and the page render wait forever.
  • Once one decode has succeeded the wasm bytes are cached, so this only affects a JPX decode that runs before any decode in the process has succeeded.
  • This looks like the same class as [Bug]: Regression: "Invalid page request" when rendering multiple documents concurrently in v5.4.624 #20629 (fixed in Set a pages mapper per loaded document #20640): a process-wide static holding per-document state.

Possible directions: fetch the wasm through the decoding document's own handler instead of a static, and/or reject pending sendWithPromise callbacks when a MessageHandler is destroyed so the decoder fails instead of hanging. Our current workaround is to keep only one document open at a time per process.

Metadata

Metadata

Assignees

Type

No type

Fields

Priority

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions