Walk a page's resources once, and charge for a picture before making it - #38
Merged
Conversation
render.Images(d, 1) on openpdf's pdfsmartcopy_bec.pdf — 208 KB, three pages — was killed at 87 GB resident and still climbing. THE CAUSE. A page's resources are a GRAPH, not a tree, and imagesIn walked every path through it. In that file the page's /Resources names 37 form XObjects and every one of them names that same /Resources dictionary straight back, so the walk visits 37 dictionaries at the first level, 1 369 at the second, 50 653 at the third — 37 to the eighth at the depth limit of 8, which is three and a half million million — and decodes each of the page's 40 pictures once per visit, four bytes a pixel. Depth three alone is 156 GB of pixels. That is also the whole of the neighbouring finding. The three pictures of fr-cerfa/cerfa_10103.pdf were not decoded 511 times because they are DRAWN 511 times: that page's forms fan out by two, and the sum of two to the power nought through eight is 511. Measured here: 1 022 visits for 2 distinct pictures, over resource dictionaries counted 1, 2, 4, 8, 16, 32, 64, 128, 256 by depth. THE FIX. Enter each XObject once, by its reference. bec.pdf now hands back 74 pictures a page and cerfa_10103.pdf 3, immediately, and the same picture reached by two forms comes back once rather than as two identical entries under the same name. THE BOUND. Walked once, a document may still name more picture than the machine will hold, and it NAMES it rather than carrying it: decodeBase makes four bytes for every pixel of the declared /Width by /Height whatever the stream holds. So the declared size is charged against a budget BEFORE the picture is decoded, and a page past it is refused whole with ErrTooMuchToDecode naming the picture and the limit. Masks are charged too — a mask is a picture in its own right. WHERE THE LIMIT COMES FROM. Over the 2 268 real forms of the corpus, 10 659 pages, 2 111 of which draw a picture at all: the page naming the most comes to 31 814 093 pixels, 127 MB decoded; the median is 90 048; the 99.9th centile is the maximum. Not one page names as much as a sixth of the 256 Mi-pixel limit, which is a gigabyte of RGBA. Page is untouched and was never affected: it is bounded by maxOperations, and all three pages of bec.pdf draw.
The budget of the commit before this one charges a picture for the /Width and /Height its DICTIONARY declares. A codestream carries its own size and need not agree, and where they disagree this package already prefers the codestream's — so the budget had a hole a small file goes straight through. image/jpeg makes the whole picture the moment it reaches the start of scan, before it looks at any scan data. A valid 8x8 JPEG of 376 bytes whose frame header is altered to claim 65 535 by 65 535 therefore allocates FOUR GIGABYTES and only then reports the scan data missing. Measured: totalAlloc delta 4 096 MB for a 376-byte input. The dictionary may say 8 by 8 and be charged 64 pixels for it. Both codecs will say how large they are from the header alone and without allocating — image/jpeg through jpeg.DecodeConfig, the JPEG 2000 reader through its own — so that is read first, and the bytes are handed over only if what they claim can be afforded. A codestream past the ceiling on a single picture is refused outright; one larger than its dictionary pays the difference into the budget, so that a page of pictures each declaring one pixel and each holding a large codestream cannot come to as much as it likes. The ceiling applies to Page too, which is the only change to Page here: it keeps no picture, so it is not charged, but it should not allocate four gigabytes for a 376-byte file either. A header nothing can be read from is left alone: the decoder gives up on it long before it allocates. The fixture is that patched JPEG, built in the test rather than carried. What it asserts is not that nothing came back — nothing came back before this too, once the decoder had allocated and then failed — but that the decoder is NEVER REACHED, which is the whole of the claim that the check happens before the allocation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #37.
[2026-08-30 19:41:01 CEST]
The cause, in one sentence
imagesInwalked a page's resources as a tree when they are a graph: ingh-openpdf/openpdf-core_src_test_resources_pdfsmartcopy_bec.pdfthe page's/Resourcesnames 37 form XObjects and every one of them names that same/Resourcesdictionary straight back, so the walk visited 37 dictionaries at the first level, 1 369 at the second, 50 653 at the third — 37 to the eighth at the depth limit, three and a half million million — and decoded each of the page's 40 pictures once per visit, at four bytes a pixel.The offending document is
openpdf-core_src_test_resources_pdfsmartcopy_bec.pdf, 208 584 bytes, three pages,fa1554ec98a2ca39. Found by running the wholegh-openpdfpopulation under a 2 GB watchdog, one document per process, so a bad one failed instead of taking the machine down.Measured by walking the tree one level at a time:
Depth three alone is 156 GB, and the limit is eight. That is the 87 GB.
The "drawn 511 times" finding is the same defect, and it is fixed
fr-cerfa/cerfa_10103.pdfdoes not decode its pictures 511 times because they are drawn 511 times. That page's forms fan out by two, and 2⁰ + 2¹ + … + 2⁸ = 511. Measured on it: resource dictionaries visited by depth are 1, 2, 4, 8, 16, 32, 64, 128, 256 — 511 in all — and 1 022 image visits for 2 distinct pictures.So it is not a separate change and does not need a separate issue. After this PR
render.Imageson that page hands back 3 entries (two pictures and a mask), each decoded once.pdfsmartcopy_bec.pdfhands back 74 a page, immediately, with no watchdog.What changed
1. Enter each XObject once, by its reference (
firstVisit). A resource graph is walked as a graph. This alone removes the exponent, and it also stops the same picture reached by two forms coming back as two identical entries under the same name.2. Charge a picture before it is made (
afford,ErrTooMuchToDecode). Walked once, a document may still name more picture than the machine holds — and it names it rather than carrying it, sincedecodeBasemakes four bytes for every pixel of the declared/Width×/Heightwhatever the stream turns out to hold. So the declared size is taken out of a budget beforedecodeBaseis called, masks included, and a page past the budget is refused whole with an error naming the picture and the limit. Nothing is returned with the refusal: half a page's pictures would be read as all of them by anything counting.3. Read what a codec says it holds before handing it the bytes (
affordDecoded,jpegSize,jpxSize). This closes a hole in (2) that I found while building the fixture, and it is why this PR is two commits rather than one. A codestream carries its own size, need not agree with the dictionary, and this package already prefers the codestream's.image/jpegallocates the whole picture the moment it reaches the start of scan — so a valid 8×8 JPEG of 376 bytes whose frame header is patched to claim 65 535 × 65 535 allocates four gigabytes (measured:totalAllocdelta 4 096 MB) and only then reports the scan data missing, while its dictionary is charged 64 pixels. Both codecs will state their size from the header alone without allocating, so that is read first. A codestream past the ceiling on one picture is refused; one larger than its dictionary pays the difference into the budget, so a page of pictures each declaring one pixel and each holding a large codestream cannot come to as much as it likes.This third part is the only change that touches
Page, which was never affected by the walk (it is bounded bymaxOperations, and all three pages ofbec.pdfdraw). It should not allocate four gigabytes for a 376-byte file either.Where the limit comes from
maxImagesPixels = 256 << 20— 268 435 456 pixels, a gigabyte of RGBA.Over the corpus of 2 268 real forms, 10 659 pages, of which 2 111 draw a picture at all, counting distinct pictures per page as the fixed walk counts them:
Not one page in the corpus names as much as a sixth of the limit; the document that started this named enough for 87 GB. The per-picture ceiling of
maxImagePixels = 64 << 20is unchanged and now applies to what a codec claims as well as to what a dictionary declares.Tests
The fixture is built in the test, not carried:
sharedResourcesPageispdfsmartcopy_bec.pdfreduced to its shape — a page whose/Resourcesnames n forms, every one of which names that same dictionary back. No corpus document is copied into the repository.TestASharedResourceDictionaryIsWalkedOnce— the reduced fixture with three small pictures. Asserts three come back, once each, in name order. WithoutfirstVisitit is 262 143 (3 × Σ4ᵈ), which the test reports.TestImagesRefusesAPageNamingMorePictureThanItWillDecode— the same fixture, with pictures large enough that what it names exceeds the budget. Assertserrors.Is(err, ErrTooMuchToDecode), that nothing comes back with it, and that the message names both the offending picture and the limit. This is the refusal, asserted on the fixture.TestAPictureOfAnImpossibleSizeIsRefusedBeforeItIsMade,TestAMaskIsChargedForToo,TestARefusalInsideAFormStopsTheWalk— a declared width past the whole budget on either side alone (so nothing overflows), a mask charged like the picture it is, and a refusal several forms down returning up rather than carrying on.TestACodestreamIsNotBelievedBeforeItIsMeasured— the 376-byte lying JPEG. It asserts the decoder is never reached, not merely that nothing came back: nothing came back before this change either, once four gigabytes had been allocated and the decode had then failed. Reaching the decoder is the allocation, so "never reached" is the whole of the claim that the check happens before it.TestAJPXCodestreamIsMeasuredBeforeItIsDecoded— the same guard on the other codec, likewise asserting the decoder is not reached.TestWhatACodecSaysItHoldsIsPaidForToo— nine cases overaffordDecodeddirectly, including both directions of "a page keeps no picture and spends nothing".TestAnOrdinaryPageIsNotRefusedandTestFormsMayStillNestAsDeepAsTheyMay— the other direction. A bound everything reaches is a broken library.Each new test was run against the code with its own fix neutered, and each fails: 262 143 pictures without
firstVisit,err=<nil>withoutafford, "handed the bytes to the decoder anyway" without the codec ceiling.TestAFormThatHoldsItselfStopsstill passes unchanged.Whole-corpus check, with the change in
render.Imagesover every page of all 2 268 documents, in one process under a 3 GB watchdog:No refusal, no panic, and the same 16 220 pictures before and after the codec guard — so nothing real is dropped by reading a header first. Before the change, one document of those 2 268 was still allocating at 87 GB.
One note on DCTDecode, not acted on here
Relevant to the finding that JPEG is the weak spot in the per-image comparison (117 exact of 443, against 3 198 of 4 198 for raw samples):
decodeJPEGtakes the size from the codestream and not from the dictionary when the two disagree, and silently returns the codestream's picture at the codestream's size. Any comparison that lines a picture up by the dictionary's/Width×/Heightwill therefore compare the wrong grid for exactly those files. Worth checking before more is read into the JPEG numbers. Not changed here.Gate
go vet,gofmt -lempty under Go 1.27.0 (local 1.26.4 disagrees with CI),go test -race, exact 100.0% statement coverage under both 1.26.4 and 1.27.0, and all nine cross-compile targets,CGO_ENABLED=0,GOWORK=off.