@@ -64,6 +64,30 @@ type PDF struct {
6464 // Zero selects defaultMaxLeafSections. A negative value disables the
6565 // cap entirely (escape hatch for callers that want the raw outline).
6666 MaxSections int
67+
68+ // ParseTimeout bounds the ENTIRE Parse of one document — row
69+ // extraction, table extraction, section building, and the leaf cap,
70+ // end to end. It is the outermost robustness valve.
71+ //
72+ // Every other time bound inside the parser caps a sub-stage
73+ // (tableExtractPageTimeout / tableExtractDocBudget cap table
74+ // extraction), but the pure-Go row extractor
75+ // (extractPDFRows -> reader.Page(n).Content()) had no bound at all —
76+ // and that is exactly where a pathological PDF was observed hanging
77+ // 600s+ in `parsing`, even in minimal mode (pre-LLM). ParseTimeout
78+ // runs the whole parse on a goroutine and abandons it on deadline,
79+ // returning a clear error so ingest fails the document fast instead
80+ // of wedging forever.
81+ //
82+ // Nothing is disabled by this bound: when the parse finishes inside
83+ // the deadline the full feature set (tables, the outline/heuristic
84+ // section tree, the cap) is produced exactly as before.
85+ //
86+ // Zero selects defaultParseTimeout (120s). A non-positive value other
87+ // than zero (i.e. negative) disables the bound entirely — Parse then
88+ // runs synchronously with no deadline (escape hatch / legacy
89+ // behaviour for callers that want an unbounded parse).
90+ ParseTimeout time.Duration
6791}
6892
6993// TableOpts controls pdftable's table-finding stage. The zero value
@@ -119,13 +143,24 @@ func NewPDFWithTables(opts *TableOpts) *PDF { return &PDF{Tables: opts} }
119143
120144// NewPDFWithOpts returns a PDF parser using the supplied table-extraction
121145// options and an explicit leaf-section cap. maxSections == 0 selects
122- // defaultMaxLeafSections; a negative value disables the cap. This is the
123- // constructor the engine wiring uses so the cap is operator-tunable via
124- // config (ingest.max_sections).
146+ // defaultMaxLeafSections; a negative value disables the cap. The parse
147+ // timeout is left at its zero value, which resolves to defaultParseTimeout.
125148func NewPDFWithOpts (opts * TableOpts , maxSections int ) * PDF {
126149 return & PDF {Tables : opts , MaxSections : maxSections }
127150}
128151
152+ // NewPDFWithConfig returns a PDF parser with the table options, leaf-
153+ // section cap, AND total-parse timeout all set explicitly. This is the
154+ // constructor the engine wiring uses so every parser robustness knob is
155+ // operator-tunable via config (ingest.max_sections,
156+ // ingest.parse_timeout_seconds).
157+ //
158+ // - maxSections == 0 → defaultMaxLeafSections; negative disables the cap.
159+ // - parseTimeout == 0 → defaultParseTimeout; negative disables the bound.
160+ func NewPDFWithConfig (opts * TableOpts , maxSections int , parseTimeout time.Duration ) * PDF {
161+ return & PDF {Tables : opts , MaxSections : maxSections , ParseTimeout : parseTimeout }
162+ }
163+
129164// Name implements Parser.
130165func (* PDF ) Name () string { return "pdf" }
131166
@@ -138,11 +173,87 @@ func (*PDF) Accepts(contentType, filename string) bool {
138173}
139174
140175// Parse implements Parser.
141- func (p * PDF ) Parse (_ context.Context , r io.Reader ) (* ParsedDoc , error ) {
176+ //
177+ // Parse is a thin timeout wrapper around the real parse work (parseDoc).
178+ // It bounds the WHOLE parse — row extraction, table extraction, section
179+ // building, and the leaf cap — by p.resolvedParseTimeout(). The work runs
180+ // on a goroutine; if it doesn't finish by the deadline (or ctx is
181+ // cancelled) Parse returns a clear error and abandons the goroutine. The
182+ // goroutine then finishes on its own (or is GC'd) — its result lands in a
183+ // buffered channel so it never blocks on send. This is the same
184+ // abandon-on-deadline pattern safeExtractTables already uses for a single
185+ // table page, lifted to cover the entire parse so ANY parse pathology
186+ // (notably the unbounded pure-Go ledongthuc row extractor) fails fast and
187+ // cleanly instead of hanging ingest forever.
188+ //
189+ // A negative ParseTimeout disables the bound and runs parseDoc inline.
190+ func (p * PDF ) Parse (ctx context.Context , r io.Reader ) (* ParsedDoc , error ) {
191+ // Read the bytes up front (outside the deadline goroutine): io.ReadAll
192+ // is bounded by the reader/storage layer, not by the PDF pathology we
193+ // are guarding against, and reading here keeps the goroutine body a
194+ // pure CPU/parse unit.
142195 buf , err := io .ReadAll (r )
143196 if err != nil {
144197 return nil , err
145198 }
199+ return runParseWithDeadline (ctx , p .resolvedParseTimeout (), func () (* ParsedDoc , error ) {
200+ return p .parseDoc (ctx , buf )
201+ })
202+ }
203+
204+ // runParseWithDeadline runs work bounded by timeout. It is the reusable
205+ // core of the whole-Parse robustness valve, factored out so the
206+ // deadline/abandon mechanism is unit-testable with an arbitrary work
207+ // closure (e.g. one that sleeps past the deadline) without needing a
208+ // pathological real PDF.
209+ //
210+ // - timeout <= 0 runs work inline with NO bound (legacy/unbounded
211+ // behaviour; the explicit escape hatch a negative ParseTimeout selects).
212+ // - Otherwise work runs on a goroutine and the function selects on the
213+ // work result, a time.After(timeout), and ctx cancellation. On
214+ // timeout/cancel the goroutine is abandoned: its result lands in a
215+ // buffered channel so it can always send and exit (no leak on send),
216+ // then runs to completion on its own and is collected. A panic inside
217+ // work is recovered and surfaced as an error so a backend bug can
218+ // never crash the ingest worker.
219+ //
220+ // The timeout/cancel errors are deliberately phrased so the ingest
221+ // pipeline's existing "parse failed → document failed" path produces a
222+ // clear, ops-visible message instead of an infinite hang.
223+ func runParseWithDeadline (ctx context.Context , timeout time.Duration , work func () (* ParsedDoc , error )) (* ParsedDoc , error ) {
224+ if timeout <= 0 {
225+ return work ()
226+ }
227+
228+ type result struct {
229+ doc * ParsedDoc
230+ err error
231+ }
232+ done := make (chan result , 1 )
233+ go func () {
234+ defer func () {
235+ if rec := recover (); rec != nil {
236+ done <- result {err : fmt .Errorf ("pdf: parse panicked: %v" , rec )}
237+ }
238+ }()
239+ doc , perr := work ()
240+ done <- result {doc : doc , err : perr }
241+ }()
242+
243+ select {
244+ case res := <- done :
245+ return res .doc , res .err
246+ case <- time .After (timeout ):
247+ return nil , fmt .Errorf ("pdf: parse exceeded %s — document too complex or malformed" , timeout )
248+ case <- ctx .Done ():
249+ return nil , fmt .Errorf ("pdf: parse cancelled: %w" , ctx .Err ())
250+ }
251+ }
252+
253+ // parseDoc is the real parse implementation. It is bounded by Parse's
254+ // deadline wrapper; on its own it has no time bound beyond the per-stage
255+ // table-extraction budgets.
256+ func (p * PDF ) parseDoc (_ context.Context , buf []byte ) (* ParsedDoc , error ) {
146257
147258 // We run TWO PDF backends in parallel here:
148259 //
@@ -407,6 +518,24 @@ func (p *PDF) resolvedMaxSections() int {
407518 return p .MaxSections
408519}
409520
521+ // defaultParseTimeout is the whole-Parse deadline applied when
522+ // ParseTimeout is left at its zero value. 120s is comfortably longer than
523+ // a healthy 300-page filing's parse (seconds to low tens of seconds) yet
524+ // short enough that a pathological/malformed document — the kind observed
525+ // hanging 600s+ in pure-Go row extraction — is reaped quickly and the
526+ // document fails fast instead of wedging ingest.
527+ const defaultParseTimeout = 120 * time .Second
528+
529+ // resolvedParseTimeout turns the configured ParseTimeout into the value
530+ // the wrapper actually uses: 0 selects defaultParseTimeout; a negative
531+ // value disables the bound (Parse runs parseDoc inline with no deadline).
532+ func (p * PDF ) resolvedParseTimeout () time.Duration {
533+ if p .ParseTimeout == 0 {
534+ return defaultParseTimeout
535+ }
536+ return p .ParseTimeout
537+ }
538+
410539// propagateSectionPages fills internal-node PageStart/PageEnd from the union
411540// of descendant leaf ranges where the internal node didn't have its own
412541// (because its body was empty / hoisted into children). Leaves keep their
0 commit comments