From b70b0e83cfd092378f44b05e4d3ddf79bba31dc5 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Tue, 13 Oct 2026 18:51:24 +0000 Subject: [PATCH] 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. --- README.md | 9 +++++++++ lib/client-api.ts | 33 ++++++++++++++++++++------------- lib/i18n-dict.ts | 2 ++ package.json | 2 +- public/sw.js | 2 +- tests/wizard-test.mjs | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 71 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e21243d..864f81d 100644 --- a/README.md +++ b/README.md @@ -1411,6 +1411,15 @@ Things I chose while building, with the reasoning: pre-blurred wallpaper — the scene is clearly visible through the glass, and the pixel-contrast scan still passes. +76. **A dropped connection says so.** A user's run failed with the wizard's generic "check your + Ollama settings" card, and it could not be replicated on the owner's machine 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, an app backgrounded by the phone OS) leaked out of the NDJSON reader as a + DOMException, and the wizard can only show that generic card for unknown errors. The reader now + classifies everything: an abort is "cancelled", a stall is "the model stopped responding", and + anything else on the wire is "the connection dropped mid-run — try again". Pinned with a + fetch stub that kills the stream mid-run in the wizard suite (95 checks). + 75. **A quiet run can no longer load forever.** 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 diff --git a/lib/client-api.ts b/lib/client-api.ts index e4d47da..4dcad75 100644 --- a/lib/client-api.ts +++ b/lib/client-api.ts @@ -76,21 +76,28 @@ export async function consumeNdjson( }; while (true) { let chunk: ReadableStreamReadResult; - if (stallMs > 0) { - let timer: ReturnType | null = null; - const stalled = new Promise((_, reject) => { - timer = setTimeout(() => reject(new ApiError(t("api.stalled"), "timeout")), stallMs); - }); - try { + let timer: ReturnType | null = null; + try { + if (stallMs > 0) { + const stalled = new Promise((_, 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 }); diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index e9b465d..a4be2a2 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -721,6 +721,7 @@ const en: Record = { "api.httpError": "The server returned HTTP {status}.", "api.empty": "The server sent an empty response.", "api.stalled": "The model stopped responding. Check it and try again.", + "api.streamLost": "The connection dropped mid-run. Check your connection and try again.", "api.almost": "Almost there…", "api.noDeck": "No deck came back from the server. Check your Ollama settings and try again.", "api.incomplete": "The server finished without sending a deck.", @@ -1677,6 +1678,7 @@ const cs: Record = { "api.httpError": "Server vrátil HTTP {status}.", "api.empty": "Server poslal prázdnou odpověď.", "api.stalled": "Model přestal odpovídat. Zkontrolujte ho a zkuste to znovu.", + "api.streamLost": "Připojení se během generování přerušilo. Zkontrolujte připojení a zkuste to znovu.", "api.almost": "Už to bude…", "api.noDeck": "Ze serveru nepřišla žádná sada. Zkontroluj nastavení Ollamy a zkus to znovu.", "api.incomplete": "Server skončil, aniž by poslal sadu.", diff --git a/package.json b/package.json index e7ca101..2eb86c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.4.0", + "version": "0.4.1", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index 032ecb0..61ff211 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v69"; +const CACHE_VERSION = "freezmo-v70"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/wizard-test.mjs b/tests/wizard-test.mjs index 3e0a086..d5e5cd5 100644 --- a/tests/wizard-test.mjs +++ b/tests/wizard-test.mjs @@ -225,6 +225,44 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 } await context.close(); } +/* 4c. a dropped stream gets its own message, not the generic card ----- */ +{ + const { context, page } = await makePage(browser); + await seed(page, { decks: [] }); + await page.addInitScript(() => { + const original = window.fetch; + window.fetch = (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (!url.includes("/api/generate")) return original(input, init); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode(`${JSON.stringify({ type: "stage", stage: "generation", message: "Finding the key ideas…" })}\n`), + ); + setTimeout(() => controller.error(new Error("socket dropped")), 300); + }, + }); + return Promise.resolve(new Response(stream, { status: 200, headers: { "Content-Type": "application/x-ndjson" } })); + }; + }); + + await page.goto(`${BASE}/create`, { waitUntil: "networkidle" }); + await page.waitForTimeout(300); + await page.getByRole("button", { name: "Paste text instead" }).click(); + await page.locator("textarea").first().fill("Photosynthesis needs light and water."); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByRole("button", { name: "Freeze my deck" }).click(); + await page.waitForTimeout(1400); + check( + "stream loss: the wizard explains the dropped connection", + await page.getByText("The connection dropped mid-run").isVisible(), + ); + check("stream loss: retry is offered", await page.getByRole("button", { name: "Retry" }).isVisible()); + await context.close(); +} + /* 5. leaving mid-run aborts: no surprise deck, no navigation --------- */ { const { context, page } = await makePage(browser);