From 85e26705da39daa276c5fc27e4a901bd3b7c0a45 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 14 Jun 2026 09:55:43 -0400 Subject: [PATCH] fix(recovery): verify offline browser state before banner (#4170) --- CHANGELOG.md | 4 ++ static/ui.js | 21 ++++++++--- tests/test_4170_offline_probe.py | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 tests/test_4170_offline_probe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fed95a5a..f7e486f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- **Offline recovery now verifies unreliable browser offline reports with the existing `/health` probe before showing or sticking on the browser-reason banner (#4170).** `navigator.onLine` is treated as a hint for the banner reason, while server reachability remains the source of truth, so browsers that incorrectly report offline can recover without a page reload or permanent connection-lost state. (#4170) + ## [v0.51.410] — 2026-06-14 — Release NW (chat Mermaid lightbox + workspace CSV table preview, #4075/#4025) ### Added diff --git a/static/ui.js b/static/ui.js index 8b801696..588ee007 100644 --- a/static/ui.js +++ b/static/ui.js @@ -85,16 +85,24 @@ async function _probeOfflineRecovery(){ try{return await _offlineHealthProbePromise;} finally{_offlineHealthProbePromise=null;} } +async function _showOfflineBannerIfProbeFails(reason){ + const ok=await _probeOfflineRecovery(); + if(ok){ + if(_offlineVisible){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();} + return true; + } + showOfflineBanner(reason||(_browserReportsOnline()?'network':'browser')); + return false; +} async function checkOfflineRecoveryNow(){ if(_offlineProbePromise)return _offlineProbePromise; _offlineProbePromise=(async()=>{ if(!_offlineVisible)return false; - if(!_browserReportsOnline()){showOfflineBanner('browser');return false;} _setOfflineChecking(true); const ok=await _probeOfflineRecovery(); _setOfflineChecking(false); if(ok){_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;} - showOfflineBanner('network'); + showOfflineBanner(_browserReportsOnline()?'network':'browser'); return false; })(); try{return await _offlineProbePromise;} @@ -153,17 +161,18 @@ function _patchOfflineFetch(){ window.fetch=async function(...args){ try{return await _offlineRawFetch(...args);} catch(e){ - if(!_browserReportsOnline())showOfflineBanner('browser'); - else if(e instanceof TypeError&&!_isAbortError(e))void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');}); + if(!_isAbortError(e)&&(e instanceof TypeError||!_browserReportsOnline())){ + void _showOfflineBannerIfProbeFails(_browserReportsOnline()?'network':'browser'); + } throw e; } }; } function initOfflineMonitor(){ _patchOfflineFetch(); - window.addEventListener('offline',()=>showOfflineBanner('browser')); + window.addEventListener('offline',()=>{void _showOfflineBannerIfProbeFails('browser');}); window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();}); - if(!_browserReportsOnline())showOfflineBanner('browser'); + if(!_browserReportsOnline())void _showOfflineBannerIfProbeFails('browser'); } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true}); else initOfflineMonitor(); diff --git a/tests/test_4170_offline_probe.py b/tests/test_4170_offline_probe.py new file mode 100644 index 00000000..15801f3c --- /dev/null +++ b/tests/test_4170_offline_probe.py @@ -0,0 +1,65 @@ +"""Regression coverage for unreliable navigator.onLine offline reports.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _strip_comments(src: str) -> str: + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) + return re.sub(r"//.*", "", src) + + +def _fn_body(src: str, marker: str) -> str: + idx = src.find(marker) + assert idx != -1, f"{marker!r} not found" + brace = src.find("{", idx) + assert brace != -1, f"{marker!r} has no body" + 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] + + +def test_recovery_loop_probes_even_when_browser_reports_offline(): + body = _strip_comments(_fn_body(UI_JS, "async function checkOfflineRecoveryNow(")) + assert "_probeOfflineRecovery()" in body + assert "if(!_browserReportsOnline()){showOfflineBanner('browser');return false;}" not in body + assert body.find("_probeOfflineRecovery()") < body.find("showOfflineBanner(") + assert "showOfflineBanner(_browserReportsOnline()?'network':'browser')" in body + + +def test_probe_backed_browser_banner_helper_uses_health_as_source_of_truth(): + body = _strip_comments(_fn_body(UI_JS, "async function _showOfflineBannerIfProbeFails(")) + assert "const ok=await _probeOfflineRecovery();" in body + assert "if(ok)" in body + assert "showOfflineBanner(reason||(_browserReportsOnline()?'network':'browser'))" in body + assert body.find("_probeOfflineRecovery()") < body.find("showOfflineBanner(") + + +def test_offline_event_and_startup_false_online_probe_before_browser_banner(): + body = _strip_comments(_fn_body(UI_JS, "function initOfflineMonitor(")) + assert "window.addEventListener('offline',()=>showOfflineBanner('browser'))" not in body + assert "if(!_browserReportsOnline())showOfflineBanner('browser');" not in body + assert "window.addEventListener('offline',()=>{void _showOfflineBannerIfProbeFails('browser');});" in body + assert "if(!_browserReportsOnline())void _showOfflineBannerIfProbeFails('browser');" in body + + +def test_fetch_catch_probes_before_browser_banner_and_rethrows(): + body = _strip_comments(_fn_body(UI_JS, "function _patchOfflineFetch(")) + fetch_body = body.split("window.fetch=async function(...args){", 1)[1] + assert "if(!_browserReportsOnline())showOfflineBanner('browser');" not in fetch_body + assert "void _showOfflineBannerIfProbeFails(_browserReportsOnline()?'network':'browser');" in fetch_body + assert "throw e;" in fetch_body + assert fetch_body.find("_showOfflineBannerIfProbeFails(") < fetch_body.find("throw e;")