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
+48
View File
@@ -65,6 +65,10 @@ const HARD_MAX_RETRY_ATTEMPTS = 6;
const HARD_MAX_RETRY_DELAY_MS = 30_000;
const HARD_MAX_RETRY_TOTAL_DELAY_MS = 120_000;
const RETRYABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
// The bundle host answers a runtime index that was never published for this
// version with one of these. On a public bundle repository they all mean the
// same thing to a user: the accurate runtime is not available to install yet.
const RUNTIME_NOT_PUBLISHED_STATUSES = new Set([401, 403, 404, 410]);
const RETRYABLE_NETWORK_CODES = new Set([
"EAI_AGAIN",
"ECONNREFUSED",
@@ -185,6 +189,22 @@ export class OcrRuntimeImportValidationError extends Error {
}
}
/**
* The signed OCR runtime index is not published (or not readable) for this
* SnapOtter version. This is a definitive "nothing to install here" answer, not
* a transient failure, so it is thrown without retrying and carries a message
* the install UI can show verbatim.
*/
export class OcrRuntimeNotPublishedError extends Error {
readonly httpStatus: number;
constructor(message: string, httpStatus: number, options?: ErrorOptions) {
super(message, options);
this.name = "OcrRuntimeNotPublishedError";
this.httpStatus = httpStatus;
}
}
interface ResolvedRetryPolicy {
maxAttempts: number;
baseDelayMs: number;
@@ -332,6 +352,22 @@ async function assertDownloadResponse(
throw new Error(message);
}
/**
* Turn an absent (or forbidden) signed runtime index into a clear, terminal
* error instead of a raw "HTTP 404". Callers invoke this before the generic
* response check so the message reaches the install UI unretried and unwrapped.
*/
function assertOcrRuntimeIndexPublished(response: Response, version: string): void {
if (!RUNTIME_NOT_PUBLISHED_STATUSES.has(response.status)) return;
cancelResponseBody(response);
throw new OcrRuntimeNotPublishedError(
`The accurate OCR runtime for SnapOtter ${version} is not available to install yet ` +
`(its signed runtime index returned HTTP ${response.status}). Fast OCR still works; ` +
`accurate OCR becomes available once its runtime is published for this version.`,
response.status,
);
}
function retryDelayMs(error: unknown, completedAttempts: number, policy: ResolvedRetryPolicy) {
if (error instanceof RetryableOcrDownloadError && error.retryAfterMs !== null) {
return Math.min(error.retryAfterMs, policy.maxDelayMs);
@@ -1748,6 +1784,7 @@ async function downloadVerifiedRuntimeReleaseUnderLease(
signal: controller.signal,
},
);
assertOcrRuntimeIndexPublished(indexResponse, options.version);
await assertDownloadResponse(indexResponse, "OCR runtime index", retryPolicy.now);
return readBoundedResponse(
indexResponse,
@@ -1965,6 +2002,17 @@ async function purgeOcrRuntimeDownloadsUnderLease(aiDataDir: string): Promise<vo
}
}
/**
* Remaining installer time budget from an optional deadline. `performance.now()`
* is fractional, so a raw `deadline - now` is a float that `runOcrRuntimeInstaller`
* rejects as a non-integer timeout. Floor it to a safe integer and keep at least
* 1ms so a live install always receives a positive, valid timeout.
*/
export function remainingInstallerTimeoutMs(deadlineMs: number | undefined, nowMs: number): number {
if (deadlineMs === undefined) return 0;
return Math.max(1, Math.floor(deadlineMs - nowMs));
}
export function buildOcrRuntimeInstallerCommand(
options: RunOcrRuntimeInstallerOptions,
): OcrRuntimeInstallerCommand {
+2 -1
View File
@@ -89,6 +89,7 @@ import {
prepareOfflineRuntimeIndex,
prepareOfflineRuntimeRelease,
purgeOcrRuntimeDownloads,
remainingInstallerTimeoutMs,
runOcrRuntimeInstaller,
runOcrRuntimeMaintenance,
waitWithOcrRuntimeHeartbeat,
@@ -227,7 +228,7 @@ function startOcrInstall(bundleId: string, jobId: string, installLockFd: number)
release,
aiDataDir: getAiDir(),
installLockFd,
timeoutMs: installDeadline ? Math.max(1, installDeadline - performance.now()) : 0,
timeoutMs: remainingInstallerTimeoutMs(installDeadline, performance.now()),
}),
reportActivation,
);
@@ -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);