feat(wdow): stream LLM-scan PDF render path, remove 200MB size gate #1015
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-wdow"
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
info.Size() > MaxPDFByteshard-reject (PR #1010 sibling) fromLLMScanActivities.Scan; PDFs of any size are now acceptedRenderPDFPageImagesFromReader(ctx, io.ReadSeeker, int64, firstN, lastN int)ininternal/filesusing pdfium'sFPDF_LoadCustomDocument(demand-driven I/O); the wfengine adapter opens the file withos.Open→ passes the*os.File(satisfiesio.ReadSeeker) — noos.ReadFile/ReadAlltrackingReadSeekerwraps the reader; ifOpenDocumentfails AND aReaderror was captured → retryable plain error; otherwiseErrNoCover(permanent). The wfengine'sScanactivity classifiesErrNoCoveras permanent (no retry), plain I/O errors as retryable — mirroring the fix in bookshelf-qwvg (#1010)Test plan
RenderPDFPageImagesFromReader: valid PDF → imagesErrNoCover(permanent)ErrNoCoverReaderror → plain error, does NOT wrapErrNoCoverErrNoCover(exercisesrenderErr != nil+len(imgs) == 0branches)bytesRead < fileSize/2openFilefails → retryable;renderPageImagesreturnsErrNoCover→ permanent; returns transient I/O error → non-permanentmake coverage→ 100% on all affected packagesmake lint→ 0 issues in modified packagesCloses bead bookshelf-wdow on merge.
Code Review — bookshelf-wdow / PR #1015
Reviewed: diff only (CI green, per policy review agents do not re-run tests).
Phase 0: DEMO Verification
No
DEMO:block was posted in the bead comments — onlyCompleted:andAWAITING REVIEW. For a bug-fix of this shape (removing a size gate + streaming), the natural DEMO would be "scan a 251 MB PDF successfully" with a before/after command. That was not provided. CI green is accepted as the effective DEMO for this session; flagged as a minor below.Phase 1 & 2: Spec compliance + Code quality
What was checked (mirror of the #1010 lesson list):
Streaming render correctness —
renderPDFPageImagesOnInstFromReaderopens viaOpenDocument{FileReader: tracker, FileReaderSize: size}, renders all sampled indices before thedefer FPDF_CloseDocument, usesscanRenderWidth/scanRenderHeight,buildPageIndicesNdedup, andpdfScanRenderTimeout. All correct.Transient-vs-permanent split —
trackingReadSeeker(defined incover_extract.go, confirmed excludesio.EOF) captures non-EOF read errors. InrenderPDFPageImagesOnInstFromReader:tracker.readErr != nil→ return bare I/O error (retryable); pdfium-rejects-content →errors.Join(err, ErrNoCover)(permanent).FPDF_GetPageCountand zero-page cases →ErrNoCover(permanent). All-pages-fail →ErrNoCover(permanent). In the wfengine adapter (Scan):errors.Is(err, files.ErrNoCover)→NewPermanentError; otherwise plain return (retryable). Architecture boundary preserved —internal/filesdoes not import the workflow engine.Memory-bound proof —
makeScanMultiPagePDFplaces large noisy 200×300 JPEGs on pages 2–7, tiny 4×4 JPEGs on pages 0,1,8,9;buildPageIndicesN(10,2,2)selects [0,1,8,9];scanCountingReadSeekercountsatomic-safe bytes; assertion isbytesRead < fileSize/2. Non-tautological and quantitatively robust.openFiledep —os.Open+f.Stat(), returns(ReadSeekCloser, size, error).f.Close()called on stat error inbuild_extended_deps.go.defer reader.Close()inScan. No resource leak.Removing
MaxPDFBytesgate — remaining bounds (WASM memory cap,pdfScanRenderDeadline, raster cap atscanRenderWidth×scanRenderHeight,maxRenderPages) still bound an adversarial PDF. Consistent with the #1010 rationale.Black-box tests —
package files_test,package wfengine_testthroughout. No white-box test files added.No new
.golangci.ymlexclusions added.engine_integration_test.goupdated — still exercises realNew()+StartWorkerwith the newOpenFilestub, proving activity registration survives the signature change.Findings
[MINOR] internal/files/pdf_page_images_streaming_test.go:292–297 — multiple
Expectcalls inside a loop in oneItblockThe
It("returns decodable PNG images")block iterates overresultand callsExpect(decErr).NotTo(HaveOccurred())for each image. Project convention: exactly ONEExpectperIt. Fix: assertExpect(result[0]).To(...)on a single representative image, or split intoIt("all images decode without error", func() { Expect(allDecodable(result)).To(BeTrue()) })with a helper.[MINOR] internal/wfengine/llm_scan_workflow_test.go:886–889 — vacuous assertion in "does not classify transient I/O error as permanent"
The block reads
if errors.As(err, &wfErr) { Expect(wfErr.Permanent).To(BeFalse()) }. When the implementation is correct (plain error, no*gowf.Errorwrapper),errors.Asreturns false and theExpectbody never executes — the test is a no-op in the passing case and provides zero regression protection. Fix:Expect(errors.As(err, &wfErr)).To(BeFalse())which positively asserts the error is never wrapped as a workflow error.[MINOR] No DEMO block in bead comments
The
AWAITING REVIEWcomment contains no executable before/after command. For a size-gate removal of this kind the expected DEMO is a command that scans a >200 MB PDF and shows the former error is gone. Post a DEMO in a follow-up bead comment or accept CI green as sufficient (orchestrator decision).REVIEW VERDICT: 0 blocker, 0 major, 3 minor
Security Review — PR #1015 (bookshelf-wdow): PDF streaming scan, MaxPDFBytes gate removal
Central question: is peak memory bounded without the 200 MB gate?
YES — the removal is correct and safe for the streaming path. Detailed analysis below.
Memory bounding
The old 200 MB gate existed to prevent
os.ReadFilefrom buffering an arbitrarily large PDF into Go heap. The newFPDF_LoadCustomDocumentpath eliminates the Go heap buffer entirely — pdfium calls back intotrackingReadSeeker.Readfor only the byte ranges it needs (cross-reference table, selected page object streams). No whole-file buffer is allocated in Go.Three independent bounds now cover peak memory:
WASM linear memory cap —
pdfiumMaxMemoryPages = 16384pages × 64 KB = 1 GB hard cap (wazero.NewRuntimeConfig().WithMemoryLimitPages). Any decompression-bomb expansion of compressed xref/object streams inside pdfium is absorbed here; the cap will kill the WASM module before it OOMs the worker process.Fixed scan raster per page —
scanRenderWidth = 667,scanRenderHeight = 1000→ ~2.67 MB RGBA per page in WASM. Pages are rendered sequentially insiderenderPDFPageImagesOnInstFromReader;resp.Cleanup()is called after each page is encoded to PNG, releasing the WASM raster allocation before the next page begins. Peak WASM ≈ one page decode at a time.maxRenderPages = 5cap —buildPageIndicesNreturns at most 5 indices regardless of document length. The accumulated PNG byte slices in Go heap are therefore bounded at 5 × ~2.67 MB ≈ 13 MB uncompressed, which compresses to far less. The bytes-read proof test (RenderPDFPageImagesFromReader: bytes-read proof) empirically confirms on-demand I/O: 4 selected pages from a 10-page PDF consume < half the total file bytes.CPU bounding
pdfScanRenderDeadline = 180 swithWithCloseOnContextDone(true)in the WASM runtime config. When the context deadline fires,pdfRenderSeams.closeInstance(inst)cancels the pdfium WASM context, interrupting the in-flight render. The<-chdrain (line 122) ensures the goroutine exits beforeRenderPDFPageImagesFromReaderreturns, so no goroutine leaks from the deadline branch.Path traversal
openFile(fullPath)wherefullPathis the output ofscanSafeJoin(libraryRoot, subPath). BothlibraryRoot(fromgetLibraryPath) andsubPath(fromgetPDFSubPath) come from trusted DB records, not from HTTP request input.scanSafeJoinvalidatesfilepath.Clean(full)+sephascleanRoot+sepas a prefix before returning. The//nolint:goseconos.Open(path)is correctly justified by this prior validation.File descriptor lifecycle
os.Openerror path: returnsnil, 0, err— no fd allocated, no Close needed. ✓f.Stat()error path:f.Close()called in the error branch before returning. ✓Scan()registersdefer reader.Close()immediately after the nil-error check; the file handle stays alive for the duration ofrenderPageImages(which holds a callback reference throughtrackingReadSeeker) and is closed onScanreturn. ✓closeInstance(inst)interrupts WASM → goroutine unblocks →<-chdrains →Scanreturns →defer reader.Close()fires. No fd leak. ✓Error retryability
openFilefailures (all errors includingos.ErrNotExist) are returned as retryable, whereas the oldreadPDFwrapped all stat/read failures asgowf.NewPermanentError. This is a behavioral change, but it is moot in practice:llmInteractiveActivityOptionssetsMaxAttempts: 1, so the Scan activity never retries regardless of error classification. The interactive caller surfaces failures to the user for manual retry.The new transient/permanent split for
renderPageImageserrors (retryable on non-ErrNoCover, permanent on ErrNoCover) is correct.Architecture boundary
internal/files/pdf_page_images.goimports only stdlib + pdfium packages — no wfengine import. Thefiles.ErrNoCover→gowf.NewPermanentErrormapping lives solely ininternal/wfengine/llm_scan_workflow.go(the one allowed layer). ✓Secrets / PII
No secrets or PII logged. The new
"llm scan page images rendered"log line recordsbook_id,page_count, andsize_bytes_per_page(PNG sizes, not content). ✓Test hygiene
All new test files declare
package files_test/package wfengine_test(black-box). ✓[MINOR] internal/files/pdf_page_images_streaming_test.go:292 — multi-Expect loop in unit test
ItblockIt("returns decodable PNG images")contains aforloop with oneExpect(decErr)per iteration, violating the strict one-Expect-per-It rule for unit tests. For a collection assertion, replace with a Gomega collection matcher such asExpect(result).To(HaveEach(Satisfy(func(b []byte) bool { _, err := png.Decode(bytes.NewReader(b)); return err == nil })))or split into a parameterisedDescribeTable. Single-page PDFs produce one element so this is currently harmless, but multi-page cases would silently produce multiple assertions under oneIt.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
768d157a7bfa8e69a7a7