From 408dbeb4f0f62d3022965ba8d2603d34c919ad6c Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 14 Jun 2026 00:46:33 +0000 Subject: [PATCH 1/3] fix(sse): close global sidebar streams on PWA window blur (#4151) Two side-by-side PWA standalone windows saturate the browser's per-origin HTTP/1.1 connection limit, causing 'Request timed out' toasts to cycle every ~30s. The idle-SSE close added in #3992/#3996 keys off the Page Visibility API (visibilitychange / document.hidden), but a PWA standalone window does not reliably fire visibilitychange when it loses focus to another window of the same app -- document.hidden only flips on minimize. So both windows stay 'visible', each holds its two global sidebar SSE streams (session-events + gateway) open, 2x3 = 6 hits the connection cap, and subsequent fetch() calls (the 30s background polls) queue behind the saturated pool and time out. The two global sidebar streams now also close on a sustained window blur (gated on document.hasFocus(), the signal visibilitychange misses) and reopen -- catching up the session list -- on focus, via a shared _sidebarSseBackgrounded() predicate and a debounced _installSidebarSseFocusHook(). The per-session live stream (messages.js startSessionStream) is intentionally left visibility-only: it carries live bg_task_complete toasts and server_turn_started live-view that an unfocused-but-visible window must still receive (the OS-notification path is gated on document.hidden, so the in-app toast is the only completion signal a visible-unfocused window gets). Closing it on blur would regress the multi-window live-view UX. Reported by @ddiall with a thorough root-cause analysis. Co-authored-by: ddiall --- CHANGELOG.md | 4 + static/sessions.js | 65 ++++++++- tests/test_issue4151_pwa_focus_sse.py | 135 ++++++++++++++++++ tests/test_webui_external_refresh_frontend.py | 5 +- 4 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue4151_pwa_focus_sse.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 15240b16..33f43d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- **Multiple PWA windows no longer saturate the connection pool and cycle "Request timed out" toasts (#4151).** The idle-SSE close added in #3992/#3996 keyed off the Page Visibility API (`visibilitychange` / `document.hidden`), but a PWA *standalone* window does not reliably fire `visibilitychange` when it loses focus to another window of the same app — `document.hidden` only flips on minimize. So two side-by-side PWA windows both stayed `visible`, each held its two global sidebar SSE streams open (session-events + gateway), and 2×3 = 6 connections hit the browser's per-origin HTTP/1.1 limit; subsequent `fetch()` calls (the 30s background polls) queued behind the saturated pool and timed out. The two global sidebar streams now also close on a sustained window `blur` (gated on `document.hasFocus()`, the signal `visibilitychange` misses) and reopen — catching up the session list — on `focus`. The per-session live stream is intentionally left visibility-only so an unfocused-but-visible window still receives live `bg_task_complete` / `server_turn_started` events. (#4151) + ## [v0.51.403] — 2026-06-13 — Release NP (notification click reuses the existing chat tab, #4109) ### Fixed diff --git a/static/sessions.js b/static/sessions.js index b31cf681..ab416550 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3645,6 +3645,62 @@ function _scheduleSessionEventsRefresh(reason){ }, 300); } +// ── #4151: focus-aware close for the two GLOBAL sidebar SSE streams ────────── +// Each WebUI window holds up to three persistent SSE connections (session-events +// + gateway + the per-session stream). #3992/#3996 close them on the Page +// Visibility API (`visibilitychange` / `document.hidden`) so a hidden tab frees +// HTTP/1.1 pool slots. But a PWA *standalone* window does NOT reliably fire +// `visibilitychange` when it merely loses focus to another window of the same +// app — `document.hidden` only flips on minimize. So two side-by-side PWA windows +// both stay `visibilityState==='visible'`, each keeps its sidebar streams open, +// and 2x3 = 6 = the per-origin HTTP/1.1 connection limit; every later fetch() +// (the 30s polls) queues behind the saturated pool and times out (#4151). +// `document.hasFocus()` is the signal `visibilitychange` misses — only one window +// holds focus at a time. +// +// Scope: ONLY the two global sidebar streams (session-events + gateway). The +// per-session live stream (messages.js `startSessionStream`) deliberately stays +// visibility-only — it carries live `bg_task_complete` toasts and +// `server_turn_started` live-view that an unfocused-but-VISIBLE window must still +// receive (the OS-notification path is gated on `document.hidden`, so the in-app +// toast is the only completion signal a visible-unfocused window gets). Closing +// it on blur would regress the multi-window live-view UX. +function _sidebarSseBackgrounded(){ + if(typeof document === 'undefined') return false; + if(document.hidden) return true; + if(typeof document.hasFocus === 'function' && !document.hasFocus()) return true; + return false; +} + +let _sidebarSseBlurCloseTimer = 0; +// Debounce the blur-close so a transient blur (native dialog, quick alt-tab and +// back) doesn't thrash the streams; a sustained blur frees the pool slots. +const _SIDEBAR_SSE_BLUR_CLOSE_MS = 1000; + +function _installSidebarSseFocusHook(){ + if(typeof window === 'undefined' || typeof document === 'undefined') return; + if(document._hermesSidebarSseFocusHook) return; + document._hermesSidebarSseFocusHook = true; + window.addEventListener('blur', () => { + if(_sidebarSseBlurCloseTimer) return; + _sidebarSseBlurCloseTimer = setTimeout(() => { + _sidebarSseBlurCloseTimer = 0; + // Re-check at fire time — focus may have returned during the debounce. + if(_sidebarSseBackgrounded()){ + _closeSessionEventsSSE(); + stopGatewaySSE(); + } + }, _SIDEBAR_SSE_BLUR_CLOSE_MS); + }); + window.addEventListener('focus', () => { + if(_sidebarSseBlurCloseTimer){ clearTimeout(_sidebarSseBlurCloseTimer); _sidebarSseBlurCloseTimer = 0; } + // Reopen (both ensure-idempotent) and catch up on anything missed while blurred. + ensureSessionEventsSSE(); + startGatewaySSE(); + void refreshSessionList('focus'); + }); +} + function _closeSessionEventsSSE(){ if(_sessionEventsSSE){ _sessionEventsSSE.close(); @@ -3664,8 +3720,9 @@ function ensureSessionEventsSSE(){ }); document._hermesSessionEventsVisibilityHook = true; } + _installSidebarSseFocusHook(); if(typeof EventSource==='undefined') return; - if(typeof document !== 'undefined' && document.hidden) return; + if(_sidebarSseBackgrounded()) return; if(_sessionEventsSSE) return; try{ // Same-origin relative URL preserves subpath mounts and normal WebUI cookies. @@ -3788,8 +3845,10 @@ function startGatewaySSE(){ }); document._hermesGatewaySSEVisibilityHook = true; } - // Don't open when tab is hidden — saves connection pool slots - if(typeof document !== 'undefined' && document.hidden) return; + _installSidebarSseFocusHook(); + // Don't open when tab is hidden OR the window has lost focus (PWA blur) — + // saves connection pool slots (#4151). + if(_sidebarSseBackgrounded()) return; try{ _gatewaySSE = new EventSource('api/sessions/gateway/stream'); _gatewaySSE.addEventListener('sessions_changed', (ev) => { diff --git a/tests/test_issue4151_pwa_focus_sse.py b/tests/test_issue4151_pwa_focus_sse.py new file mode 100644 index 00000000..eb385cbb --- /dev/null +++ b/tests/test_issue4151_pwa_focus_sse.py @@ -0,0 +1,135 @@ +"""Structural tests for #4151 — PWA two-window connection-pool saturation. + +#3992/#3996 close the idle SSE streams on the Page Visibility API +(`visibilitychange` / `document.hidden`). But a PWA *standalone* window does NOT +reliably fire `visibilitychange` when it loses focus to another window of the +same app — `document.hidden` only flips on minimize. So two side-by-side PWA +windows both stay `visibilityState==='visible'`, each holds its sidebar SSE +streams open, and 2x3 = 6 = the per-origin HTTP/1.1 connection limit; every +later fetch() queues behind the saturated pool and times out (#4151). + +The fix makes the two GLOBAL sidebar streams (session-events + gateway) also +close on window `blur` (gated on `document.hasFocus()`, the signal +`visibilitychange` misses) and reopen on `focus`, via a shared +`_sidebarSseBackgrounded()` predicate and a debounced `_installSidebarSseFocusHook()`. + +CRITICAL SCOPE GUARD (the regression these tests lock): the PER-SESSION live +stream (`startSessionStream` in messages.js) must stay visibility-only and must +NOT be torn down on blur — it carries live `bg_task_complete` toasts + +`server_turn_started` live-view that an unfocused-but-VISIBLE window must still +receive. So the focus hook lives in sessions.js and only touches +`_closeSessionEventsSSE()` + `stopGatewaySSE()`. + +Source-grep checks (the hooks live in static JS with no server round trip). +""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") +SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def test_backgrounded_predicate_uses_hasfocus_not_only_hidden(): + """_sidebarSseBackgrounded() must consult document.hasFocus(), not only document.hidden. + + document.hidden alone is exactly what misses the PWA blur case; the predicate + has to treat a visible-but-unfocused window as backgrounded. + """ + assert "function _sidebarSseBackgrounded()" in SESSIONS_JS + start = SESSIONS_JS.find("function _sidebarSseBackgrounded()") + block = SESSIONS_JS[start:start + 320] + assert "document.hidden" in block + assert "document.hasFocus" in block + assert "!document.hasFocus()" in block + + +def test_focus_hook_closes_both_global_sidebar_streams(): + """The blur path closes the session-events AND gateway streams.""" + assert "function _installSidebarSseFocusHook()" in SESSIONS_JS + start = SESSIONS_JS.find("function _installSidebarSseFocusHook()") + block = SESSIONS_JS[start:start + 1100] + # Installed once. + assert "_hermesSidebarSseFocusHook" in block + # Blur listener tears down both global streams. + assert "window.addEventListener('blur'" in block + assert "_closeSessionEventsSSE()" in block + assert "stopGatewaySSE()" in block + # Focus listener reopens both and refreshes the list. + assert "window.addEventListener('focus'" in block + assert "ensureSessionEventsSSE()" in block + assert "startGatewaySSE()" in block + + +def test_blur_close_is_debounced_and_rechecks_focus_at_fire_time(): + """A transient blur must not thrash the streams. + + The blur close is scheduled on a timer and re-checks _sidebarSseBackgrounded() + when it fires, so focus returning during the debounce cancels the teardown. + """ + start = SESSIONS_JS.find("function _installSidebarSseFocusHook()") + block = SESSIONS_JS[start:start + 1100] + assert "_sidebarSseBlurCloseTimer" in block + assert "setTimeout(" in block + # Re-check guards the actual close so a returned focus is a no-op. + assert "if(_sidebarSseBackgrounded()){" in block + # Focus listener clears any pending blur-close timer. + assert "clearTimeout(_sidebarSseBlurCloseTimer)" in block + + +def test_session_events_open_guard_uses_backgrounded_predicate(): + """ensureSessionEventsSSE installs the focus hook and gates open on the predicate.""" + start = SESSIONS_JS.find("function ensureSessionEventsSSE()") + assert start != -1 + block = SESSIONS_JS[start:start + 700] + assert "_installSidebarSseFocusHook()" in block + # Open guard is the focus-aware predicate, not the old hidden-only check. + assert "if(_sidebarSseBackgrounded()) return;" in block + + +def test_gateway_open_guard_uses_backgrounded_predicate(): + """startGatewaySSE installs the focus hook and gates open on the predicate.""" + start = SESSIONS_JS.find("function startGatewaySSE()") + assert start != -1 + block = SESSIONS_JS[start:start + 700] + assert "_installSidebarSseFocusHook()" in block + assert "if(_sidebarSseBackgrounded()) return;" in block + + +def test_per_session_stream_NOT_closed_on_blur(): + """REGRESSION GUARD: the per-session live stream must stay visibility-only. + + startSessionStream carries live bg_task_complete toasts + server_turn_started + live-view that an unfocused-but-visible window must still get. The focus hook + must NOT tear it down, so: + (a) messages.js must not add a window 'blur' listener that calls stopSessionStream, and + (b) the focus hook in sessions.js must not reference stopSessionStream/startSessionStream. + """ + # (a) the per-session stream file must not tear down the stream on blur. + # (A pre-existing composer speech-synthesis blur handler on _msgEl is + # fine — the guard is specifically that no blur path calls + # stopSessionStream.) Scan each blur-listener body in messages.js. + import re + for m in re.finditer(r"addEventListener\(\s*['\"]blur['\"]", MESSAGES_JS): + tail = MESSAGES_JS[m.start():m.start() + 200] + assert "stopSessionStream" not in tail, ( + "a blur listener in messages.js tears down the per-session stream — " + "that regresses live bg_task_complete / server_turn_started for an " + "unfocused-but-visible window (#4151 scope guard)" + ) + # (b) the sidebar focus hook only manages the two global streams. + start = SESSIONS_JS.find("function _installSidebarSseFocusHook()") + block = SESSIONS_JS[start:start + 1100] + assert "stopSessionStream" not in block + assert "startSessionStream" not in block + + +def test_per_session_stream_still_visibility_gated(): + """Sanity: the per-session stream keeps its existing visibility hook untouched.""" + assert "_hermesSessionStreamVisibilityHook" in MESSAGES_JS + start = MESSAGES_JS.find("function startSessionStream(sid)") + block = MESSAGES_JS[start:start + 1700] + assert "visibilitychange" in block + assert "document.hidden" in block diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index b8ce081d..cc59d30c 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -47,7 +47,10 @@ def test_session_list_external_refresh_uses_sse_invalidation_not_polling(): assert "ensureSessionEventsSSE();" in SESSIONS_JS assert "document._hermesSessionEventsVisibilityHook" in SESSIONS_JS ensure_fn = SESSIONS_JS[SESSIONS_JS.find("function ensureSessionEventsSSE()") :] - assert ensure_fn.find("document._hermesSessionEventsVisibilityHook") < ensure_fn.find("document.hidden) return") + # The visibility hook must be installed before the open-guard early-return. + # #4151 replaced the `document.hidden) return` open guard with the focus-aware + # `_sidebarSseBackgrounded()) return` predicate (which also covers PWA blur). + assert ensure_fn.find("document._hermesSessionEventsVisibilityHook") < ensure_fn.find("_sidebarSseBackgrounded()) return") assert "_sessionListExternalRefreshMs" not in SESSIONS_JS assert "addEventListener('sessions_changed', (ev) => {" in ensure_fn assert "const activeProfile = S.activeProfile || 'default';" in ensure_fn From 266d9cdbd3f466349c85b2b783aa6e66e740d64b Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sat, 13 Jun 2026 18:07:12 -0700 Subject: [PATCH 2/3] fix(sse): guard gateway reopen on focus so transient blur can't thrash it (#4151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #4151 focus hook reopened both global sidebar streams on `focus` with a bare `startGatewaySSE()`. But `startGatewaySSE()` is NOT idempotent — it begins with an unconditional `stopGatewaySSE()` (sessions.js:3778), which closes the live gateway EventSource, cancels its poll fallback (`stopGatewayPollFallback`), and resets `_gatewayProbeInFlight` / `_gatewaySSEWarningShown`. So on a transient blur shorter than the 1s debounce — where the blur-close timer is cleared on the following focus and the stream was never torn down — the focus handler still dropped and reconnected the gateway on every window-switch, cancelling its fallback and resetting probe/warning state. That is the exact thrash the debounce exists to prevent, in the multi-window PWA scenario this PR targets. (`ensureSessionEventsSSE()` is already idempotent via `if(_sessionEventsSSE) return`, so the session-events stream was unaffected; only the gateway churned. greptile P1.) Guard the reopen on the gateway actually being closed: `if(!_gatewaySSE) startGatewaySSE()`, mirroring the session-events idempotency. A sustained blur (timer fires → both streams closed) still reopens the gateway on focus; a transient blur leaves the still-open gateway untouched. Verified with a blur/focus/timer harness (transient → no teardown/reconnect; sustained → close then reopen; CLI-disabled → stays closed) and a new regression guard `test_focus_reopen_does_not_thrash_the_gateway_stream`. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/sessions.js | 11 +++++++-- tests/test_issue4151_pwa_focus_sse.py | 34 ++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index ab416550..5dda0ba9 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3694,9 +3694,16 @@ function _installSidebarSseFocusHook(){ }); window.addEventListener('focus', () => { if(_sidebarSseBlurCloseTimer){ clearTimeout(_sidebarSseBlurCloseTimer); _sidebarSseBlurCloseTimer = 0; } - // Reopen (both ensure-idempotent) and catch up on anything missed while blurred. + // Reopen and catch up on anything missed while blurred. ensureSessionEventsSSE() + // is idempotent (`if(_sessionEventsSSE) return`), but startGatewaySSE() is NOT — it + // begins with an unconditional stopGatewaySSE(). So only reopen the gateway when it + // was actually closed; otherwise a transient blur shorter than the debounce (where + // the blur-close timer was cleared and the stream was never torn down) would + // drop+reconnect the live gateway on every window switch, cancelling its poll + // fallback and resetting probe/warning state — the exact thrash the debounce exists + // to prevent, in the multi-window scenario this fix targets (#4151). ensureSessionEventsSSE(); - startGatewaySSE(); + if(!_gatewaySSE) startGatewaySSE(); void refreshSessionList('focus'); }); } diff --git a/tests/test_issue4151_pwa_focus_sse.py b/tests/test_issue4151_pwa_focus_sse.py index eb385cbb..bcf60d45 100644 --- a/tests/test_issue4151_pwa_focus_sse.py +++ b/tests/test_issue4151_pwa_focus_sse.py @@ -50,7 +50,7 @@ def test_focus_hook_closes_both_global_sidebar_streams(): """The blur path closes the session-events AND gateway streams.""" assert "function _installSidebarSseFocusHook()" in SESSIONS_JS start = SESSIONS_JS.find("function _installSidebarSseFocusHook()") - block = SESSIONS_JS[start:start + 1100] + block = SESSIONS_JS[start:start + 1700] # Installed once. assert "_hermesSidebarSseFocusHook" in block # Blur listener tears down both global streams. @@ -70,7 +70,7 @@ def test_blur_close_is_debounced_and_rechecks_focus_at_fire_time(): when it fires, so focus returning during the debounce cancels the teardown. """ start = SESSIONS_JS.find("function _installSidebarSseFocusHook()") - block = SESSIONS_JS[start:start + 1100] + block = SESSIONS_JS[start:start + 1700] assert "_sidebarSseBlurCloseTimer" in block assert "setTimeout(" in block # Re-check guards the actual close so a returned focus is a no-op. @@ -79,6 +79,34 @@ def test_blur_close_is_debounced_and_rechecks_focus_at_fire_time(): assert "clearTimeout(_sidebarSseBlurCloseTimer)" in block +def test_focus_reopen_does_not_thrash_the_gateway_stream(): + """The focus handler must NOT unconditionally restart the gateway stream. + + startGatewaySSE() begins with an unconditional stopGatewaySSE() (it is NOT + idempotent, unlike ensureSessionEventsSSE()'s `if(_sessionEventsSSE) return`). + On a transient blur shorter than the 1s debounce, the blur-close timer is + cleared and the gateway stream is never torn down — so an unconditional + startGatewaySSE() on the following focus would drop+reconnect the live gateway, + cancel its poll fallback, and reset probe/warning state on every window switch + (the exact thrash the debounce exists to prevent, in the multi-window scenario + #4151 targets). The reopen must therefore be guarded on the gateway actually + being closed. (greptile P1.) + """ + start = SESSIONS_JS.find("function _installSidebarSseFocusHook()") + block = SESSIONS_JS[start:start + 1700] + focus_idx = block.find("window.addEventListener('focus'") + assert focus_idx != -1 + focus_body = block[focus_idx:] + # The gateway reopen is guarded on the stream being closed (mirrors the + # session-events idempotency), not called unconditionally. + assert "if(!_gatewaySSE) startGatewaySSE()" in focus_body, ( + "focus handler must guard startGatewaySSE() on `!_gatewaySSE` so a " + "transient blur+focus does not drop+reconnect a still-open gateway stream" + ) + # And it must NOT call startGatewaySSE() bare (unguarded) on focus. + assert "\n startGatewaySSE();" not in focus_body + + def test_session_events_open_guard_uses_backgrounded_predicate(): """ensureSessionEventsSSE installs the focus hook and gates open on the predicate.""" start = SESSIONS_JS.find("function ensureSessionEventsSSE()") @@ -121,7 +149,7 @@ def test_per_session_stream_NOT_closed_on_blur(): ) # (b) the sidebar focus hook only manages the two global streams. start = SESSIONS_JS.find("function _installSidebarSseFocusHook()") - block = SESSIONS_JS[start:start + 1100] + block = SESSIONS_JS[start:start + 1700] assert "stopSessionStream" not in block assert "startSessionStream" not in block From b8a84f13e1c17c9e51cab6cb0173651274e5b690 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 14 Jun 2026 01:33:14 +0000 Subject: [PATCH 3/3] docs(changelog): stamp #4151 PWA multi-window SSE fix as v0.51.404 (Release NQ) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33f43d15..96ccbc0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] +## [v0.51.404] — 2026-06-14 — Release NQ (PWA multi-window connection-pool saturation fix, #4151) + ### Fixed - **Multiple PWA windows no longer saturate the connection pool and cycle "Request timed out" toasts (#4151).** The idle-SSE close added in #3992/#3996 keyed off the Page Visibility API (`visibilitychange` / `document.hidden`), but a PWA *standalone* window does not reliably fire `visibilitychange` when it loses focus to another window of the same app — `document.hidden` only flips on minimize. So two side-by-side PWA windows both stayed `visible`, each held its two global sidebar SSE streams open (session-events + gateway), and 2×3 = 6 connections hit the browser's per-origin HTTP/1.1 limit; subsequent `fetch()` calls (the 30s background polls) queued behind the saturated pool and timed out. The two global sidebar streams now also close on a sustained window `blur` (gated on `document.hasFocus()`, the signal `visibilitychange` misses) and reopen — catching up the session list — on `focus`. The per-session live stream is intentionally left visibility-only so an unfocused-but-visible window still receives live `bg_task_complete` / `server_turn_started` events. (#4151)