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:
Martin Dvořák 2026-10-13 18:06:25 +00:00
commit 20bb08b0e7
8 changed files with 125 additions and 19 deletions

View file

@ -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();