# Phase 73 / Task 01 — Repro + root-cause findings **Bug (TODO.md L3):** "Clicking on another tab while an answer is generating stops that answer from being generated. Responses should continue to generate unless you outright close the tab." **Environment (the "test browser"):** real (headful) Chromium `151.0.0.0` (Playwright chromium-1234 build, X11/XWayland on the owner's KDE Plasma Wayland desktop, display :0), launched with ONLY `--disable-popup-blocking` added (needed for the scripted second tab) — **no** `--disable-*-background*` flags, so real hidden-tab behavior is under test. The app ran against the deterministic mock LLM (`tests/e2e/mock_llm.py`) behind a re-pacing proxy (0.2 s per 12-char SSE frame → a ~90 s stream, a guaranteed mid-stream hidden window), on a scratch Postgres DB (`bor_repro73`) so no owner data was touched. Method: two REAL tabs in ONE window (second tab opened via `window.open`, tab switched via CDP `Target.activateTarget` / `Page.bringToFront` — verified real by the `document.visibilityState` transitions). In-page instrumentation (temporary, removed — no repo changes): `pagehide`/`pageshow`/`visibilitychange`/`freeze`/`resume`/ `beforeunload`/window focus/blur events with timestamps; a 400 ms recorder (visibility, `hasFocus`, live-bubble text length = frame arrivals, status/banner/button text); CDP `Network.*` lifecycle of `POST /api/chat`; `performance.getEntriesByType('resource')`; localStorage `bor.chat.v1`; server (uvicorn) log. ## Scenario A — mid-stream real tab switch, 65 s hidden (main repro) `task01_A_evidence.json`, screenshots `task01_A_0…5_*.png`, server log `server_A.log`. 1. Long answer started; at ~7 s (bubble ≈ 460 chars, `streaming`) the driver switched to the other tab. Tab A went `hidden` — exactly ONE `visibilitychange {state:"hidden"}` event, at the switch. 2. **While hidden (65 s) the answer kept generating at full wire rate**: the live bubble grew linearly 456 → 4141 chars (≈ 58 chars/s = the stream's 12 chars / 0.2 s frame pace) — SSE frames kept arriving the entire time; no stall, no drop, no error copy, no banner. (The 400 ms recorder cadence never degraded — no background timer throttling was observed in this XWayland/KWin environment; noted as an environment property, not an app defect.) 3. Returned to the tab at ~72 s: `visibilitychange {state:"visible"}`; the turn settled ~18 s later — button back to "Send", no banner. 4. `bor.chat.v1`: exactly ONE brain turn (the full 5 379-char answer, ending `LONG-ANSWER-END`) for the one question. 5. **`pagehide` did NOT fire** on the tab switch (nor `freeze`/`resume`/ `beforeunload`/`pageshow`). In real-mode Chromium 151, a merely-hidden tab fires only `visibilitychange`. 6. Network: `POST /api/chat` → `requestWillBeSent` → `responseReceived (200)` → `loadingFinished`. **No `loadingFailed`** (verified on a clean re-probe: the earlier duplicate/failed entries were a bug in the repro's own CDP event router, not the browser). 7. Server: the turn's settle line shows `total_ms=90143/90145` — the server streamed the FULL 90 s to the hidden tab; there is **NO `chat: turn cancelled`** line for this turn (the phase-48 line that would prove a real consumer departure). **⇒ In the test browser, a tab switch does NOT stop the answer (C3 and C4 ruled out; C1's trigger did not occur; the guard was already cleared by the first frame, so C2 was not on the path).** ## Scenario C — C1 double-record path (synthetic `pagehide` mid-stream) `task01_C_evidence.json`, screenshots `task01_C_1…3_*.png`. Chromium does not fire `pagehide` on a tab switch, but browsers that DO (e.g. Safari, which bfcaches/freeze-then-`pagehide`s hidden tabs) would run the app's `pagehide` handler (`frontend/assets/app.js` ~L2162) mid-turn. Dispatching a synthetic `PageTransitionEvent('pagehide', {persisted:true})` at ~7 s into the stream (bubble ≈ 327 chars) — the EXACT code path a real `pagehide` executes — then letting the turn complete: - `bor.chat.v1` ended with **TWO brain turns for ONE question**: the 336-char partial (the pagehide persist) AND the full 5 379-char answer (the `done` persist). - After a reload, the restore renders BOTH: user → brain (327) → brain (5 228) — the duplicated / "truncated-looking" conversation the bug report describes. **⇒ C1's corruption branch is real and deterministic whenever the browser fires `pagehide` on a merely-hidden tab; it never double-persists in Chromium because Chromium never fires that event for a tab switch. This is the branch task 02 hardens unconditionally.** ## Scenario B — C2 probe: the 120 s guard in a hidden pre-token window `task01_B_evidence.json`, screenshots `task01_B_1…3_*.png`. The proxy was re-run holding the FIRST frame for 130 s (pre-token silence > `TURN_TIMEOUT_MS`). The tab was switched away at ~7 s and stayed hidden past the guard's deadline: - Probes every 10 s while hidden: no banner through t≈117 s; at t≈127 s the button had returned to "Send" and the banner read **"That's taking a long time — the answer may be stuck."** — the guard fired at its 120 s deadline **during the hidden window** and errored the turn. - The first frame would have arrived at t≈130 s — too late: the guard had already aborted the fetch. On return the error banner is still up; the user perceives "switching tabs killed the answer", but the trigger is the pre-token silence, not the switch. - `bor.chat.v1` holds only the user message (pre-token abort persists nothing brain-side — phase-20 convention). - Server: `chat: turn cancelled … total_ms=119999` — the cancellation is the CLIENT's guard abort (client-initiated), not a browser/proxy drop of the hidden tab. **⇒ C2 is confirmed as a real, conditional stop path: any turn whose first frame takes > 120 s errors out if the tab is hidden at the 120 s mark (the guard is a plain `setTimeout` with no visibility awareness; where a browser DOES throttle background timers, its fire time drifts relative to token arrival, as the C2 branch describes).** ## Decision-tree verdict - **C1 — `pagehide` on tab switch:** did NOT fire in the test browser (Chromium 151 real mode fires only `visibilitychange` for a hidden tab). Its double-record corruption path was nonetheless demonstrated deterministically (synthetic `pagehide`) — latent in browsers that do fire `pagehide` on hide (e.g. Safari); the trigger is browser-dependent, so task 02's fix applies unconditionally. - **C2 — 120 s guard while hidden:** CONFIRMED to fire (scenario B) in the pre-token-only window, erroring the turn with the "stuck" copy. - **C3 — real connection cancel:** ruled out (no `turn cancelled` for the hidden-window turn; `loadingFinished`, no `loadingFailed`; full 90 s server-side stream completed). - **C4 — none of the above:** in the test browser a merely-hidden tab did NOT stop the answer — it kept generating at full rate and completed on return. The owner's symptom did not reproduce on Chromium 151 desktop; the reproducible stop paths are C2 (slow pre-token + hidden) and C1's corruption (browsers that fire `pagehide` on hide, e.g. Safari — where a hidden tab is also the likelier place for the stream to actually pause). **One-line root cause (for the phase-73 commit message body):** `none of C1–C3 — in Chromium 151 (real mode) a merely-hidden tab neither stops the stream (frames arrive at full rate; turn completes) nor fires pagehide on tab switch; C1's double-record path was proven latent via a synthetic pagehide (trigger is browser-dependent, e.g. Safari) and C2 (the 120s pre-token guard) was confirmed to fire while hidden.` ## Cleanup - No permanent code changes: `git status` shows only this (untracked) evidence directory; all instrumentation lived in throwaway scripts under `/tmp/repro73/` and in the browser profiles. - Scratch DB `bor_repro73` dropped; repro stack (mock 8901 / proxy 8900 / app 8123) terminated.