From aeda75271cadde658fd28fefd838b97f852d1867 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Fri, 15 May 2026 11:25:53 -0700 Subject: [PATCH] fix: defer stream errors while mobile tabs are hidden --- CHANGELOG.md | 2 ++ static/messages.js | 51 ++++++++++++++++++++++++++++++++++++ tests/test_offline_banner.py | 21 +++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 139ec3ef..7a6abab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ ### Fixed +- Mobile/backgrounded chat streams no longer immediately insert a permanent `**Error:** Connection lost` bubble when Android/browser tab suspension drops the SSE connection. The client now defers stream-error finalization while the page is hidden/discarded, keeps the stream marked active locally, and reattaches or restores the settled session when the tab returns before showing a real failure. + - **PR #2275** by @ai-ag2026 — CLI/messaging continuation sessions (sessions stitched from a `parent_session_id` chain) now return their full transcript instead of an empty list. Pre-fix, `get_cli_session_messages()` called `_is_continuation_session()` while walking the parent chain, but `api/models.py` didn't import that helper. The exception was swallowed by `except Exception: return []`, so valid external sessions could fall through silently. Adds regression coverage that a stitched continuation chain returns a non-empty transcript. - **PR #2277** by @eleboucher — Rootless container runtimes (k8s `runAsNonRoot: true`, OpenShift restricted SCC, `docker --user`, rootless Podman) no longer hit a cascade of permission errors at startup. Pre-fix, the rootless branch skipped the root init phase entirely, but root init also did rsync, `/uv_cache` permissions, `~/hermeswebui` home directory creation, and `/workspace` writability. `docker_init.bash` now distinguishes "no root init available" from "root init available but skipped", running the work that doesn't need root in the rootless branch too. diff --git a/static/messages.js b/static/messages.js index 66a3c90d..12557e6f 100644 --- a/static/messages.js +++ b/static/messages.js @@ -578,6 +578,54 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // ── Shared SSE handler wiring (used for initial connection and reconnect) ── let _reconnectAttempted=false; let _terminalStateReached=false; + let _deferredStreamRecoveryBound=false; + + function _pageHiddenForStreamError(){ + return (typeof document!=='undefined'&&document.visibilityState==='hidden')|| + (typeof document!=='undefined'&&document.wasDiscarded===true); + } + + function _reattachOrRestoreAfterDeferredStreamError(){ + if(_terminalStateReached||_streamFinalized) return; + (async()=>{ + try{ + if(streamId){ + const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`); + if(st.active){ + setComposerStatus('Reconnected'); + _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true})); + return; + } + } + }catch(_){ + if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return; + } + if(await _restoreSettledSession()) return; + if(_deferStreamErrorIfOffline()||_pageHiddenForStreamError()) return; + _handleStreamError(); + })(); + } + + function _deferStreamErrorIfPageHidden(){ + if(!_pageHiddenForStreamError()) return false; + setComposerStatus('Connection paused. Reconnecting when this tab returns…'); + if(S.session&&S.session.session_id===activeSid&&streamId) S.activeStreamId=streamId; + if(!_deferredStreamRecoveryBound){ + _deferredStreamRecoveryBound=true; + const resume=()=>{ + if(_pageHiddenForStreamError()) return; + window.removeEventListener('focus',resume); + window.removeEventListener('pageshow',resume); + document.removeEventListener('visibilitychange',resume); + _deferredStreamRecoveryBound=false; + _reattachOrRestoreAfterDeferredStreamError(); + }; + document.addEventListener('visibilitychange',resume); + window.addEventListener('focus',resume); + window.addEventListener('pageshow',resume); + } + return true; + } // Bug A fix (#631): track whether the stream has been finalized so any rAF // scheduled by a trailing 'token'/'reasoning' event that arrives in the same @@ -1617,6 +1665,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('error',async e=>{ source.close(); if(_deferStreamErrorIfOffline()) return; + if(_deferStreamErrorIfPageHidden()) return; if(_terminalStateReached || _streamFinalized){ _closeSource(); return; @@ -1638,12 +1687,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } if(await _restoreSettledSession()) return; if(_deferStreamErrorIfOffline()) return; + if(_deferStreamErrorIfPageHidden()) return; _handleStreamError(); },1500); return; } if(await _restoreSettledSession()) return; if(_deferStreamErrorIfOffline()) return; + if(_deferStreamErrorIfPageHidden()) return; _handleStreamError(); }); diff --git a/tests/test_offline_banner.py b/tests/test_offline_banner.py index 4942d8fe..45177975 100644 --- a/tests/test_offline_banner.py +++ b/tests/test_offline_banner.py @@ -70,3 +70,24 @@ def test_sse_network_error_defers_to_offline_banner_instead_of_inline_error(): assert "if(_deferStreamErrorIfOffline()) return;" in MESSAGES_JS error_handler = MESSAGES_JS.split("source.addEventListener('error',async e=>{", 1)[1].split("source.addEventListener('cancel'", 1)[0] assert error_handler.find("_deferStreamErrorIfOffline()") < error_handler.rfind("_handleStreamError()") + + +def test_sse_error_defers_while_page_hidden_until_tab_returns(): + assert "function _deferStreamErrorIfPageHidden()" in MESSAGES_JS + assert "document.visibilityState==='hidden'" in MESSAGES_JS + assert "document.wasDiscarded===true" in MESSAGES_JS + assert "Connection paused. Reconnecting when this tab returns…" in MESSAGES_JS + assert "document.addEventListener('visibilitychange',resume)" in MESSAGES_JS + assert "window.addEventListener('pageshow',resume)" in MESSAGES_JS + error_handler = MESSAGES_JS.split("source.addEventListener('error',async e=>{", 1)[1].split("source.addEventListener('cancel'", 1)[0] + assert "if(_deferStreamErrorIfPageHidden()) return;" in error_handler + assert error_handler.find("_deferStreamErrorIfPageHidden()") < error_handler.rfind("_handleStreamError()") + + +def test_deferred_hidden_stream_error_reattaches_or_restores_before_inline_error(): + recovery_block = MESSAGES_JS.split("function _reattachOrRestoreAfterDeferredStreamError(){", 1)[1].split("function _deferStreamErrorIfPageHidden()", 1)[0] + assert "api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`)" in recovery_block + assert "if(st.active)" in recovery_block + assert "_wireSSE(new EventSource" in recovery_block + assert "if(await _restoreSettledSession()) return;" in recovery_block + assert recovery_block.find("if(await _restoreSettledSession()) return;") < recovery_block.rfind("_handleStreamError()")