Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


commit cc3467c56967867422ac0d8a7a88d539a4584441
truex <truex@equwal.com>
2026-09-26 23:11:53 -0700

Give the start of OCR a time limit

A download of the language data that stalls, for example behind the
login page of a public Wi-Fi, does not end by itself. The page time
limit did not cover the start, so await ocr.start() in readPage waited
for ever: the strip kept "Reading 0/40 ยท page 1" and showed no Retry.

createOcrJob (ocr-job.ts) now gives the start a time limit,
START_LIMIT_MS, the same 120 s as a page. After the limit the start
rejects with OcrStartError, each page gets that error, and the strip
shows "OCR could not start" with Retry. The job gives the start an
AbortSignal. It aborts the signal at the limit and when the job stops
during a start. createOcr (ocr.ts) then fails the start and stops the
Web Worker that it kept. A worker that starts after the limit stops.

 README.md            | 16 ++++++++----
 src/main.ts          | 11 +++++----
 src/ocr-job.ts       | 61 +++++++++++++++++++++++++++++++++++++++++++---
 src/ocr.ts           | 28 ++++++++++++++++-----
 test/ocr-job.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/ocr.test.ts     | 24 ++++++++++++++++++
 6 files changed, 190 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index c586655..5bd0343 100644
--- a/README.md
+++ b/README.md
@@ -101,8 +101,9 @@ The strip under the top bar shows three things:
   It goes away when all pages are read. A page that cannot be read does
   not stop the others: at the end the strip says "2 pages could not be
   read." with Retry, which reads only those pages. When OCR cannot
-  start, for example with no network on the first run, the strip says
-  so, with Retry. "Force OCR is on" shows while it is on for the book.
+  start, for example with no network on the first run, or its start
+  takes more than 2 minutes, the strip says so, with Retry. "Force OCR
+  is on" shows while it is on for the book.
 - The last event, for example a lookup or an error. It fades after 6
   seconds. Screen readers read this part.
 
@@ -179,7 +180,11 @@ starts a new worker, so one bad page does not stop the reading.
 
 When the OCR worker cannot start, for example because the language data
 does not load, the app stops that worker too. tesseract.js does not stop
-it, so without this each Retry left one more worker in memory.
+it, so without this each Retry left one more worker in memory. The start
+also has a time limit of 2 minutes: a download of the language data that
+stalls, for example behind the login page of a public Wi-Fi, does not
+end by itself. After the limit the strip says that OCR could not start,
+with Retry.
 
 Both give the same tokens: one box per character with a line id and a
 word id. The alignment, the mark, the page turn and the lookup work the
@@ -474,8 +479,9 @@ in a browser bundle.
 - `src/token-cache.ts`: IndexedDB store for the page tokens, the copy of
   the last book and the meta data of each book (thin).
 - `src/ocr.ts`, `src/pdf.ts`: thin browser wrappers around the libraries.
-  `src/ocr-job.ts`: the start error, the time limit of a page, the new
-  worker after a failed page and the stop of OCR, without tesseract.js.
+  `src/ocr-job.ts`: the start error, the time limits of the start and of
+  a page, the new worker after a failed page and the stop of OCR, without
+  tesseract.js.
 - `src/view.ts`: the page view: render to fit the width, draw the mark,
   scroll to it, render again on a resize.
 - `src/nav.ts`: page turns by the user: the arrows, the keys, the swipe
diff --git a/src/main.ts b/src/main.ts
index 4cb13e2..cdacad6 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -524,8 +524,8 @@ function watchDb(): void {
 /**
  * The tokens of one page: from the cache, else the text layer, else OCR.
  * `force` skips the text layer. Rejects with OcrStartError when the page
- * needs OCR and OCR cannot start, and with OcrTimeoutError when the OCR of
- * the page takes too long.
+ * needs OCR and OCR cannot start or its start takes too long, and with
+ * OcrTimeoutError when the OCR of the page takes too long.
  */
 async function readPage(
   pdf: PdfDoc,
@@ -605,10 +605,11 @@ async function readBook(retry = false): Promise<void> {
   };
   // The OCR worker, and tesseract.js itself, load on the first page that
   // needs them. After a page that fails or takes too long, the job stops
-  // the worker, and the next page starts a new one.
-  const ocr = createOcrJob<HTMLCanvasElement>(() =>
+  // the worker, and the next page starts a new one. A start that takes too
+  // long gives each page the start error, and the strip shows Retry.
+  const ocr = createOcrJob<HTMLCanvasElement>((signal) =>
     import('./ocr').then(
-      ({ createOcr }) => createOcr(lang, onOcrProgress),
+      ({ createOcr }) => createOcr(lang, onOcrProgress, signal),
       (err: unknown) => {
         throw new OcrStartError(err);
       },
diff --git a/src/ocr-job.ts b/src/ocr-job.ts
index 11f42c8..929f21c 100644
--- a/src/ocr-job.ts
+++ b/src/ocr-job.ts
@@ -40,6 +40,14 @@ export function pageTimeLimit(pixels: number): number {
   return PAGE_LIMIT_MS * Math.max(1, pixels / PAGE_LIMIT_PIXELS);
 }
 
+/**
+ * The time limit of the start of OCR: the worker and its core load from the
+ * app, and at the first run of a language its data loads from the network
+ * (a few MB). A download that stalls, for example behind the login page of a
+ * public Wi-Fi, does not end by itself. The limit is the same as for a page.
+ */
+export const START_LIMIT_MS = PAGE_LIMIT_MS;
+
 /** An OCR worker. In the app, it is tesseract.js (src/ocr.ts). */
 export interface OcrWorker<Image> {
   /** Rejects when the worker stops or fails before the page is read. */
@@ -49,7 +57,10 @@ export interface OcrWorker<Image> {
 
 /** The OCR of one reading of the book. */
 export interface OcrJob<Image> {
-  /** Starts a worker when no worker runs. Rejects with the start error. */
+  /**
+   * Starts a worker when no worker runs. Rejects with the start error, also
+   * with OcrStartError when the start takes longer than its time limit.
+   */
   start(): Promise<void>;
   /**
    * Reads one page. When the page fails or takes longer than `limitMs`, the
@@ -66,13 +77,56 @@ export interface OcrJob<Image> {
  * settle the page of a worker that crashed, so the time limit ends such a page,
  * and the reading goes on. A worker that did not start is not started again:
  * each page gets the same start error.
+ *
+ * A start that takes longer than `startLimitMs` rejects with OcrStartError.
+ * The signal that `startWorker` gets then aborts, with that error as its
+ * reason, so that the start can stop its worker. A stop of the job during a
+ * start aborts the signal too. A worker that starts after the limit stops.
  */
-export function createOcrJob<Image>(startWorker: () => Promise<OcrWorker<Image>>): OcrJob<Image> {
+export function createOcrJob<Image>(
+  startWorker: (signal: AbortSignal) => Promise<OcrWorker<Image>>,
+  startLimitMs = START_LIMIT_MS,
+): OcrJob<Image> {
   let running: Promise<OcrWorker<Image>> | undefined;
+  /** The control of the start that runs now, if any. */
+  let starting: AbortController | undefined;
   let stopped = false;
+
+  function startInTime(): Promise<OcrWorker<Image>> {
+    const control = new AbortController();
+    starting = control;
+    const started = startWorker(control.signal);
+    let timer: ReturnType<typeof setTimeout> | undefined;
+    const cut = new Promise<never>((_resolve, reject) => {
+      control.signal.addEventListener('abort', () => reject(control.signal.reason), {
+        once: true,
+      });
+      timer = setTimeout(() => {
+        const s = Math.round(startLimitMs / 1000);
+        control.abort(new OcrStartError(new Error(`The start took more than ${s} s.`)));
+      }, startLimitMs);
+    });
+    const end = (): void => {
+      clearTimeout(timer);
+      if (starting === control) starting = undefined;
+    };
+    return Promise.race([started, cut]).then(
+      (ocr) => {
+        end();
+        return ocr;
+      },
+      (err: unknown) => {
+        end();
+        // A worker that starts after the cut stops at once.
+        stopOcr(started);
+        throw err;
+      },
+    );
+  }
+
   const worker = (): Promise<OcrWorker<Image>> => {
     if (stopped) return Promise.reject(new Error('OCR stopped.'));
-    running ??= startWorker();
+    running ??= startInTime();
     return running;
   };
   return {
@@ -102,6 +156,7 @@ export function createOcrJob<Image>(startWorker: () => Promise<OcrWorker<Image>>
     },
     stop() {
       stopped = true;
+      starting?.abort(new Error('OCR stopped.'));
       stopOcr(running);
       running = undefined;
     },
diff --git a/src/ocr.ts b/src/ocr.ts
index 3411341..7a51b86 100644
--- a/src/ocr.ts
+++ b/src/ocr.ts
@@ -28,14 +28,20 @@ export interface Ocr {
 /**
  * Start an OCR worker for `lang` ("eng", "jpn", "jpn_vert", "jpn+eng", ...).
  * `onProgress` gets values in [0, 1] while a page is recognized. Rejects with
- * OcrStartError when the worker cannot start, and then the worker stops.
+ * OcrStartError when the worker cannot start, and then the worker stops. An
+ * abort of `signal` before the worker started stops the start in the same
+ * way: see createOcrJob.
  */
-export async function createOcr(lang: string, onProgress: (p: number) => void): Promise<Ocr> {
+export async function createOcr(
+  lang: string,
+  onProgress: (p: number) => void,
+  signal?: AbortSignal,
+): Promise<Ocr> {
   let worker: TesseractWorker;
   try {
-    worker = await startWorker(lang, onProgress);
+    worker = await startWorker(lang, onProgress, signal);
   } catch (err) {
-    throw new OcrStartError(err);
+    throw err instanceof OcrStartError ? err : new OcrStartError(err);
   }
   if (lang.includes('vert')) {
     try {
@@ -110,10 +116,19 @@ function ppmOf(canvas: HTMLCanvasElement): ImageLike {
  * gives the app the worker only after the start. So the start keeps the Web
  * Worker (see spawnedBy) and stops it when the start fails. Without this,
  * each Retry, book or setting change left one more worker, each with the
- * WebAssembly core in its memory.
+ * WebAssembly core in its memory. An abort of `signal` during the start
+ * fails the start with the reason of the abort.
  */
-function startWorker(lang: string, onProgress: (p: number) => void): Promise<TesseractWorker> {
+function startWorker(
+  lang: string,
+  onProgress: (p: number) => void,
+  signal?: AbortSignal,
+): Promise<TesseractWorker> {
   return new Promise((resolve, reject) => {
+    if (signal?.aborted) {
+      reject(signal.reason);
+      return;
+    }
     let settled = false;
     let spawned: Worker | undefined;
     const fail = (err: unknown): void => {
@@ -136,6 +151,7 @@ function startWorker(lang: string, onProgress: (p: number) => void): Promise<Tes
       }),
     );
     spawned = made.worker;
+    signal?.addEventListener('abort', () => fail(signal.reason), { once: true });
     made.result.then((worker) => {
       settled = true;
       resolve(worker);
diff --git a/test/ocr-job.test.ts b/test/ocr-job.test.ts
index 1fea666..750b6b8 100644
--- a/test/ocr-job.test.ts
+++ b/test/ocr-job.test.ts
@@ -5,6 +5,7 @@ import {
   OcrStartError,
   OcrTimeoutError,
   pageTimeLimit,
+  START_LIMIT_MS,
   stopOcr,
   type OcrWorker,
 } from '../src/ocr-job';
@@ -168,6 +169,74 @@ describe('createOcrJob', () => {
     expect(start).toHaveBeenCalledTimes(1);
   });
 
+  /** A start that never ends, as a stalled download. It keeps the signal of each start. */
+  function stalledStart() {
+    const signals: AbortSignal[] = [];
+    const start = vi.fn((signal: AbortSignal) => {
+      signals.push(signal);
+      return new Promise<OcrWorker<string>>(() => undefined);
+    });
+    return { start, signals };
+  }
+
+  it('gives the start error when the start takes longer than its limit, and stops the start', async () => {
+    // The finding: a download of the language data that stalls (a captive
+    // portal) held the start, and so the reading, for ever, with no Retry.
+    const { start, signals } = stalledStart();
+    const job = createOcrJob(start, 1000);
+    let settled = false;
+    const started = job
+      .start()
+      .catch((e: unknown) => e)
+      .finally(() => {
+        settled = true;
+      });
+    await vi.advanceTimersByTimeAsync(999);
+    expect(settled).toBe(false);
+    await vi.advanceTimersByTimeAsync(1);
+    expect(settled).toBe(true);
+    const error = await started;
+    expect(error).toBeInstanceOf(OcrStartError);
+    expect((error as Error).message).toBe(
+      'OCR could not start: Error: The start took more than 1 s.',
+    );
+    // The start of the worker gets the stop, so that it can stop its worker.
+    expect(signals[0]?.aborted).toBe(true);
+    expect(signals[0]?.reason).toBe(error);
+    // Each page gets the same start error. No new start runs.
+    await expect(job.recognize('page 1', 0, 1000)).rejects.toBe(error);
+    expect(start).toHaveBeenCalledTimes(1);
+    expect(vi.getTimerCount()).toBe(0);
+  });
+
+  it('stops a worker that starts after the start limit', async () => {
+    const worker = fakeWorker(readPage);
+    const start = vi.fn(
+      () => new Promise<OcrWorker<string>>((resolve) => setTimeout(() => resolve(worker), 2000)),
+    );
+    const job = createOcrJob(start, 1000);
+    const started = job.start().catch((e: unknown) => e);
+    await vi.advanceTimersByTimeAsync(1000);
+    expect(await started).toBeInstanceOf(OcrStartError);
+    expect(worker.terminate).not.toHaveBeenCalled();
+    await vi.advanceTimersByTimeAsync(1000);
+    expect(worker.terminate).toHaveBeenCalledTimes(1);
+  });
+
+  it('stops a start that runs when the job stops', async () => {
+    const { start, signals } = stalledStart();
+    const job = createOcrJob(start, 1000);
+    const started = job.start().catch((e: unknown) => e);
+    job.stop();
+    expect(signals[0]?.aborted).toBe(true);
+    expect(await started).toEqual(new Error('OCR stopped.'));
+    expect(vi.getTimerCount()).toBe(0);
+  });
+
+  it('gives the start the limit of a page by default', () => {
+    expect(START_LIMIT_MS).toBe(pageTimeLimit(0));
+  });
+
   it('rejects the page that runs on stop, stops the worker once, and starts no new worker', async () => {
     const worker = stoppableWorker();
     const start = vi.fn(() => Promise.resolve(worker));
diff --git a/test/ocr.test.ts b/test/ocr.test.ts
index 7235a02..f2c0b49 100644
--- a/test/ocr.test.ts
+++ b/test/ocr.test.ts
@@ -145,6 +145,30 @@ describe('createOcr', () => {
     expect(FakeWebWorker.made[0]!.terminate).toHaveBeenCalledTimes(1);
   });
 
+  it('stops the Web Worker when the start is cut off', async () => {
+    // OcrJob cuts off a start that takes too long, for example a download
+    // of the language data that stalls.
+    vi.stubGlobal('Worker', FakeWebWorker);
+    createWorker.mockImplementation(spawningCreateWorker(() => new Promise(() => undefined)));
+    const controller = new AbortController();
+    const start = createOcr('jpn', () => undefined, controller.signal);
+    const reason = new OcrStartError(new Error('The start took more than 120 s.'));
+    controller.abort(reason);
+    await expect(start).rejects.toBe(reason);
+    expect(FakeWebWorker.made[0]!.terminate).toHaveBeenCalledTimes(1);
+  }, 1000);
+
+  it('starts no worker when the start is cut off before it begins', async () => {
+    const controller = new AbortController();
+    controller.abort(new Error('OCR stopped.'));
+    const error = await createOcr('eng', () => undefined, controller.signal).catch(
+      (e: unknown) => e,
+    );
+    expect(error).toBeInstanceOf(OcrStartError);
+    expect((error as OcrStartError).cause).toEqual(new Error('OCR stopped.'));
+    expect(createWorker).not.toHaveBeenCalled();
+  });
+
   it('does not stop the Web Worker for an error after the start', async () => {
     // After the start, an error belongs to a page. OcrJob stops the worker then.
     vi.stubGlobal('Worker', FakeWebWorker);