WorkerPool.ts
src/worker/WorkerPool.ts · raw · all examples · site home
Exports: WorkerCallWorkerArgsWorkerResultCallOptionsWorkerLeaseWorkerPool
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253
// Up to `size` workers running core/ (worker/coreWorker.ts), each
// made only when a call needs one: a pool that is never used costs nothing.
// call() runs on the next free worker (callers queue FIFO); acquire() takes
// one worker exclusively until release(). ArrayBuffers in args are
// transferred, so the caller's arrays are detached until the result hands
// them back. A worker that fails outside a handler (an uncaught error, a
// message that cannot be read) rejects its pending calls and is replaced.
// Workers import the one core module in the bundle (WorkerPool.coreUrl),
// which an app's own workers can import too.
import { ArrayData } from "../core/ArrayData";
import type { Progress, ProgressStage } from "../core/progress";
import coreModule from "./coreModule?module&inline";
import type { WorkerHandlers } from "./coreWorker";
let coreUrl: string | undefined;
let shimUrl: string | undefined;
const blobUrl = (code: string) => URL.createObjectURL(new Blob([code], { type: "text/javascript" }));
export type WorkerCall = keyof WorkerHandlers;
export type WorkerArgs<N extends WorkerCall> = Parameters<WorkerHandlers[N]>[0];
export type WorkerResult<N extends WorkerCall> = Awaited<ReturnType<WorkerHandlers[N]>>;
export interface CallOptions {
// The handler's progress (throttled by the worker)
onProgress?: Progress;
// Rejects the call with signal.reason at once and asks the worker to stop
// (handlers that take a signal stop at their next progress report); the
// worker stays
signal?: AbortSignal;
}
interface Pending {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
onProgress?: Progress;
}
let nextId = 1;
class PooledWorker {
readonly worker = new Worker((shimUrl ??= blobUrl(`import { serveWorker } from ${JSON.stringify(WorkerPool.coreUrl)}; serveWorker();`)), { type: "module" });
// Set once the worker failed or was terminated; calls then reject
dead?: Error;
private pending = new Map<number, Pending>();
constructor(onDeath: (worker: PooledWorker) => void) {
this.worker.onmessage = (event: MessageEvent<{ id: number; result?: unknown; error?: string; progress?: [number, ProgressStage] }>) => {
const { id, result, error, progress } = event.data;
const entry = this.pending.get(id);
if (progress) {
entry?.onProgress?.(progress[0], progress[1]);
return;
}
this.pending.delete(id);
if (error !== undefined) {
entry?.reject(new Error(error));
} else {
entry?.resolve(result);
}
};
this.worker.onerror = (event) => {
event.preventDefault();
this.terminate(new Error(`Worker failed: ${event.message || "uncaught error"}`));
onDeath(this);
};
this.worker.onmessageerror = () => {
this.terminate(new Error("Worker message could not be deserialized"));
onDeath(this);
};
}
call<N extends WorkerCall>(name: N, args: WorkerArgs<N>, { onProgress, signal }: CallOptions = {}): Promise<WorkerResult<N>> {
if (this.dead) {
return Promise.reject(this.dead);
}
if (signal?.aborted) {
return Promise.reject(signal.reason);
}
const id = nextId++;
return new Promise((resolve, reject) => {
const abort = () => {
this.pending.delete(id);
this.worker.postMessage({ id, cancel: true });
reject(signal!.reason);
};
const settle = <T>(finish: (value: T) => void) => (value: T) => {
signal?.removeEventListener("abort", abort);
finish(value);
};
signal?.addEventListener("abort", abort, { once: true });
this.pending.set(id, { resolve: settle(resolve as (value: unknown) => void), reject: settle(reject), onProgress });
this.worker.postMessage({ id, name, args }, { transfer: ArrayData.transferables(args) });
});
}
terminate(reason = new Error("Worker terminated")) {
this.dead ??= reason;
this.worker.terminate();
this.pending.forEach(({ reject }) => reject(reason));
this.pending.clear();
}
}
// One worker, held until release()
export class WorkerLease {
private worker?: PooledWorker;
private pool: WorkerPool;
constructor(worker: PooledWorker, pool: WorkerPool) {
this.worker = worker;
this.pool = pool;
}
call<N extends WorkerCall>(name: N, args: WorkerArgs<N>, options?: CallOptions): Promise<WorkerResult<N>> {
if (!this.worker) {
throw new Error("WorkerLease already released");
}
return this.worker.call(name, args, options);
}
release() {
if (this.worker) {
this.pool.give(this.worker);
this.worker = undefined;
}
}
}
export class WorkerPool {
readonly size: number;
private static shared?: WorkerPool;
private workers: PooledWorker[] = [];
private idle: PooledWorker[] = [];
private waiting: { resolve: (worker: PooledWorker) => void; reject: (error: Error) => void }[] = [];
private disposed = false;
constructor({ size = 4 }: { size?: number } = {}) {
this.size = size;
}
// The shared pool, made on first use (still without workers)
static get default(): WorkerPool {
return (WorkerPool.shared ??= new WorkerPool());
}
// A blob URL of core/ as one ES module (made on first use), for
// an app's own module workers to `await import(url)`: the copy the pool's
// workers run, so no second file is needed
static get coreUrl(): string {
return (coreUrl ??= blobUrl(coreModule));
}
// Whether this context can make workers (a page; not Node, not a worker)
static get available(): boolean {
return typeof Worker !== "undefined" && typeof window !== "undefined";
}
// Workers made so far (alive)
get workerCount() {
return this.workers.length;
}
async call<N extends WorkerCall>(name: N, args: WorkerArgs<N>, options?: CallOptions): Promise<WorkerResult<N>> {
const lease = await this.acquire({ signal: options?.signal });
try {
return await lease.call(name, args, options);
} finally {
lease.release();
}
}
// A worker for the caller alone, once one is free; `signal` stops waiting.
// A disposed pool makes no more workers: it rejects.
async acquire({ signal }: { signal?: AbortSignal } = {}): Promise<WorkerLease> {
if (this.disposed) {
throw new Error("WorkerPool disposed");
}
signal?.throwIfAborted();
const worker = this.idle.pop() ?? this.spawn() ?? await this.wait(signal);
return new WorkerLease(worker, this);
}
// Back from a lease: to the longest waiting caller, else idle. A dead
// worker is dropped; a waiter then gets a new one.
give(worker: PooledWorker) {
if (this.disposed) {
worker.terminate(new Error("WorkerPool disposed"));
return;
}
if (worker.dead) {
const replacement = this.waiting.length > 0 ? this.spawn() : undefined;
if (replacement) {
this.waiting.shift()!.resolve(replacement);
}
return;
}
const waiter = this.waiting.shift();
if (waiter) {
waiter.resolve(worker);
} else {
this.idle.push(worker);
}
}
// Terminates the workers (their pending calls reject) and rejects the
// queued acquire() and call() waiters
dispose() {
this.disposed = true;
this.workers.forEach((worker) => worker.terminate(new Error("WorkerPool disposed")));
this.workers = [];
this.idle = [];
this.waiting.splice(0).forEach(({ reject }) => reject(new Error("WorkerPool disposed")));
}
// In line for the next worker given back; an abort leaves the line
private wait(signal?: AbortSignal) {
return new Promise<PooledWorker>((resolve, reject) => {
const abort = () => {
this.waiting = this.waiting.filter((w) => w !== waiter);
reject(signal!.reason);
};
const waiter = {
resolve: (worker: PooledWorker) => {
signal?.removeEventListener("abort", abort);
resolve(worker);
},
reject: (error: Error) => {
signal?.removeEventListener("abort", abort);
reject(error);
},
};
signal?.addEventListener("abort", abort, { once: true });
this.waiting.push(waiter);
});
}
private spawn() {
if (this.workers.length >= this.size) {
return undefined;
}
const worker = new PooledWorker((dead) => this.remove(dead));
this.workers.push(worker);
return worker;
}
// A worker died: forget it (a leased one is replaced when given back)
private remove(worker: PooledWorker) {
this.workers = this.workers.filter((w) => w !== worker);
this.idle = this.idle.filter((w) => w !== worker);
}
}