feat(api/ocr): verify which Tesseract OCR actually binds, and say so - #463
Conversation
Commons.loadTesseract() has been setting libTesseractLoaded on a false premise: loadNatives() not throwing means Legerix loaded its own file by absolute path. It says nothing about the library tess4j binds when OCR runs, because tess4j resolves the short name "tesseract" through JNA at its own static init and can land somewhere else entirely. The banner then reported a version string, which describes the extracted file rather than the bound one. Investigating Legerix#20 across macOS, two Linux tiers and native Windows showed both halves of that going wrong in the field: a host binding Homebrew's tesseract while the suite stayed green, and a host where the bundled payload ships only versioned filenames so the JNA lookup misses and OCR fails outright. Adds NativeProvenance, which reports rather than enforces — a wrong binding is a diagnosis problem, not a crash, and the point is that it is currently invisible: - captures the tier directory loadNatives() already returns and we discarded - after the first real OCR call, resolves the short names tess4j uses and logs any that landed outside that directory. Deliberately after OCR: before it the check proves nothing, and asking JNA for an unresolved name would itself trigger the load under test. A copy that is byte-identical to ours is reported as equivalent, not as a warning, so tess4j's own temp extraction does not cry wolf - flags natives that are not Legerix's own when Legerix is shaded into our jar. Its extraction reads getProtectionDomain().getCodeSource(), so a fat jar makes that our jar; comparing code sources is exact, where comparing filenames would misfire on the large transitive set shipped for Windows - turns the JNA link failure into a diagnosis. "Reinstall OculiX" is the wrong advice when the payload is present and merely lacks the unversioned alias JNA asks for, which is what happens on a Mac with no system tesseract The banner now names the natives directory instead of only a version, and isBindingVerified() stays false until a real OCR call has been observed, so it can never read as "fine" merely because nothing was checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
|
On the checks shown here — worth stating rather than leaving to be inferred.
So the green tick here is thinner evidence than it looks. What was actually done locally:
Happy to re-run anything specific if you would rather see it from a clean checkout. |
Fleet review found two defects that pointed in opposite directions, and both were in claims the code made about itself rather than in the logic. resolveQuietly() called NativeLibrary.getInstance(name), which loads when the name is not cached. For any name the consumer had not bound — all three of them on Windows — the check therefore performed the very short-name resolution it exists to observe, and could then report a library OCR never touched as the one servicing it. On Linux it is worse than misleading: speculatively mapping a system tesseract alongside our bundled leptonica is how the crash we reported upstream begins. A diagnostic must not be able to cause the fault it looks for. It now reads JNA's own cache and reports only what is genuinely bound, matching on getName() rather than on the map key so it does not depend on how JNA composes that key. If the cache cannot be read it reports nothing rather than forcing a load. Confirmed on this Mac: the check previously reported three resolved names and now reports two, because the third was never bound by the consumer at all — the old code was manufacturing the observation it reported. isBindingVerified() could also return true having checked nothing. allInside started true and every unbound name hit continue, so the all-unbound case — the normal Windows one — came out verified. Its own javadoc promised the opposite. Now requires at least one observation. Also narrowed two sentences to what was actually checked: the warning speaks of the name being bound outside the bundled directory rather than asserting what is servicing OCR, and the link-failure note no longer generalises from one filename to "the bundled files carry version suffixes only". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
…ser's terms Builds on oculix-org#463, which detects that the bundled natives lack the unversioned filename JNA's exact-name lookup asks for. Detecting it does not help a user whose OCR is broken today, and the repair is a single symlink. It is not done silently. This writes into a cache directory OculiX did not create, and people hold views about that, so it is a stored three-state choice: ASK (default, the IDE prompts), AUTO (repair at every startup), NEVER (never write). NEVER still prints the exact ln -s command, so declining never leaves someone worse off than not being asked. Re-asserted on every startup rather than once at install. The cache is keyed by version, so an upgrade extracts a fresh directory and any alias made earlier is gone — the same reason a package manager re-points its "latest" link after each build instead of assuming it holds. Idempotent, so if Legerix ever ships these itself this quietly does nothing. Which aliases are needed was measured across macOS, both Linux x86-64 tiers and native Windows, and the rules are not symmetric: - unversioned only for the JNA lookup. A versioned alias satisfies isVersionedName, enters JNA's version-pooling fallback and competes with whatever the distro ships — reintroducing the bug it is meant to fix - Linux additionally needs versioned liblept.so.5, because the bundled tesseract's ELF NEEDED is that literal string and no such file exists. Safe only alongside the unversioned link, which wins the exact-name pass first - Windows needs nothing and is excluded: tess4j binds fully versioned names it ships itself, and NTFS symlinks would need admin or Developer Mode Aliases always target the real versioned library, never another alias — the directory scan uses NOFOLLOW_LINKS, so a link made earlier in the same pass cannot become the target of a later one and leave a chain that breaks when the middle link goes. Verified on a Mac with no system tesseract, where OCR previously failed outright with UnsatisfiedLinkError: after repair all three short names resolve inside the bundled directory, isBindingVerified() is true, and OCR returns its text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
verifyBinding() logged the byte-identical case and fell through without touching allInside, so bindingVerified stayed true while OCR was serviced from outside the extraction directory. That is a false pass in the normal Windows case, where tess4j binds its own byte-identical temp copy. The question this check answers is one of path, not content: if a short name resolves outside our directory then our directory lost, whatever the bytes say. Content equality cannot distinguish "our payload reached by another route" from "a different build that happens to match", and under a shaded jar the directory's own contents may not be ours either — so suppressing the signal returned success in exactly the case the check exists to detect. It now lowers the message severity and leaves the flag false. Carried back from the self-heal branch so this PR is correct on its own. The two are stacked, so merging both in either order already produced this result; the exposure was this PR merging alone if the other stalls, which would have shipped the false pass with nothing following to correct it. Found by a fleet reviewer reading the diffs rather than my summary of them, after an earlier review of these PRs turned out to have been of the summary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
…ser's terms Builds on oculix-org#463, which detects that the bundled natives lack the unversioned filename JNA's exact-name lookup asks for. Detecting it does not help a user whose OCR is broken today, and the repair is a single symlink. It is not done silently. This writes into a cache directory OculiX did not create, and people hold views about that, so it is a stored three-state choice: ASK (default, the IDE prompts), AUTO (repair at every startup), NEVER (never write). NEVER still prints the exact ln -s command, so declining never leaves someone worse off than not being asked. Re-asserted on every startup rather than once at install. The cache is keyed by version, so an upgrade extracts a fresh directory and any alias made earlier is gone — the same reason a package manager re-points its "latest" link after each build instead of assuming it holds. Idempotent, so if Legerix ever ships these itself this quietly does nothing. Which aliases are needed was measured across macOS, both Linux x86-64 tiers and native Windows, and the rules are not symmetric: - unversioned only for the JNA lookup. A versioned alias satisfies isVersionedName, enters JNA's version-pooling fallback and competes with whatever the distro ships — reintroducing the bug it is meant to fix - Linux additionally needs versioned liblept.so.5, because the bundled tesseract's ELF NEEDED is that literal string and no such file exists. Safe only alongside the unversioned link, which wins the exact-name pass first - Windows needs nothing and is excluded: tess4j binds fully versioned names it ships itself, and NTFS symlinks would need admin or Developer Mode Aliases always target the real versioned library, never another alias — the directory scan uses NOFOLLOW_LINKS, so a link made earlier in the same pass cannot become the target of a later one and leave a chain that breaks when the middle link goes. Verified on a Mac with no system tesseract, where OCR previously failed outright with UnsatisfiedLinkError: after repair all three short names resolve inside the bundled directory, isBindingVerified() is true, and OCR returns its text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XD7mNUrETDESsPY22Dubv
|
@davidyoung8196504567-sudo — the decision on this one, with its reasoning and the rest of your September batch, is in #469. It is not a merge, and I would rather be contradicted there than agreed with here. |
Why
Commons.loadTesseract()setslibTesseractLoadedwhenLegerix.loadNatives()merely doesn't throw. That means "Legerix loaded its own file by absolute path" — it says nothing about the library tess4j binds when OCR runs, because tess4j resolves the short nametesseractthrough JNA at its own static init and can land somewhere else. The startup banner then reports a version string, which describes the extracted file rather than the bound one.Testing the Legerix
v5.5.0-9-DO-NOT-USEpayload across macOS arm64, both Linux x86-64 tiers and native Windows showed both halves of that going wrong in the field:Neither is visible from anything OculiX currently logs.
What this adds
NativeProvenance— reporting, not enforcement. A wrong binding is a diagnosis problem rather than a crash, and the point is that it is currently invisible.loadNatives()already returns. We were discarding it. It anchors everything else, and needs no change from Legerix.getProtectionDomain().getCodeSource(), so a fat jar makes that our jar. Comparing code sources is exact; comparing filenames would misfire on the large transitive set shipped for Windows.The banner now names the natives directory rather than only a version, and
isBindingVerified()staysfalseuntil a real OCR call has been observed — so it can never read as "fine" merely because nothing has been checked.It already found something
Running the API with a real OCR call on a Mac with no system tesseract:
Testing
NativeProvenanceTest— 6 tests, covering the diagnosis text and thatisBindingVerified()is false until observedOCR.readText()call, which produced the output aboveAPI+IDEsuite run on this branch and on the untouched base: identical results — 41 errors, 44 skipped, all pre-existing and headless-related. This branch adds 6 passing tests and changes nothing elseNot included, deliberately
Self-healing — creating the missing unversioned alias — writes into the cache rather than only observing it, and should be a user-visible choice rather than a silent side effect. Proposed separately.