Harden connection flapping aftercare

Keep sidebar retries boot-scoped, preserve immediate source-change cache rebuilds, and tighten session stream ownership before closing duplicate SSE sockets.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frank SongandPaperclip authored and nesquena-hermes committed 2026-06-29 19:52:02 +00:00
1 parent cbb644da69
commit 47d8ac94d3
9 files changed
+52 -33

No files matched your search

+19
View File
@@ -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
+3 -1
View File
@@ -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:
+1 -1
View File
@@ -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();
}
+7 -7
View File
@@ -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;
@@ -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();
+2 -1
View File
@@ -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
+4
View File
@@ -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
+10 -15
View File
@@ -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 = []
+6 -4
View File
@@ -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