feat: whisper-side cancellation + SSE-triggered retry
All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 48s
All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 48s
- Add cancelJob() to whisper.ts: sends DELETE /jobs/:id to the whisper server (best-effort, errors silently ignored) - DELETE /api/jobs/[id] now calls cancelJob() when cancelling an active job that has a whisperJobId, stopping GPU use immediately - Webhook handler guards against locally-cancelled jobs: returns ok early so whisper's late completion cannot overwrite cancelled status or send a phantom 'Transcript ready' notification - Replace blind sleep(Retry-After + 1s) in submitJob() with waitForModelReady(): subscribes to /model/events SSE and proceeds as soon as state:ready arrives; falls back to the Retry-After timeout if SSE is unreachable or closes without model_ready - Refactor retry tests to use URL-aware makeJobFetch() helper; add 7 new tests (3 SSE-triggered retry, 3 cancelJob, 1 webhook cancelled-guard) — 144/144 passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -4,8 +4,6 @@ function whisperUrl() {
|
||||
return process.env.WHISPER_URL ?? 'http://localhost:8080';
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Get the current model state from whisper-rtx2080. */
|
||||
export async function getModelStatus(): Promise<ModelStatus> {
|
||||
const { default: fetch } = await import('node-fetch');
|
||||
@@ -16,11 +14,69 @@ export async function getModelStatus(): Promise<ModelStatus> {
|
||||
return res.json() as Promise<ModelStatus>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the whisper model to become ready.
|
||||
*
|
||||
* Subscribes to /model/events SSE and resolves as soon as a payload with
|
||||
* state:"ready" arrives. Falls back to a plain timeout (`timeoutMs`) if the
|
||||
* SSE connection fails or closes without that event, so the retry loop can
|
||||
* try again without hanging indefinitely.
|
||||
*/
|
||||
async function waitForModelReady(timeoutMs: number): Promise<void> {
|
||||
const { default: fetch } = await import('node-fetch');
|
||||
const ac = new AbortController();
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
ac.abort();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(finish, timeoutMs);
|
||||
|
||||
fetch(`${whisperUrl()}/model/events`, { signal: ac.signal as AbortSignal })
|
||||
.then(async (res) => {
|
||||
if (!res.body) {
|
||||
clearTimeout(timer);
|
||||
return finish();
|
||||
}
|
||||
let buf = '';
|
||||
for await (const chunk of res.body) {
|
||||
if (ac.signal.aborted) break;
|
||||
buf += chunk.toString();
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
try {
|
||||
const payload = JSON.parse(line.slice(5).trim());
|
||||
if (payload.state === 'ready') {
|
||||
clearTimeout(timer);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
// Stream ended without model_ready → proceed to retry immediately
|
||||
clearTimeout(timer);
|
||||
finish();
|
||||
})
|
||||
.catch(() => {
|
||||
// SSE unreachable — fallback timer will fire eventually
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an audio file to whisper-rtx2080. Returns the whisper job id.
|
||||
*
|
||||
* Handles 503 (model not ready) transparently: retries using the
|
||||
* `Retry-After` header until the model loads or maxAttempts is exhausted.
|
||||
* Handles 503 (model not ready) transparently: retries after subscribing to
|
||||
* /model/events SSE — proceeds as soon as state:"ready" arrives, or after the
|
||||
* Retry-After timeout elapses (whichever comes first).
|
||||
* Calls `onModelWaiting` on each 503 so the caller can surface the wait to the user.
|
||||
*/
|
||||
export async function submitJob(
|
||||
@@ -60,7 +116,7 @@ export async function submitJob(
|
||||
const state = body.state ?? 'unloaded';
|
||||
const waitSecs = body.retry_after_secs ?? parseInt(res.headers.get('Retry-After') ?? '15');
|
||||
onModelWaiting?.(state, waitSecs);
|
||||
await sleep((waitSecs + 1) * 1000);
|
||||
await waitForModelReady((waitSecs + 1) * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -71,6 +127,20 @@ export async function submitJob(
|
||||
throw new Error(`Whisper model did not become ready after ${maxAttempts} attempts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a queued or running job on the whisper server (best-effort).
|
||||
* Errors are silently ignored — local job status is already set to cancelled.
|
||||
*/
|
||||
export async function cancelJob(whisperJobId: string): Promise<void> {
|
||||
try {
|
||||
const { default: fetch } = await import('node-fetch');
|
||||
await fetch(`${whisperUrl()}/jobs/${whisperJobId}`, {
|
||||
method: 'DELETE',
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/** Open an SSE stream from whisper and call onProgress/onDone callbacks. */
|
||||
export async function streamJob(
|
||||
whisperJobId: string,
|
||||
|
||||
Reference in New Issue
Block a user