feat(producer): add shaderTransitionWorkerPool (hf#732 PR 3/5) (#758)

## Summary

PR 3 of 5 in the hf#732 decomposition stack. Adds a `worker_threads`-based pool that runs the shader-transition blend (one of 15 transition shaders) on a fixed-size worker pool. **No production wiring yet** — the pool stands alone; PR 4 wires it.

The shader blend is a hot inner loop over every pixel of every transition frame at 16bpc. Moving it off the main event loop removes the JS-event-loop ceiling that capped throughput in earlier hf#732 iterations.

### New files

- `packages/producer/src/services/shaderTransitionWorker.ts` — worker entry. Imports from `@hyperframes/engine/shader-transitions` (zero-import TS source).
- `packages/producer/src/services/shaderTransitionWorkerPool.ts` — fixed-size pool. Uses `transferList` so the 16bpc HDR `from`/`to`/`out` buffers move by ownership.
- `packages/producer/src/services/shaderTransitionWorkerPool.test.ts` — 6 vitest tests pinning byte-equivalence across all 15 shaders, transferList correctness, pool lifecycle. All pass.

### Build wiring

- `packages/cli/tsup.config.ts`: third tsup entry emits `dist/shaderTransitionWorker.js`.
- `packages/producer/build.mjs`: fourth esbuild entry for direct producer consumers.
- `packages/engine/package.json`: adds `./shader-transitions` subpath export.

## Stack

Stacked on top of #757 (PR 2: pngDecodeBlit pool). No behavior change in any render.

## Test plan

- [x] 6 pool tests pass
- [x] Producer + engine typecheck clean
- [x] oxlint clean

— Vai
This commit is contained in:
Vance Ingalls
2026-05-13 15:17:58 -07:00
committed by GitHub
parent a52c73ebe1
commit 30348af3f4
6 changed files with 860 additions and 1 deletions
+11
View File
@@ -19,6 +19,9 @@ export default defineConfig({
entry: {
cli: "src/cli.ts",
pngDecodeBlitWorker: "../producer/src/services/pngDecodeBlitWorker.ts",
// hf#677/#732: shader-blend worker. Same `new Worker(<path>)`
// bundling rationale as `pngDecodeBlitWorker` above.
shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts",
},
format: ["esm"],
outDir: "dist",
@@ -72,6 +75,14 @@ var __dirname = __hf_dirname(__filename);`,
// `alphaBlit.ts` is import-free (only zlib) so the worker survives
// the worker_thread loader boundary directly via this TS source.
"@hyperframes/engine/alpha-blit": resolve(__dirname, "../engine/src/utils/alphaBlit.ts"),
// hf#677 follow-up: the shader-blend worker imports from
// `@hyperframes/engine/shader-transitions` (subpath export) — a
// standalone TS file with zero internal imports that survives the
// worker_thread loader boundary.
"@hyperframes/engine/shader-transitions": resolve(
__dirname,
"../engine/src/utils/shaderTransitions.ts",
),
};
options.loader = { ...options.loader, ".browser.js": "text" };
},
+2 -1
View File
@@ -12,7 +12,8 @@
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./alpha-blit": "./src/utils/alphaBlit.ts"
"./alpha-blit": "./src/utils/alphaBlit.ts",
"./shader-transitions": "./src/utils/shaderTransitions.ts"
},
"scripts": {
"build": "tsc",
+19
View File
@@ -24,6 +24,9 @@ const workspaceAliasPlugin = {
build.onResolve({ filter: /^@hyperframes\/engine\/alpha-blit$/ }, () => ({
path: resolve(scriptDir, "../engine/src/utils/alphaBlit.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/engine\/shader-transitions$/ }, () => ({
path: resolve(scriptDir, "../engine/src/utils/shaderTransitions.ts"),
}));
build.onResolve({ filter: /^@hyperframes\/core$/ }, () => ({
path: resolve(scriptDir, "../core/src/index.ts"),
}));
@@ -74,6 +77,22 @@ await Promise.all([
entryPoints: ["src/services/pngDecodeBlitWorker.ts"],
outfile: "dist/services/pngDecodeBlitWorker.js",
}),
// Shader-blend worker (hf#677 follow-up). Loaded by
// `shaderTransitionWorkerPool.createShaderTransitionWorkerPool` via
// `new Worker(<path>)`. Same bundling rationale as the
// `pngDecodeBlitWorker` entry above.
build({
bundle: true,
platform: "node",
target: "node22",
format: "esm",
external: ["puppeteer", "esbuild", "postcss"],
plugins: [workspaceAliasPlugin],
minify: false,
sourcemap: true,
entryPoints: ["src/services/shaderTransitionWorker.ts"],
outfile: "dist/services/shaderTransitionWorker.js",
}),
]);
// Copy core runtime artifacts so the producer can find them at dist/
@@ -0,0 +1,127 @@
/**
* Worker entry point for off-main-thread shader-blend execution.
*
* The hf#677 follow-up moved the layered transition pipeline (dual-scene
* seek/mask/screenshot) onto per-worker DOM sessions, but the per-pixel JS
* shader-blend at the tail of `processLayeredTransitionFrame` still ran on
* the orchestrator's main event loop. Complex shaders (`domain-warp`,
* `swirl-vortex`, `glitch`) iterate every pixel of the rgb48le buffer with
* multiple noise/sample calls per pixel — hundreds of milliseconds per call
* — so N concurrent DOM workers all firing shader-blends saturated the
* single Node thread. The empirical worker-count sweep on the #677 fixture
* (w=1=218s, w=2=183s, w=6=184s, w=12=188s) flattens after w=2, which is the
* single-threaded-downstream signature.
*
* This worker runs `TRANSITIONS[shader](from, to, output, w, h, p)` on a
* dedicated Node `worker_threads` Worker. The pool dispatches one frame at
* a time per worker. The rgb48le scratch Buffers are moved in and out via
* `transferList` — zero-copy at the ArrayBuffer level — so the only
* per-frame cost is the postMessage round-trip (~sub-millisecond on the
* 2.4 MB 854×480 buffers) plus the shader-blend itself.
*
* Lifecycle:
*
* 1. Pool constructor spawns N of these workers up front.
* 2. Main thread posts `{ shader, bufferA, bufferB, output, width, height,
* progress }` with `transferList: [bufferA, bufferB, output]`. The three
* ArrayBuffers are detached on the sender; the caller must NOT touch
* them until the worker replies.
* 3. Worker wraps each ArrayBuffer as a Node Buffer view (zero-copy),
* invokes `TRANSITIONS[shader] ?? crossfade`, and posts `{ ok: true,
* output }` back with `transferList: [output]`. (The two input ArrayBuffers
* are also returned so the main thread can re-attach them to the worker's
* `LayeredTransitionBuffers` slot for reuse on the next frame.)
* 4. On unknown shader / runtime exception, worker posts `{ ok: false, error,
* bufferA, bufferB, output }` — all three are still transferred back so
* the caller can release them.
*
* The worker holds no per-frame state. It is shared across DOM-session
* workers and across the entire render — only spawned once at render start
* and terminated at render end.
*/
import { parentPort } from "node:worker_threads";
// Import the shader-blend table from a dedicated `./shader-transitions`
// subpath export of `@hyperframes/engine` rather than the package root.
// Rationale:
//
// 1. `shaderTransitions.ts` is fully self-contained (no internal imports).
// Going through engine's root index pulls in the rest of the engine
// graph, which fails under `worker_threads` + tsx in dev/test: the
// tsx loader's `.js → .ts` rewrite does NOT survive the Worker
// boundary, so internal specifiers like `./config.js` from `index.ts`
// fail to resolve. The subpath sidesteps that by pointing the
// resolver straight at the import-free file.
//
// 2. In the production esbuild bundle (build.mjs entry
// `src/services/shaderTransitionWorker.ts`) the workspace alias plugin
// redirects `@hyperframes/engine/shader-transitions` to the same TS
// source and bundles it inline, so behavior is identical.
import { TRANSITIONS, crossfade } from "@hyperframes/engine/shader-transitions";
interface ShaderJobRequest {
shader: string;
bufferA: ArrayBuffer;
bufferB: ArrayBuffer;
output: ArrayBuffer;
width: number;
height: number;
progress: number;
}
interface ShaderJobOk {
ok: true;
bufferA: ArrayBuffer;
bufferB: ArrayBuffer;
output: ArrayBuffer;
}
interface ShaderJobErr {
ok: false;
error: string;
bufferA: ArrayBuffer;
bufferB: ArrayBuffer;
output: ArrayBuffer;
}
export type ShaderJobResult = ShaderJobOk | ShaderJobErr;
if (!parentPort) {
// Defensive — this module is only meaningful inside a worker_thread.
// If imported on the main thread (e.g. by an accidental top-level test),
// do nothing rather than throwing, so static analysis stays clean.
// eslint-disable-next-line no-console
console.warn("[shaderTransitionWorker] no parentPort; module loaded on main thread");
} else {
parentPort.on("message", (msg: ShaderJobRequest) => {
const { shader, bufferA, bufferB, output, width, height, progress } = msg;
// Re-wrap the transferred ArrayBuffers as Node Buffers. Buffer.from(ab)
// is a zero-copy view over the same underlying memory — no allocation,
// no data copy. The shader functions are typed to take Buffer and use
// its readUInt16LE/writeUInt16LE API.
const bufA = Buffer.from(bufferA);
const bufB = Buffer.from(bufferB);
const out = Buffer.from(output);
try {
const fn = TRANSITIONS[shader] ?? crossfade;
fn(bufA, bufB, out, width, height, progress);
const reply: ShaderJobOk = {
ok: true,
bufferA,
bufferB,
output,
};
parentPort!.postMessage(reply, [bufferA, bufferB, output]);
} catch (err) {
const reply: ShaderJobErr = {
ok: false,
error: err instanceof Error ? err.message : String(err),
bufferA,
bufferB,
output,
};
parentPort!.postMessage(reply, [bufferA, bufferB, output]);
}
});
}
@@ -0,0 +1,319 @@
/**
* Tests for the hf#677 follow-up worker_threads shader-blend pool. The pool
* is correctness-critical: a regression here either corrupts transition
* output or leaks Worker handles. Tests pin three properties:
*
* 1. Byte-equivalence with the inline path. The shader code in the
* worker is the exact same `TRANSITIONS` table from `@hyperframes/engine`
* that the legacy path uses on the main thread; the pool round-trip
* must not perturb the result.
* 2. Buffer transfer semantics. After `run` resolves, the original input
* Buffer's `.length` must be 0 (ArrayBuffer detached), and the returned
* Buffer must hold the shader output over the same underlying memory.
* 3. Concurrent dispatch. N concurrent `run` calls against a pool sized
* to N all complete with correct output — no slot leakage, no result
* misrouting.
*
* The pool's clean-shutdown path is also exercised so a test failure here
* doesn't leak worker_threads handles into other tests in the same vitest
* run.
*/
import { afterEach, describe, expect, it } from "vitest";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { TRANSITIONS, crossfade } from "@hyperframes/engine";
import {
createShaderTransitionWorkerPool,
type ShaderTransitionWorkerPool,
} from "./shaderTransitionWorkerPool.js";
const WIDTH = 16;
const HEIGHT = 8;
const BUF_SIZE = WIDTH * HEIGHT * 6;
function fillSolid(width: number, height: number, r: number, g: number, b: number): Buffer {
const buf = Buffer.alloc(width * height * 6);
for (let i = 0; i < width * height; i++) {
const off = i * 6;
buf.writeUInt16LE(r, off);
buf.writeUInt16LE(g, off + 2);
buf.writeUInt16LE(b, off + 4);
}
return buf;
}
describe("ShaderTransitionWorkerPool", () => {
const pools: ShaderTransitionWorkerPool[] = [];
afterEach(async () => {
while (pools.length > 0) {
const p = pools.pop();
if (p) await p.terminate();
}
});
async function makePool(size: number): Promise<ShaderTransitionWorkerPool> {
const p = await createShaderTransitionWorkerPool({ size });
pools.push(p);
return p;
}
it("runs crossfade to byte-equivalence with the inline implementation", async () => {
const pool = await makePool(1);
const from = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const to = fillSolid(WIDTH, HEIGHT, 60000, 60000, 60000);
const output = Buffer.alloc(BUF_SIZE);
const result = await pool.run({
shader: "crossfade",
bufferA: from,
bufferB: to,
output,
width: WIDTH,
height: HEIGHT,
progress: 0.5,
});
// Reference: run the same crossfade inline on independent buffers.
const refFrom = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const refTo = fillSolid(WIDTH, HEIGHT, 60000, 60000, 60000);
const refOut = Buffer.alloc(BUF_SIZE);
crossfade(refFrom, refTo, refOut, WIDTH, HEIGHT, 0.5);
expect(result.output.length).toBe(BUF_SIZE);
expect(Buffer.compare(result.output, refOut)).toBe(0);
});
it("produces byte-identical output for every shader in TRANSITIONS at progress=0.37", async () => {
const pool = await makePool(2);
// Use a couple of non-uniform input frames so shaders that sample
// texture content (warp, glitch, swirl) actually differ from the
// trivial crossfade result.
const buildGradient = (rOff: number, gOff: number, bOff: number): Buffer => {
const buf = Buffer.alloc(BUF_SIZE);
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const i = (y * WIDTH + x) * 6;
buf.writeUInt16LE(Math.min(65535, rOff + x * 1000), i);
buf.writeUInt16LE(Math.min(65535, gOff + y * 1000), i + 2);
buf.writeUInt16LE(Math.min(65535, bOff + (x + y) * 500), i + 4);
}
}
return buf;
};
for (const shaderName of Object.keys(TRANSITIONS)) {
const from = buildGradient(1000, 2000, 3000);
const to = buildGradient(40000, 35000, 30000);
const out = Buffer.alloc(BUF_SIZE);
const result = await pool.run({
shader: shaderName,
bufferA: from,
bufferB: to,
output: out,
width: WIDTH,
height: HEIGHT,
progress: 0.37,
});
const refFrom = buildGradient(1000, 2000, 3000);
const refTo = buildGradient(40000, 35000, 30000);
const refOut = Buffer.alloc(BUF_SIZE);
const fn = TRANSITIONS[shaderName] ?? crossfade;
fn(refFrom, refTo, refOut, WIDTH, HEIGHT, 0.37);
expect(
Buffer.compare(result.output, refOut),
`shader ${shaderName} diverged from inline output`,
).toBe(0);
}
});
it("detaches the caller's input Buffers after transferList", async () => {
const pool = await makePool(1);
const from = fillSolid(WIDTH, HEIGHT, 1234, 5678, 9012);
const to = fillSolid(WIDTH, HEIGHT, 30000, 31000, 32000);
const output = Buffer.alloc(BUF_SIZE);
// Capture identity before the call. After transfer, the underlying
// ArrayBuffer is detached on the sender side; the Buffer's `.length`
// collapses to 0 (Node behavior on a detached ArrayBuffer).
expect(from.length).toBe(BUF_SIZE);
expect(to.length).toBe(BUF_SIZE);
expect(output.length).toBe(BUF_SIZE);
const result = await pool.run({
shader: "crossfade",
bufferA: from,
bufferB: to,
output,
width: WIDTH,
height: HEIGHT,
progress: 0.5,
});
// Originals are detached.
expect(from.length).toBe(0);
expect(to.length).toBe(0);
expect(output.length).toBe(0);
// Returned views are fresh and full-sized.
expect(result.bufferA.length).toBe(BUF_SIZE);
expect(result.bufferB.length).toBe(BUF_SIZE);
expect(result.output.length).toBe(BUF_SIZE);
});
it("falls back to crossfade for an unknown shader name (matches inline behavior)", async () => {
const pool = await makePool(1);
const from = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const to = fillSolid(WIDTH, HEIGHT, 65000, 65000, 65000);
const output = Buffer.alloc(BUF_SIZE);
const result = await pool.run({
shader: "this-shader-does-not-exist",
bufferA: from,
bufferB: to,
output,
width: WIDTH,
height: HEIGHT,
progress: 0.5,
});
const refFrom = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const refTo = fillSolid(WIDTH, HEIGHT, 65000, 65000, 65000);
const refOut = Buffer.alloc(BUF_SIZE);
crossfade(refFrom, refTo, refOut, WIDTH, HEIGHT, 0.5);
expect(Buffer.compare(result.output, refOut)).toBe(0);
});
it("dispatches concurrent tasks across the pool and returns correct output for each", async () => {
const pool = await makePool(4);
const progresses = [0.1, 0.25, 0.5, 0.75, 0.9, 0.33, 0.66, 0.0];
// Each task uses its own buffer triple so they can run truly concurrently
// without transfer aliasing.
const tasks = progresses.map((p) => {
const from = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const to = fillSolid(WIDTH, HEIGHT, 50000, 50000, 50000);
const out = Buffer.alloc(BUF_SIZE);
return pool.run({
shader: "crossfade",
bufferA: from,
bufferB: to,
output: out,
width: WIDTH,
height: HEIGHT,
progress: p,
});
});
const results = await Promise.all(tasks);
for (let i = 0; i < progresses.length; i++) {
const refFrom = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const refTo = fillSolid(WIDTH, HEIGHT, 50000, 50000, 50000);
const refOut = Buffer.alloc(BUF_SIZE);
const progress = progresses[i];
const result = results[i];
if (progress === undefined || !result) throw new Error("missing test data");
crossfade(refFrom, refTo, refOut, WIDTH, HEIGHT, progress);
expect(
Buffer.compare(result.output, refOut),
`concurrent task ${i} (progress=${progress}) diverged`,
).toBe(0);
}
});
it("spawns from an explicit workerEntryPath, bypassing the import.meta.url resolver", async () => {
// Regression for the hf#677 bundled-CLI bug: when the pool is inlined
// into a separate bundle (e.g. cli.js), `import.meta.url` resolves to
// the bundle's path rather than the bundled worker's emitted path, and
// the sibling-probe fallback computes a path the worker file does not
// live at. The explicit `workerEntryPath` plumbed by the call site
// bypasses the heuristic entirely.
const here = dirname(fileURLToPath(import.meta.url));
const explicitPath = resolve(here, "shaderTransitionWorker.ts");
const pool = await createShaderTransitionWorkerPool({
size: 1,
workerEntryPath: explicitPath,
});
pools.push(pool);
const from = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const to = fillSolid(WIDTH, HEIGHT, 60000, 60000, 60000);
const output = Buffer.alloc(BUF_SIZE);
const result = await pool.run({
shader: "crossfade",
bufferA: from,
bufferB: to,
output,
width: WIDTH,
height: HEIGHT,
progress: 0.5,
});
// Compare to inline reference to confirm the explicit-path spawn actually
// ran real work (not just spawned and crashed silently).
const refFrom = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const refTo = fillSolid(WIDTH, HEIGHT, 60000, 60000, 60000);
const refOut = Buffer.alloc(BUF_SIZE);
crossfade(refFrom, refTo, refOut, WIDTH, HEIGHT, 0.5);
expect(Buffer.compare(result.output, refOut)).toBe(0);
});
it("rejects queued tasks on terminate without leaking workers", async () => {
// Pool of 1 forces a queue. Spawn one task to occupy the worker, then
// immediately terminate before any further dispatch.
const pool = await makePool(1);
const from = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const to = fillSolid(WIDTH, HEIGHT, 60000, 60000, 60000);
const output = Buffer.alloc(BUF_SIZE);
const first = pool.run({
shader: "crossfade",
bufferA: from,
bufferB: to,
output,
width: WIDTH,
height: HEIGHT,
progress: 0.5,
});
// Queue a second task using its own buffers so this one will sit in
// the queue until the first completes.
const queuedFrom = fillSolid(WIDTH, HEIGHT, 0, 0, 0);
const queuedTo = fillSolid(WIDTH, HEIGHT, 60000, 60000, 60000);
const queuedOut = Buffer.alloc(BUF_SIZE);
const second = pool.run({
shader: "crossfade",
bufferA: queuedFrom,
bufferB: queuedTo,
output: queuedOut,
width: WIDTH,
height: HEIGHT,
progress: 0.5,
});
// Attach a catch handler immediately so that whichever outcome
// (resolve / reject after terminate) doesn't surface as an
// unhandled rejection.
const secondSettled = second.then(
() => "resolved" as const,
() => "rejected" as const,
);
// First will resolve normally. Force a terminate while second may
// still be queued OR mid-dispatch. Whatever its state, the pool
// teardown must not hang.
await first;
await pool.terminate();
// Either second resolved before terminate kicked in (race-tolerant)
// or it rejected. Both are acceptable; the only failure mode we're
// ruling out is hanging.
const result = await Promise.race([
secondSettled,
new Promise<"hung">((resolve) => setTimeout(() => resolve("hung"), 2000)),
]);
expect(result).not.toBe("hung");
// Remove from `pools` so afterEach doesn't double-terminate.
pools.pop();
});
});
@@ -0,0 +1,382 @@
/**
* Pool of Node `worker_threads` Workers for off-main-thread shader-blend
* execution. See `shaderTransitionWorker.ts` for the per-worker contract and
* the hf#677 follow-up rationale (closing the JS event-loop ceiling on the
* layered transition path).
*
* Pool shape:
*
* - Spawned once at the start of a layered render and terminated in the
* `finally`. Worker spawn cost is ~1050 ms each; amortized over the
* full transition phase (typically 100+ frames) it's negligible.
* - Pool size is sized to `min(layeredWorkerCount, cpuCount)`. We don't
* spawn more workers than DOM sessions (no benefit — at most N DOM
* sessions can be dispatching to us at any moment) and we don't oversubscribe
* beyond physical cores.
* - Each Worker holds zero per-frame state. Pool simply dispatches one
* shader-blend per Worker at a time; ordering within the pool doesn't
* matter because each frame's output is gated by the encoder's
* `FrameReorderBuffer` upstream.
*
* API:
*
* const pool = await createShaderTransitionWorkerPool({ size, log });
* const result = await pool.run({
* shader, bufferA, bufferB, output, width, height, progress,
* });
* // result.bufferA / result.bufferB / result.output are the same memory,
* // now re-attached to the main thread.
* await pool.terminate();
*
* Buffer transfer contract: `run` takes Node Buffers, transfers their
* underlying ArrayBuffers to the worker, and returns NEW Buffer views over
* the transferred-back ArrayBuffers. The caller is responsible for
* swapping its Buffer references — the *original* Buffers passed in are
* detached (their `.length` becomes 0 / accessing throws) after `run` resolves.
*/
import { Worker } from "node:worker_threads";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { cpus } from "node:os";
interface PoolLogger {
info?: (msg: string, meta?: Record<string, unknown>) => void;
warn?: (msg: string, meta?: Record<string, unknown>) => void;
error?: (msg: string, meta?: Record<string, unknown>) => void;
}
export interface ShaderTransitionPoolOptions {
/** Number of worker threads. Clamped to [1, cpus().length]. */
size: number;
/** Optional logger; falls back to no-op. */
log?: PoolLogger;
/**
* Absolute filesystem path to the worker entry module. When provided, the
* pool spawns workers from this exact path and skips the fallback
* `import.meta.url`-based resolver entirely. Required by callers that
* bundle the worker via a separate build (e.g. the CLI's tsup bundle):
* `import.meta.url` inside the bundled pool resolves to the bundle's own
* location, NOT the bundled worker entry's location, so the heuristic
* resolver below cannot find the worker. Path extension determines the
* loader behaviour (`.ts` → tsx/esm loader is appended to execArgv).
*/
workerEntryPath?: string;
}
export interface ShaderBlendRequest {
shader: string;
bufferA: Buffer;
bufferB: Buffer;
output: Buffer;
width: number;
height: number;
progress: number;
}
export interface ShaderBlendResult {
/** Re-attached buffer A (zero-copy view over the transferred-back ArrayBuffer). */
bufferA: Buffer;
/** Re-attached buffer B. */
bufferB: Buffer;
/** Re-attached output buffer holding the shader-blended frame. */
output: Buffer;
}
interface PendingTask {
req: ShaderBlendRequest;
resolve: (r: ShaderBlendResult) => void;
reject: (err: Error) => void;
/** Set when `HF_SHADER_POOL_TRACE=1`; used to log dispatch latency. */
enqueuedAtMs?: number;
/** Set when `HF_SHADER_POOL_TRACE=1`; assigned at dispatch. */
traceId?: number;
}
interface WorkerSlot {
worker: Worker;
busy: boolean;
current: PendingTask | null;
}
interface WorkerReply {
ok: boolean;
error?: string;
bufferA: ArrayBuffer;
bufferB: ArrayBuffer;
output: ArrayBuffer;
}
export interface ShaderTransitionWorkerPool {
readonly size: number;
run(req: ShaderBlendRequest): Promise<ShaderBlendResult>;
terminate(): Promise<void>;
}
/**
* Resolve the path to the compiled worker module.
*
* Resolution order (first match wins):
* 1. Explicit `workerEntryPath` factory option — callers that bundle the
* worker via a separate build pipeline (e.g. the CLI's tsup bundle that
* emits `shaderTransitionWorker.js` next to `cli.js`) must use this.
* The bundled-CLI case is the *only* one where the fallback below
* cannot find the worker: `import.meta.url` inside the inlined pool
* resolves to the bundle path, not the worker's emitted path, so the
* sibling probe lands in the wrong directory.
* 2. `HF_SHADER_WORKER_ENTRY` env var — test/dev infra override (file
* path or `file://` URL).
* 3. Same-directory `.js` sibling — works when both pool source and
* worker source compile into the same `dist/services/` directory
* (in-tree dev builds and the colocated tsc emit).
* 4. Same-directory `.ts` sibling — vitest/bun raw-TS execution path.
*/
function resolveWorkerEntry(explicit: string | undefined): { path: string; isTs: boolean } {
if (explicit && explicit.length > 0) {
return { path: explicit, isTs: explicit.endsWith(".ts") };
}
const override = process.env.HF_SHADER_WORKER_ENTRY;
if (override && override.length > 0) {
const isTs = override.endsWith(".ts");
return { path: override, isTs };
}
const moduleDir = dirname(fileURLToPath(import.meta.url));
const jsPath = join(moduleDir, "shaderTransitionWorker.js");
if (existsSync(jsPath)) return { path: jsPath, isTs: false };
const tsPath = join(moduleDir, "shaderTransitionWorker.ts");
return { path: tsPath, isTs: true };
}
/**
* Probe whether the parent process already has a TS loader registered
* (tsx, ts-node, esm-loader). Worker threads inherit the parent's loader
* only if we copy `process.execArgv` AND the relevant flag is present.
* Vitest runs its own transformer and does NOT register a loader on
* `process.execArgv`, so when the resolved entry is `.ts` and no loader
* is detected we try to inject `tsx/esm` so `new Worker(<.ts file>)`
* loads correctly.
*
* This is best-effort: if `tsx/esm` can't be resolved (e.g. minimal prod
* install), we fall back to plain `process.execArgv` and the Worker will
* surface a clear "cannot find module" error rather than silently
* misbehaving.
*/
function buildExecArgv(entryIsTs: boolean): string[] {
const inherited = [...process.execArgv];
if (!entryIsTs) return inherited;
const hasLoader = inherited.some(
(a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import"),
);
if (hasLoader) return inherited;
try {
const require = createRequire(import.meta.url);
const tsxEsm = require.resolve("tsx/esm");
inherited.push("--import", pathToFileURL(tsxEsm).href);
} catch {
// tsx not installed (prod) — leave execArgv as-is. The caller will
// get a clear error if the .ts entry can't be loaded.
}
return inherited;
}
/**
* Spawn a worker pool ready to run shader-blends. The returned pool is
* usable as soon as the function resolves. If any worker fails to spawn,
* all already-spawned workers are terminated and the error is propagated.
*/
export async function createShaderTransitionWorkerPool(
opts: ShaderTransitionPoolOptions,
): Promise<ShaderTransitionWorkerPool> {
const cpuCount = Math.max(1, cpus().length);
const size = Math.max(1, Math.min(opts.size, cpuCount));
const log = opts.log ?? {};
const { path: entry, isTs: entryIsTs } = resolveWorkerEntry(opts.workerEntryPath);
const slots: WorkerSlot[] = [];
const queue: PendingTask[] = [];
let terminated = false;
// hf#732 follow-up: instrumentation flag to log per-task dispatch /
// completion timestamps so we can confirm the pool actually runs blends
// concurrently when N DOM workers each dispatch K tasks. Enabled by
// setting `HF_SHADER_POOL_TRACE=1`. Off by default — the per-task log
// line is high-volume on long shader-transition renders.
const traceEnabled = process.env.HF_SHADER_POOL_TRACE === "1";
let nextTaskId = 0;
// Bind the parent's execArgv (e.g. tsx's `--import tsx/esm` loader) into
// every Worker so a `.ts` entry point loads under tsx in dev without a
// separate loader registration step. In the bundled prod build the
// entry is `.js` and execArgv is typically empty — passing it is a no-op.
// Under vitest the parent has no tsx loader on execArgv; `buildExecArgv`
// appends one so the `.ts` worker entry still loads.
const execArgv = buildExecArgv(entryIsTs);
const dispatchNext = (slot: WorkerSlot): void => {
if (terminated || slot.busy) return;
const task = queue.shift();
if (!task) return;
slot.busy = true;
slot.current = task;
if (traceEnabled) {
const slotIdx = slots.indexOf(slot);
const waitMs = task.enqueuedAtMs ? Date.now() - task.enqueuedAtMs : 0;
const busyCount = slots.filter((s) => s.busy).length;
log.info?.("[shaderPool] dispatch", {
task: task.traceId,
slot: slotIdx,
shader: task.req.shader,
waitMs,
busyCount,
queueDepth: queue.length,
});
}
const { bufferA, bufferB, output, shader, width, height, progress } = task.req;
// `Buffer.alloc` always returns a Buffer over a plain ArrayBuffer (not
// SharedArrayBuffer) at runtime — TS narrows `.buffer` to the union
// `ArrayBuffer | SharedArrayBuffer`, so cast at the boundary. The pool
// would not work with SharedArrayBuffer-backed Buffers anyway because
// transferList rejects them.
const abA = bufferA.buffer as ArrayBuffer;
const abB = bufferB.buffer as ArrayBuffer;
const abOut = output.buffer as ArrayBuffer;
try {
slot.worker.postMessage(
{
shader,
bufferA: abA,
bufferB: abB,
output: abOut,
width,
height,
progress,
},
[abA, abB, abOut],
);
} catch (err) {
// postMessage can throw if the ArrayBuffer was already detached
// (e.g. caller reused a buffer mid-flight). Surface clearly.
slot.busy = false;
slot.current = null;
task.reject(err instanceof Error ? err : new Error(String(err)));
}
};
const onWorkerMessage = (slot: WorkerSlot, reply: WorkerReply): void => {
const task = slot.current;
slot.current = null;
slot.busy = false;
if (!task) {
// Spurious message; nothing to resolve. Drain queue anyway.
dispatchNext(slot);
return;
}
if (!reply.ok) {
task.reject(new Error(reply.error ?? "shader-blend worker failed"));
} else {
task.resolve({
bufferA: Buffer.from(reply.bufferA),
bufferB: Buffer.from(reply.bufferB),
output: Buffer.from(reply.output),
});
}
dispatchNext(slot);
};
const onWorkerError = (slot: WorkerSlot, err: Error): void => {
const task = slot.current;
slot.current = null;
slot.busy = false;
if (task) {
// The in-flight task's buffers were transferred to the worker. They're
// lost on the worker crash — the caller's original Buffers are
// already detached. Reject so the render fails fast rather than
// continuing with corrupted state.
task.reject(new Error(`shader-blend worker crashed mid-task: ${err.message}; buffers lost`));
}
log.warn?.("[shaderTransitionWorkerPool] worker errored", { err: err.message });
};
const onWorkerExit = (slot: WorkerSlot, code: number): void => {
if (terminated) return;
// Unexpected exit — fail any in-flight task and drop the slot. We don't
// auto-respawn in the middle of a render because the lost transferList
// buffers can't be reconstructed, and silently shrinking the pool would
// mask the real failure. Pool teardown handles graceful shutdown.
if (slot.current) {
slot.current.reject(new Error(`shader-blend worker exited (code=${code}) mid-task`));
slot.current = null;
slot.busy = false;
}
log.warn?.("[shaderTransitionWorkerPool] worker exited unexpectedly", { code });
};
// Spawn workers. If any throws synchronously we still want to terminate
// the partially-spawned set before rejecting.
try {
for (let i = 0; i < size; i++) {
const worker = new Worker(entry, { execArgv });
const slot: WorkerSlot = { worker, busy: false, current: null };
worker.on("message", (msg: WorkerReply) => onWorkerMessage(slot, msg));
worker.on("error", (err: unknown) =>
onWorkerError(slot, err instanceof Error ? err : new Error(String(err))),
);
worker.on("exit", (code) => onWorkerExit(slot, code));
slots.push(slot);
}
} catch (err) {
terminated = true;
await Promise.all(slots.map((s) => s.worker.terminate().catch(() => undefined)));
throw err;
}
log.info?.("[shaderTransitionWorkerPool] spawned", { size, entry });
return {
size,
async run(req: ShaderBlendRequest): Promise<ShaderBlendResult> {
if (terminated) {
throw new Error("shader-blend pool already terminated");
}
return new Promise<ShaderBlendResult>((resolve, reject) => {
const task: PendingTask = traceEnabled
? { req, resolve, reject, enqueuedAtMs: Date.now(), traceId: ++nextTaskId }
: { req, resolve, reject };
// Find an idle slot; otherwise queue.
const idle = slots.find((s) => !s.busy);
if (idle) {
queue.unshift(task);
dispatchNext(idle);
} else {
queue.push(task);
}
});
},
async terminate(): Promise<void> {
if (terminated) return;
terminated = true;
// Reject any queued (not-yet-dispatched) tasks. Their buffers are
// still attached on the main thread — caller can recover.
while (queue.length > 0) {
const t = queue.shift();
if (t) t.reject(new Error("shader-blend pool terminated before task ran"));
}
// Reject any in-flight tasks before worker.terminate() races with
// the message reply. Calling Worker.terminate() forcefully stops
// the worker; if a task was mid-execution its parentPort.postMessage
// never lands, so the `current` task promise would otherwise leak.
for (const slot of slots) {
const t = slot.current;
if (t) {
slot.current = null;
slot.busy = false;
t.reject(new Error("shader-blend pool terminated mid-task"));
}
}
await Promise.all(slots.map((s) => s.worker.terminate().catch(() => undefined)));
log.info?.("[shaderTransitionWorkerPool] terminated", { size });
},
};
}