From f3c045151d819ae512aee7d31ace498355ea32a1 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Fri, 24 Jul 2026 23:17:08 -0400 Subject: [PATCH] perf(textures): warm the image worker pool on a timer after construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker pool spawned lazily on the first getImage call: the very first image request paid for blob serialization plus one Worker construction per slot before its own decode could start. That cost lands during boot image loading, exactly when the main thread is busiest. Schedule spawnWorkers() from the ImageWorkerManager constructor via setTimeout(0) instead. It stays off the synchronous construction path, but the pool is warm before textures start arriving. The manager is built inside CoreTextureManager.initialize(), which itself may run async after the createImageBitmap capability probes settle, so the constructor is the reliable hook — there is no synchronous post-construction point in Renderer.ts that is guaranteed to run after the manager exists. The lazy spawn in getImage stays as a fallback for a request that beats the timer; the existing `workers.length > 0` guard in spawnWorkers keeps the two paths mutually exclusive, so the pool can never double-spawn. Wrap the timer callback in try/catch. spawnWorkers previously only ever ran inside getImage's try/catch, so a failing `new Worker(blob:)` — real where CSP forbids blob workers — rejected that image's promise. Detached in a timer the same throw becomes an uncaught exception at boot instead. Swallowing it leaves the pool empty so getImage retries and surfaces the failure the pre-warmup way. Fires once at boot, not a hot path. Tests use fake timers so the warmup spawn stays under each test's control — otherwise it fires after teardown has removed the self/Worker stubs and throws. Adds coverage for the constructor timer spawning the pool and for the timer being a no-op when a request already spawned it lazily. Co-Authored-By: Claude Opus 5 --- src/core/lib/ImageWorker.test.ts | 28 ++++++++++++++++++++++++++-- src/core/lib/ImageWorker.ts | 27 ++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/core/lib/ImageWorker.test.ts b/src/core/lib/ImageWorker.test.ts index 73aed5c..263a3b2 100644 --- a/src/core/lib/ImageWorker.test.ts +++ b/src/core/lib/ImageWorker.test.ts @@ -34,6 +34,10 @@ let createObjectURL: ReturnType; let revokeObjectURL: ReturnType; beforeEach(() => { + // The constructor schedules a warmup `setTimeout`. Fake timers keep that + // spawn under each test's control — otherwise it fires after the test ends, + // once `unstubAllGlobals` has removed `self`/`Worker`, and throws. + vi.useFakeTimers(); FakeWorker.reset(); createObjectURL = vi.fn(() => 'blob:fake-url'); revokeObjectURL = vi.fn(); @@ -51,6 +55,9 @@ beforeEach(() => { }); afterEach(() => { + // Discards any warmup timer the test left pending, before the globals it + // depends on are torn down. + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -58,12 +65,29 @@ const load = (mgr: ImageWorkerManager) => void mgr.getImage('img.png', null, null, null, null, null); describe('ImageWorkerManager pool spawning', () => { - it('does not spawn any workers at construction', () => { + it('does not spawn any workers synchronously at construction', () => { new ImageWorkerManager(3, support); - // Spawning is lazy — nothing happens until the first image request. + // Spawning is deferred a macrotask so it stays off the synchronous + // renderer-construction path. expect(FakeWorker.instances.length).toBe(0); }); + it('spawns the whole pool on the timer scheduled at construction', () => { + new ImageWorkerManager(3, support); + expect(FakeWorker.instances.length).toBe(0); + vi.runAllTimers(); + expect(FakeWorker.instances.length).toBe(3); + }); + + it('does not respawn when the warmup timer fires after a lazy spawn', () => { + const mgr = new ImageWorkerManager(3, support); + load(mgr); // request beats the timer — spawns the pool lazily + expect(FakeWorker.instances.length).toBe(3); + vi.runAllTimers(); + // Timer must be a no-op now, not a second pool. + expect(FakeWorker.instances.length).toBe(3); + }); + it('spawns the whole pool at once on the first image request', () => { const mgr = new ImageWorkerManager(3, support); load(mgr); diff --git a/src/core/lib/ImageWorker.ts b/src/core/lib/ImageWorker.ts index a50ed4d..628eb16 100644 --- a/src/core/lib/ImageWorker.ts +++ b/src/core/lib/ImageWorker.ts @@ -198,11 +198,32 @@ export class ImageWorkerManager { ) { this.maxWorkers = numImageWorkers; this.createImageBitmapSupport = createImageBitmapSupport; + // Warm the pool on the next macrotask instead of waiting for the first + // image request. Spawning costs main-thread time (blob serialization + + // one Worker construction per slot); deferring it by a task keeps it off + // the synchronous renderer-construction path, while still having the pool + // ready before textures start arriving. `getImage` keeps its lazy spawn + // for a request that beats this timer. + setTimeout(() => { + // Not a hot path (fires once, at boot). The guard matters because this + // runs detached from any caller: `new Worker(blob:)` can throw outright + // where CSP forbids blob workers, and an uncaught throw here would take + // out whatever task the timer landed in. Swallowing it leaves the pool + // empty, so `getImage` retries the spawn inside its own try/catch and + // surfaces the failure as a rejected image promise — the pre-warmup + // behavior. + try { + this.spawnWorkers(); + } catch (e) { + /* empty */ + } + }, 0); } /** * Build the shared worker source once and spawn the full pool in a single - * burst. Called lazily on the first image request. No-op once spawned. + * burst. Scheduled from the constructor via `setTimeout`; also called + * lazily from `getImage` if a request arrives first. No-op once spawned. */ private spawnWorkers(): void { if (this.workers.length > 0) { @@ -333,8 +354,8 @@ export class ImageWorkerManager { try { let nextWorkerIndex = this.getNextWorkerIndex(); if (nextWorkerIndex === -1) { - // Pool not spawned yet — spin up all workers at once on the first - // image request, off the boot/first-render critical path. + // Request beat the constructor's scheduled warmup — spin up the + // whole pool now rather than making this image wait a task. this.spawnWorkers(); nextWorkerIndex = this.getNextWorkerIndex(); }