You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]: In Node.js, rendering a JPEG 2000 image never finishes after another document opened later was destroyed #22131
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
npm i pdfjs-dist@6.4.299 @napi-rs/canvas
node make-pdfs.mjs
node repro.mjs sequential → both pages render (jpx centre pixel is rgb(255, 0, 0)), exit 0
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").importfsfrom"fs";constRED_JP2=Buffer.from("AAAADGpQICANCocKAAAAFGZ0eXBqcDIgAAAAAGpwMiAAAAAtanAyaAAAABZpaGRyAAAACAAAAAgAAwcHAAAAAAAPY29scgEAAAAAABAAAACkanAyY/9P/1EALwAAAAAACAAAAAgAAAAAAAAAAAAAAAgAAAAIAAAAAAAAAAAAAwcBAQcBAQcBAf9SAAwAAAABAAMEBAAB/1wADUBASEhQSEhQSEhQ/2QAJQABQ3JlYXRlZCBieSBPcGVuSlBFRyB2ZXJzaW9uIDIuNS40/5AACgAAAAAAIwAB/5PPtAQA34AIB9+ACAeAgICAgICAgID/2Q==","base64");functionpdf(content,resources,extraObjects=[]){constobjects=["<< /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,];letout="%PDF-1.7\n";constoffsets=[];objects.forEach((o,i)=>{offsets.push(Buffer.byteLength(out,"binary"));out+=`${i+1} 0 obj\n${o}\nendobj\n`;});constxref=Buffer.byteLength(out,"binary");out+=`xref\n0 ${objects.length+1}\n0000000000 65535 f \n`;for(constoffofoffsets)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`;returnBuffer.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).importfsfrom"fs";import{createRequire}from"module";importpathfrom"path";import{getDocument}from"pdfjs-dist/legacy/build/pdf.mjs";constroot=path.dirname(createRequire(import.meta.url).resolve("pdfjs-dist/package.json"));constparams={cMapUrl: path.join(root,"cmaps/"),cMapPacked: true,standardFontDataUrl: path.join(root,"standard_fonts/"),wasmUrl: path.join(root,"wasm/"),};constopen=(file)=>getDocument({ ...params,data: newUint8Array(fs.readFileSync(file))});asyncfunctionrender(name,loadingTask){constdoc=awaitloadingTask.promise;constpage=awaitdoc.getPage(1);constviewport=page.getViewport({scale: 1});const{ canvas, context }=doc.canvasFactory.create(viewport.width,viewport.height);awaitpage.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.asyncfunctionwithWatchdog(name,promise){lettimer;constwatchdog=newPromise((resolve)=>{timer=setTimeout(()=>resolve("timeout"),5000);});constresult=awaitPromise.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);}}constmode=process.argv[2];if(mode==="sequential"){// One document at a time: open, render, destroy.consta=open("jpx.pdf");awaitwithWatchdog("jpx",render("jpx",a));awaita.destroy();constb=open("plain.pdf");awaitwithWatchdog("plain",render("plain",b));awaitb.destroy();}elseif(mode==="interleaved"){// Two documents open; the one opened second is rendered and destroyed first.consta=open("jpx.pdf");awaita.promise;constb=open("plain.pdf");awaitwithWatchdog("plain",render("plain",b));awaitb.destroy();awaitwithWatchdog("jpx",render("jpx",a));awaita.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.
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.
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.mjswith@napi-rs/canvas1.0.11Operating 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
npm i pdfjs-dist@6.4.299 @napi-rs/canvasnode make-pdfs.mjsnode repro.mjs sequential→ both pages render (jpxcentre pixel is rgb(255, 0, 0)), exit 0node repro.mjs interleaved→plainrenders, thenjpx: page.render() has not settled after 5 s (hang), exit 1 (5 of 5 runs)The interleaved case is: open
jpx.pdf(awaitloadingTask.promise), then openplain.pdf, render it anddestroy()it, then render page 1 ofjpx.pdf.repro.mjsfollowsexamples/node/pdf2pngand setscMapUrl,standardFontDataUrlandwasmUrlto the package's own folders.make-pdfs.mjs
repro.mjs
What is the expected behavior?
Page 1 of
jpx.pdfrenders (red centre pixel), as in the sequential run.What went wrong?
page.render().promiseforjpx.pdfnever 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):
PDFWorkeruses the fake worker, so all documents share one instance of the worker modules and their statics.useWorkerFetch: false(the Node.js case),WasmImage.setOptions(src/core/wasm_image.js, called fromsrc/core/pdf_manager.jsper document) stores that document'sMessageHandlerin the staticWasmImage.#handler— i.e. always the handler of the most recently opened document.WasmImage#instantiateWasmfetchesopenjpeg.wasmviaWasmImage.#handler.sendWithPromise("FetchBinaryData", …). If the document owning that handler has already been destroyed, itsLoopbackPorthas no listeners, the message is dropped and the promise never settles; thePromise.racein_getModulenever resolves, so the JPX decode and the page render wait forever.Possible directions: fetch the wasm through the decoding document's own handler instead of a static, and/or reject pending
sendWithPromisecallbacks when aMessageHandleris destroyed so the decoder fails instead of hanging. Our current workaround is to keep only one document open at a time per process.