fix(tui-gateway): reap stale deferred active-session leases (#62823 zombie slot)
When a compute-host turn has not settled by session close, the session's real active-session lease is parked in _deferred_active_session_leases and the turn's completion callback is supposed to release it. If that callback is lost (supervisor restart/reload, child killed without failing its pending turns, dropped completion), the lease sat in the registry forever: _own_live_lease_ids vouches for it so the orphan sweep never reclaims it, and the concurrent-session cap treats the dead session as active - new sessions could not send until a backend restart. Track when each lease was deferred and have the reaper tick force-release deferred leases past a generous 30-minute ceiling (the longest legitimate isolated turn is the compression ceiling, minutes). Normal settlement still releases immediately and clears the age entry. Fixes #62823 (zombie-concurrency-slot half; cross-window queue-sync was fixed by #122953 and visual-merge by #70986 - not touched here)
This commit is contained in:
82
tests/tui_gateway/test_deferred_lease_reaper.py
Normal file
82
tests/tui_gateway/test_deferred_lease_reaper.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Stale deferred active-session leases are reaped, not held forever (#62823).
|
||||||
|
|
||||||
|
When a compute-host turn has not settled by session close, the session's real
|
||||||
|
active-session lease is parked in ``_deferred_active_session_leases`` and the
|
||||||
|
turn's completion callback is supposed to release it. If that callback is lost
|
||||||
|
(supervisor restart, child killed without failing pending turns), the lease
|
||||||
|
previously sat in the registry FOREVER: ``_own_live_lease_ids`` vouches for it,
|
||||||
|
the orphan sweep skips it, and the concurrent-session cap treats the dead
|
||||||
|
session as active — new sessions could not send until a backend restart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hermes_cli.active_sessions import ActiveSessionLease, try_acquire_active_session
|
||||||
|
from tui_gateway import server
|
||||||
|
|
||||||
|
|
||||||
|
def _lease(tmp_path: Path, key: str = "stored-session") -> ActiveSessionLease:
|
||||||
|
lease, err = try_acquire_active_session(
|
||||||
|
session_id=key, surface="desktop", config={}, registry_home=tmp_path, track_liveness=True)
|
||||||
|
assert err is None
|
||||||
|
assert lease is not None
|
||||||
|
return lease
|
||||||
|
|
||||||
|
|
||||||
|
def _defer(lease: ActiveSessionLease, age_seconds: float) -> None:
|
||||||
|
lease_id = str(lease.lease_id)
|
||||||
|
server._deferred_active_session_leases[lease_id] = lease
|
||||||
|
server._deferred_active_session_lease_ages[lease_id] = time.time() - age_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_deferred_lease_is_force_released(tmp_path):
|
||||||
|
lease = _lease(tmp_path)
|
||||||
|
_defer(lease, age_seconds=server._DEFERRED_ACTIVE_SESSION_LEASE_TTL_SECONDS + 60)
|
||||||
|
|
||||||
|
reaped = server._reap_stale_deferred_leases()
|
||||||
|
|
||||||
|
assert reaped == 1
|
||||||
|
assert lease.released is True
|
||||||
|
assert str(lease.lease_id) not in server._deferred_active_session_leases
|
||||||
|
assert str(lease.lease_id) not in server._deferred_active_session_lease_ages
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_deferred_lease_is_kept(tmp_path):
|
||||||
|
lease = _lease(tmp_path)
|
||||||
|
_defer(lease, age_seconds=10)
|
||||||
|
|
||||||
|
reaped = server._reap_stale_deferred_leases()
|
||||||
|
|
||||||
|
assert reaped == 0
|
||||||
|
assert lease.released is False
|
||||||
|
assert str(lease.lease_id) in server._deferred_active_session_leases
|
||||||
|
|
||||||
|
# cleanup: settle normally
|
||||||
|
session = {"history_lock": threading.Lock(), "_deferred_active_session_lease": lease}
|
||||||
|
server._release_deferred_active_session_lease(session)
|
||||||
|
assert lease.released is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_settlement_clears_the_age_entry(tmp_path):
|
||||||
|
lease = _lease(tmp_path)
|
||||||
|
_defer(lease, age_seconds=0)
|
||||||
|
session = {"history_lock": threading.Lock(), "_deferred_active_session_lease": lease}
|
||||||
|
|
||||||
|
server._release_deferred_active_session_lease(session)
|
||||||
|
|
||||||
|
assert lease.released is True
|
||||||
|
assert str(lease.lease_id) not in server._deferred_active_session_lease_ages
|
||||||
|
assert str(lease.lease_id) not in server._deferred_active_session_leases
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaped_lease_no_longer_vouched_by_own_live_lease_ids(monkeypatch, tmp_path):
|
||||||
|
lease = _lease(tmp_path)
|
||||||
|
_defer(lease, age_seconds=server._DEFERRED_ACTIVE_SESSION_LEASE_TTL_SECONDS + 60)
|
||||||
|
|
||||||
|
assert str(lease.lease_id) in server._own_live_lease_ids()
|
||||||
|
server._reap_stale_deferred_leases()
|
||||||
|
assert str(lease.lease_id) not in server._own_live_lease_ids()
|
||||||
@@ -549,16 +549,54 @@ def _settle_isolated_turn_before_close(session: dict) -> None:
|
|||||||
return
|
return
|
||||||
session["_deferred_active_session_lease"] = lease
|
session["_deferred_active_session_lease"] = lease
|
||||||
_deferred_active_session_leases[str(lease.lease_id)] = lease
|
_deferred_active_session_leases[str(lease.lease_id)] = lease
|
||||||
|
_deferred_active_session_lease_ages[str(lease.lease_id)] = time.time()
|
||||||
logger.warning("isolated turn still live after %.1fs close grace; holding lease for %s until the child settles",
|
logger.warning("isolated turn still live after %.1fs close grace; holding lease for %s until the child settles",
|
||||||
_TURN_SETTLE_BEFORE_CLOSE_SECONDS, session.get("session_key"))
|
_TURN_SETTLE_BEFORE_CLOSE_SECONDS, session.get("session_key"))
|
||||||
|
|
||||||
|
|
||||||
|
# A deferred lease is released by the compute-host turn's completion callback
|
||||||
|
# (_on_compute_host_turn_done → _release_deferred_active_session_lease). If that callback
|
||||||
|
# is lost — supervisor restart/reload, a child killed without failing its pending turns,
|
||||||
|
# a dropped completion — the lease sits in the registry FOREVER: _own_live_lease_ids
|
||||||
|
# vouches for it, so the orphan sweep never reclaims it, and the concurrency cap treats
|
||||||
|
# the dead session as active (#62823 zombie slot). A deferred lease past this generous
|
||||||
|
# ceiling (the longest legitimate isolated turn is the compression ceiling, minutes) is
|
||||||
|
# force-released by the reaper tick instead of leaking the slot until process exit.
|
||||||
|
_DEFERRED_ACTIVE_SESSION_LEASE_TTL_SECONDS = 1800.0
|
||||||
|
_deferred_active_session_lease_ages: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _reap_stale_deferred_leases(now: float | None = None) -> int:
|
||||||
|
"""Force-release deferred leases whose settlement callback never arrived. Returns the count."""
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
stale = [
|
||||||
|
lease_id for lease_id, deferred_at in list(_deferred_active_session_lease_ages.items())
|
||||||
|
if now - deferred_at > _DEFERRED_ACTIVE_SESSION_LEASE_TTL_SECONDS
|
||||||
|
]
|
||||||
|
reaped = 0
|
||||||
|
for lease_id in stale:
|
||||||
|
_deferred_active_session_lease_ages.pop(lease_id, None)
|
||||||
|
lease = _deferred_active_session_leases.pop(lease_id, None)
|
||||||
|
if lease is None:
|
||||||
|
continue
|
||||||
|
if (err := _lease_retry(3, lease.release)) is not None:
|
||||||
|
logger.warning("Failed to force-release stale deferred active session lease %s", lease_id,
|
||||||
|
exc_info=err)
|
||||||
|
continue
|
||||||
|
logger.warning("Force-released deferred active session lease %s held past %.0fs without a "
|
||||||
|
"compute-host settlement (zombie concurrency slot, #62823)",
|
||||||
|
lease_id, _DEFERRED_ACTIVE_SESSION_LEASE_TTL_SECONDS)
|
||||||
|
reaped += 1
|
||||||
|
return reaped
|
||||||
|
|
||||||
|
|
||||||
def _release_deferred_active_session_lease(session: dict) -> None:
|
def _release_deferred_active_session_lease(session: dict) -> None:
|
||||||
"""Settlement half of ``_settle_isolated_turn_before_close``; a no-op for sessions that never deferred."""
|
"""Settlement half of ``_settle_isolated_turn_before_close``; a no-op for sessions that never deferred."""
|
||||||
lease = session.pop("_deferred_active_session_lease", None)
|
lease = session.pop("_deferred_active_session_lease", None)
|
||||||
if lease is None:
|
if lease is None:
|
||||||
return
|
return
|
||||||
_deferred_active_session_leases.pop(str(lease.lease_id), None)
|
_deferred_active_session_leases.pop(str(lease.lease_id), None)
|
||||||
|
_deferred_active_session_lease_ages.pop(str(lease.lease_id), None)
|
||||||
if (err := _lease_retry(3, lease.release)) is not None:
|
if (err := _lease_retry(3, lease.release)) is not None:
|
||||||
logger.warning("Failed to release deferred active session slot", exc_info=err)
|
logger.warning("Failed to release deferred active session slot", exc_info=err)
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,12 @@ def _repair_missing_ws_orphan_reaps() -> None:
|
|||||||
|
|
||||||
def _reclaim_orphaned_leases() -> None:
|
def _reclaim_orphaned_leases() -> None:
|
||||||
"""Hand the registry the lease ids we still own so it can drop the rest."""
|
"""Hand the registry the lease ids we still own so it can drop the rest."""
|
||||||
|
try:
|
||||||
|
# Stale deferred leases first: a settlement callback that never arrived must not
|
||||||
|
# keep vouching for a zombie slot (#62823). Released leases leave _own_live_lease_ids.
|
||||||
|
_reap_stale_deferred_leases()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("stale deferred lease sweep failed", exc_info=True)
|
||||||
try:
|
try:
|
||||||
from hermes_cli.active_sessions import release_orphaned_leases
|
from hermes_cli.active_sessions import release_orphaned_leases
|
||||||
if dropped := release_orphaned_leases(_own_live_lease_ids()):
|
if dropped := release_orphaned_leases(_own_live_lease_ids()):
|
||||||
|
|||||||
Reference in New Issue
Block a user