/**
* Worker-side helpers for the generic workload protocol whose main-thread
* half is `WorkloadHost` (`./host.ts`).
*
* Call {@link installWorkloadHandler} once at the top of a dedicated
* worker module. Domain code only implements `run`.
*/
///
import type { WorkloadRequest, WorkloadResponse } from "./protocol";
/** What a job implementation gets besides its payload. */
export interface WorkloadWorkerContext {
/** Correlated job id from the host. */
id: number;
/**
* True after the host sent `{ type: "cancel", id }` for this job. A flag to
* poll, not a signal that interrupts — nothing stops until the job checks it.
*/
isCancelled: () => boolean;
/**
* Stream progress (any domain shape) to the host as
* `{ type: "progress", id, progress }`. Also arms the heartbeat: see
* {@link WorkloadHandlerOptions.heartbeatMs}.
*/
reportProgress: (progress: unknown) => void;
/**
* Ask the host to do something on the worker's behalf and await its answer.
*
* Posts `{ type: "host-call", callId, call }` (with `transfer` forwarded to
* `postMessage`, so byte payloads move zero-copy) and resolves with the
* host's `result` when the matching `{ type: "host-reply", callId, ok: true }`
* arrives. An `ok: false` reply rejects with
* `[workload] host call : `. The `call` payload is opaque to
* this layer — its shape is a domain contract with the host's `onHostCall`.
*
* `callId` is auto-incremented per worker realm and independent of job ids;
* pending calls are shared across concurrently interleaved jobs and die with
* the worker (host `dispose()` needs no cross-realm cleanup).
*/
callHost: (call: unknown, transfer?: Transferable[]) => Promise;
}
/**
* The slice of `DedicatedWorkerGlobalScope` that
* {@link installWorkloadHandler} touches.
*
* Exists so a unit test can drive the handler with a plain object instead of a
* real worker global — production callers pass nothing and get `self`.
*/
export interface WorkloadWorkerScope {
onmessage: ((ev: MessageEvent>) => void) | null;
postMessage(msg: unknown, transfer?: Transferable[]): void;
}
/** Domain hooks for {@link installWorkloadHandler}. */
export interface WorkloadHandlerOptions {
/**
* Execute one job. May be async; cooperative cancel via
* `ctx.isCancelled()` between chunks. A thrown error is reported to the host
* as `{ type: "error" }` — it never becomes an unhandled rejection.
*
* Buffers returned in `transfer` are transferred with the `done` message
* (and detached inside the worker), so return every result array that the
* worker no longer needs.
*/
run: (
job: TJob,
ctx: WorkloadWorkerContext,
) => Promise<{ result: TResult; transfer?: Transferable[] }>;
/**
* Re-post the **last** progress payload this often while a job runs, so a
* host-side stall watch does not fire during a long silent stretch.
*
* The heartbeat repeats real progress and never invents a percentage, so no
* beat is sent until the job has called `ctx.reportProgress` at least once.
* And, like any worker timer, a beat can only land when the job yields to the
* worker event loop: a fully synchronous WebAssembly (WASM) call blocks the heartbeat as
* well, and the pending beat arrives once that call returns.
*
* Default 8 s; set 0 to disable.
*/
heartbeatMs?: number;
/**
* How run requests share the single worker realm. Default `"fifo"`.
*
* - `"fifo"` — strictly serial: requests queue in arrival order and a job's
* `run` is not invoked until the previous job posted `done` or `error`.
* One heavy WASM job per realm, which keeps two big
* allocations from meeting in the same heap.
* - `"interleaved"` — every request starts immediately; jobs cooperatively
* interleave at their own `await` boundaries (e.g. a `ctx.callHost`
* round-trip). This is event-loop interleaving, **not** parallelism: the
* realm still has one thread and one WASM heap, so a fully synchronous
* stretch in one job blocks all others until it yields. Use it when a
* long streaming job must not starve short requests; spawn another worker
* for real parallelism.
*
* Cancel semantics are identical in both modes: the flag is remembered
* until the job it names settles, so a queued fifo job sees it on its first
* poll and an interleaved job should check `ctx.isCancelled()` at its start.
*/
scheduling?: "fifo" | "interleaved";
}
declare const self: DedicatedWorkerGlobalScope;
/** Default: frequent enough that a 2-minute stall watch rarely false-fires. */
const DEFAULT_HEARTBEAT_MS = 8_000;
/** The seat both scheduling modes fill: accept one run request. */
interface WorkloadScheduler {
enqueue(id: number, job: TJob): void;
}
/**
* Strictly serial scheduling: each job chains behind the previous one's
* settlement. Not exported — selected via `scheduling: "fifo"` (the default).
*/
class FifoScheduler implements WorkloadScheduler {
/** Tail of the chain of jobs run so far. */
private chain: Promise = Promise.resolve();
constructor(
private readonly execute: (id: number, job: TJob) => Promise,
) {}
enqueue(id: number, job: TJob): void {
// Keep the chain resolved: a rejected tail would skip every later job's
// `.then` and leave the host waiting forever. `execute` already reports
// its own failures as `{ type: "error" }`.
this.chain = this.chain.then(() => this.execute(id, job)).catch(() => {});
}
}
/**
* Concurrent-start scheduling: every job begins at once and interleaves with
* the others at its own await boundaries. Not exported — selected via
* `scheduling: "interleaved"`.
*/
class InterleavedScheduler implements WorkloadScheduler {
constructor(
private readonly execute: (id: number, job: TJob) => Promise,
) {}
enqueue(id: number, job: TJob): void {
// `execute` reports its own failures; the catch is belt-and-braces so a
// scheduler bug can never surface as an unhandled rejection.
void this.execute(id, job).catch(() => {});
}
}
/** One worker-initiated host call awaiting its `host-reply`. */
interface PendingHostCall {
resolve: (result: unknown) => void;
reject: (err: Error) => void;
}
/**
* Install the standard run/cancel/ready loop on the worker global, then post
* `{ type: "ready" }` so the host's `whenReady()` resolves.
*
* Call it exactly once per worker module: it assigns `scope.onmessage`, so a
* second call replaces the first handler.
*
* **Scheduling is `"fifo"` by default** — first-in-first-out, one job at a
* time — and can be switched to `"interleaved"` per worker module; see
* {@link WorkloadHandlerOptions.scheduling} for the trade-off (single WASM
* heap, cooperative interleaving, not parallelism).
*
* **Cancel is per job, and never too early.** A `cancel` is remembered for its
* id whether the job is running, still queued, or the id is unknown, so
* `ctx.isCancelled()` is already true on a queued job's first poll and it can
* skip the work entirely. The flag is dropped when that job settles — in
* either scheduling mode.
*
* **The worker can call back.** Each job's `ctx.callHost` opens a
* `host-call`/`host-reply` round-trip answered by the host's `onHostCall`;
* a `host-reply` whose `callId` matches no pending call is silently ignored.
*
* @param options the domain `run` implementation, plus the heartbeat interval
* and scheduling mode
* @param scope where to install, defaulting to the worker global. It exists for
* unit tests, which pass a plain {@link WorkloadWorkerScope} so the loop runs
* with no real worker; production worker modules omit it.
*/
export function installWorkloadHandler(
options: WorkloadHandlerOptions,
scope: WorkloadWorkerScope = self,
): void {
/** Ids the host asked to cancel, kept until the job they name settles. */
const cancelled = new Set();
/** Worker-realm host-call ids: shared across jobs, unrelated to job ids. */
let nextCallId = 0;
/** Host calls awaiting their reply; dies with the worker realm. */
const pendingCalls = new Map();
const heartbeatMs =
options.heartbeatMs === undefined
? DEFAULT_HEARTBEAT_MS
: options.heartbeatMs;
function post(msg: WorkloadResponse, transfer?: Transferable[]): void {
if (transfer && transfer.length > 0) {
scope.postMessage(msg, transfer);
} else {
scope.postMessage(msg);
}
}
function callHost(
call: unknown,
transfer?: Transferable[],
): Promise {
const callId = nextCallId++;
return new Promise((resolve, reject) => {
pendingCalls.set(callId, { resolve, reject });
post({ type: "host-call", callId, call }, transfer);
});
}
async function executeJob(id: number, job: TJob): Promise {
let lastProgress: unknown = null;
let settled = false;
let hb: ReturnType | null = null;
if (heartbeatMs > 0) {
hb = setInterval(() => {
if (settled || lastProgress == null) return;
// Re-emit last known progress so the UI stall watch resets.
post({ type: "progress", id, progress: lastProgress });
}, heartbeatMs);
}
try {
const { result, transfer } = await options.run(job, {
id,
isCancelled: () => cancelled.has(id),
reportProgress: (progress) => {
lastProgress = progress;
post({ type: "progress", id, progress });
},
callHost,
});
post(
{ type: "done", id, result },
transfer && transfer.length > 0 ? transfer : undefined,
);
} catch (err) {
const message =
err instanceof Error ? err.message : String(err ?? "Workload failed");
post({ type: "error", id, message });
} finally {
settled = true;
if (hb != null) clearInterval(hb);
cancelled.delete(id);
}
}
const scheduler: WorkloadScheduler =
options.scheduling === "interleaved"
? new InterleavedScheduler(executeJob)
: new FifoScheduler(executeJob);
scope.onmessage = (ev: MessageEvent>) => {
const msg = ev.data;
if (!msg || typeof msg !== "object") return;
if (msg.type === "cancel") {
cancelled.add(msg.id);
return;
}
if (msg.type === "host-reply") {
const pending = pendingCalls.get(msg.callId);
// A reply nobody is waiting for (e.g. its job already settled some
// other way) is dropped, mirroring the host's unknown-id rule.
if (!pending) return;
pendingCalls.delete(msg.callId);
if (msg.ok) {
pending.resolve(msg.result);
} else {
pending.reject(
new Error(
`[workload] host call ${msg.callId}: ${msg.error ?? "unknown error"}`,
),
);
}
return;
}
if (msg.type !== "run") return;
scheduler.enqueue(msg.id, msg.job);
};
post({ type: "ready" });
}