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