HERMES-GIELIE/tests/test_issue4151_pwa_focus_sse.py

165 lines
8 KiB
Python
Raw Normal View History

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 <ddiall@users.noreply.github.com>
2026-06-14 00:46:33 +00:00
"""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()")
fix(sse): guard gateway reopen on focus so transient blur can't thrash it (#4151) 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) <noreply@anthropic.com>
2026-06-14 01:07:12 +00:00
block = SESSIONS_JS[start:start + 1700]
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 <ddiall@users.noreply.github.com>
2026-06-14 00:46:33 +00:00
# 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
assert "_refreshSessionListAfterSidebarResume('focus')" in block
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 <ddiall@users.noreply.github.com>
2026-06-14 00:46:33 +00:00
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()")
fix(sse): guard gateway reopen on focus so transient blur can't thrash it (#4151) 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) <noreply@anthropic.com>
2026-06-14 01:07:12 +00:00
block = SESSIONS_JS[start:start + 1700]
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 <ddiall@users.noreply.github.com>
2026-06-14 00:46:33 +00:00
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
fix(sse): guard gateway reopen on focus so transient blur can't thrash it (#4151) 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) <noreply@anthropic.com>
2026-06-14 01:07:12 +00:00
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
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 <ddiall@users.noreply.github.com>
2026-06-14 00:46:33 +00:00
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()")
fix(sse): guard gateway reopen on focus so transient blur can't thrash it (#4151) 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) <noreply@anthropic.com>
2026-06-14 01:07:12 +00:00
block = SESSIONS_JS[start:start + 1700]
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 <ddiall@users.noreply.github.com>
2026-06-14 00:46:33 +00:00
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