Root cause (task 01): 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. - C1: the pagehide partial-persist is correlated with the turn's settle (leavePartialIndex) - the done/stop settle REPLACES it in place (identity-guarded rememberBrainTurn in-place mode), so bor.chat.v1 and the auto-saved saved_chats row keep exactly ONE brain turn per question; a real navigation never runs a settle, so the leave-save is unchanged. - C2: the visibility re-arm gives the still-armed pre-token guard a fresh TURN_TIMEOUT_MS when the tab returns to visible - hidden time no longer counts toward the 120s guard. - Phase-48 teardown contract untouched: Stop / tab close / real navigation still cancel the fetch and stop the model. - Unit pins: tests/unit/test_frontend_hidden_tab.py (the app.js mechanisms without a browser). - E2E pins: tests/e2e/test_hidden_tab_stream.py - synthetic pagehide mid-stream completes exactly once with one brain turn (localStorage + auto-saved row), reload restores one bubble, no-event baseline, and the fake-clock pre-token guard re-arm (discriminating: fails with the re-arm disabled).
7.9 KiB
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.
- Long answer started; at ~7 s (bubble ≈ 460 chars,
streaming) the driver switched to the other tab. Tab A wenthidden— exactly ONEvisibilitychange {state:"hidden"}event, at the switch. - 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.)
- Returned to the tab at ~72 s:
visibilitychange {state:"visible"}; the turn settled ~18 s later — button back to "Send", no banner. bor.chat.v1: exactly ONE brain turn (the full 5 379-char answer, endingLONG-ANSWER-END) for the one question.pagehidedid NOT fire on the tab switch (norfreeze/resume/beforeunload/pageshow). In real-mode Chromium 151, a merely-hidden tab fires onlyvisibilitychange.- Network:
POST /api/chat→requestWillBeSent→responseReceived (200)→loadingFinished. NoloadingFailed(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). - Server: the turn's settle line shows
total_ms=90143/90145— the server streamed the FULL 90 s to the hidden tab; there is NOchat: turn cancelledline 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-pagehides 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.v1ended with TWO brain turns for ONE question: the 336-char partial (the pagehide persist) AND the full 5 379-char answer (thedonepersist).- 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.v1holds 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 —
pagehideon tab switch: did NOT fire in the test browser (Chromium 151 real mode fires onlyvisibilitychangefor a hidden tab). Its double-record corruption path was nonetheless demonstrated deterministically (syntheticpagehide) — latent in browsers that do firepagehideon 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 cancelledfor the hidden-window turn;loadingFinished, noloadingFailed; 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
pagehideon 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 statusshows only this (untracked) evidence directory; all instrumentation lived in throwaway scripts under/tmp/repro73/and in the browser profiles. - Scratch DB
bor_repro73dropped; repro stack (mock 8901 / proxy 8900 / app 8123) terminated.