Merge pull request #4157 from nesquena/stage-4156
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
Release NQ (v0.51.404): PWA multi-window connection-pool saturation fix (#4151)
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
|
||||
## [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)
|
||||
|
||||
## [v0.51.403] — 2026-06-13 — Release NP (notification click reuses the existing chat tab, #4109)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -3645,6 +3645,69 @@ 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 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();
|
||||
if(!_gatewaySSE) startGatewaySSE();
|
||||
void refreshSessionList('focus');
|
||||
});
|
||||
}
|
||||
|
||||
function _closeSessionEventsSSE(){
|
||||
if(_sessionEventsSSE){
|
||||
_sessionEventsSSE.close();
|
||||
@@ -3664,8 +3727,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 +3852,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) => {
|
||||
|
||||
163
tests/test_issue4151_pwa_focus_sse.py
Normal file
163
tests/test_issue4151_pwa_focus_sse.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""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 + 1700]
|
||||
# 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 + 1700]
|
||||
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_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()")
|
||||
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 + 1700]
|
||||
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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user