Infinite loading guard: heartbeats + stall watchdogs on the model runs
A user got stuck on the wizard's loading screen: a hung model meant silence, and silence was indistinguishable from work - the server's own hard deadline is 2x OLLAMA_TIMEOUT_MS (ten minutes by default), and the client waited on a dead stream indefinitely. Both generate routes (deck, homework) now send an NDJSON heartbeat every 15s, and the client guards the two quiet phases: no response at all for 45s, and no bytes for 90s mid-stream, both surfacing the regular error state with a Retry. The caller's abort still maps to the friendly "cancelled" state. Verified with a stalled route and Playwright's fake clock in the wizard suite (93 checks), plus contrast 0 findings, mobile 124/124, chat 101/101, regression 44/44, i18n 13/13, native 33/33. Version 0.4.0, SW -> freezmo-v69.
This commit is contained in:
parent
2b78dcf0d1
commit
20bb08b0e7
8 changed files with 125 additions and 19 deletions
|
|
@ -39,7 +39,8 @@ const requestSchema = z.object({
|
|||
type StreamEvent =
|
||||
| { type: "stage"; stage: PipelineStage; message: string }
|
||||
| { type: "deck"; deck: unknown; meta: Record<string, unknown> }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
| { type: "error"; error: string; kind?: string }
|
||||
| { type: "ping" };
|
||||
|
||||
function encoder() {
|
||||
const enc = new TextEncoder();
|
||||
|
|
@ -112,6 +113,10 @@ export async function POST(request: Request) {
|
|||
const abort = new AbortController();
|
||||
// A single hard deadline for the whole pipeline (two sequential model calls).
|
||||
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2);
|
||||
// Heartbeats: one quiet model call can run for minutes, and the client's
|
||||
// stall guard needs to tell "slow" from "dead" — a silent stream is how the
|
||||
// wizard ended up loading forever. Cleared with the stream either way.
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
|
|
@ -123,6 +128,7 @@ export async function POST(request: Request) {
|
|||
}
|
||||
};
|
||||
|
||||
heartbeat = setInterval(() => send({ type: "ping" }), 15_000);
|
||||
try {
|
||||
let sourceText: string;
|
||||
let deckTitle: string | undefined;
|
||||
|
|
@ -206,6 +212,7 @@ export async function POST(request: Request) {
|
|||
kind: error instanceof OllamaError ? error.kind : "unknown",
|
||||
});
|
||||
} finally {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
try {
|
||||
|
|
@ -216,6 +223,7 @@ export async function POST(request: Request) {
|
|||
}
|
||||
},
|
||||
cancel() {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
abort.abort();
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ const requestSchema = z.object({
|
|||
type StreamEvent =
|
||||
| { type: "stage"; stage: PipelineStage; message: string }
|
||||
| { type: "homework"; homework: unknown; meta: Record<string, unknown> }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
| { type: "error"; error: string; kind?: string }
|
||||
| { type: "ping" };
|
||||
|
||||
function encoder() {
|
||||
const enc = new TextEncoder();
|
||||
|
|
@ -105,6 +106,8 @@ export async function POST(request: Request) {
|
|||
const abort = new AbortController();
|
||||
// A single hard deadline for the whole pipeline (two sequential model calls).
|
||||
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2);
|
||||
// Heartbeats so the client's stall guard can tell "slow" from "dead".
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
|
|
@ -115,6 +118,7 @@ export async function POST(request: Request) {
|
|||
/* client went away */
|
||||
}
|
||||
};
|
||||
heartbeat = setInterval(() => send({ type: "ping" }), 15_000);
|
||||
|
||||
try {
|
||||
let assignmentText: string;
|
||||
|
|
@ -262,6 +266,7 @@ export async function POST(request: Request) {
|
|||
kind: error instanceof OllamaError ? error.kind : "unknown",
|
||||
});
|
||||
} finally {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
try {
|
||||
|
|
@ -272,6 +277,7 @@ export async function POST(request: Request) {
|
|||
}
|
||||
},
|
||||
cancel() {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
abort.abort();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue