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:
parent
20bb08b0e7
commit
b70b0e83cf
6 changed files with 71 additions and 15 deletions
|
|
@ -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
|
pre-blurred wallpaper — the scene is clearly visible through the glass, and the
|
||||||
pixel-contrast scan still passes.
|
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:
|
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
|
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
|
deadline is 2x `OLLAMA_TIMEOUT_MS` (ten minutes by default), and the client waited on a dead
|
||||||
|
|
|
||||||
|
|
@ -76,21 +76,28 @@ export async function consumeNdjson(
|
||||||
};
|
};
|
||||||
while (true) {
|
while (true) {
|
||||||
let chunk: ReadableStreamReadResult<Uint8Array>;
|
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||||
if (stallMs > 0) {
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
try {
|
||||||
const stalled = new Promise<never>((_, reject) => {
|
if (stallMs > 0) {
|
||||||
timer = setTimeout(() => reject(new ApiError(t("api.stalled"), "timeout")), stallMs);
|
const stalled = new Promise<never>((_, reject) => {
|
||||||
});
|
timer = setTimeout(() => reject(new ApiError(t("api.stalled"), "timeout")), stallMs);
|
||||||
try {
|
});
|
||||||
chunk = await Promise.race([reader.read(), stalled]);
|
chunk = await Promise.race([reader.read(), stalled]);
|
||||||
} catch (error) {
|
} else {
|
||||||
void reader.cancel().catch(() => {});
|
chunk = await reader.read();
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
chunk = await reader.read();
|
// 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;
|
if (chunk.done) break;
|
||||||
buffer += decoder.decode(chunk.value, { stream: true });
|
buffer += decoder.decode(chunk.value, { stream: true });
|
||||||
|
|
|
||||||
|
|
@ -721,6 +721,7 @@ const en: Record<string, string> = {
|
||||||
"api.httpError": "The server returned HTTP {status}.",
|
"api.httpError": "The server returned HTTP {status}.",
|
||||||
"api.empty": "The server sent an empty response.",
|
"api.empty": "The server sent an empty response.",
|
||||||
"api.stalled": "The model stopped responding. Check it and try again.",
|
"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.almost": "Almost there…",
|
||||||
"api.noDeck": "No deck came back from the server. Check your Ollama settings and try again.",
|
"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.",
|
"api.incomplete": "The server finished without sending a deck.",
|
||||||
|
|
@ -1677,6 +1678,7 @@ const cs: Record<string, string> = {
|
||||||
"api.httpError": "Server vrátil HTTP {status}.",
|
"api.httpError": "Server vrátil HTTP {status}.",
|
||||||
"api.empty": "Server poslal prázdnou odpověď.",
|
"api.empty": "Server poslal prázdnou odpověď.",
|
||||||
"api.stalled": "Model přestal odpovídat. Zkontrolujte ho a zkuste to znovu.",
|
"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.almost": "Už to bude…",
|
||||||
"api.noDeck": "Ze serveru nepřišla žádná sada. Zkontroluj nastavení Ollamy a zkus to znovu.",
|
"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.",
|
"api.incomplete": "Server skončil, aniž by poslal sadu.",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "freezmo",
|
"name": "freezmo",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,44 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
|
||||||
await context.close();
|
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 --------- */
|
/* 5. leaving mid-run aborts: no surprise deck, no navigation --------- */
|
||||||
{
|
{
|
||||||
const { context, page } = await makePage(browser);
|
const { context, page } = await makePage(browser);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue