A dropped connection says so, instead of the generic error card

A user's run failed with the wizard's generic "check your Ollama
settings" card and could not be replicated with the same key - because
the key and model were never the problem: a raw error (a mid-run socket
drop, a wifi/LTE switch, the app backgrounded by the phone OS) leaked
out of the NDJSON reader as a DOMException, and the wizard can only
show the generic card for unknown errors.

The reader now classifies everything: abort -> "cancelled", stall ->
"the model stopped responding", anything else on the wire -> "the
connection dropped mid-run, try again". Pinned with a fetch stub that
kills the stream mid-run in the wizard suite (95 checks).

Contrast 0 findings, mobile 124/124, chat 101/101, regression 44/44,
i18n 13/13, native 33/33. Version 0.4.1, SW -> freezmo-v70.
This commit is contained in:
Martin Dvořák 2026-10-13 18:51:24 +00:00
commit b70b0e83cf
6 changed files with 71 additions and 15 deletions

View file

@ -76,21 +76,28 @@ export async function consumeNdjson(
};
while (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 {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
if (stallMs > 0) {
const stalled = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new ApiError(t("api.stalled"), "timeout")), stallMs);
});
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();
}
} else {
chunk = await reader.read();
} catch (error) {
// A connection that dropped mid-run used to leak a raw DOMException, and
// the wizard answered with its generic "check your Ollama settings"
// card for what was really a network blip. Classify everything here.
void reader.cancel().catch(() => {});
if (error instanceof ApiError) throw error;
if (error instanceof Error && error.name === "AbortError") {
throw new ApiError(t("api.cancelled"), "aborted");
}
throw new ApiError(t("api.streamLost"), "network");
} finally {
if (timer) clearTimeout(timer);
}
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });