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
|
|
@ -30,10 +30,17 @@ export type GenerateProgress = {
|
|||
message: string;
|
||||
};
|
||||
|
||||
/** No response at all for this long: the run is stuck, not slow. */
|
||||
const RESPONSE_GUARD_MS = 45_000;
|
||||
/** No bytes for this long while streaming: the model stopped talking. The
|
||||
* routes heartbeat every 15s, so 90s of silence really is silence. */
|
||||
const STREAM_GUARD_MS = 90_000;
|
||||
|
||||
type StreamEvent =
|
||||
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
|
||||
| { type: "deck"; deck: StreamedDeck; meta: Record<string, unknown> }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
| { type: "error"; error: string; kind?: string }
|
||||
| { type: "ping" };
|
||||
|
||||
export class ApiError extends Error {
|
||||
kind: string;
|
||||
|
|
@ -52,11 +59,13 @@ export class ApiError extends Error {
|
|||
export async function consumeNdjson(
|
||||
response: Response,
|
||||
handleEvent: (event: unknown) => void,
|
||||
options: { stallMs?: number } = {},
|
||||
): Promise<void> {
|
||||
if (!response.body) throw new ApiError(t("api.empty"), "bad_response");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const stallMs = options.stallMs ?? 0;
|
||||
const flush = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
|
|
@ -66,9 +75,25 @@ export async function consumeNdjson(
|
|||
}
|
||||
};
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||
if (stallMs > 0) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const stalled = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new ApiError(t("api.stalled"), "timeout")), stallMs);
|
||||
});
|
||||
try {
|
||||
chunk = await Promise.race([reader.read(), stalled]);
|
||||
} catch (error) {
|
||||
void reader.cancel().catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
} else {
|
||||
chunk = await reader.read();
|
||||
}
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
flush(buffer.slice(0, newline));
|
||||
|
|
@ -87,19 +112,35 @@ export async function generateDeck(
|
|||
input: GenerateInput,
|
||||
options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {},
|
||||
): Promise<{ deck: StreamedDeck; meta: Record<string, unknown> }> {
|
||||
// A run that goes quiet is worse than one that fails: the wizard used to sit
|
||||
// on its loading screen until the server's own 2x deadline (ten minutes by
|
||||
// default) when the model hung. The caller's signal is forwarded, and a
|
||||
// no-response guard covers the "nothing at all" phase.
|
||||
const controller = new AbortController();
|
||||
const forwarded = options.signal;
|
||||
let headersTimedOut = false;
|
||||
if (forwarded) {
|
||||
if (forwarded.aborted) controller.abort();
|
||||
else forwarded.addEventListener("abort", () => controller.abort(), { once: true });
|
||||
}
|
||||
const headersTimer = setTimeout(() => {
|
||||
headersTimedOut = true;
|
||||
controller.abort();
|
||||
}, RESPONSE_GUARD_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||
body: JSON.stringify(input),
|
||||
signal: options.signal,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError(t("api.cancelled"), "aborted");
|
||||
}
|
||||
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
|
||||
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
|
||||
throw new ApiError(t("api.unreachable"), "network");
|
||||
} finally {
|
||||
clearTimeout(headersTimer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -124,7 +165,7 @@ export async function generateDeck(
|
|||
} else if (event.type === "error") {
|
||||
streamError = new ApiError(event.error, event.kind);
|
||||
}
|
||||
});
|
||||
}, { stallMs: STREAM_GUARD_MS });
|
||||
|
||||
if (streamError) throw streamError;
|
||||
if (!deck) {
|
||||
|
|
@ -183,7 +224,8 @@ export type StreamedHomework =
|
|||
type HomeworkStreamEvent =
|
||||
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
|
||||
| { type: "homework"; homework: StreamedHomework; meta: Record<string, unknown> }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
| { type: "error"; error: string; kind?: string }
|
||||
| { type: "ping" };
|
||||
|
||||
/**
|
||||
* Calls POST /api/homework and consumes the NDJSON progress stream. Same shape
|
||||
|
|
@ -193,19 +235,32 @@ export async function generateHomework(
|
|||
input: HomeworkInput,
|
||||
options: { onProgress?: (progress: HomeworkProgress) => void; signal?: AbortSignal } = {},
|
||||
): Promise<{ homework: StreamedHomework; meta: Record<string, unknown> }> {
|
||||
// Same guards as generateDeck: no infinite loading screen on a hung model.
|
||||
const controller = new AbortController();
|
||||
const forwarded = options.signal;
|
||||
let headersTimedOut = false;
|
||||
if (forwarded) {
|
||||
if (forwarded.aborted) controller.abort();
|
||||
else forwarded.addEventListener("abort", () => controller.abort(), { once: true });
|
||||
}
|
||||
const headersTimer = setTimeout(() => {
|
||||
headersTimedOut = true;
|
||||
controller.abort();
|
||||
}, RESPONSE_GUARD_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/homework", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||
body: JSON.stringify(input),
|
||||
signal: options.signal,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError(t("api.cancelled"), "aborted");
|
||||
}
|
||||
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
|
||||
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
|
||||
throw new ApiError(t("api.unreachable"), "network");
|
||||
} finally {
|
||||
clearTimeout(headersTimer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -228,7 +283,7 @@ export async function generateHomework(
|
|||
} else if (event.type === "error") {
|
||||
streamError = new ApiError(event.error, event.kind);
|
||||
}
|
||||
});
|
||||
}, { stallMs: STREAM_GUARD_MS });
|
||||
|
||||
if (streamError) throw streamError;
|
||||
if (!homework) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue