From b2477974c56663c0b0bbc5c3a13b6cfa6334a527 Mon Sep 17 00:00:00 2001 From: george-andraws <96165956+george-andraws@users.noreply.github.com> Date: Fri, 22 May 2026 11:20:44 -0700 Subject: [PATCH 1/2] fix: make in-flight recovery storage quota-safe --- CHANGELOG.md | 3 + api/config.py | 10 +++ static/boot.js | 7 ++ static/ui.js | 102 +++++++++++++++++++++++++-- tests/test_inflight_storage_quota.py | 78 ++++++++++++++++++++ 5 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/test_inflight_storage_quota.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aa3a8185..e41aa179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] +### Fixed +- Bound browser in-flight recovery snapshots with configurable limits and make active-stream marker writes recover from localStorage quota errors, so submitting a chat does not fail when older recovery data has filled the browser storage budget. + ## [v0.51.116] — 2026-05-22 — Release CN (stage-pr2676 — 1-PR — per-skill enable/disable toggle in Skills panel, CLI-parity with `hermes skills config`) ### Added diff --git a/api/config.py b/api/config.py index 784cec70..54c2bce5 100644 --- a/api/config.py +++ b/api/config.py @@ -4355,6 +4355,11 @@ _SETTINGS_DEFAULTS = { "session_jump_buttons": False, # show Start/End transcript jump pills "session_endless_scroll": False, # auto-load older transcript pages while scrolling upward "pinned_sessions_limit": 3, # maximum active pinned sessions shown in the sidebar + "inflight_state_max_sessions": 8, # max active-stream recovery snapshots kept in browser localStorage + "inflight_state_max_messages": 24, # max recent messages kept per recovery snapshot + "inflight_state_max_tool_calls": 48, # max recent tool-call records kept per recovery snapshot + "inflight_state_max_string_chars": 60000, # max string length kept inside a recovery snapshot field + "inflight_state_max_json_chars": 1500000, # max serialized recovery snapshot payload before pruning "hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible "language": "en", # UI locale code; must match a key in static/i18n.js LOCALES "bot_name": os.getenv( @@ -4485,6 +4490,11 @@ _SETTINGS_ENUM_VALUES = { } _SETTINGS_INT_RANGES = { "pinned_sessions_limit": (1, 99), + "inflight_state_max_sessions": (1, 25), + "inflight_state_max_messages": (1, 100), + "inflight_state_max_tool_calls": (1, 200), + "inflight_state_max_string_chars": (1000, 500000), + "inflight_state_max_json_chars": (100000, 4000000), } _SETTINGS_BOOL_KEYS = { "onboarding_completed", diff --git a/static/boot.js b/static/boot.js index f2704d0a..3cc6445d 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1459,6 +1459,13 @@ function applyBotName(){ window._simplifiedToolCalling=s.simplified_tool_calling!==false; window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact'); window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3; + window._inflightStateLimits={ + maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8, + messages:parseInt(s.inflight_state_max_messages||24,10)||24, + toolCalls:parseInt(s.inflight_state_max_tool_calls||48,10)||48, + stringChars:parseInt(s.inflight_state_max_string_chars||60000,10)||60000, + jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000, + }; window._busyInputMode=(s.busy_input_mode||'queue'); window._sessionEndlessScrollEnabled=!!s.session_endless_scroll; window._botName=s.bot_name||'Hermes'; diff --git a/static/ui.js b/static/ui.js index cc0c7435..8e123c3f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4068,6 +4068,29 @@ function autoReadLastAssistant(){ // ── Reconnect banner (B4/B5: reload resilience) ── const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'; // localStorage snapshots for mid-stream reload recovery +const INFLIGHT_STATE_DEFAULT_LIMITS = { + maxSessions:8, + messages:24, + toolCalls:48, + stringChars:60000, + jsonChars:1500000, +}; + +function _boundedInflightInt(value, fallback, min, max){ + const n=parseInt(value,10); + if(!Number.isFinite(n)) return fallback; + return Math.max(min, Math.min(max, n)); +} +function _inflightStateLimits(){ + const configured=(typeof window!=='undefined'&&window._inflightStateLimits&&typeof window._inflightStateLimits==='object')?window._inflightStateLimits:{}; + return { + maxSessions:_boundedInflightInt(configured.maxSessions, INFLIGHT_STATE_DEFAULT_LIMITS.maxSessions, 1, 25), + messages:_boundedInflightInt(configured.messages, INFLIGHT_STATE_DEFAULT_LIMITS.messages, 1, 100), + toolCalls:_boundedInflightInt(configured.toolCalls, INFLIGHT_STATE_DEFAULT_LIMITS.toolCalls, 1, 200), + stringChars:_boundedInflightInt(configured.stringChars, INFLIGHT_STATE_DEFAULT_LIMITS.stringChars, 1000, 500000), + jsonChars:_boundedInflightInt(configured.jsonChars, INFLIGHT_STATE_DEFAULT_LIMITS.jsonChars, 100000, 4000000), + }; +} function _readInflightStateMap(){ try{ @@ -4078,13 +4101,75 @@ function _readInflightStateMap(){ return {}; } } +function _isStorageQuotaError(err){ + return !!err && ( + err.name==='QuotaExceededError' || + err.name==='NS_ERROR_DOM_QUOTA_REACHED' || + err.code===22 || + err.code===1014 + ); +} +function _truncateInflightValue(value, maxChars){ + const limits=_inflightStateLimits(); + const stringLimit=_boundedInflightInt(maxChars, limits.stringChars, 1000, 500000); + if(typeof value==='string'){ + if(value.length<=stringLimit) return value; + return value.slice(0,stringLimit)+'\n\n[truncated for browser recovery storage]'; + } + if(Array.isArray(value)) return value.map(v=>_truncateInflightValue(v, Math.max(2000, Math.floor(stringLimit/2)))); + if(value&&typeof value==='object'){ + const out={}; + for(const [k,v] of Object.entries(value)) out[k]=_truncateInflightValue(v, stringLimit); + return out; + } + return value; +} +function _compactInflightState(state){ + const limits=_inflightStateLimits(); + const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[]; + const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[]; + return _truncateInflightValue({ + streamId:state.streamId||null, + messages, + uploaded:Array.isArray(state.uploaded)?state.uploaded.slice(-20):[], + toolCalls, + }, limits.stringChars); +} +function _writeInflightStateMap(all){ + const limits=_inflightStateLimits(); + const entries=Object.entries(all||{}) + .sort((a,b)=>Number(b[1]&&b[1].updated_at||0)-Number(a[1]&&a[1].updated_at||0)) + .slice(0,limits.maxSessions); + const compact={}; + for(const [sid,entry] of entries) compact[sid]=entry; + let json=JSON.stringify(compact); + if(json.length>limits.jsonChars){ + const current=entries[0]; + json=JSON.stringify(current?{[current[0]]:current[1]}:{}); + } + if(json.length>limits.jsonChars){ + localStorage.removeItem(INFLIGHT_STATE_KEY); + return false; + } + localStorage.setItem(INFLIGHT_STATE_KEY,json); + return true; +} function saveInflightState(sid, state){ if(!sid||!state) return; + const entry={..._compactInflightState(state),updated_at:Date.now()}; try{ const all=_readInflightStateMap(); - all[sid]={...state,updated_at:Date.now()}; - localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all)); - }catch(_){ } + all[sid]=entry; + _writeInflightStateMap(all); + }catch(err){ + if(!_isStorageQuotaError(err)) return; + try{ + localStorage.removeItem(INFLIGHT_STATE_KEY); + _writeInflightStateMap({[sid]:entry}); + }catch(_){ + try{localStorage.removeItem(INFLIGHT_STATE_KEY);}catch(__){} + } + } } function loadInflightState(sid, streamId){ if(!sid) return null; @@ -4171,7 +4256,16 @@ function restoreLiveTurnHtmlForSession(sid){ } function markInflight(sid, streamId) { - localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()})); + const payload=JSON.stringify({sid, streamId, ts: Date.now()}); + try{ + localStorage.setItem(INFLIGHT_KEY, payload); + }catch(err){ + if(!_isStorageQuotaError(err)) return; + try{ + localStorage.removeItem(INFLIGHT_STATE_KEY); + localStorage.setItem(INFLIGHT_KEY, payload); + }catch(_){} + } } function clearInflight() { localStorage.removeItem(INFLIGHT_KEY); diff --git a/tests/test_inflight_storage_quota.py b/tests/test_inflight_storage_quota.py new file mode 100644 index 00000000..615863c7 --- /dev/null +++ b/tests/test_inflight_storage_quota.py @@ -0,0 +1,78 @@ +"""Regression coverage for browser in-flight localStorage quota handling.""" +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8") +BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8") +UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + brace = src.index("{", start) + depth = 1 + i = brace + 1 + while depth and i < len(src): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + return src[brace + 1 : i - 1] + + +def test_inflight_state_is_compacted_before_localstorage_write(): + """Persisted recovery state must stay bounded instead of storing full long sessions.""" + save_body = _function_body(UI_JS, "saveInflightState") + compact_body = _function_body(UI_JS, "_compactInflightState") + + assert "const entry={..._compactInflightState(state),updated_at:Date.now()};" in save_body + assert "const limits=_inflightStateLimits();" in compact_body + assert ".slice(-limits.messages)" in compact_body + assert ".slice(-limits.toolCalls)" in compact_body + assert "limits.jsonChars" in UI_JS + + +def test_inflight_state_limits_are_configurable_from_settings(): + """Recovery snapshots should be bounded by settings, not hardcoded at 3 sessions / 8 messages.""" + assert '\"inflight_state_max_sessions\": 8' in CONFIG_PY + assert '\"inflight_state_max_messages\": 24' in CONFIG_PY + assert '\"inflight_state_max_tool_calls\": 48' in CONFIG_PY + assert '\"inflight_state_max_string_chars\": 60000' in CONFIG_PY + assert '\"inflight_state_max_json_chars\": 1500000' in CONFIG_PY + assert '\"inflight_state_max_sessions\": (1, 25)' in CONFIG_PY + assert '\"inflight_state_max_messages\": (1, 100)' in CONFIG_PY + assert '\"inflight_state_max_tool_calls\": (1, 200)' in CONFIG_PY + assert '\"inflight_state_max_string_chars\": (1000, 500000)' in CONFIG_PY + assert '\"inflight_state_max_json_chars\": (100000, 4000000)' in CONFIG_PY + assert "window._inflightStateLimits={" in BOOT_JS + assert "maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8" in BOOT_JS + assert "messages:parseInt(s.inflight_state_max_messages||24,10)||24" in BOOT_JS + assert "function _inflightStateLimits()" in UI_JS + assert "window._inflightStateLimits" in UI_JS + assert "INFLIGHT_STATE_MAX_SESSIONS = 3" not in UI_JS + assert "INFLIGHT_STATE_MAX_MESSAGES = 8" not in UI_JS + + +def test_inflight_marker_write_handles_quota_by_dropping_recovery_snapshots(): + """The tiny active-stream marker must not crash submit when recovery snapshots fill quota.""" + mark_body = _function_body(UI_JS, "markInflight") + + assert "try{" in mark_body + assert "localStorage.setItem(INFLIGHT_KEY, payload);" in mark_body + assert "_isStorageQuotaError(err)" in mark_body + assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in mark_body + assert mark_body.index("localStorage.removeItem(INFLIGHT_STATE_KEY);") < mark_body.rindex( + "localStorage.setItem(INFLIGHT_KEY, payload);" + ) + + +def test_save_inflight_state_clears_snapshots_when_quota_retry_fails(): + """Quota failures should degrade recovery, not preserve a storage-filling blob.""" + save_body = _function_body(UI_JS, "saveInflightState") + + assert "catch(err)" in save_body + assert "if(!_isStorageQuotaError(err)) return;" in save_body + assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in save_body + assert "_writeInflightStateMap({[sid]:entry});" in save_body From aa0c7b7144fe6267dae220d485025e5ed6905664 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Fri, 22 May 2026 19:53:05 +0000 Subject: [PATCH 2/2] Stamp CHANGELOG for v0.51.117 (Release CO / stage-pr2766 / 1-PR) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e41aa179..ea880f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,11 @@ ## [Unreleased] +## [v0.51.117] — 2026-05-22 — Release CO (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe) + ### Fixed -- Bound browser in-flight recovery snapshots with configurable limits and make active-stream marker writes recover from localStorage quota errors, so submitting a chat does not fail when older recovery data has filled the browser storage budget. + +- **PR #2766** by @george-andraws — Make in-flight recovery storage quota-safe so a full browser `localStorage` budget no longer blocks chat submission with `QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'hermes-webui-inflight' exceeded the quota.` The fix has four parts: (1) compact recovery snapshots before writing to keep only recent messages, tool calls, uploaded-file metadata, stream id, and updated timestamp; (2) truncate individual large strings per field (`60000` chars default) and prune the serialized payload (`1500000` chars default); (3) catch quota errors specifically on `markInflight()`, drop the larger `hermes-webui-inflight-state` key, then retry the tiny marker write; (4) expose 5 new configurable settings (`inflight_state_max_sessions`, `_max_messages`, `_max_tool_calls`, `_max_string_chars`, `_max_json_chars`) with int-range validation so operators can tune the budget. Self-healing on the very first chat submit after upgrade for users with already-quota-exhausted storage — no manual reload required. Graceful degradation if storage is still too full even after compaction (clears recovery snapshots but never blocks chat submit). ## [v0.51.116] — 2026-05-22 — Release CN (stage-pr2676 — 1-PR — per-skill enable/disable toggle in Skills panel, CLI-parity with `hermes skills config`)