diff --git a/api/route_session_list_cache.py b/api/route_session_list_cache.py index 2acb8cda..c0397d1c 100644 --- a/api/route_session_list_cache.py +++ b/api/route_session_list_cache.py @@ -175,6 +175,25 @@ def _session_list_cache_get( return None, False +def _session_list_cache_stale_reason(key: tuple) -> str | None: + """Return why an existing cache entry is stale, if it is stale.""" + now = time.monotonic() + current_stamp = _session_list_cache_resolved_source_stamp(key) + with _SESSIONS_CACHE_LOCK: + entry = _SESSIONS_CACHE.get(key) + if not entry: + return None + ts, stamp, _payload = entry + if stamp != current_stamp: + return "source" + ttl = _SESSIONS_CACHE_TTL_SECONDS + if _session_list_cache_streaming_freeze_marker() is not None: + ttl = _SESSIONS_CACHE_STREAMING_TTL_SECONDS + if (now - ts) >= ttl: + return "age" + return None + + def _session_list_cache_set(key: tuple, payload: dict) -> None: if not isinstance(payload, dict): return diff --git a/api/routes.py b/api/routes.py index 65e46b95..1c3b7878 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1668,6 +1668,7 @@ _session_list_runtime_sort_key = _route_session_list_cache._session_list_runtime _session_list_cache_set = _route_session_list_cache._session_list_cache_set _session_list_cache_source_stamp = _route_session_list_cache._session_list_cache_source_stamp _session_list_cache_state_db_fingerprint = _route_session_list_cache._session_list_cache_state_db_fingerprint +_session_list_cache_stale_reason = _route_session_list_cache._session_list_cache_stale_reason _session_list_cache_streaming_freeze_marker = _route_session_list_cache._session_list_cache_streaming_freeze_marker _ROUTE_SESSION_LIST_CACHE_DYNAMIC_EXPORTS = { @@ -2068,7 +2069,8 @@ def _get_cached_session_list_payload( return cached stale = cached # now actually a stale payload when one exists, else None - if stale is not None: + stale_reason = _session_list_cache_stale_reason(key) if stale is not None else None + if stale is not None and stale_reason != "source": event, is_owner = _session_list_cache_claim_rebuild(key) if is_owner: if diag is not None: diff --git a/static/messages.js b/static/messages.js index 79571240..254a6dd6 100644 --- a/static/messages.js +++ b/static/messages.js @@ -6456,7 +6456,7 @@ function _chatStreamActiveForSession(sid) { function _suspendSessionStreamForLiveChat(sid) { if (!sid) return; - if (_sessionStreamSessionId !== sid && !_sessionEventSource) return; + if (_sessionStreamSessionId !== sid) return; _sessionStreamHiddenSid = sid; stopSessionStream(); } diff --git a/static/sessions.js b/static/sessions.js index cd7efd3c..118eb061 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4298,13 +4298,13 @@ async function _runRenderSessionListRefresh(opts, _gen){ try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; const sessionListQS = _sessionListQueryString(); - const sessionRequestOpts={ - timeoutToast:false, - timeoutMs:_sessionListHasLoadedOnce?30000:_SESSION_LIST_BOOT_TIMEOUT_MS, - retries:1, - retryTimeouts:true, - retryStatuses:[502,503,504], - }; + const sessionRequestOpts={timeoutToast:false}; + if(!_sessionListHasLoadedOnce){ + sessionRequestOpts.timeoutMs=_SESSION_LIST_BOOT_TIMEOUT_MS; + sessionRequestOpts.retries=1; + sessionRequestOpts.retryTimeouts=true; + sessionRequestOpts.retryStatuses=[502,503,504]; + } const {sessData, projData}=await _loadSidebarSessionListPayload(sessionListQS, sessionRequestOpts); // Discard stale response — a newer renderSessionList() call superseded us. if (_gen !== _renderSessionListGen) return; diff --git a/tests/test_issue4759_parallel_sidebar_boot_fetch.py b/tests/test_issue4759_parallel_sidebar_boot_fetch.py index 7c19c53f..1ba63a9e 100644 --- a/tests/test_issue4759_parallel_sidebar_boot_fetch.py +++ b/tests/test_issue4759_parallel_sidebar_boot_fetch.py @@ -290,10 +290,6 @@ global.api = (url, opts) => {{ (async () => {{ const run = _loadSidebarSessionListPayload('?v=1', {{ timeoutToast:false, - timeoutMs:90000, - retries:1, - retryTimeouts:true, - retryStatuses:[502,503,504], }}); await Promise.resolve(); diff --git a/tests/test_performance_polling_hardening.py b/tests/test_performance_polling_hardening.py index a37a3f1f..23f072d9 100644 --- a/tests/test_performance_polling_hardening.py +++ b/tests/test_performance_polling_hardening.py @@ -24,7 +24,8 @@ def test_session_list_refreshes_are_coalesced_while_in_flight(): assert "_renderSessionListQueuedRequest={" in src assert "opts:_mergeRenderSessionListOptions" in src assert "if (_gen !== _renderSessionListGen) return" in src - assert "api('/api/sessions' + sessionListQS,{timeoutToast:false})" in src + assert "const sessionRequestOpts={timeoutToast:false};" in src + assert "api('/api/sessions' + sessionListQS,sessionRequestOpts)" in src assert "api('/api/projects' + projectQS,{timeoutToast:false})" in src diff --git a/tests/test_session_channel_option_x.py b/tests/test_session_channel_option_x.py index 6f511491..6888681a 100644 --- a/tests/test_session_channel_option_x.py +++ b/tests/test_session_channel_option_x.py @@ -452,6 +452,10 @@ def test_session_stream_pauses_while_chat_stream_is_active(): assert "_sessionStreamHiddenSid = null;" in resume_src assert "startSessionStream(sid);" in resume_src + suspend_src = _js_function_decl(js, "_suspendSessionStreamForLiveChat") + assert "if (_sessionStreamSessionId !== sid) return;" in suspend_src + assert "stopSessionStream();" in suspend_src + def test_frontend_busy_race_gate_obsoleted_by_option_z_pivot(): """Per the Option Z PIVOT note baked into the handler body, the browser diff --git a/tests/test_session_sidebar_cache.py b/tests/test_session_sidebar_cache.py index c03c1e77..7834f613 100644 --- a/tests/test_session_sidebar_cache.py +++ b/tests/test_session_sidebar_cache.py @@ -198,7 +198,7 @@ def test_session_list_cache_follower_wait_stage_when_rebuild_inflight(monkeypatc assert "session_list_cache_hit" in owner_diag.stages or "session_list_cache_stored" in owner_diag.stages -def test_session_list_cache_follower_reuses_stale_payload_during_slow_rebuild(monkeypatch): +def test_session_list_cache_source_changed_owner_rebuilds_while_follower_reuses_stale(monkeypatch): routes._session_list_cache_clear() key = routes._session_list_cache_key( @@ -216,10 +216,10 @@ def test_session_list_cache_follower_reuses_stale_payload_during_slow_rebuild(mo stamp, payload, ) - # Simulate the #4834 path: state.db/WAL/fingerprint changes after the - # stale payload was cached. Followers must still be able to use that stale - # payload while the owner rebuild is blocked; otherwise sidebar polling can - # pile up behind a slow rebuild. + # Simulate state.db/WAL/fingerprint changing after the stale payload was + # cached. The owner must rebuild synchronously so committed external state is + # visible immediately, but followers can still use stale while that rebuild + # is blocked; otherwise sidebar polling can pile up behind a slow rebuild. monkeypatch.setattr( routes, "_session_list_cache_source_stamp", @@ -265,17 +265,15 @@ def test_session_list_cache_follower_reuses_stale_payload_during_slow_rebuild(mo owner_thread.join(2.0) follower_thread.join(2.0) - assert owner_result["payload"] == _session_cache_payload("stale") + assert owner_result["payload"] == _session_cache_payload("fresh") assert follower_result["payload"] == _session_cache_payload("stale") - assert "session_list_cache_stale_background_rebuild" in owner_diag.stages - assert ( - "session_list_cache_stale_return" in follower_diag.stages - or "session_list_cache_stale_background_rebuild" in follower_diag.stages - ) + assert "session_list_cache_rebuild_owner" in owner_diag.stages + assert "session_list_cache_wait_stale_fallback" in follower_diag.stages def test_session_list_cache_owner_returns_stale_and_rebuilds_in_background(monkeypatch): routes._session_list_cache_clear() + monkeypatch.setattr(routes, "_session_list_cache_source_stamp", lambda _key: ("stable",)) key = routes._session_list_cache_key( active_profile="default", @@ -292,8 +290,6 @@ def test_session_list_cache_owner_returns_stale_and_rebuilds_in_background(monke stamp, payload, ) - monkeypatch.setattr(routes, "_session_list_cache_source_stamp", lambda _key: ("changed",)) - started = threading.Event() release = threading.Event() diag = _StageRecorder() @@ -327,6 +323,7 @@ def test_session_list_cache_owner_returns_stale_and_rebuilds_in_background(monke def test_session_list_cache_stale_background_rebuild_failure_releases_owner(monkeypatch): routes._session_list_cache_clear() + monkeypatch.setattr(routes, "_session_list_cache_source_stamp", lambda _key: ("stable",)) key = routes._session_list_cache_key( active_profile="default", @@ -343,8 +340,6 @@ def test_session_list_cache_stale_background_rebuild_failure_releases_owner(monk stamp, payload, ) - monkeypatch.setattr(routes, "_session_list_cache_source_stamp", lambda _key: ("changed",)) - started = threading.Event() logged = [] diff --git a/tests/test_session_sidebar_resilience.py b/tests/test_session_sidebar_resilience.py index bef1a508..f80d528e 100644 --- a/tests/test_session_sidebar_resilience.py +++ b/tests/test_session_sidebar_resilience.py @@ -56,7 +56,7 @@ def test_sessions_and_projects_load_independently_so_projects_failure_cannot_bla assert "_applySessionListPayload(sessData,projData)" in block -def test_sessions_api_uses_longer_timeout_and_timeout_retry_for_boot_refresh(): +def test_sessions_api_uses_longer_timeout_and_timeout_retry_for_boot_refresh_only(): sessions_src = _sessions_js() workspace_src = _workspace_js() helper_start = sessions_src.find("async function _loadSidebarSessionListPayload") @@ -65,11 +65,13 @@ def test_sessions_api_uses_longer_timeout_and_timeout_retry_for_boot_refresh(): assert helper_end > helper_start helper = sessions_src[helper_start:helper_end] - assert "timeoutMs:_sessionListHasLoadedOnce?30000:_SESSION_LIST_BOOT_TIMEOUT_MS" in sessions_src + assert "const sessionRequestOpts={timeoutToast:false};" in sessions_src + assert "if(!_sessionListHasLoadedOnce){" in sessions_src + assert "sessionRequestOpts.timeoutMs=_SESSION_LIST_BOOT_TIMEOUT_MS;" in sessions_src assert "const sessData = await api('/api/sessions' + sessionListQS,sessionRequestOpts);" in helper assert "api('/api/sessions' + sessionListQS,{timeoutToast:false})" not in helper - assert "retryTimeouts:true" in sessions_src - assert "retryStatuses:[502,503,504]" in sessions_src + assert "sessionRequestOpts.retryTimeouts=true" in sessions_src + assert "sessionRequestOpts.retryStatuses=[502,503,504]" in sessions_src assert "retryTimeouts" in workspace_src assert "retryStatuses" in workspace_src