103 lines
4.5 KiB
Python
103 lines
4.5 KiB
Python
|
|
"""Offline recovery must reattach softly, not hard-reload the whole page.
|
||
|
|
|
||
|
|
Symptom (Android PWA): backgrounding the app for even a second often showed a
|
||
|
|
"Connection lost" banner, and on returning the whole page did a multi-second
|
||
|
|
cold reload. Confirmed from the live server journal: the client's offline
|
||
|
|
recovery probe (`GET /health?offline_probe=...`) fired repeatedly from the
|
||
|
|
phone, and each successful probe ran `window.location.reload()`.
|
||
|
|
|
||
|
|
Root cause: `checkOfflineRecoveryNow()` in static/ui.js called
|
||
|
|
`window.location.reload()` on the first healthy probe. The offline banner is
|
||
|
|
raised on any fetch/SSE error — which mobile backgrounding triggers constantly
|
||
|
|
— so a transient background turned into a full app cold boot (re-run boot,
|
||
|
|
re-pull /api/sessions + /api/session). Intermittent because it only fired when
|
||
|
|
a request actually errored that cycle.
|
||
|
|
|
||
|
|
Fix: recover softly via `_recoverFromOfflineSoftly()` — hide the banner,
|
||
|
|
restart the gateway SSE, and re-fetch the active session through the existing
|
||
|
|
`refreshSession()` reattach path. The server keeps the agent running and
|
||
|
|
buffers stream events while no subscriber is attached (#2307), so a hard reload
|
||
|
|
is never required. A full `window.location.reload()` remains only as the catch
|
||
|
|
fallback if the soft reattach throws.
|
||
|
|
|
||
|
|
State layer: this only changes the *client* recovery transition (banner →
|
||
|
|
reattach). It does not touch server stream buffering, session persistence, or
|
||
|
|
the compression/replay paths.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
ROOT = Path(__file__).parent.parent
|
||
|
|
|
||
|
|
|
||
|
|
def _ui_js() -> str:
|
||
|
|
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||
|
|
|
||
|
|
|
||
|
|
def _fn_body(src: str, marker: str) -> str:
|
||
|
|
idx = src.find(marker)
|
||
|
|
assert idx != -1, f"{marker!r} not found in ui.js"
|
||
|
|
brace = src.find("{", idx)
|
||
|
|
depth = 1
|
||
|
|
i = brace + 1
|
||
|
|
while i < len(src) and depth:
|
||
|
|
if src[i] == "{":
|
||
|
|
depth += 1
|
||
|
|
elif src[i] == "}":
|
||
|
|
depth -= 1
|
||
|
|
i += 1
|
||
|
|
assert depth == 0, f"{marker!r} body did not close"
|
||
|
|
return src[brace + 1 : i - 1]
|
||
|
|
|
||
|
|
|
||
|
|
class TestOfflineSoftRecovery:
|
||
|
|
def test_recovery_does_not_hard_reload_on_success(self):
|
||
|
|
"""The success branch must call the soft recover, not location.reload()."""
|
||
|
|
src = _ui_js()
|
||
|
|
body = _fn_body(src, "async function checkOfflineRecoveryNow(")
|
||
|
|
assert "_recoverFromOfflineSoftly(" in body, (
|
||
|
|
"checkOfflineRecoveryNow must recover via _recoverFromOfflineSoftly() "
|
||
|
|
"on a healthy probe instead of hard-reloading the page"
|
||
|
|
)
|
||
|
|
assert "window.location.reload()" not in body, (
|
||
|
|
"checkOfflineRecoveryNow must not call window.location.reload() on "
|
||
|
|
"recovery — that is the multi-second cold-boot regression"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_soft_recover_helper_exists(self):
|
||
|
|
"""The soft recovery helper must exist."""
|
||
|
|
assert "async function _recoverFromOfflineSoftly(" in _ui_js(), (
|
||
|
|
"_recoverFromOfflineSoftly() helper must exist"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_soft_recover_hides_banner_and_reattaches(self):
|
||
|
|
"""Soft recovery must hide the banner, restart SSE, and refresh the session."""
|
||
|
|
body = _fn_body(_ui_js(), "async function _recoverFromOfflineSoftly(")
|
||
|
|
assert "_hideOfflineBanner()" in body, (
|
||
|
|
"_recoverFromOfflineSoftly must hide the offline banner"
|
||
|
|
)
|
||
|
|
assert "startGatewaySSE" in body, (
|
||
|
|
"_recoverFromOfflineSoftly must restart the gateway SSE "
|
||
|
|
"(background/bfcache kills the connection)"
|
||
|
|
)
|
||
|
|
assert "refreshSession" in body, (
|
||
|
|
"_recoverFromOfflineSoftly must reattach the active session via "
|
||
|
|
"refreshSession() so messages that arrived while away appear"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_soft_recover_falls_back_to_hard_reload(self):
|
||
|
|
"""A failed soft reattach must fall back to a full reload (never stuck)."""
|
||
|
|
body = _fn_body(_ui_js(), "async function _recoverFromOfflineSoftly(")
|
||
|
|
assert "window.location.reload()" in body, (
|
||
|
|
"_recoverFromOfflineSoftly must keep window.location.reload() as the "
|
||
|
|
"catch fallback so a failed soft reattach never leaves the user stuck"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_refresh_session_guarded_with_typeof(self):
|
||
|
|
"""Reattach calls must be typeof-guarded for safe degradation."""
|
||
|
|
body = _fn_body(_ui_js(), "async function _recoverFromOfflineSoftly(")
|
||
|
|
assert "typeof refreshSession==='function'" in body or \
|
||
|
|
"typeof refreshSession === 'function'" in body, (
|
||
|
|
"refreshSession() call must be typeof-guarded"
|
||
|
|
)
|