Skip to content

fix(parser): bound OCR worker init so offline scanned PDFs degrade, never hang (#298) - #311

Merged
thewrz merged 3 commits into
mainfrom
feat/issue-298
Jun 28, 2026
Merged

fix(parser): bound OCR worker init so offline scanned PDFs degrade, never hang (#298)#311
thewrz merged 3 commits into
mainfrom
feat/issue-298

Conversation

@thewrz

@thewrz thewrz commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Why

The PDF OCR fallback (#246/#290) initializes the Tesseract worker via
Tesseract.createWorker('eng', 1, …). When the model cache is empty and
OCR_LANG_PATH is unset, tesseract.js fetches eng.traineddata from a CDN on
first use. Offline, two failure modes exist:

  1. Fail-fast (already handled, feat(parser): PDF OCR fallback + font-encoding recovery (#246) #290): the fetch rejects (TypeError: fetch failed) → clean pdf-ocr-unusable warning.
  2. Indefinite stall (was NOT handled): if the network accepts the connection
    but never responds, createWorker neither resolves nor rejects, and the first
    scanned-PDF parse job hangs forever — wedging a Piscina worker thread.

There was no bounded init timeout on the production OCR path.

What

  • Bounded worker init (the spine). initManagedRecognizer races worker
    creation against a configurable timer. On timeout it rejects with a typed
    ParserError, which the existing applyOcrIfNeeded catch degrades to a
    pdf-ocr-unusable warning — so a stall now completes within a bounded time
    instead of hanging. A late-resolving worker is terminated (terminateLater) so
    no Tesseract process leaks.
  • New env knob OCR_INIT_TIMEOUT_MS (Zod-validated, positive int, default
    30000), threaded parse-worker → ParseOptions → PdfOcrOptions.initTimeoutMs.
    Documented in .env.example.
  • feat(parser): PDF OCR fallback + font-encoding recovery (#246) #290 fail-fast preserved. Error wrapping is centralized at the init
    boundary, so a rejecting init still surfaces a ParserErrorpdf-ocr-unusable.
  • DI seam PdfOcrOptions.createWorker (mirrors the existing
    renderPageAsImage / recognize seams) makes the stall unit-testable by
    simulating a never-resolving init — no network, no real traineddata.

Design decisions

The issue floated three options (bounded init timeout, local traineddata,
pre-flight check). ADR-039 records the call:

  • Bounded init timeout = the load-bearing fix. Lowest-risk, and it subsumes
    every "model not readily available" case (absent, unreadable, or hung) with
    one mechanism. It is the only thing added to the hot path.
  • Local traineddata = the documented production requirement. Production OCR
    must not depend on a CDN: vendor eng.traineddata (Apache-2.0) and point
    OCR_LANG_PATH at it. The timeout is the safety net for when that is missed.
    The ~15 MB model is an ops artifact and is not committed to the repo.
  • Pre-flight check = rejected. It would duplicate tesseract.js's own
    resolution logic, risk drifting from it, and still not cover a path that exists
    but whose fetch stalls — which the timeout already covers.

See docs/adr/039-offline-ocr-provisioning.md for the full Context/Decision/
Consequences, building on ADR-034.

Testing

  • pnpm lint clean (eslint + tsc + prettier)
  • pnpm test — 1185 unit tests pass (with DATABASE_URL/NODE_ENV set, as CI does)
  • New regression test: ocr: worker init stall degrades to pdf-ocr-unusable within timeout, never hangs (parsePdf boundary) + focused recognizePdfPages tests for ParserError-on-stall, feat(parser): PDF OCR fallback + font-encoding recovery (#246) #290 fail-fast preservation, and no-leak late-worker termination — all offline, no real traineddata
  • CI green
  • Real-OCR e2e (SPECR_OCR_E2E=1) unaffected — the bounded-timeout tests do not require network or real traineddata

🤖 Co-authored by Claude Opus 4.8 (1M context). Closes #298.

Summary by CodeRabbit

  • New Features
    • Added OCR settings for worker init timeout and strict local-traineddata mode.
    • Improved offline OCR support by allowing local model checks before starting OCR processing.
  • Bug Fixes
    • Prevents OCR from hanging when worker initialization stalls.
    • Avoids worker leaks and fails gracefully when OCR worker startup or model availability checks fail.
    • Updates OCR handling so missing local training data no longer triggers CDN-dependent behavior in restricted environments.

…ever hang

When eng.traineddata is uncached AND OCR_LANG_PATH is unset, tesseract.js
fetches the model from a CDN on first use. #290 already handles the fetch
*rejection* (fail-fast -> pdf-ocr-unusable). The unhandled case was a
connection accepted but never answered: createWorker never settles and the
whole parse job hangs.

Race worker init against a configurable timer (OCR_INIT_TIMEOUT_MS, default
30s). On timeout, reject with a typed ParserError -- which the existing
applyOcrIfNeeded catch degrades to a pdf-ocr-unusable warning -- and
terminate any late-resolving worker so no Tesseract process leaks. Wrapping
is centralized at the init boundary, preserving the #290 fail-fast. A
createWorker DI seam makes the stall unit-testable with no network or real
traineddata. ADR-039 records the offline-OCR provisioning strategy: vendored
traineddata via OCR_LANG_PATH, never a production CDN dependency.

Closes #298

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e8d2eb62-44f6-43e6-81c6-7e164e434913

📥 Commits

Reviewing files that changed from the base of the PR and between 1536323 and 5dcf1f0.

📒 Files selected for processing (8)
  • .env.example
  • docs/adr/039-offline-ocr-provisioning.md
  • src/lib/env.ts
  • src/lib/parse-worker.ts
  • src/parser/index.ts
  • src/parser/pdf/index.test.ts
  • src/parser/pdf/ocr.test.ts
  • src/parser/pdf/ocr.ts

📝 Walkthrough

Walkthrough

Adds two complementary offline OCR safety mechanisms: a configurable worker-initialization timeout (OCR_INIT_TIMEOUT_MS, default 30s) that races Tesseract worker creation and degrades to pdf-ocr-unusable on stall, and an opt-in strict pre-flight (OCR_REQUIRE_LOCAL_TRAINEDDATA) that refuses to spawn a worker when local traineddata is absent. Config propagation, tests, .env.example, and ADR-039 are included.

Changes

Offline OCR provisioning

Layer / File(s) Summary
OCR init timeout, pre-flight, and traineddata probe
src/parser/pdf/ocr.ts
Expands PdfOcrOptions with initTimeoutMs, createWorker, requireLocalTraineddata, and hasLocalTraineddata DI fields. Exports ManagedRecognizer, ManagedRecognizerFactory, and LocalTraineddataCheck. Adds DEFAULT_OCR_INIT_TIMEOUT_MS = 30_000, refactors worker creation into a timeout-raced initManagedRecognizer (terminates late-resolving workers to prevent leaks), and adds hasLocalTraineddata probe plus assertLocalTraineddataIfRequired pre-flight invoked from withRecognizer.
Config schema, ParseOptions, and worker wiring
src/lib/env.ts, src/parser/index.ts, src/lib/parse-worker.ts
Extends Zod env schema with OCR_INIT_TIMEOUT_MS and OCR_REQUIRE_LOCAL_TRAINEDDATA. Adds matching optional fields to ParseOptions, updates ocrOptionsFromParseOptions with a hasAnyOcrOption helper that includes the new fields, and propagates them through parseOptionsFromConfig.
Tests: timeout, pre-flight, and traineddata probe
src/parser/pdf/ocr.test.ts, src/parser/pdf/index.test.ts
ocr.test.ts covers timeout-based ParserError, worker termination on late resolution, reject fail-fast, strict-offline pre-flight that blocks createWorker, and hasLocalTraineddata detection of .traineddata/.traineddata.gz. index.test.ts adds three parsePdf integration tests for the same degradation paths.
ADR-039 and .env.example
docs/adr/039-offline-ocr-provisioning.md, .env.example
ADR-039 documents the offline provisioning decision, traineddata probing rules, consequences, rejected alternatives, and a revision noting the worker-leak gap not addressed by timeout alone. .env.example adds the two new settings with expanded CDN-avoidance guidance.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • wrzonance/SpecR#290: Introduced the OCR fallback pipeline in src/parser/pdf/ocr.ts and the pdf-ocr-unusable degradation path that this PR extends with timeout and pre-flight logic.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: bounded OCR init with offline scan degradation instead of hangs.
Linked Issues check ✅ Passed The changes satisfy #298 by adding bounded OCR init, offline local-traineddata handling, and ADR-039 documentation.
Out of Scope Changes check ✅ Passed The added docs, config, types, and tests all support the OCR timeout/offline provisioning scope.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-298

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

thewrz and others added 2 commits June 27, 2026 10:51
Invariant 2 (a rejecting worker init must keep degrading to a
pdf-ocr-unusable warning, never throw) was only pinned at the
recognizePdfPages surface (-> ParserError). Add the parsePdf-boundary
assertion mirroring the offline `TypeError: fetch failed` case, so all three
#298 invariants are pinned at the parser boundary, not internals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…top OCR worker leak

PR #311's bounded init timeout stops the offline hang but not a worker LEAK
that a Codex (GPT-5.5) adversarial review surfaced: tesseract.js v7 spawns the
worker thread synchronously BEFORE fetching eng.traineddata, so in the
black-hole stall (connection accepted, never answered) createWorker never
settles, terminateLater() never fires, and the already-spawned worker leaks.
The timeout can't terminate a worker it has no handle to. Timeout and
pre-flight are therefore complementary, not substitutes.

Add an opt-in strict mode (OCR_REQUIRE_LOCAL_TRAINEDDATA, default false) that
pre-flights local traineddata BEFORE spawning: if eng.traineddata is absent
(probing langPath + cache for .traineddata and .traineddata.gz, matching
tesseract.js v7's resolution), refuse via ParserError -> pdf-ocr-unusable
WITHOUT invoking the worker factory. Default false preserves the convenient
networked-dev behavior (CDN fetch on first run, bounded by the timeout);
production sets it true and provisions local data. New DI seams
(requireLocalTraineddata, hasLocalTraineddata) keep it fully offline-testable.

Plumbed config -> ParseOptions -> PdfOcrOptions; env via z.stringbool. ADR-039
corrected: pre-flight is adopted as complementary to the timeout, with the
filename contract pinned by a test; credits the PR #311 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thewrz
thewrz marked this pull request as ready for review June 28, 2026 18:39
@thewrz

thewrz commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@thewrz
thewrz merged commit e655c77 into main Jun 28, 2026
5 checks passed
@thewrz
thewrz deleted the feat/issue-298 branch June 28, 2026 20:36
thewrz added a commit that referenced this pull request Jul 1, 2026
#325)

* docs(readme): sync capabilities to last month of merged PRs

Reflect shipped work in the README's "Included Today", "API Surface", and MCP
tool table, validated against the merged diffs and current main:

- PDF ingest (text-layer + OCR + font-encoding recovery) accepted by POST /parse
  (#287, #290, #311)
- coordination / E&O report + submittal register (#241, #269, #277, #282, #283,
  #284) and article-role tagging (#273)
- onboarding pipeline: library import, editability review/override, reclassify,
  finalize/reopen, open-comments (#243, #247, #248, #249, #272)
- spec/project soft-delete + restore (#257, #313), document concurrency (#197),
  revision/addendum manual rendering (#221), numbering profiles (#317, #322)
- add missing MCP tools get_numbering_profile, submittal_register,
  open_comments_report; document GET /docs (Scalar) (#213, #285)
- add Example Client pointer to examples/web_ui_demo (#225)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(roadmap): move shipped work to done; re-date to 2026-07-01

Reconcile the roadmap with merged reality (was stamped 2026-06-17). Moved from
planned/in-progress to Included, each validated against the diff:

- PDF ingest (#287, #290, #311) — remove from "Later"
- deep paragraph nesting pr6/pr7 (#215)
- revision nomenclature (#216) + revision/addendum manual rendering (#221) —
  the two "Near Term" Phase 2e items are done
- coordination / E&O report, required-sections, article-role, submittal register
  (#239, #241, #269, #273, #277, #282, #283, #284) — new "Coordination and
  Semantics" section; removed "coordination report" from planned Phase 4
- onboarding APIs (#243, #247, #248, #249, #272) — API done; UI remains planned
- soft-delete/withdraw (#257, #313), section-number format (#266, #271),
  external-content associations (#242), structural numbering profiles (#317)

Kept as planned (foundation only): header/footer composition (#222, #314) and
keynote surfacing (#315) — DB/AST exist, no resolution/render/export yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(architecture): reflect merged structural changes

Update the architecture spec for shipped work, validated against the diffs and
current schema/routes:

- Tech Stack + Data Flow: Parse — PDF text-layer (unpdf/pdfjs-dist) + OCR
  (tesseract.js/@napi-rs/canvas) path and numberingProfileId override (#287,
  #290, #311, #317; ADR-034, ADR-039)
- DB schema — specs.onboarding_status/withdrawn_at, projects.section_number_format
  /deleted_at/deleted_by, paragraphs.source_facts/classification/
  editability_override; "Additional tables" summary for editing_conventions,
  paragraph_associations, required_sections, keynotes, header_footer_configs,
  numbering_profiles, revision_nomenclature_profiles (foundation-only tables
  flagged) (ADR-021/022/023/028/031/032; #187, #242)
- new Coordination Report / E&O section (finding vocabulary) and Document
  Concurrency section (locks/optimistic/lifecycle) (#197, #241, #269, #277,
  #282, #283, #284; ADR-018, ADR-033/035/036/037)
- AST meta.articleRole (#273, ADR-033); API-surface note pointing at the
  CI-enforced openapi.yaml + GET /docs; refreshed MCP tool list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(parser): OCR worker init can hang parse jobs offline when traineddata is uncached (#246 follow-up)

1 participant