Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/CODE_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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'})`
Expand Down
69 changes: 60 additions & 9 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):
* ┌─────────────────────────────────────────────────────────┐
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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…` });
Expand Down
179 changes: 139 additions & 40 deletions test/worker.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>): Promise<void> {
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
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Comment thread
devlux76 marked this conversation as resolved.
// 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 ──────────────────────────────────────────────────────
Expand Down
Loading