Serialize ExifTool work in the worker with a FIFO job queue.

Overlapping parseMetadata calls on one WASM instance caused wrong metadata when multiple files were read in parallel; callers can still postMessage concurrently.
This commit is contained in:
2026-10-15 20:43:22 +02:00
parent e10341d44b
commit 5b6304709e
2 changed files with 55 additions and 10 deletions
@@ -0,0 +1,3 @@
export type ExiftoolWorkerJob =
| { type: 'init' }
| { type: 'read-metadata'; id: string; file: File };
+52 -10
View File
@@ -2,11 +2,15 @@ import zeroperlWasmUrl from '@6over3/zeroperl-ts/zeroperl.wasm?url';
import { parseMetadata } from '@uswriting/exiftool'; import { parseMetadata } from '@uswriting/exiftool';
import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg'; import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg';
import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage'; import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage';
import type { ExiftoolWorkerJob } from '@/types/workers/exiftoolWorkerJob';
import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
/** Custom fetch function for `@uswriting/exiftool` to load `zeroperl.wasm` from the worker. */ /** Custom fetch function for `@uswriting/exiftool` to load `zeroperl.wasm` from the worker. */
const exiftoolParseOptions = { fetch: () => fetch(zeroperlWasmUrl) }; const exiftoolParseOptions = { fetch: () => fetch(zeroperlWasmUrl) };
const jobQueue: ExiftoolWorkerJob[] = [];
let drainPromise: Promise<void> | null = null;
let initialized = false; let initialized = false;
let initializeWorkerPromise: Promise<void> | null = null; let initializeWorkerPromise: Promise<void> | null = null;
@@ -20,8 +24,6 @@ let initializeWorkerPromise: Promise<void> | null = null;
* We run `parseMetadata` on {@link EXIFTOOL_WORKER_INIT_JPEG} so the library performs * We run `parseMetadata` on {@link EXIFTOOL_WORKER_INIT_JPEG} so the library performs
* a real read without needing a user `File`. Output is discarded; sets {@link initialized}. * a real read without needing a user `File`. Output is discarded; sets {@link initialized}.
* *
* Concurrent callers (init message, read-metadata, future write) share one in-flight warmup.
*
* @throws When warmup `parseMetadata` fails (network, WASM, or ExifTool error). * @throws When warmup `parseMetadata` fails (network, WASM, or ExifTool error).
* @see Main thread `initializeExiftool()` posts `{ type: 'init' }` when idle. * @see Main thread `initializeExiftool()` posts `{ type: 'init' }` when idle.
*/ */
@@ -54,10 +56,36 @@ const initializeWorker = async (): Promise<void> => {
return initializeWorkerPromise; return initializeWorkerPromise;
}; };
self.addEventListener('message', async (event: MessageEvent<ExiftoolWorkerInMessage>) => { const enqueue = (job: ExiftoolWorkerJob): void => {
const message = event.data; jobQueue.push(job);
if (message.type === 'init') { if (!drainPromise) {
drainPromise = (async () => {
try {
await drain();
} catch {
console.error('Failed to drain ExifTool worker');
}
drainPromise = null;
})();
}
};
const drain = async (): Promise<void> => {
while (jobQueue.length > 0) {
const job = jobQueue.shift();
if (!job) {
return;
}
await processJob(job);
}
};
const processJob = async (job: ExiftoolWorkerJob): Promise<void> => {
if (job.type === 'init') {
try { try {
await initializeWorker(); await initializeWorker();
@@ -72,25 +100,39 @@ self.addEventListener('message', async (event: MessageEvent<ExiftoolWorkerInMess
return; return;
} }
if (message.type === 'read-metadata') { if (job.type === 'read-metadata') {
try { try {
await initializeWorker(); await initializeWorker();
const result = await parseMetadata(message.file, exiftoolParseOptions); const result = await parseMetadata(job.file, exiftoolParseOptions);
if (!result.success) { if (!result.success) {
self.postMessage({ type: 'read-metadata-error', id: message.id, message: result.error }); self.postMessage({ type: 'read-metadata-error', id: job.id, message: result.error });
return; return;
} }
self.postMessage({ type: 'read-metadata-done', id: message.id, output: result.data }); self.postMessage({ type: 'read-metadata-done', id: job.id, output: result.data });
} catch (error) { } catch (error) {
self.postMessage({ self.postMessage({
type: 'read-metadata-error', type: 'read-metadata-error',
id: message.id, id: job.id,
message: resolveErrorMessage(error, 'Could not read image metadata') message: resolveErrorMessage(error, 'Could not read image metadata')
}); });
} }
} }
};
self.addEventListener('message', (event: MessageEvent<ExiftoolWorkerInMessage>) => {
const message = event.data;
if (message.type === 'init') {
enqueue({ type: 'init' });
return;
}
if (message.type === 'read-metadata') {
enqueue({ type: 'read-metadata', id: message.id, file: message.file });
}
}); });