commit 9dca5aa0c4532d593d473c25e693c46757e0bf2f
truex <truex@equwal.com>
2026-09-26 21:37:20 -0700
Stop the OCR worker when its start fails
When the language data does not load, tesseract.js gives the error to
errorHandler, and its start promise does not settle: src/createWorker.js
rejects that promise only when the core does not load, and the start
chain ends in .catch(() => {}). An earlier change let the error reject
the start, but the Web Worker stayed, with the WebAssembly core in its
memory. tesseract.js gives the worker to the app only after the start,
so the app could not stop it. Each Retry, book open or setting change
then left one more worker, for as long as the data did not load.
startWorker (ocr.ts) now keeps the Web Worker that createWorker makes:
createWorker makes it in the synchronous part of the call, so during
the call a Worker class that keeps each new worker stands in for the
class of the browser. When the start fails, the app stops that worker.
An error after the start belongs to a page, and OcrJob handles it.
A Retry still starts a new worker, so OCR starts when the network comes
back. A check of the language data before the start was not used: the
URL of the data is internal to tesseract.js and is not one path for
"jpn+eng", and a failed or stalled start still left its worker.
README.md | 4 +++
src/ocr.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++++----------
test/ocr.test.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 134 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 138796e..c586655 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,10 @@ million pixels. When it takes longer, or when the OCR worker fails, the
app stops the worker and counts the page as not read. The next page
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.
+
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
same way on a scan and on a text PDF.
diff --git a/src/ocr.ts b/src/ocr.ts
index 8ca327f..3411341 100644
--- a/src/ocr.ts
+++ b/src/ocr.ts
@@ -28,7 +28,7 @@ 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.
+ * OcrStartError when the worker cannot start, and then the worker stops.
*/
export async function createOcr(lang: string, onProgress: (p: number) => void): Promise<Ocr> {
let worker: TesseractWorker;
@@ -103,20 +103,69 @@ function ppmOf(canvas: HTMLCanvasElement): ImageLike {
/**
* Starts the worker of tesseract.js. When the language data does not load,
* tesseract.js gives the error to errorHandler, and the promise of
- * createWorker does not settle. So the first error rejects the start. That
- * worker cannot be stopped: tesseract.js gives no handle to it.
+ * createWorker does not settle (src/createWorker.js rejects it only when
+ * the core does not load). So the first error rejects the start.
+ *
+ * tesseract.js does not stop the Web Worker of a start that failed, and it
+ * 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.
*/
function startWorker(lang: string, onProgress: (p: number) => void): Promise<TesseractWorker> {
return new Promise((resolve, reject) => {
- createWorker(lang, 1, {
- workerPath,
- corePath,
- logger: (m) => {
- if (m.status === 'recognizing text') onProgress(m.progress);
- },
- // Also after the start: without an errorHandler, tesseract.js throws
- // each error of a job in the message handler of the worker.
- errorHandler: reject,
- }).then(resolve, reject);
+ let settled = false;
+ let spawned: Worker | undefined;
+ const fail = (err: unknown): void => {
+ // After the start, an error belongs to a page: OcrJob stops the worker.
+ if (settled) return;
+ settled = true;
+ spawned?.terminate();
+ reject(err);
+ };
+ const made = spawnedBy(() =>
+ createWorker(lang, 1, {
+ workerPath,
+ corePath,
+ logger: (m) => {
+ if (m.status === 'recognizing text') onProgress(m.progress);
+ },
+ // Also after the start: without an errorHandler, tesseract.js throws
+ // each error of a job in the message handler of the worker.
+ errorHandler: fail,
+ }),
+ );
+ spawned = made.worker;
+ made.result.then((worker) => {
+ settled = true;
+ resolve(worker);
+ }, fail);
});
}
+
+/**
+ * Runs `create`, and gives its result and the Web Worker that it made, if
+ * any. createWorker of tesseract.js makes its Web Worker in the synchronous
+ * part of the call (src/createWorker.js calls spawnWorker before it
+ * returns). So during the call, a Worker class that keeps each new worker
+ * stands in for the Worker class of the browser. The class of the browser
+ * comes back before any other code runs.
+ */
+function spawnedBy<T>(create: () => T): { result: T; worker: Worker | undefined } {
+ const Native = globalThis.Worker;
+ // Node has no Worker: tesseract.js uses worker_threads there.
+ if (typeof Native !== 'function') return { result: create(), worker: undefined };
+ let worker: Worker | undefined;
+ globalThis.Worker = class extends Native {
+ constructor(url: string | URL, options?: WorkerOptions) {
+ super(url, options);
+ worker = this;
+ }
+ };
+ try {
+ const result = create();
+ return { result, worker };
+ } finally {
+ globalThis.Worker = Native;
+ }
+}
diff --git a/test/ocr.test.ts b/test/ocr.test.ts
index dd1c4d2..7235a02 100644
--- a/test/ocr.test.ts
+++ b/test/ocr.test.ts
@@ -38,9 +38,33 @@ function fakeCanvas(): HTMLCanvasElement {
/** A path on the origin of the app, for example "/assets/worker.min-abc.js". */
const LOCAL = /^\/(?!\/)/;
+/** The Web Worker of the browser, for the tests. It keeps each worker that it makes. */
+class FakeWebWorker extends EventTarget {
+ static made: FakeWebWorker[] = [];
+ terminate = vi.fn();
+ constructor(readonly url: string | URL) {
+ super();
+ FakeWebWorker.made.push(this);
+ }
+}
+
+/**
+ * createWorker as tesseract.js makes it in the browser: it makes its Web
+ * Worker at once (src/worker/browser/spawnWorker.js). `start` gives the
+ * promise of the start.
+ */
+function spawningCreateWorker(start: (opts: Options) => Promise<unknown>) {
+ return (_lang: string, _oem: number, opts: Options): Promise<unknown> => {
+ new Worker('blob:tesseract-worker');
+ return start(opts);
+ };
+}
+
describe('createOcr', () => {
afterEach(() => {
createWorker.mockReset();
+ vi.unstubAllGlobals();
+ FakeWebWorker.made = [];
});
it('loads the worker script and the core from the app, not from jsDelivr', async () => {
@@ -92,6 +116,50 @@ describe('createOcr', () => {
expect((error as OcrStartError).cause).toBe('TypeError: Failed to fetch');
});
+ it('stops the Web Worker when the language data does not load', async () => {
+ // The finding: tesseract.js gives no handle to this worker, and it does
+ // not stop it. Each Retry, book or setting left one more worker.
+ vi.stubGlobal('Worker', FakeWebWorker);
+ createWorker.mockImplementation(
+ spawningCreateWorker((opts) => {
+ setTimeout(() => opts.errorHandler?.('TypeError: Failed to fetch'), 0);
+ return new Promise(() => undefined);
+ }),
+ );
+ const error = await createOcr('jpn', () => undefined).catch((e: unknown) => e);
+ expect(error).toBeInstanceOf(OcrStartError);
+ expect(FakeWebWorker.made).toHaveLength(1);
+ expect(FakeWebWorker.made[0]!.terminate).toHaveBeenCalledTimes(1);
+ // The browser gets its own Worker constructor back.
+ expect(globalThis.Worker).toBe(FakeWebWorker);
+ });
+
+ it('stops the Web Worker when the start fails', async () => {
+ // tesseract.js rejects the start when its worker script does not load,
+ // and it does not stop the Web Worker.
+ vi.stubGlobal('Worker', FakeWebWorker);
+ createWorker.mockImplementation(
+ spawningCreateWorker(() => Promise.reject('Uncaught NetworkError: failed to load.')),
+ );
+ await expect(createOcr('eng', () => undefined)).rejects.toBeInstanceOf(OcrStartError);
+ expect(FakeWebWorker.made[0]!.terminate).toHaveBeenCalledTimes(1);
+ });
+
+ 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);
+ let handler: ((error: unknown) => void) | undefined;
+ createWorker.mockImplementation(
+ spawningCreateWorker((opts) => {
+ handler = opts.errorHandler;
+ return Promise.resolve(fakeWorker());
+ }),
+ );
+ await createOcr('eng', () => undefined);
+ handler?.('The image is empty.');
+ expect(FakeWebWorker.made[0]!.terminate).not.toHaveBeenCalled();
+ });
+
it('gives OcrStartError and stops the worker when the page mode cannot be set', async () => {
const worker = fakeWorker();
worker.setParameters.mockRejectedValue('The worker stopped.');