Merge PR #4179: verify browser offline reports with /health probe

This commit is contained in:
nesquena-hermes 2026-06-14 16:56:41 +00:00
commit d8a42760d9
4 changed files with 96 additions and 12 deletions

View file

@ -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.** `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

View file

@ -85,16 +85,27 @@ async function _probeOfflineRecovery(){
try{return await _offlineHealthProbePromise;}
finally{_offlineHealthProbePromise=null;}
}
async function _showOfflineBannerIfProbeFails(reason){
const visibleAtStart=_offlineVisible;
if(visibleAtStart)_setOfflineChecking(true);
const ok=await _probeOfflineRecovery();
if(visibleAtStart)_setOfflineChecking(false);
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');
if(ok){if(!_offlineVisible)return true;_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;}
showOfflineBanner(_browserReportsOnline()?'network':'browser');
return false;
})();
try{return await _offlineProbePromise;}
@ -153,17 +164,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();

View file

@ -0,0 +1,69 @@
"""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 "if(ok){if(!_offlineVisible)return true;_stopOfflineProbeTimer();await _recoverFromOfflineSoftly();return true;}" in body
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 visibleAtStart=_offlineVisible;" in body
assert "if(visibleAtStart)_setOfflineChecking(true);" in body
assert "const ok=await _probeOfflineRecovery();" in body
assert "if(visibleAtStart)_setOfflineChecking(false);" 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;")

View file

@ -36,7 +36,7 @@ def test_offline_banner_markup_styles_and_copy_exist():
def test_offline_monitor_patches_fetch_and_auto_reloads_after_health_probe():
assert "const OFFLINE_RECHECK_MS=2500" in UI_JS
assert "window.fetch=async function(...args)" in UI_JS
assert "window.addEventListener('offline',()=>showOfflineBanner('browser'))" in UI_JS
assert "window.addEventListener('offline',()=>{void _showOfflineBannerIfProbeFails('browser');});" in UI_JS
assert "window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();})" in UI_JS
assert "setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS)" in UI_JS
assert "new URL('health',document.baseURI||location.href)" in UI_JS
@ -58,10 +58,9 @@ def test_offline_recovery_probe_is_serialized_and_stops_timer_before_reload():
def test_fetch_typeerror_is_gated_by_health_probe_not_blind_banner():
fetch_patch = UI_JS.split("window.fetch=async function(...args){", 1)[1].split("function initOfflineMonitor", 1)[0]
assert "function _isAbortError(e)" in UI_JS
assert "e instanceof TypeError&&!_isAbortError(e)" in fetch_patch
assert "void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');})" in fetch_patch
assert "if(!_browserReportsOnline())showOfflineBanner('browser');" in fetch_patch
assert "e instanceof TypeError||!_browserReportsOnline()" not in fetch_patch
assert "!_isAbortError(e)&&(e instanceof TypeError||!_browserReportsOnline())" in fetch_patch
assert "void _showOfflineBannerIfProbeFails(_browserReportsOnline()?'network':'browser');" in fetch_patch
assert "if(!_browserReportsOnline())showOfflineBanner('browser');" not in fetch_patch
def test_sse_network_error_defers_to_offline_banner_instead_of_inline_error():