fix(ocr): unblock and harden accurate-OCR install (#552)

Two OCR-install fixes surfaced while verifying the accurate-OCR (v3 runtime) path end to end:

- Installer timeout must be a safe integer, not a performance.now() float. With the default INSTALL_MAX_MS this failed every accurate-OCR install via the app right after the download (masked by the unpublished runtime; CI drives install_runtime.py directly so it never surfaced). Fixed via remainingInstallerTimeoutMs().
- Classify an absent or forbidden runtime index (401/403/404/410) as OcrRuntimeNotPublishedError with a clear "Fast OCR still works" message instead of a raw HTTP 404, without retrying.

Refs #552
This commit is contained in:
SnapOtter
2026-07-20 13:17:17 +08:00
committed by GitHub
parent 5558cf18b8
commit bda4db3f35
3 changed files with 132 additions and 1 deletions
@@ -28,10 +28,12 @@ import {
downloadVerifiedRuntimeRelease as downloadVerifiedRuntimeReleaseWithLease,
loadOcrRuntimeTrustKeys,
OcrRuntimeImportValidationError,
OcrRuntimeNotPublishedError,
type OcrRuntimeTrustKey,
prepareOfflineRuntimeIndex,
prepareOfflineRuntimeRelease,
purgeOcrRuntimeDownloads as purgeOcrRuntimeDownloadsWithLease,
remainingInstallerTimeoutMs,
verifyRuntimeIndex,
writeBufferFully,
} from "../../../apps/api/src/lib/ocr-runtime-install.js";
@@ -157,6 +159,28 @@ function signedIndex(
return { artifact, index, raw: Buffer.from(canonicalRuntimeJson(index)), trustKey };
}
describe("remainingInstallerTimeoutMs", () => {
it("floors a fractional remaining budget to the safe integer the installer requires", () => {
// The deadline is set at one performance.now() read and the remaining time is
// computed at a later read, so `deadline - later` is fractional in practice.
const started = 1_000.4269;
const deadline = started + 7_200_000;
const later = 1_500.8731;
// The old caller passed this raw float; runOcrRuntimeInstaller rejected it as
// a non-integer timeout, which broke every default-config install.
expect(Number.isSafeInteger(deadline - later)).toBe(false);
const remaining = remainingInstallerTimeoutMs(deadline, later);
expect(Number.isSafeInteger(remaining)).toBe(true);
expect(remaining).toBe(Math.floor(deadline - later));
});
it("returns 0 when there is no deadline and clamps a passed deadline to at least 1ms", () => {
expect(remainingInstallerTimeoutMs(undefined, 5)).toBe(0);
expect(remainingInstallerTimeoutMs(10.9, 10.1)).toBe(1);
expect(remainingInstallerTimeoutMs(5, 999.7)).toBe(1);
});
});
describe("verifyRuntimeIndex", () => {
it("loads the independently pinned release key from the official image environment", () => {
const fixture = signedIndex();
@@ -451,6 +475,64 @@ describe("downloadVerifiedRuntimeRelease", () => {
expect(fetchImpl).toHaveBeenCalledTimes(3);
});
it("reports a clear not-published error when the runtime index is absent", async () => {
const directory = mkdtempSync(join(tmpdir(), "snapotter-ocr-index-absent-"));
temporaryDirectories.push(directory);
const canceled = vi.fn();
const missingBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Buffer.from("Entry not found"));
},
cancel: canceled,
});
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(missingBody, { status: 404 }));
const error = await downloadVerifiedRuntimeRelease({
aiDataDir: directory,
bundleRepo: "snapotter-hq/feature-bundles",
version: "2.1.0",
target: TARGET,
trustKeys: [],
fetchImpl,
}).then(
() => {
throw new Error("expected the absent runtime index to reject");
},
(reason: unknown) => reason,
);
expect(error).toBeInstanceOf(OcrRuntimeNotPublishedError);
const message = (error as Error).message;
expect(message).toContain("2.1.0");
expect(message).toContain("Fast OCR");
expect(message).toContain("404");
// Absent means absent: this is not a transient failure and must not retry.
expect(fetchImpl).toHaveBeenCalledOnce();
expect(canceled).toHaveBeenCalledOnce();
});
it("treats a forbidden runtime index as not published rather than a hard error", async () => {
const directory = mkdtempSync(join(tmpdir(), "snapotter-ocr-index-forbidden-"));
temporaryDirectories.push(directory);
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response("Forbidden", { status: 403 }));
await expect(
downloadVerifiedRuntimeRelease({
aiDataDir: directory,
bundleRepo: "snapotter-hq/feature-bundles",
version: "2.1.0",
target: TARGET,
trustKeys: [],
fetchImpl,
}),
).rejects.toBeInstanceOf(OcrRuntimeNotPublishedError);
expect(fetchImpl).toHaveBeenCalledOnce();
});
it("does not retry an oversized index and cancels its unconsumed body", async () => {
const directory = mkdtempSync(join(tmpdir(), "snapotter-ocr-index-size-policy-"));
temporaryDirectories.push(directory);