fix(whisper): handle model warmup events
All checks were successful
Build & Push Docker Image / test (push) Successful in 12s
Build & Push Docker Image / build-and-push (push) Successful in 52s

- Ignore backend model lifecycle webhooks so model warmup does not
  mark jobs done early
- Parse batched SSE messages and relay model load states during
  submit retries

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-15 00:08:32 +02:00
parent f70cefc5e9
commit 1072679360
4 changed files with 249 additions and 87 deletions

View File

@@ -106,6 +106,64 @@ describe('POST /api/webhook/[jobId] — job not found', () => {
});
});
// ── Ignore backend model lifecycle webhooks ─────────────────────────────────────
describe('POST /api/webhook/[jobId] — non-job webhook payloads', () => {
it('ignores model_ready events sent to job webhooks', async () => {
mockGetJob.mockReturnValue(makeJob('job-model-ready'));
const res = await POST(
makeEvent('job-model-ready', {
type: 'model_ready',
loaded_at: new Date().toISOString()
}) as any
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, ignored: 'not_a_job_event' });
expect(mockSetJobStatus).not.toHaveBeenCalled();
expect(mockUpdateJob).not.toHaveBeenCalled();
expect(mockWriteOutputs).not.toHaveBeenCalled();
expect(mockSendNotification).not.toHaveBeenCalled();
});
it('ignores model_unloaded events sent to job webhooks', async () => {
mockGetJob.mockReturnValue(makeJob('job-model-unloaded'));
const res = await POST(
makeEvent('job-model-unloaded', {
type: 'model_unloaded',
unloaded_at: new Date().toISOString()
}) as any
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, ignored: 'not_a_job_event' });
expect(mockSetJobStatus).not.toHaveBeenCalled();
expect(mockUpdateJob).not.toHaveBeenCalled();
expect(mockWriteOutputs).not.toHaveBeenCalled();
expect(mockSendNotification).not.toHaveBeenCalled();
});
it('ignores payloads with invalid status values', async () => {
mockGetJob.mockReturnValue(makeJob('job-invalid-status'));
const res = await POST(
makeEvent('job-invalid-status', {
id: 'bogus-whisper-id',
status: 'model_ready',
segments: []
}) as any
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, ignored: 'not_a_job_event' });
expect(mockSetJobStatus).not.toHaveBeenCalled();
expect(mockUpdateJob).not.toHaveBeenCalled();
expect(mockWriteOutputs).not.toHaveBeenCalled();
});
});
// ── Local cancellation guard ──────────────────────────────────────────────────
describe('POST /api/webhook/[jobId] — locally cancelled job', () => {

View File

@@ -354,6 +354,32 @@ describe('submitJob — SSE-triggered retry', () => {
const id = await submitJob('/tmp/audio.wav', 'http://host/webhook');
expect(id).toBe('stream-closed-id');
});
it('relays intermediate model states from /model/events while waiting to retry', async () => {
let jobCallIdx = 0;
mocks.fetch.mockImplementation((url: string) => {
if (String(url).includes('/model/events')) {
return Promise.resolve({
ok: true,
status: 200,
body: Readable.from([
'data: {"state":"loading"}\n\ndata: {"state":"waiting_for_gpu"}\n\ndata: {"state":"ready"}\n\n'
])
});
}
jobCallIdx++;
if (jobCallIdx === 1) return Promise.resolve(make503('unloaded', 30));
return Promise.resolve(make202('state-relay-id'));
});
const onModelWaiting = vi.fn();
const id = await submitJob('/tmp/audio.wav', 'http://host/webhook', undefined, onModelWaiting);
expect(id).toBe('state-relay-id');
expect(onModelWaiting).toHaveBeenNthCalledWith(1, 'unloaded', 30);
expect(onModelWaiting).toHaveBeenNthCalledWith(2, 'loading', 30);
expect(onModelWaiting).toHaveBeenNthCalledWith(3, 'waiting_for_gpu', 30);
expect(onModelWaiting).toHaveBeenCalledTimes(3);
});
});
// ── unloadModel ───────────────────────────────────────────────────────────────
@@ -509,6 +535,10 @@ function makeSSEResponse(lines: string[]) {
return { ok: true, body };
}
function makeSSEChunkResponse(chunks: string[]) {
return { ok: true, body: Readable.from(chunks) };
}
describe('streamJob — SSE event parsing', () => {
it('calls onProgress for progress events with percent, chunk, total', async () => {
const onProgress = vi.fn();
@@ -602,6 +632,27 @@ describe('streamJob — SSE event parsing', () => {
expect(onProgress).toHaveBeenNthCalledWith(3, 75, 3, 4);
});
it('handles multiple SSE events delivered in a single chunk', async () => {
const onProgress = vi.fn();
const onDone = vi.fn();
const onError = vi.fn();
mocks.fetch.mockResolvedValue(
makeSSEChunkResponse([
'data: {"type":"progress","percent":25,"chunk":1,"total":2}\n\n' +
'data: {"type":"progress","percent":50,"chunk":2,"total":2}\n\n' +
'data: {"type":"done","job":{}}\n\n'
])
);
await streamJob('whisper-id', onProgress, onDone, onError);
expect(onProgress).toHaveBeenCalledTimes(2);
expect(onProgress).toHaveBeenNthCalledWith(1, 25, 1, 2);
expect(onProgress).toHaveBeenNthCalledWith(2, 50, 2, 2);
expect(onDone).toHaveBeenCalledOnce();
expect(onError).not.toHaveBeenCalled();
});
it('defaults chunk and total to 0 when missing from progress event', async () => {
const onProgress = vi.fn();
const onDone = vi.fn();