mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
fix: defer stream errors while mobile tabs are hidden
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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()")
|
||||
|
||||
Reference in New Issue
Block a user