Spaces:
Running
Running
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | |
| import { BrowserEngineClient } from './client'; | |
| import type { EngineWorkerMessage } from './protocol'; | |
| class FakeWorker { | |
| static instances: FakeWorker[] = []; | |
| onmessage: ((event: MessageEvent<EngineWorkerMessage>) => void) | null = null; | |
| onerror: ((event: ErrorEvent) => void) | null = null; | |
| onmessageerror: (() => void) | null = null; | |
| readonly postMessage = vi.fn<(message: unknown) => void>(); | |
| readonly terminate = vi.fn<() => void>(); | |
| constructor(..._args: unknown[]) { | |
| FakeWorker.instances.push(this); | |
| } | |
| crash(message: string): void { | |
| this.onerror?.({ message } as ErrorEvent); | |
| } | |
| send(message: EngineWorkerMessage): void { | |
| this.onmessage?.({ data: message } as MessageEvent<EngineWorkerMessage>); | |
| } | |
| } | |
| function latestWorker(): FakeWorker { | |
| const worker = FakeWorker.instances.at(-1); | |
| if (!worker) throw new Error('Expected a fake worker instance.'); | |
| return worker; | |
| } | |
| describe('BrowserEngineClient worker lifecycle', () => { | |
| beforeEach(() => { | |
| FakeWorker.instances = []; | |
| vi.stubGlobal('Worker', FakeWorker); | |
| }); | |
| afterEach(() => { | |
| vi.unstubAllGlobals(); | |
| }); | |
| it('terminates and recreates the worker when model loading is aborted', async () => { | |
| const client = new BrowserEngineClient(); | |
| const firstWorker = latestWorker(); | |
| const controller = new AbortController(); | |
| const loading = client.loadModel({ | |
| manifestUrl: '/manifest/models.json', | |
| modelId: '1_7b', | |
| backend: 'webgpu', | |
| }, { | |
| requestId: 'load-request', | |
| signal: controller.signal, | |
| }); | |
| firstWorker.send({ | |
| type: 'event', | |
| requestId: 'load-request', | |
| event: 'progress', | |
| phase: 'load', | |
| loadedBytes: 0, | |
| totalBytes: 100, | |
| shardIndex: null, | |
| shardCount: 1, | |
| shardPath: null, | |
| stageProgress: 0, | |
| nativeStage: null, | |
| residentBytes: null, | |
| shards: [], | |
| }); | |
| controller.abort(); | |
| await expect(loading).rejects.toMatchObject({ name: 'AbortError' }); | |
| expect(firstWorker.terminate).toHaveBeenCalledOnce(); | |
| expect(FakeWorker.instances).toHaveLength(2); | |
| const replacement = latestWorker(); | |
| firstWorker.crash('late event from terminated worker'); | |
| expect(replacement.terminate).not.toHaveBeenCalled(); | |
| const estimate = client.storageEstimate(); | |
| const request = replacement.postMessage.mock.calls[0]?.[0] as { requestId?: string }; | |
| expect(request.requestId).toBeTruthy(); | |
| replacement.send({ | |
| type: 'response', | |
| requestId: request.requestId!, | |
| method: 'storageEstimate', | |
| ok: true, | |
| result: { usageBytes: 100, quotaBytes: 1_000, persisted: false }, | |
| }); | |
| await expect(estimate).resolves.toEqual({ | |
| usageBytes: 100, | |
| quotaBytes: 1_000, | |
| persisted: false, | |
| }); | |
| client.close(); | |
| }); | |
| it('hard-restarts only after soft download-abort cleanup times out', async () => { | |
| vi.useFakeTimers(); | |
| try { | |
| const client = new BrowserEngineClient(); | |
| const worker = latestWorker(); | |
| const controller = new AbortController(); | |
| const loading = client.loadModel({ | |
| manifestUrl: '/manifest/models.json', | |
| modelId: '1_7b', | |
| backend: 'webgpu', | |
| }, { | |
| requestId: 'stuck-download', | |
| signal: controller.signal, | |
| }); | |
| const loadingFailure = expect(loading).rejects.toMatchObject({ name: 'AbortError' }); | |
| worker.send({ | |
| type: 'event', | |
| requestId: 'stuck-download', | |
| event: 'progress', | |
| phase: 'download', | |
| loadedBytes: 1, | |
| totalBytes: 100, | |
| shardIndex: 0, | |
| shardCount: 1, | |
| shardPath: 'model.gguf', | |
| stageProgress: null, | |
| nativeStage: null, | |
| residentBytes: null, | |
| shards: [{ | |
| index: 0, | |
| path: 'model.gguf', | |
| loadedBytes: 1, | |
| verifiedBytes: 0, | |
| totalBytes: 100, | |
| state: 'downloading', | |
| }], | |
| }); | |
| controller.abort(); | |
| await vi.advanceTimersByTimeAsync(29_999); | |
| expect(worker.terminate).not.toHaveBeenCalled(); | |
| await vi.advanceTimersByTimeAsync(1); | |
| await loadingFailure; | |
| expect(worker.terminate).toHaveBeenCalledOnce(); | |
| expect(FakeWorker.instances).toHaveLength(2); | |
| client.close(); | |
| } finally { | |
| vi.useRealTimers(); | |
| } | |
| }); | |
| it('lets the runtime clean partial shards when download is aborted', async () => { | |
| const client = new BrowserEngineClient(); | |
| const worker = latestWorker(); | |
| const controller = new AbortController(); | |
| const loading = client.loadModel({ | |
| manifestUrl: '/manifest/models.json', | |
| modelId: '1_7b', | |
| backend: 'webgpu', | |
| }, { | |
| requestId: 'download-request', | |
| signal: controller.signal, | |
| }); | |
| worker.send({ | |
| type: 'event', | |
| requestId: 'download-request', | |
| event: 'progress', | |
| phase: 'download', | |
| loadedBytes: 50, | |
| totalBytes: 100, | |
| shardIndex: 0, | |
| shardCount: 1, | |
| shardPath: 'model.gguf', | |
| stageProgress: null, | |
| nativeStage: null, | |
| residentBytes: null, | |
| shards: [{ | |
| index: 0, | |
| path: 'model.gguf', | |
| loadedBytes: 50, | |
| verifiedBytes: 0, | |
| totalBytes: 100, | |
| state: 'downloading', | |
| }], | |
| }); | |
| controller.abort(); | |
| expect(worker.terminate).not.toHaveBeenCalled(); | |
| expect(FakeWorker.instances).toHaveLength(1); | |
| const abortRequest = worker.postMessage.mock.calls | |
| .map((call) => call[0] as { requestId: string; method: string }) | |
| .find((request) => request.method === 'abort'); | |
| expect(abortRequest).toBeTruthy(); | |
| worker.send({ | |
| type: 'response', | |
| requestId: 'download-request', | |
| method: 'loadModel', | |
| ok: false, | |
| error: { | |
| code: 'SHARD_DOWNLOAD_ABORTED', | |
| message: 'Partial shard removed.', | |
| details: { retryFromByteZero: true }, | |
| }, | |
| }); | |
| if (!abortRequest) throw new Error('Expected abort request.'); | |
| worker.send({ | |
| type: 'response', | |
| requestId: abortRequest.requestId, | |
| method: 'abort', | |
| ok: true, | |
| result: { targetRequestId: 'download-request', aborted: true }, | |
| }); | |
| await expect(loading).rejects.toMatchObject({ code: 'SHARD_DOWNLOAD_ABORTED' }); | |
| expect(worker.terminate).not.toHaveBeenCalled(); | |
| client.close(); | |
| }); | |
| it('keeps the loaded model when generation responds to a soft abort', async () => { | |
| const client = new BrowserEngineClient(); | |
| const worker = latestWorker(); | |
| const controller = new AbortController(); | |
| const generation = client.generate({ | |
| messages: [{ role: 'user', content: 'hello' }], | |
| }, { | |
| requestId: 'soft-generation', | |
| signal: controller.signal, | |
| }); | |
| const generationFailure = expect(generation).rejects.toMatchObject({ code: 'ABORTED' }); | |
| controller.abort(); | |
| const abortRequest = worker.postMessage.mock.calls | |
| .map((call) => call[0] as { requestId: string; method: string }) | |
| .find((request) => request.method === 'abort'); | |
| expect(abortRequest).toBeTruthy(); | |
| worker.send({ | |
| type: 'response', | |
| requestId: 'soft-generation', | |
| method: 'generate', | |
| ok: false, | |
| error: { code: 'ABORTED', message: 'Generation stopped.' }, | |
| }); | |
| if (!abortRequest) throw new Error('Expected abort request.'); | |
| worker.send({ | |
| type: 'response', | |
| requestId: abortRequest.requestId, | |
| method: 'abort', | |
| ok: true, | |
| result: { targetRequestId: 'soft-generation', aborted: true }, | |
| }); | |
| await generationFailure; | |
| expect(worker.terminate).not.toHaveBeenCalled(); | |
| expect(FakeWorker.instances).toHaveLength(1); | |
| client.close(); | |
| }); | |
| it('routes and softly aborts diagnostic sequence scoring', async () => { | |
| const client = new BrowserEngineClient(); | |
| const worker = latestWorker(); | |
| const controller = new AbortController(); | |
| const scoring = client.scoreSequence({ | |
| promptTokenIds: Array.from({ length: 38 }, (_, index) => index + 1), | |
| referenceTokenIds: Array.from({ length: 1_024 }, (_, index) => index + 1_000), | |
| topK: 5, | |
| }, { | |
| requestId: 'soft-score-sequence', | |
| signal: controller.signal, | |
| }); | |
| const scoringFailure = expect(scoring).rejects.toMatchObject({ code: 'ABORTED' }); | |
| expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ | |
| requestId: 'soft-score-sequence', | |
| method: 'scoreSequence', | |
| })); | |
| controller.abort(); | |
| const abortRequest = worker.postMessage.mock.calls | |
| .map((call) => call[0] as { requestId: string; method: string }) | |
| .find((request) => request.method === 'abort'); | |
| expect(abortRequest).toBeTruthy(); | |
| worker.send({ | |
| type: 'response', | |
| requestId: 'soft-score-sequence', | |
| method: 'scoreSequence', | |
| ok: false, | |
| error: { code: 'ABORTED', message: 'Sequence scoring stopped.' }, | |
| }); | |
| if (!abortRequest) throw new Error('Expected abort request.'); | |
| worker.send({ | |
| type: 'response', | |
| requestId: abortRequest.requestId, | |
| method: 'abort', | |
| ok: true, | |
| result: { targetRequestId: 'soft-score-sequence', aborted: true }, | |
| }); | |
| await scoringFailure; | |
| expect(worker.terminate).not.toHaveBeenCalled(); | |
| client.close(); | |
| }); | |
| it('invalidates the model when generation ignores abort beyond the grace period', async () => { | |
| vi.useFakeTimers(); | |
| try { | |
| const client = new BrowserEngineClient(); | |
| const worker = latestWorker(); | |
| const controller = new AbortController(); | |
| const generation = client.generate({ | |
| messages: [{ role: 'user', content: 'hello' }], | |
| }, { | |
| requestId: 'hung-generation', | |
| signal: controller.signal, | |
| }); | |
| const generationFailure = expect(generation).rejects.toMatchObject({ | |
| code: 'ENGINE_WORKER_FAILED', | |
| message: expect.stringContaining('did not stop within 5 seconds'), | |
| details: { | |
| recoverable: true, | |
| nextAction: 'reload-model', | |
| cause: 'generation-abort-timeout', | |
| graceMs: 5_000, | |
| }, | |
| }); | |
| controller.abort(); | |
| await vi.advanceTimersByTimeAsync(4_999); | |
| expect(worker.terminate).not.toHaveBeenCalled(); | |
| await vi.advanceTimersByTimeAsync(1); | |
| await generationFailure; | |
| expect(worker.terminate).toHaveBeenCalledOnce(); | |
| expect(FakeWorker.instances).toHaveLength(2); | |
| client.close(); | |
| } finally { | |
| vi.useRealTimers(); | |
| } | |
| }); | |
| it('rejects pending work on a crash and recreates the worker for the next request', async () => { | |
| const client = new BrowserEngineClient(); | |
| const firstWorker = latestWorker(); | |
| const pending = client.storageEstimate(); | |
| firstWorker.crash('synthetic worker crash'); | |
| await expect(pending).rejects.toMatchObject({ | |
| code: 'ENGINE_WORKER_FAILED', | |
| message: expect.stringContaining('synthetic worker crash'), | |
| details: { recoverable: true, nextAction: 'reload-model' }, | |
| }); | |
| expect(firstWorker.terminate).toHaveBeenCalledOnce(); | |
| expect(FakeWorker.instances).toHaveLength(1); | |
| const recovered = client.storageEstimate(); | |
| expect(FakeWorker.instances).toHaveLength(2); | |
| const replacement = latestWorker(); | |
| const request = replacement.postMessage.mock.calls[0]?.[0] as { requestId?: string }; | |
| replacement.send({ | |
| type: 'response', | |
| requestId: request.requestId!, | |
| method: 'storageEstimate', | |
| ok: true, | |
| result: { usageBytes: null, quotaBytes: null, persisted: false }, | |
| }); | |
| await expect(recovered).resolves.toEqual({ | |
| usageBytes: null, | |
| quotaBytes: null, | |
| persisted: false, | |
| }); | |
| client.close(); | |
| }); | |
| it('surfaces WebGPU device loss and replaces the invalid worker before the next action', async () => { | |
| const client = new BrowserEngineClient(); | |
| const firstWorker = latestWorker(); | |
| const generation = client.generate({ | |
| messages: [{ role: 'user', content: 'hello' }], | |
| }, { requestId: 'generation' }); | |
| firstWorker.send({ | |
| type: 'response', | |
| requestId: 'generation', | |
| method: 'generate', | |
| ok: false, | |
| error: { | |
| code: 'WEBGPU_DEVICE_LOST', | |
| message: 'The WebGPU device was lost during generate.', | |
| details: { recoverable: true, nextAction: 'reload-model' }, | |
| }, | |
| }); | |
| await expect(generation).rejects.toMatchObject({ | |
| name: 'EngineClientError', | |
| code: 'WEBGPU_DEVICE_LOST', | |
| details: { recoverable: true, nextAction: 'reload-model' }, | |
| }); | |
| expect(firstWorker.terminate).toHaveBeenCalledOnce(); | |
| expect(FakeWorker.instances).toHaveLength(2); | |
| const replacement = latestWorker(); | |
| const reload = client.loadModel({ | |
| manifestUrl: '/manifest/models.json', | |
| modelId: '1_7b', | |
| backend: 'webgpu', | |
| }, { requestId: 'reload' }); | |
| expect(replacement.postMessage).toHaveBeenCalledWith(expect.objectContaining({ | |
| requestId: 'reload', | |
| method: 'loadModel', | |
| })); | |
| replacement.send({ | |
| type: 'response', | |
| requestId: 'reload', | |
| method: 'loadModel', | |
| ok: false, | |
| error: { code: 'MODEL_GATE_REJECTED', message: 'synthetic stop' }, | |
| }); | |
| await expect(reload).rejects.toMatchObject({ code: 'MODEL_GATE_REJECTED' }); | |
| client.close(); | |
| }); | |
| it('types a crash during generation as model-invalidating before recreating the worker', async () => { | |
| const client = new BrowserEngineClient(); | |
| const firstWorker = latestWorker(); | |
| const generation = client.generate({ | |
| messages: [{ role: 'user', content: 'hello' }], | |
| }); | |
| firstWorker.crash('GPU worker process exited'); | |
| await expect(generation).rejects.toMatchObject({ | |
| code: 'ENGINE_WORKER_FAILED', | |
| details: { recoverable: true, nextAction: 'reload-model' }, | |
| }); | |
| expect(firstWorker.terminate).toHaveBeenCalledOnce(); | |
| const reload = client.loadModel({ | |
| manifestUrl: '/manifest/models.json', | |
| modelId: '1_7b', | |
| backend: 'webgpu', | |
| }, { requestId: 'crash-reload' }); | |
| const replacement = latestWorker(); | |
| expect(replacement.postMessage).toHaveBeenCalledWith(expect.objectContaining({ | |
| requestId: 'crash-reload', | |
| method: 'loadModel', | |
| })); | |
| replacement.send({ | |
| type: 'response', | |
| requestId: 'crash-reload', | |
| method: 'loadModel', | |
| ok: false, | |
| error: { code: 'MODEL_GATE_REJECTED', message: 'synthetic stop' }, | |
| }); | |
| await expect(reload).rejects.toMatchObject({ code: 'MODEL_GATE_REJECTED' }); | |
| client.close(); | |
| }); | |
| it('routes live tool-call progress independently from visible text tokens', async () => { | |
| const client = new BrowserEngineClient(); | |
| const worker = latestWorker(); | |
| const onToken = vi.fn(); | |
| const onToolCallProgress = vi.fn(); | |
| const generation = client.generate({ | |
| messages: [{ role: 'user', content: 'Create an app.' }], | |
| }, { | |
| requestId: 'tool-progress', | |
| onToken, | |
| onToolCallProgress, | |
| }); | |
| worker.send({ | |
| type: 'event', | |
| requestId: 'tool-progress', | |
| event: 'tool-call', | |
| index: 0, | |
| id: 'call_artifact', | |
| name: 'html_artifact', | |
| argumentCharacters: 4_096, | |
| }); | |
| expect(onToolCallProgress).toHaveBeenCalledWith(expect.objectContaining({ | |
| name: 'html_artifact', | |
| argumentCharacters: 4_096, | |
| })); | |
| expect(onToken).not.toHaveBeenCalled(); | |
| worker.send({ | |
| type: 'response', | |
| requestId: 'tool-progress', | |
| method: 'generate', | |
| ok: true, | |
| result: { | |
| text: '', | |
| reasoningText: '', | |
| tokenIds: null, | |
| tokenTrace: null, | |
| finishReason: 'tool_calls', | |
| toolCalls: [{ | |
| id: 'call_artifact', | |
| type: 'function', | |
| function: { name: 'html_artifact', arguments: '{"html":"<main />"}' }, | |
| }], | |
| usage: null, | |
| timings: null, | |
| }, | |
| }); | |
| await expect(generation).resolves.toMatchObject({ finishReason: 'tool_calls' }); | |
| client.close(); | |
| }); | |
| it('never recreates a worker after close', async () => { | |
| const client = new BrowserEngineClient(); | |
| const worker = latestWorker(); | |
| const pending = client.storageEstimate(); | |
| client.close(); | |
| client.restart(); | |
| await expect(pending).rejects.toThrow('closed'); | |
| await expect(client.storageEstimate()).rejects.toThrow('closed'); | |
| expect(worker.terminate).toHaveBeenCalledOnce(); | |
| expect(FakeWorker.instances).toHaveLength(1); | |
| }); | |
| }); | |