diff --git a/static/sessions.js b/static/sessions.js index 1e0bf696..5884c541 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -338,6 +338,14 @@ async function loadSession(sid){ if(_msgInner){ if(e.status===404){ _msgInner.innerHTML='
Session not available in web UI.
'; + // If this 404 was for the saved active-session ID (not a click-into request), + // wipe the stale localStorage value and rethrow so boot can fall through to + // the empty-state instead of sticking to a broken "Session not available" view. + if(!currentSid&&localStorage.getItem('hermes-webui-session')===sid){ + localStorage.removeItem('hermes-webui-session'); + if (_loadingSessionId === sid) _loadingSessionId = null; + throw e; + } } else { _msgInner.innerHTML='
Failed to load session. Try switching sessions or refreshing.
'; if(typeof showToast==='function') showToast('Failed to load session',3000,'error'); diff --git a/static/workspace.js b/static/workspace.js index 87c71220..611774fa 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -16,8 +16,15 @@ async function api(path,opts={}){ const text=await res.text(); // Parse JSON error body and surface the human-readable message, // rather than showing raw JSON like {"error":"Profile 'x' does not exist."} - try{const j=JSON.parse(text);throw new Error(j.error||j.message||text);} - catch(e){if(e instanceof SyntaxError)throw new Error(text);throw e;} + let message=text; + try{const j=JSON.parse(text);message=j.error||j.message||text;}catch(e){} + // Attach the raw HTTP context so callers can branch on status (404 stale-session + // cleanup, 401 redirect, 503 retry, etc.) without re-parsing the message string. + const err=new Error(message); + err.status=res.status; + err.statusText=res.statusText; + err.body=text; + throw err; } const ct=res.headers.get('content-type')||''; return ct.includes('application/json')?res.json():res.text(); diff --git a/tests/test_1038_pwa_auth_redirect.py b/tests/test_1038_pwa_auth_redirect.py index 7dbcf79f..3830967b 100644 --- a/tests/test_1038_pwa_auth_redirect.py +++ b/tests/test_1038_pwa_auth_redirect.py @@ -36,12 +36,18 @@ class TestPWAAuthRedirect: "workspace.js api() must redirect to /login on 401" def test_workspace_js_401_before_throw(self): - """The 401 redirect must come before the generic error throw.""" + """The 401 redirect must come before any error throw.""" src = _workspace_js() idx_401 = src.find("res.status===401") + # api() may throw via `throw new Error(...)` or via the structured + # `const err=new Error(...); ... throw err;` pattern that attaches HTTP + # context for callers. Either is fine — what matters is the 401 redirect + # short-circuits before the generic throw. idx_throw = src.find("throw new Error") + if idx_throw == -1: + idx_throw = src.find("throw err") assert idx_401 != -1, "401 guard not found in workspace.js" - assert idx_throw != -1, "throw not found in workspace.js" + assert idx_throw != -1, "no error throw found in workspace.js" assert idx_401 < idx_throw, \ "401 redirect must appear before the generic throw in workspace.js" diff --git a/tests/test_stale_empty_session_restore.py b/tests/test_stale_empty_session_restore.py new file mode 100644 index 00000000..11edf522 --- /dev/null +++ b/tests/test_stale_empty_session_restore.py @@ -0,0 +1,83 @@ +"""Regression tests for stale empty sessions after a WebUI restart. + +When a saved session ID returns 404 (e.g. the session was deleted from another +browser, or a state DB rotation removed it), the prior behavior was to show +\"Session not available in web UI.\" and stick there forever — the saved +localStorage entry never got cleared, so every reload reproduced the broken +state. + +These tests lock in: + 1. ``api()`` attaches HTTP context (``.status``, ``.statusText``, ``.body``) + to thrown errors so callers can branch on status without re-parsing text. + 2. ``loadSession()`` clears the stale ``hermes-webui-session`` key on a 404 + for the currently-saved session and rethrows, so boot can fall through + to the empty state. +""" + +from pathlib import Path +import re + + +REPO = Path(__file__).parent.parent +WORKSPACE_JS = (REPO / "static" / "workspace.js").read_text(encoding="utf-8") +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _api_body() -> str: + m = re.search(r"async function api\(path,opts=.*?\n\}", WORKSPACE_JS, re.DOTALL) + assert m, "api() function must exist in workspace.js" + return m.group(0) + + +def _load_session_error_block() -> str: + start = SESSIONS_JS.find("data = await api(`/api/session?") + assert start > 0, "loadSession metadata request not found" + catch_idx = SESSIONS_JS.find("} catch(e) {", start) + assert catch_idx > start, "loadSession metadata catch block not found" + end = SESSIONS_JS.find("return;", catch_idx) + assert end > catch_idx, "loadSession metadata catch return not found" + return SESSIONS_JS[catch_idx:end] + + +def test_api_http_errors_preserve_response_status(): + """Callers must be able to distinguish stale-session 404s from generic failures.""" + body = _api_body() + assert re.search(r"\w+\.status\s*=\s*res\.status", body), ( + "api() must attach res.status to thrown HTTP errors" + ) + assert re.search(r"\w+\.statusText\s*=\s*res\.statusText", body), ( + "api() must attach res.statusText to thrown HTTP errors" + ) + assert re.search(r"\w+\.body\s*=\s*text", body), ( + "api() must attach the raw error body to thrown HTTP errors" + ) + + +def test_load_session_clears_saved_stale_404_and_rethrows_to_boot(): + """A missing saved session should be removed and let boot show the empty state.""" + block = _load_session_error_block() + assert "e.status===404" in block, "loadSession must keep a 404-specific branch" + assert "localStorage.getItem('hermes-webui-session')===sid" in block, ( + "loadSession must only clear the saved active session key" + ) + assert "localStorage.removeItem('hermes-webui-session')" in block, ( + "loadSession must clear stale saved session IDs on 404" + ) + assert "_loadingSessionId = null" in block, ( + "loadSession must clear the in-flight load marker before rethrowing" + ) + assert re.search(r"throw\s+e", block), ( + "loadSession must rethrow the stale saved-session 404 so boot can fall " + "through to the no-session empty state" + ) + + +def test_click_into_404_does_not_clear_saved_session(): + """A 404 from a click into a different session must NOT clear the saved active key.""" + block = _load_session_error_block() + # The clearing branch is gated on `!currentSid` — i.e. only when the + # request was for the *saved* active session, not a user click on another. + assert "!currentSid" in block, ( + "404 cleanup must be gated on !currentSid so user-initiated clicks " + "into a missing session don't wipe the saved active-session key" + )