Stream PDF cover render via FPDF_LoadCustomDocument (bookshelf-qwvg) #1010
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-qwvg"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
ExtractCoverFromFilePDF path fromio.ReadAll(buffer whole file) to streamingio.ReadSeekervia go-pdfium'sFileReader/FileReaderSizefields — maps toFPDF_LoadCustomDocumentcallback readsmaxPDFBytes(200MB) rejection guard that was blocking cover extraction on large PDFs like the 233MB "Shattered Nation" game bookrenderPDFCoverFromReader+renderPDFPage0FromReaderstreaming path with same pool/instance/deadline structure as the existing byte-slice pathcountingReaderAt+ bytes-read proof test empirically confirming pdfium reads far fewer bytes than total file size for multi-page PDFsTest plan
make test— all packages passmake coverage—internal/files100.0%make lint(files package) — 0 issuesreadSeekerAt"buffer larger than remaining" truncation branch coveredCloses bead bookshelf-qwvg on merge.
Security Review — PR #1010 (bookshelf-qwvg)
Central question: is removing the 200 MB
maxPDFBytescap from the PDF cover-extraction path safe?Bounds audit
The original cap comment said pdfium reads the whole document; a crafted PDF could cause unbounded allocation. The PR replaces that single file-size gate with four independent hard bounds that together cover the same threat surface:
1. WASM 1 GB linear-memory cap (
pdfiumMaxMemoryPages = 16 384× 64 KB)Set on the pool at init time via
WithMemoryLimitPages. Any compressed-stream decompression bomb — giant XRef table, huge flate/LZW page stream — is hard-killed when the WASM allocator hits 1 GB. This directly addresses the original threat the comment described, and does so regardless of on-disk file size.2. 60 s render deadline +
WithCloseOnContextDone(true)interruptrenderPDFCoverFromReaderappliescontext.WithTimeout(ctx, pdfRenderTimeout)before spawning the goroutine. The pool is configured withWithCloseOnContextDone(true), so callinginst.Close()in thectx.Done()branch immediately interrupts in-flight WASM execution. A pathological PDF that makes pdfium loop indefinitely (crafted circular XRef, adversarial linearisation hint stream, etc.) is killed at 60 s. The goroutine is then drained with<-chbeforerenderPDFCoverFromReaderreturns — no goroutine leak, no pool-slot leak.3. 1 200 × 1 800 pixel raster cap (
maxPDFRenderWidth / maxPDFRenderHeight)RenderPageInPixelsis called with these constants on both the buffered and streaming paths. A PDF whose MediaBox declares a physically enormous page (e.g. 1 m × 1 m at 300 DPI) is scaled by pdfium to fit within 1 200 × 1 800 — at most ~8.6 MB of RGBA. Output raster size is not proportional to file size or declared page dimensions.4. MaxTotal=1 pool serialises rendering
Only one WASM instance ever exists (
MaxTotal: 1). Concurrent PDF render requests queue and wait up topdfiumInstanceTimeout(30 s) for the one slot; beyond that they return a transient error. A flood of adversarially large PDFs cannot trigger parallel memory amplification — at most one render runs at a time, bounded by bounds 1–3 above.Pool-slot release correctness (no leak on deadline)
renderPDFCoverFromReaderstructure (new):closeInstance(inst)before returning result.closeInstance(inst)to signal WASM interruption, then<-chto drain the goroutine, then returnErrNoCover.This is structurally identical to the pre-existing
renderPDFCover(buffered path) and its deadline test coverage carries over via the newSetPdfSimpleRenderPage0ReaderForTestseam. No goroutine leak, no pool-slot leak.io.SectionReader / io.ReadSeeker adapter
io.NewSectionReader(f, 0, size)satisfiesio.ReadSeekernatively (stdlib).FileReaderSize = sizeis the actual declared file size passed in by the caller, not a request-supplied value — correct. Negative or zerosizecausesSectionReader.Readto return EOF immediately, which causesOpenDocumentto fail and return ErrNoCover (PERMANENT). No allocation happens in the adapter layer.Remaining call sites of the old
extractPDFCover(buffered path)extractPDFCoveris still called byExtractCover(fileExt string, data []byte)at cover_extract.go:309. That function takes already-buffered bytes from callers that are separately responsible for any size limit; the file-size cap removal does not affect it.Findings
[MINOR] internal/files/cover_extract_test.go:2858 — bytes-read proof assertion is fragile against pdfium read-pattern changes
The test asserts
bytesRead < fileSizeusing acountingReaderAtthat tallies every byte returned byReadAt(including repeated/overlapping reads). The test comment acknowledges that for small PDFs pdfium's random-access pattern can produce a read total exceeding the file size, which is why pages 1 and 2 are made artificially large (400 × 600 JPEG). The assertion therefore relies on the current ratio of page-0 content to total file size holding across pdfium WASM updates. If an upstream release changes its XRef or hint-stream prefetch strategy, the assertion could flip from<to>=and become a CI flake. Consider either (a) asserting a more generous bound (e.g.,< fileSize * 2 / 3with a comment explaining why) or (b) treating this as a doc-only sanity check that logs the ratio without a hardExpect, since the security claim rests on the WASM memory cap and the deadline — not on the read-count proof.Conclusion on cap removal safety: YES — the 200 MB file-size gate is safely replaced by the four bounds above (1 GB WASM memory, 60 s deadline + WASM interrupt, 1 200 × 1 800 raster cap, MaxTotal=1 pool serialisation). No unbounded memory allocation vector remains on the streaming path. The claim in the updated
maxPDFBytescomment and theExtractCoverFromFiledoc comment is accurate.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
CODE REVIEW: NOT APPROVED
Reviewed diff only; did not re-run tests (CI is the source of behavioral truth per
.claude/rules/review-standard.md).Phase 0 — no DEMO block check
Not applicable: this is a pure refactor/optimization bead reviewed on diff.
Phase 1 — Streaming Correctness (all pass)
io.NewSectionReader(f, 0, size)→ implementsio.ReadSeeker✓FileReader: reader, FileReaderSize: size→ correct FPDF_LoadCustomDocument API ✓Index: 0) ✓maxPDFRenderWidth/maxPDFRenderHeightcaps preserved inrenderPDFPage0FromReader✓pdfRenderTimeoutcontext deadline applied inrenderPDFCoverFromReader✓renderPDFCover✓maxPDFBytesconstant not dead: used bypdf_metadata.go:36,library/scan/meta.go,wfengine/llm_scan_workflow.go✓ErrCoverTooLargenot dead: still used by audio/CBX paths ✓pdf_page_images.gountouched; scan path takes[]byte, still buffered, LLM caller still limits viaio.LimitReader(..., files.MaxPDFBytes)✓SetPdfSimpleRenderPage0ReaderForTestseam consistent with existingSetPdfSimpleRenderPage0ForTestpattern ✓Findings
[MAJOR]
internal/files/cover_extract.go:596-600— transient ReadAt I/O errors misclassified as PERMANENT via ErrNoCoverrenderPDFPage0FromReaderwraps allOpenDocumentfailures withErrNoCover:OpenDocumentfails both for structurally corrupt PDFs and for transient ReadAt I/O errors (NFS disconnect, disk error mid-read) that occur inside the FPDF_LoadCustomDocument callback. Pdfium cannot distinguish the two failure modes, and neither can this code.The old code correctly separated them:
Concrete impact:
ErrNoCoverreachesinternal/cover/generate.go:165where it is treated as "book has no cover" (skip + enqueue fallback template), andwfengine/cover_workflow.go:112(isPermanentCoverErr) wraps it ingowf.NewPermanentError. An NFS disconnect during the FPDF callback therefore permanently assigns the book a fallback/template cover with no retry — the real cover is lost until the workflow is manually re-triggered. OnDISK_TYPE=NETWORKsetups this is a real operational regression.The test change at
cover_extract_test.go:2739("PDF with a ReadAt failure") now assertserrors.Is(err, files.ErrNoCover)for an injected ReadAt failure — explicitly endorsing the misclassification.Suggested fix: track ReadAt errors separately via a thin wrapper passed to
OpenDocument. AfterOpenDocumentfails, if the wrapper recorded a non-nil ReadAt error, return that error withoutErrNoCoverso the activity remains retryable.[MINOR]
internal/files/cover_extract.go:1111-1144+internal/files/export_test.go:112-114—readSeekerAtis now dead production codeThe only production instantiation of
readSeekerAt(&readSeekerAt{r: f, size: size}insideExtractCoverFromFile) was deleted by this PR. The type remains defined, its export shimNewReadSeekerAtremains inexport_test.go, and this PR adds a new branch test ("Read with buffer larger than remaining bytes") that exercises dead code. All three should be deleted.[MINOR]
internal/files/cover_extract_test.go"bytes-read proof" — assertion is weaker than stated claimThe assertion:
claims to prove pdfium reads "far fewer bytes than file size," but the fixture uses solid-color 400×600 JPEG images. Solid-color images compress to near-zero AC DCT coefficients; at quality 75 each 400×600 image is likely 1–3 KB in practice, making the total 3-page PDF perhaps 8–15 KB. Pdfium's FPDF_LoadCustomDocument makes overlapping ReadAt calls (the counter counts bytes, not unique byte ranges), so the margin between
bytesReadandfileSizemay be thin.The assertion should express the intent quantitatively (e.g.,
< fileSize/2) to be a meaningful streaming proof, and the fixture images should use non-uniform content (e.g., random noise viarand.Reador a gradient) to guarantee genuine byte-bulk in pages 1 and 2.Note: CI is green so the test passes with the current pdfium version, but the proof is weaker than claimed.
Verdict
The major blocks merge: the ReadAt I/O error → ErrNoCover misclassification is a silent retry-semantic regression that will permanently misflag books on network filesystems.
051af2533411d6400286