fix(whisper): handle model warmup events
- 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:
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user