diff --git a/docs/CODE_DOCUMENTATION.md b/docs/CODE_DOCUMENTATION.md index 9b99195..f03e39c 100644 --- a/docs/CODE_DOCUMENTATION.md +++ b/docs/CODE_DOCUMENTATION.md @@ -146,8 +146,9 @@ Web Worker with model load + generation + embeddings. #### view environment / constants - `isIOS()`: iOS / iPadOS detection, WebNN/WebGPU skip - `workerLog(level,msg,args)` common logger -- `DEVICE_PRIORITY`: `['webgl','wasm']` for iOS; else `['webnn','webgpu','webgl','wasm']` +- `DEVICE_PRIORITY`: `['wasm']` for iOS; else `['webnn','webgpu','wasm']` (webgl removed — not valid in transformers.js@next v4.x) - `BACKEND_HANG_TIMEOUT_MS=30000` +- `probeAvailableDevices(devices)`: preflight check — filters device list to those whose browser API is present (`navigator.gpu` for webgpu, `navigator.ml` for webnn) before any model download begins #### state - `pipe: TextGenerationPipeline | null` @@ -162,7 +163,8 @@ Web Worker with model load + generation + embeddings. - `loadModel(modelId,dtype,apiToken)` - sets `env.accessToken` if provided - `send({status:'loading'})` - - try each backend in `DEVICE_PRIORITY` with progress callback + - runs `probeAvailableDevices()` preflight to filter unreachable backends before download + - try each surviving backend with progress callback - hang guard interval fallback after 30s idle - on success wraps pipeline, sets `activeDtype`, `send({status:'ready'})` - on failure after all backends: `send({type:'error'})` diff --git a/src/worker.ts b/src/worker.ts index 1d6bde2..fcfe999 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -4,9 +4,13 @@ * Runs any Hugging Face ONNX text-generation model inside a dedicated Web * Worker so that heavy computation never blocks the UI thread. * - * Device fallback order (default): WebNN → WebGPU → WebGL → WASM + * Device fallback order (default): WebNN → WebGPU → WASM * On iOS / iPadOS, where WebNN is unavailable and WebGPU currently hangs, - * the worker skips those backends and falls back to: WebGL → WASM. + * the worker skips those backends and falls back directly to WASM. + * + * NOTE: WebGL is NOT a valid device in transformers.js@next (v4.x). + * Valid devices are: webnn, webgpu, wasm (and webnn sub-variants). + * Passing 'webgl' causes an immediate "Unsupported device" error. * * Architecture notes (LFM2.5-1.2B, when that model is selected): * ┌─────────────────────────────────────────────────────────┐ @@ -135,7 +139,7 @@ const workerScope = self as unknown as DedicatedWorkerGlobalScope; * Chrome, Firefox, and every other iOS browser. WebNN is not implemented on * iOS WebKit, and WebGPU (present in Safari 17+) hangs indefinitely when * loading ONNX models instead of throwing a catchable error. Skipping both - * lets the backend loop fall straight through to WebGL → WASM. + * lets the backend loop fall straight through to WASM. */ function isIOS(): boolean { const ua = navigator.userAgent; @@ -164,17 +168,21 @@ function workerLog(level: 'debug' | 'info' | 'warn' | 'error', message: string, * The worker tries each one in turn and settles on the first that succeeds. * webnn – Web Neural Network API (hardware-accelerated where available) * webgpu – GPU-accelerated via the WebGPU API - * webgl – Fallback GPU path via WebGL * wasm – Pure WebAssembly (always available) * + * NOTE: 'webgl' was removed because it is not a valid device in + * transformers.js@next (v4.x). Passing it throws: + * "Unsupported device: 'webgl'. Should be one of: webnn-npu, webnn-gpu, + * webnn-cpu, webnn, webgpu, wasm." + * * On iOS, WebNN is absent and WebGPU hangs without throwing, so we skip - * both and start directly from WebGL. + * both and start directly from WASM. */ -type BackendName = 'webnn' | 'webgpu' | 'webgl' | 'wasm'; +type BackendName = 'webnn' | 'webgpu' | 'wasm'; const DEVICE_PRIORITY: readonly BackendName[] = isIOS() - ? ['webgl', 'wasm'] - : ['webnn', 'webgpu', 'webgl', 'wasm']; + ? ['wasm'] + : ['webnn', 'webgpu', 'wasm']; /** * Milliseconds of silence (no progress callback) after which a backend is @@ -190,6 +198,42 @@ const DEVICE_PRIORITY: readonly BackendName[] = isIOS() */ const BACKEND_HANG_TIMEOUT_MS = 30_000; +/** + * Preflight check: filters `devices` to those whose underlying browser API + * is present in the current environment, WITHOUT downloading any model files. + * + * Running this before the download loop lets the worker skip backends that + * are guaranteed to fail at session-creation time — avoiding a multi-GB + * download that ends in an "Unsupported device" error in the user's browser. + * + * Checks performed: + * webgpu – requires navigator.gpu (WebGPU API) + * webnn – requires navigator.ml (Web Neural Network API) + * wasm – always available (WebAssembly is universally supported) + * + * If every device in the input list is filtered out, 'wasm' is returned as + * the guaranteed last-resort fallback. + */ +function probeAvailableDevices(devices: readonly BackendName[]): BackendName[] { + const available: BackendName[] = []; + for (const device of devices) { + if (device === 'webgpu' && !('gpu' in navigator)) { + workerLog('info', 'preflight: WebGPU API not present, skipping', { device }); + continue; + } + if (device === 'webnn' && !('ml' in navigator)) { + workerLog('info', 'preflight: WebNN API not present, skipping', { device }); + continue; + } + available.push(device); + } + if (available.length === 0) { + workerLog('warn', 'preflight: no backends passed capability check; forcing wasm fallback'); + available.push('wasm'); + } + return available; +} + // ─── Environment ────────────────────────────────────────────────────────────── // Only fetch from HuggingFace Hub; disable Node-FS cache (we're in a browser). @@ -251,8 +295,15 @@ async function loadModel(modelId: string, dtype: Dtype, apiToken?: string): Prom // the underlying JS implementation supports. const loadPipeline = pipeline as unknown as PipelineFactory; + // ── Preflight: filter to backends whose browser API is present ───────────── + // This runs synchronously before any network request so that an unsupported + // device (e.g. 'webgpu' on a browser without navigator.gpu) is skipped + // immediately rather than failing after a multi-GB download. + const devicesToTry = probeAvailableDevices(DEVICE_PRIORITY); + workerLog('info', 'preflight complete', { devicesToTry }); + let lastErr: unknown; - for (const device of DEVICE_PRIORITY) { + for (const device of devicesToTry) { try { workerLog('info', 'Attempting backend', { device }); send({ type: 'status', status: 'loading', detail: `Trying ${device.toUpperCase()} backend…` }); diff --git a/test/worker.e2e.test.ts b/test/worker.e2e.test.ts index 57c9cd1..c2f3b57 100644 --- a/test/worker.e2e.test.ts +++ b/test/worker.e2e.test.ts @@ -186,6 +186,40 @@ afterAll(() => { // ─── Tests ──────────────────────────────────────────────────────────────────── +/** + * Stubs navigator.gpu and navigator.ml for the duration of a test callback, + * restoring originals in a finally block. + * + * jsdom does not expose these browser APIs, so the worker's preflight check + * (probeAvailableDevices) would otherwise filter out webgpu and webnn, leaving + * only wasm. Tests that need to exercise the full three-backend chain must + * call this helper to make the preflight check pass all three backends through. + */ +async function withAllNavigatorApiStubs(fn: () => Promise): Promise { + const origGpuDescriptor = Object.getOwnPropertyDescriptor(navigator, 'gpu'); + const origMlDescriptor = Object.getOwnPropertyDescriptor(navigator, 'ml'); + + Object.defineProperty(navigator, 'gpu', { value: {}, configurable: true, writable: true }); + Object.defineProperty(navigator, 'ml', { value: {}, configurable: true, writable: true }); + try { + await fn(); + } finally { + if (origGpuDescriptor) { + Object.defineProperty(navigator, 'gpu', origGpuDescriptor); + } else { + // Property did not originally exist; remove the stub to avoid leaking it between tests. + delete (navigator as unknown as { gpu?: unknown }).gpu; + } + + if (origMlDescriptor) { + Object.defineProperty(navigator, 'ml', origMlDescriptor); + } else { + // Property did not originally exist; remove the stub to avoid leaking it between tests. + delete (navigator as unknown as { ml?: unknown }).ml; + } + } +} + describe('Worker E2E: transformers.js happy-path integration', () => { // Shared fake pipeline callable — set during the load test and reused by @@ -221,9 +255,9 @@ describe('Worker E2E: transformers.js happy-path integration', () => { expect.objectContaining({ dtype: 'q4' }), ); - // The device property must be one of the four supported backends. + // The device property must be one of the three supported backends. const [, , opts] = mockPipelineFactory.mock.calls[0] as [string, string, { device: string }]; - expect(['webnn', 'webgpu', 'webgl', 'wasm']).toContain(opts.device); + expect(['webnn', 'webgpu', 'wasm']).toContain(opts.device); }); // ── Generate – happy path ─────────────────────────────────────────────────── @@ -464,50 +498,115 @@ describe('Worker E2E: transformers.js happy-path integration', () => { // Reset the pipeline mock so the next load uses a fresh sequence. mockPipelineFactory.mockReset(); - // Simulate webnn and webgpu failing, wasm succeeding. - const fallbackPipe = vi.fn().mockImplementation( - async ( - _messages: ChatMessage[], - opts: { streamer?: { callback_function: ((t: string) => void) | null } }, - ) => { - opts.streamer?.callback_function?.('hi'); - return [{ generated_text: [{ role: 'assistant', content: 'hi' }] }]; - }, - ); - (fallbackPipe as unknown as { tokenizer: object }).tokenizer = {}; - - let callCount = 0; - mockPipelineFactory.mockImplementation( - async (_task: string, _model: string, opts: { device: string }) => { - callCount++; - if (opts.device === 'wasm') { - return fallbackPipe; - } - throw new Error(`${opts.device} backend not available`); - }, - ); + // jsdom does not expose navigator.gpu or navigator.ml — the preflight + // function in worker.ts correctly skips those backends. withAllNavigatorApiStubs + // stubs them so all three valid backends (webnn, webgpu, wasm) pass the + // capability check, allowing us to exercise the full fallback chain. + await withAllNavigatorApiStubs(async () => { + // Simulate webnn and webgpu failing, wasm succeeding. + const fallbackPipe = vi.fn().mockImplementation( + async ( + _messages: ChatMessage[], + opts: { streamer?: { callback_function: ((t: string) => void) | null } }, + ) => { + opts.streamer?.callback_function?.('hi'); + return [{ generated_text: [{ role: 'assistant', content: 'hi' }] }]; + }, + ); + (fallbackPipe as unknown as { tokenizer: object }).tokenizer = {}; + + let callCount = 0; + mockPipelineFactory.mockImplementation( + async (_task: string, _model: string, opts: { device: string }) => { + callCount++; + if (opts.device === 'wasm') { + return fallbackPipe; + } + throw new Error(`${opts.device} backend not available`); + }, + ); - const msgs = await collectMessages(async () => { - sendToWorker({ - type: 'load', - modelId: 'onnx-community/test-fallback', - dtype: 'q8', + const msgs = await collectMessages(async () => { + sendToWorker({ + type: 'load', + modelId: 'onnx-community/test-fallback', + dtype: 'q8', + }); + await waitForMessage( + (m) => m.type === 'status' && (m as { type: string; status: string }).status === 'ready', + ); }); - await waitForMessage( - (m) => m.type === 'status' && (m as { type: string; status: string }).status === 'ready', - ); + + // The worker must have tried multiple backends. + expect(callCount).toBeGreaterThan(1); + + // The final status must be 'ready' (wasm succeeded). + const statuses = msgs.filter((m) => m.type === 'status').map((m) => (m as { type: string; status: string }).status); + expect(statuses).toContain('ready'); + expect(statuses).not.toContain('error'); + + // Update fakePipe for subsequent generate tests. + fakePipe = fallbackPipe; }); + }); - // The worker must have tried multiple backends. - expect(callCount).toBeGreaterThan(1); + // ── Preflight / webgl regression ──────────────────────────────────────────── - // The final status must be 'ready' (wasm succeeded). - const statuses = msgs.filter((m) => m.type === 'status').map((m) => (m as { type: string; status: string }).status); - expect(statuses).toContain('ready'); - expect(statuses).not.toContain('error'); + it('load: pipeline is never called with device=webgl (unsupported in transformers.js@next)', async () => { + // Regression test: webgl was removed from DEVICE_PRIORITY because + // transformers.js@next (v4.x) rejects it with: + // "Unsupported device: 'webgl'. Should be one of: webnn-npu, webnn-gpu, + // webnn-cpu, webnn, webgpu, wasm." + // withAllNavigatorApiStubs ensures all valid backends pass the preflight + // check; then verify webgl never appears in any pipeline() call. + mockPipelineFactory.mockReset(); + + await withAllNavigatorApiStubs(async () => { + const testPipe = vi.fn().mockImplementation( + async ( + _messages: ChatMessage[], + opts: { streamer?: { callback_function: ((t: string) => void) | null } }, + ) => { + opts.streamer?.callback_function?.('ok'); + return [{ generated_text: [{ role: 'assistant', content: 'ok' }] }]; + }, + ); + (testPipe as unknown as { tokenizer: object }).tokenizer = {}; + + const devicesAttempted: string[] = []; + mockPipelineFactory.mockImplementation( + async (_task: string, _model: string, opts: { device: string }) => { + devicesAttempted.push(opts.device); + // Force traversal of the full backend fallback chain by simulating + // initialization failure on all devices except the final fallback. + if (opts.device !== 'wasm') { + throw new Error(`Simulated pipeline init failure for device=${opts.device}`); + } + return testPipe; + }, + ); - // Update fakePipe for subsequent generate tests. - fakePipe = fallbackPipe; + await collectMessages(async () => { + sendToWorker({ + type: 'load', + modelId: 'onnx-community/test-no-webgl', + dtype: 'q4', + }); + await waitForMessage( + (m) => m.type === 'status' && (m as { type: string; status: string }).status === 'ready', + ); + }); + + // webgl must never appear in any pipeline() call. + expect(devicesAttempted).not.toContain('webgl'); + + // Every device attempted must be one of the valid v4.x devices. + for (const d of devicesAttempted) { + expect(['webnn', 'webgpu', 'wasm']).toContain(d); + } + + fakePipe = testPipe; + }); }); // ── Progress callbacks ──────────────────────────────────────────────────────