diff --git a/docs/Decisions.md b/docs/Decisions.md index aeca5a1..d791789 100644 --- a/docs/Decisions.md +++ b/docs/Decisions.md @@ -702,3 +702,56 @@ future model's peak lands awkwardly relative to the 0.1 fraction). is no real crash-telemetry feedback loop. If field reports show 0.1 is still too generous (or too aggressive) for some device class, adjust the constant rather than the shape of the heuristic. + +## D-030 — Error taxonomy: pattern-matched classification, not structured error types +**Status:** Accepted (2026-07-29) +Every failure path (decode, model load, transcription, a crashed worker, a +live session) used to end at `setError(rawMessage)`, rendered as one plain +string — no distinction between "this file won't decode" (try another file), +"the tab ran out of memory" (try a smaller model), a lost WebGPU device +(switch backend), or a dropped network connection mid-download (retry). The +WorkBreakdown M6 item asked for exactly those four categories. +**Shape:** `src/lib/errors.ts` exports a pure `classifyError(raw, context)`: +`context.stage` (`"decode" | "load" | "transcribe" | "worker-crash"`) plus +`context.offline` (`navigator.onLine`) disambiguate cases the message text +alone can't, then substring pattern-matching on the message sorts the rest +into `unsupported-file` / `out-of-memory` / `device-lost` / `network` / +`generic`, each with a user-facing `message`, an optional actionable +`suggestion`, and `retryable`. **Why pattern-matching, not typed errors from +each layer:** the failures cross Web Audio, Transformers.js, onnxruntime-web +(WASM and the WebGPU/WebNN JSEP EPs), and `fetch` — none of which expose a +shared error hierarchy the app controls, so there is nothing to `instanceof` +against; the only thing every layer reliably produces is an `Error` (or +DOMException) with a message string. A worker crash (`Worker.onerror`) with +an **empty** message is itself classified as out-of-memory: D-027 found that +the browser's silent OOM tab-kill fires no JS error event with details at +all, so an empty message *is* the OOM signal in that one context — the +classifier only applies that inference when `context.stage === +"worker-crash"`, not to an empty message anywhere else. +**UI wiring (`src/App.tsx`):** the `error` state changed from a `string` to +`{ classified: ClassifiedError; retryKind?: "load" | "file" }`. `retryKind` +is resolved to an actual retry action **at render time** (`onClick={() => +startLoad(modelId, devicePreference)}` / replaying `lastFileRef.current` +through `handleFile`), reading current component state directly — not +computed inside the worker's `message`/`error` event listeners, which are +registered once per worker lifetime (`ensureWorker`) and would otherwise +close over whichever render's `modelId`/`devicePreference` happened to be +current at registration, going stale on every later model/device switch. +**Consequence:** only `network` and `device-lost` are `retryable` (an +unsupported file or an OOM won't succeed by retrying the identical action); +the error box shows a Retry button only for those, alongside the suggestion +text. Live-mode errors (mic permission, WS disconnects) still classify +through the same function for a consistent error box, but never carry +`retryKind` — the existing Start/Stop controls are already the retry path +there. +**Alternatives rejected:** wrapping each subsystem's calls in typed error +classes at the boundary (real fix, but Transformers.js/onnxruntime-web +throw plain `Error`s from deep internals — retrofitting types there means +forking dependencies, out of scope); classifying in the worker instead of +the UI (the worker already forwards a string message over postMessage — +classifying there would just move the same pattern-matching one hop over +for no benefit, and the UI is where `context.offline`/retry actions live). +**Revisit:** if a future onnxruntime-web/Transformers.js version starts +surfacing richer structured errors (e.g. a `GPUDeviceLostInfo` object +instead of a stringified message), prefer inspecting that directly over +adding more substring patterns. diff --git a/docs/WorkBreakdown.md b/docs/WorkBreakdown.md index 3ee6045..e9c8ed1 100644 --- a/docs/WorkBreakdown.md +++ b/docs/WorkBreakdown.md @@ -218,8 +218,14 @@ Notes (M5): exercised for real (browser half still needs the manual run). ## M6 — Hardening & polish -- [ ] Error taxonomy: unsupported file, out-of-memory (suggest smaller model), - WebGPU device-lost, network loss mid-download (resume or clear retry) +- [x] Error taxonomy: unsupported file, out-of-memory (suggest smaller model), + WebGPU device-lost, network loss mid-download (resume or clear retry) — + pure `classifyError` in `src/lib/errors.ts` (D-030) sorts every failure + path (decode, load, transcribe, a crashed worker) into one of the four + categories plus a generic fallback, each with a user-facing message and + an actionable suggestion; the error box in `App.tsx` adds a Retry + button for the two retryable kinds (network, device-lost), replaying + the failed load or the last file. Unit-tested in `tests/errors.test.ts`. - [x] Cancel in-flight transcription (both engines) — browser engine via worker termination (D-018), server engine via aborting the `/api/transcribe` fetch (D-018 amendment; landed with M5's EngineToggle) diff --git a/src/App.tsx b/src/App.tsx index 7e8bd8c..3adebd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { StorageManager } from "./components/StorageManager"; import { TranscriptView } from "./components/TranscriptView"; import { TranscriptionOptions } from "./components/TranscriptionOptions"; import { decodeAudioFile } from "./lib/audio"; +import { classifyError, type ClassifiedError } from "./lib/errors"; import { BrowserEngine, ServerEngine, @@ -54,6 +55,17 @@ interface TranscriptResult { chunks: TranscriptChunk[]; } +/** + * Displayed error state (M6 error taxonomy, `src/lib/errors.ts`). `retryKind` + * says which retry affordance (if any) the error box should offer — computed + * at render time against current component state, not baked into a closure + * captured when the worker's message handler was first registered. + */ +interface AppErrorState { + classified: ClassifiedError; + retryKind?: "load" | "file"; +} + const MODEL_IDS = MODELS.map((model) => model.id); export function App() { @@ -82,6 +94,11 @@ export function App() { // audio to the (fresh) worker. Decoding itself cannot be aborted. const fileRun = useRef(0); + // Last file handed to handleFile, so a retryable transcribe-stage error + // (network failure talking to the server engine) can offer a Retry button + // without holding onto File objects in state. + const lastFileRef = useRef(null); + // Accelerator failures learned this session (D-025), keyed // "modelId::preference" → the explicit device to load with instead, so // repeat loads skip the doomed accelerator attempt (and its restart) @@ -124,7 +141,7 @@ export function App() { totalSeconds: number; } | null>(null); const [result, setResult] = useState(null); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [fileName, setFileName] = useState(null); const [liveStatus, setLiveStatus] = useState("idle"); @@ -201,6 +218,11 @@ export function App() { }; }, [auth.user]); + /** `navigator.onLine`, guarded for non-browser test environments. */ + function isOffline(): boolean { + return typeof navigator !== "undefined" && !navigator.onLine; + } + function overrideKey(forModelId: string, preference: DevicePreference): string { return `${forModelId}::${preference}`; } @@ -358,7 +380,14 @@ export function App() { if (message.stage === "load") { loadGate.current?.reject(new Error(message.message)); } - setError(message.message); + const classified = classifyError(message.message, { + stage: message.stage, + offline: isOffline(), + }); + setError({ + classified, + retryKind: classified.retryable ? (message.stage === "load" ? "load" : "file") : undefined, + }); setTranscribeProgress(null); setBusy(false); setRunEngine(null); @@ -405,14 +434,20 @@ export function App() { } }); worker.addEventListener("error", (event) => { - const failure = new Error( - event.message || "The inference worker crashed unexpectedly.", - ); + // Classify on the raw (possibly empty) message: an empty message is + // itself the signal for a silent OOM tab-kill (D-027 — no JS error + // event carries details when that happens), which a fallback string + // would otherwise hide from the classifier. + const classified = classifyError(event.message, { + stage: "worker-crash", + offline: isOffline(), + }); + const failure = new Error(classified.message); // Unblock any live passes so a live session can drain and stop. for (const pending of pendingLive.current.values()) pending.reject(failure); pendingLive.current.clear(); loadGate.current?.reject(failure); - setError(failure.message); + setError({ classified, retryKind: classified.retryable ? "load" : undefined }); setBusy(false); }); workerRef.current = worker; @@ -534,6 +569,7 @@ export function App() { async function handleFile(file: File) { if (busy || liveStatus !== "idle") return; + lastFileRef.current = file; // The toggle's choice only takes effect with a session and an available // server engine (D-010); everything else runs on-device as always. const useServer = @@ -587,9 +623,7 @@ export function App() { audio = await decodeAudioFile(file); } catch (decodeError) { if (fileRun.current === run) { - setError( - decodeError instanceof Error ? decodeError.message : String(decodeError), - ); + setError({ classified: classifyError(decodeError, { stage: "decode" }) }); setBusy(false); setRunEngine(null); } @@ -608,7 +642,8 @@ export function App() { await browserModelReady(); } catch (loadError) { if (fileRun.current !== run) return; // cancelled while loading - setError(loadError instanceof Error ? loadError.message : String(loadError)); + const classified = classifyError(loadError, { stage: "load", offline: isOffline() }); + setError({ classified, retryKind: classified.retryable ? "load" : undefined }); setBusy(false); setRunEngine(null); return; @@ -645,11 +680,11 @@ export function App() { setRunEngine(null); } catch (transcribeError) { if (fileRun.current !== run) return; // cancellation rejections are expected - setError( - transcribeError instanceof Error - ? transcribeError.message - : String(transcribeError), - ); + const classified = classifyError(transcribeError, { + stage: "transcribe", + offline: isOffline(), + }); + setError({ classified, retryKind: classified.retryable ? "file" : undefined }); setTranscribeProgress(null); setBusy(false); setRunEngine(null); @@ -726,7 +761,7 @@ export function App() { setLiveCommitted(""); setLiveTentative(""); setLiveStatus("idle"); - if (message) setError(message); + if (message) setError({ classified: classifyError(message) }); }; ws.onmessage = (event) => { @@ -748,7 +783,7 @@ export function App() { setLiveCommitted(update.committedText); setLiveTentative(update.tentativeText); setLivePending(update.pendingFinalPasses ?? 0); - if (update.error) setError(update.error); + if (update.error) setError({ classified: classifyError(update.error) }); } else if (data.type === "result") { setResult(data.result as TranscriptResult); endSession(null); @@ -762,9 +797,12 @@ export function App() { // does the actual cleanup — this only needs to supply the message // (onclose can't distinguish a clean vs. abrupt closure on its own). if (liveWsRef.current === ws) { - setError( - "Lost connection to the server during live transcription. The microphone has been stopped.", - ); + setError({ + classified: classifyError( + "Lost connection to the server during live transcription. The microphone has been stopped.", + { offline: isOffline() }, + ), + }); } }; @@ -798,13 +836,13 @@ export function App() { } catch (micError) { ws.close(); setLiveStatus("idle"); - setError( + const message = micError instanceof DOMException && micError.name === "NotAllowedError" ? "Microphone access was denied. Allow it in your browser's site settings and try again." : micError instanceof Error ? micError.message - : String(micError), - ); + : String(micError); + setError({ classified: classifyError(message) }); } }; return; @@ -832,7 +870,7 @@ export function App() { setLiveCommitted(update.committedText); setLiveTentative(update.tentativeText); setLivePending(update.pendingFinalPasses); - if (update.error) setError(update.error); + if (update.error) setError({ classified: classifyError(update.error) }); }, }); @@ -842,13 +880,13 @@ export function App() { setLiveStatus("listening"); } catch (micError) { setLiveStatus("idle"); - setError( + const message = micError instanceof DOMException && micError.name === "NotAllowedError" ? "Microphone access was denied. Allow it in your browser's site settings and try again." : micError instanceof Error ? micError.message - : String(micError), - ); + : String(micError); + setError({ classified: classifyError(message) }); } } @@ -871,7 +909,12 @@ export function App() { liveWsRef.current = null; liveRef.current = null; setLiveStatus("idle"); - setError("Live transcription lost its connection to the server before it could finish."); + setError({ + classified: classifyError( + "Live transcription lost its connection to the server before it could finish.", + { offline: isOffline() }, + ), + }); } } else { try { @@ -1013,9 +1056,33 @@ export function App() { )} {error && ( -

- {error} -

+
+

{error.classified.message}

+ {error.classified.suggestion && ( +

{error.classified.suggestion}

+ )} + {error.retryKind === "load" && ( + + )} + {error.retryKind === "file" && lastFileRef.current && ( + + )} +
)} {showModelProgress && ( diff --git a/src/index.css b/src/index.css index e32f36c..51a9f0c 100644 --- a/src/index.css +++ b/src/index.css @@ -546,6 +546,33 @@ body { color: #f2b8b5; } +.error-message { + margin: 0; +} + +.error-suggestion { + margin: 0.4rem 0 0; + font-size: 0.85rem; + color: #d99a97; +} + +.error-retry-button { + margin-top: 0.6rem; + border: 1px solid #f2b8b5; + background: transparent; + color: #f2b8b5; + border-radius: 8px; + padding: 0.3rem 0.9rem; + font-size: 0.85rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.error-retry-button:hover { + background: #f2b8b5; + color: #2a1c1e; +} + /* --- ModelLoadProgress --- */ .model-progress { diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..fa6ec30 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,126 @@ +/** + * Error taxonomy for user-facing failures (WorkBreakdown M6: "Error + * taxonomy: unsupported file, out-of-memory (suggest smaller model), WebGPU + * device-lost, network loss mid-download"). + * + * Pure and unit-tested — turns a raw `Error`/string/unknown thrown somewhere + * in the app (worker load/transcribe, decode, a crashed worker, a failed + * fetch) into a `ClassifiedError` the UI can render consistently: a message, + * an optional actionable suggestion, and whether a Retry button makes sense. + * + * Classification is pattern-matching on the error message plus a `stage` + * hint from the caller (decode errors are always "unsupported file"; a + * worker crash with no message at all is almost always the browser's silent + * OOM tab-kill, per Decisions.md D-027's finding that no JS `error` event + * carries details when that happens) — there is no structured error type to + * inspect across Web Audio / Transformers.js / onnxruntime-web / fetch. + */ + +export type ErrorStage = "decode" | "load" | "transcribe" | "worker-crash"; + +export type ErrorKind = "unsupported-file" | "out-of-memory" | "device-lost" | "network" | "generic"; + +export interface ClassifiedError { + kind: ErrorKind; + message: string; + /** Actionable next step to show alongside the message, if any. */ + suggestion?: string; + /** Whether retrying the same action stands a reasonable chance of working. */ + retryable: boolean; +} + +export interface ClassifyContext { + /** Which part of the pipeline the error came from. */ + stage?: ErrorStage; + /** `navigator.onLine` at classification time, if known. */ + offline?: boolean; +} + +function toMessage(raw: unknown): string { + return raw instanceof Error ? raw.message : String(raw); +} + +function matchesAny(haystack: string, needles: readonly string[]): boolean { + const lower = haystack.toLowerCase(); + return needles.some((needle) => lower.includes(needle)); +} + +// Substrings observed (or documented upstream) for each failure mode. Case +// folded via matchesAny, so mixed-case engine messages still match. +const OOM_PATTERNS = [ + "out of memory", + "(oom)", + "failed to allocate", + "could not allocate", + "allocation failed", + "cannot enlarge memory", +]; + +const DEVICE_LOST_PATTERNS = [ + "device was lost", + "device lost", + "device has been lost", + "lost the gpu device", + "gpudevicelostinfo", +]; + +const NETWORK_PATTERNS = [ + "failed to fetch", + "networkerror", + "network connection was lost", + "network request failed", + "err_internet_disconnected", + "err_network", + "err_connection", + "load failed", // Safari's fetch-failure message is literally "Load failed" +]; + +/** Classify a raw thrown value into a `ClassifiedError` for display. */ +export function classifyError(raw: unknown, context: ClassifyContext = {}): ClassifiedError { + const message = toMessage(raw); + + if (context.stage === "decode") { + return { + kind: "unsupported-file", + message, + suggestion: + "Try converting the file to a common format (WAV, MP3, M4A) or pick a different file.", + retryable: false, + }; + } + + const trimmed = message.trim(); + if (matchesAny(message, OOM_PATTERNS) || (context.stage === "worker-crash" && trimmed.length === 0)) { + return { + kind: "out-of-memory", + message: trimmed.length > 0 ? message : "The browser ran out of memory and had to stop.", + suggestion: + "Try a smaller model (Tiny, Base, or Small) — it needs far less memory to load and run.", + retryable: false, + }; + } + + if (matchesAny(message, DEVICE_LOST_PATTERNS)) { + return { + kind: "device-lost", + message, + suggestion: + "Your GPU driver reset or the browser lost its graphics context. Switch the Backend to WASM, or try again.", + retryable: true, + }; + } + + if (matchesAny(message, NETWORK_PATTERNS) || context.offline) { + return { + kind: "network", + message: context.offline ? "You appear to be offline. Check your connection and try again." : message, + suggestion: + context.stage === "load" + ? "Check your connection, then retry — the model download will restart." + : "Check your connection and try again.", + retryable: true, + }; + } + + return { kind: "generic", message, retryable: false }; +} diff --git a/tests/errors.test.ts b/tests/errors.test.ts new file mode 100644 index 0000000..8e38b46 --- /dev/null +++ b/tests/errors.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect } from "bun:test"; +import { classifyError } from "../src/lib/errors"; + +// Pure pattern-matching (M6 error taxonomy) — no browser needed. + +describe("classifyError", () => { + test("decode-stage failures are always unsupported-file, regardless of message", () => { + const result = classifyError(new Error("Unable to decode audio data"), { stage: "decode" }); + expect(result.kind).toBe("unsupported-file"); + expect(result.retryable).toBe(false); + expect(result.suggestion).toMatch(/different file|convert/i); + }); + + test("recognizes out-of-memory messages", () => { + for (const message of [ + "RuntimeError: Aborted(OOM)", + "Cannot enlarge memory arrays to size 123456, (OOM)", + "Failed to allocate memory for tensor", + "could not allocate buffer", + ]) { + const result = classifyError(new Error(message)); + expect(result.kind).toBe("out-of-memory"); + expect(result.retryable).toBe(false); + expect(result.suggestion).toMatch(/smaller model/i); + } + }); + + test("a worker crash with an empty message is treated as out-of-memory (D-027)", () => { + const result = classifyError(new Error(""), { stage: "worker-crash" }); + expect(result.kind).toBe("out-of-memory"); + expect(result.message.length).toBeGreaterThan(0); + }); + + test("a worker crash with a real message is not assumed to be OOM", () => { + const result = classifyError(new Error("TypeError: x is not a function"), { + stage: "worker-crash", + }); + expect(result.kind).toBe("generic"); + }); + + test("recognizes WebGPU device-lost messages", () => { + for (const message of [ + "GPUDeviceLostInfo: device was lost", + "WebGPU device was lost: reason=destroyed", + "the GPU device has been lost", + ]) { + const result = classifyError(new Error(message)); + expect(result.kind).toBe("device-lost"); + expect(result.retryable).toBe(true); + expect(result.suggestion).toMatch(/backend|try again/i); + } + }); + + test("recognizes network failures", () => { + for (const message of [ + "TypeError: Failed to fetch", + "NetworkError when attempting to fetch resource.", + "Load failed", + ]) { + const result = classifyError(new Error(message), { stage: "load" }); + expect(result.kind).toBe("network"); + expect(result.retryable).toBe(true); + } + }); + + test("offline context forces a network classification with a clear message", () => { + const result = classifyError(new Error("something obscure"), { + stage: "load", + offline: true, + }); + expect(result.kind).toBe("network"); + expect(result.retryable).toBe(true); + expect(result.message).toMatch(/offline/i); + expect(result.suggestion).toMatch(/restart/i); + }); + + test("load-stage network suggestion mentions the download restarting", () => { + const result = classifyError(new Error("Failed to fetch"), { stage: "load" }); + expect(result.suggestion).toMatch(/restart/i); + }); + + test("transcribe-stage network suggestion is stage-agnostic", () => { + const result = classifyError(new Error("Failed to fetch"), { stage: "transcribe" }); + expect(result.suggestion).not.toMatch(/restart/i); + }); + + test("falls back to a generic, non-retryable classification", () => { + const result = classifyError(new Error("Something went wrong doing the thing")); + expect(result).toEqual({ + kind: "generic", + message: "Something went wrong doing the thing", + retryable: false, + }); + }); + + test("accepts non-Error raw values", () => { + expect(classifyError("plain string failure").message).toBe("plain string failure"); + expect(classifyError(42).message).toBe("42"); + }); +});