fix(gateway): never prune sessions when active-process check fails

prune_old_entries' active-process guard failed open: when
has_active_processes_fn raised, the except block logged at debug and
fell through to the age check, so sessions with live background
processes attached could still be pruned — violating the documented
invariant that such sessions are never dropped. Add a continue so an
exception in the safety check fails safe (the entry is kept).

Commit 6b408e131 fixed the session_key/session_id mismatch in this
same guard but left the exception path failing open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Rory Ford 2026-06-12 21:04:41 +10:00 committed by kshitij
parent cd53718761
commit ca559a7852
2 changed files with 35 additions and 0 deletions

View file

@ -2179,6 +2179,10 @@ class SessionStore:
"has_active_processes_fn raised during prune for %s: %s",
entry.session_key, exc,
)
# Fail safe: if we can't tell whether a background
# process is attached, keep the entry rather than
# risk orphaning live work.
continue
if entry.updated_at < cutoff:
removed_keys.append(key)
for key in removed_keys:

View file

@ -165,6 +165,37 @@ class TestPruneBasics:
assert removed == 1
assert "active" not in store._entries
def test_prune_keeps_entry_when_active_check_raises(self, tmp_path):
"""A failing active-process check must fail safe, not fail open.
If has_active_processes_fn raises, we can't tell whether a live
background process is attached so the entry must be kept.
Previously the except block logged and fell through to the age
check, pruning the session anyway.
"""
def _broken(session_key: str) -> bool:
raise RuntimeError("process registry unavailable")
store = _make_store(tmp_path, has_active_processes_fn=_broken)
store._entries["old"] = _entry("old", age_days=1000)
removed = store.prune_old_entries(max_age_days=90)
assert removed == 0
assert "old" in store._entries
def test_prune_removes_old_entry_when_active_check_returns_false(self, tmp_path):
"""Sibling guard: a callback that cleanly reports no active process
must still allow the old entry to be pruned.
"""
store = _make_store(tmp_path, has_active_processes_fn=lambda key: False)
store._entries["old"] = _entry("old", age_days=1000)
removed = store.prune_old_entries(max_age_days=90)
assert removed == 1
assert "old" not in store._entries
def test_prune_does_not_write_disk_when_no_removals(self, tmp_path):
"""If nothing is evictable, _save() should NOT be called."""
store = _make_store(tmp_path)