diff --git a/apps/web/app/play/page.tsx b/apps/web/app/play/page.tsx index 7094028..1d635be 100644 --- a/apps/web/app/play/page.tsx +++ b/apps/web/app/play/page.tsx @@ -346,6 +346,15 @@ async function triggerExtraction(args: { await new Promise((r) => setTimeout(r, 4000)); return attempt(); } + // HARD failure (HTTP error / network throw) gets one delayed retry too — + // live-caught 2026-07-20: an OpenRouter timeout chain 502'd the extract + // and the page silently had NO labels, NO codex, NO geo, with a console + // error as the only trace. Extraction transients are exactly as + // stochastic as the 0/0 flake above; the second pass usually lands. + if (first === null) { + await new Promise((r) => setTimeout(r, 8000)); + return attempt(); + } return first; } @@ -679,6 +688,9 @@ export default function PlayPage() { const abortRef = useRef(null); // The last generate body, any kind — powers the error banner's "Try again". const lastGenerateRef = useRef(null); + // True when the current error landed while the tab was HIDDEN — the + // freeze-suspension class; the visibilitychange effect auto-retries once. + const erroredWhileHiddenRef = useRef(false); const streamRef = useRef(null); const [streamStatus, setStreamStatus] = useState("off"); const [fallbackVideoUrl, setFallbackVideoUrl] = useState(null); @@ -1134,6 +1146,16 @@ export default function PlayPage() { } setError((err as Error).message); setPhase("error"); + // Freeze-death marker (live-caught 2026-07-20): Chromium suspends a + // background tab's fetches (ERR_NETWORK_IO_SUSPENDED), so a 60-120s + // generation dies the moment the user tab-switches — and the backend + // cancels the work on disconnect. A failure that lands while HIDDEN + // is near-certainly the suspension, not the request: remember it and + // auto-retry once on return instead of greeting the user with an + // error banner for something they never watched fail. + erroredWhileHiddenRef.current = + typeof document !== "undefined" && + document.visibilityState === "hidden"; setMorphFx(null); setProgressiveDraft(false); // Best-effort error sink so /status's "recent errors" panel can @@ -1167,6 +1189,21 @@ export default function PlayPage() { void generate(rest); }, [generate]); + // Resume-retry: when the tab comes back to the foreground and the current + // error was recorded while HIDDEN (the freeze-suspension class), fire the + // banner's own retry automatically — once. A real failure re-errors and + // the banner takes over as usual. + useEffect(() => { + const onVisible = () => { + if (document.visibilityState !== "visible") return; + if (!erroredWhileHiddenRef.current) return; + erroredWhileHiddenRef.current = false; + if (lastGenerateRef.current) retryLast(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => document.removeEventListener("visibilitychange", onVisible); + }, [retryLast]); + // Build the expand body from the current page + session state and hand it to // the bloom hook (which owns the SSE loop, tray state, persistence + abort). const triggerExpand = useCallback(() => { diff --git a/apps/web/e2e/error-retry.spec.ts b/apps/web/e2e/error-retry.spec.ts index 1ed4151..491c1d2 100644 --- a/apps/web/e2e/error-retry.spec.ts +++ b/apps/web/e2e/error-retry.spec.ts @@ -35,3 +35,46 @@ test("forced failure shows the banner; retry sends a FRESH idempotency key", asy expect(keys[1]).not.toBe(keys[0]); expect(statuses[1]).toBe(200); }); + +test("a failure that lands while HIDDEN auto-retries on return — no banner click needed", async ({ + page, +}) => { + // The freeze-suspension class (live-caught): Chromium suspends background + // tabs' fetches, killing in-flight generations; the backend cancels on + // disconnect. The client remembers hidden-time errors and fires the + // banner's own retry automatically when the tab returns. + let posts = 0; + page.on("request", (req) => { + if (req.url().includes("/api/generate-page") && req.method() === "POST") { + posts += 1; + } + }); + + // The override must exist before the app's FIRST script: the mock error + // frame lands well under a second after hydration, so a post-goto evaluate + // loses the race and the error records as visible (caught live: the first + // version of this test did exactly that). + await page.addInitScript(() => { + let vis = "hidden"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => vis, + }); + (window as unknown as { __setVis: (v: string) => void }).__setVis = (v) => { + vis = v; + document.dispatchEvent(new Event("visibilitychange")); + }; + }); + await page.goto("/play?q=" + encodeURIComponent("MOCK_ERROR hidden town")); + + await page + .getByRole("button", { name: /Try again/ }) + .waitFor({ state: "visible", timeout: 60_000 }); + expect(posts).toBe(1); + + // The user comes back — the retry must fire on its own. + await page.evaluate(() => + (window as unknown as { __setVis: (v: string) => void }).__setVis("visible"), + ); + await expect.poll(() => posts, { timeout: 20_000 }).toBe(2); +});