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 17a2d7be14e2645b9beb79b6e47ac7a2c551277b
truex <truex@equwal.com>
2026-09-26 23:41:10 -0700

Cut off the OCR start with its own error, not AbortSignal.reason

abort(reason) and AbortSignal.reason came in Chrome 98, and the app
runs on WebView 91 and later (the SIMD core of tesseract.js). On an
older WebView, a start that took too long rejected with undefined, not
with OcrStartError. The strip then said "40 pages could not be read."
in place of "OCR could not start".

createOcrJob now rejects the start with the error that it keeps, and
also aborts the signal of the start with it. A test makes abort() drop
its reason, as an older WebView does.

 src/ocr-job.ts       | 30 +++++++++++++++++-------------
 test/ocr-job.test.ts | 20 ++++++++++++++++++++
 2 files changed, 37 insertions(+), 13 deletions(-)
diff --git a/src/ocr-job.ts b/src/ocr-job.ts
index 929f21c..9cc8f3c 100644
--- a/src/ocr-job.ts
+++ b/src/ocr-job.ts
@@ -88,27 +88,31 @@ export function createOcrJob<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;
+  /** Cuts off the start that runs now, if any. */
+  let cutStart: ((reason: Error) => void) | 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;
+    // The start rejects with the reason itself: AbortSignal.reason needs
+    // WebView 98, and the app runs on WebView 91 and later.
+    let cutOff: (reason: Error) => void = () => 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);
+      cutOff = (reason) => {
+        reject(reason);
+        control.abort(reason);
+      };
     });
+    cutStart = cutOff;
+    const s = Math.round(startLimitMs / 1000);
+    const timer = setTimeout(
+      () => cutOff(new OcrStartError(new Error(`The start took more than ${s} s.`))),
+      startLimitMs,
+    );
     const end = (): void => {
       clearTimeout(timer);
-      if (starting === control) starting = undefined;
+      if (cutStart === cutOff) cutStart = undefined;
     };
     return Promise.race([started, cut]).then(
       (ocr) => {
@@ -156,7 +160,7 @@ export function createOcrJob<Image>(
     },
     stop() {
       stopped = true;
-      starting?.abort(new Error('OCR stopped.'));
+      cutStart?.(new Error('OCR stopped.'));
       stopOcr(running);
       running = undefined;
     },
diff --git a/test/ocr-job.test.ts b/test/ocr-job.test.ts
index 750b6b8..edbcecc 100644
--- a/test/ocr-job.test.ts
+++ b/test/ocr-job.test.ts
@@ -209,6 +209,26 @@ describe('createOcrJob', () => {
     expect(vi.getTimerCount()).toBe(0);
   });
 
+  it('gives the start error also where abort() keeps no reason (WebView before 98)', async () => {
+    // abort(reason) and AbortSignal.reason came in Chrome 98. The SIMD core of
+    // the app runs from WebView 91.
+    const abort = AbortController.prototype.abort;
+    const noReason = vi.spyOn(AbortController.prototype, 'abort').mockImplementation(function (
+      this: AbortController,
+    ) {
+      abort.call(this);
+    });
+    try {
+      const { start } = stalledStart();
+      const job = createOcrJob(start, 1000);
+      const started = job.start().catch((e: unknown) => e);
+      await vi.advanceTimersByTimeAsync(1000);
+      expect(await started).toBeInstanceOf(OcrStartError);
+    } finally {
+      noReason.mockRestore();
+    }
+  });
+
   it('stops a worker that starts after the start limit', async () => {
     const worker = fakeWorker(readPage);
     const start = vi.fn(