Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1769,6 +1769,36 @@ def write(self, *a, **k):
assert server._ws_session_is_orphaned(done) is False


def test_ws_orphan_reap_spares_detached_session_still_in_progress(monkeypatch):
"""A crashed desktop window must not reap work that can still resume."""

building_ready = threading.Event()
building = _session(
transport=server._detached_ws_transport,
running=False,
agent_ready=building_ready,
agent_build_started=True,
)
assert server._ws_session_is_orphaned(building, "building-sid") is False

inflight = _session(
transport=server._detached_ws_transport,
running=False,
inflight_turn={"user": "still streaming"},
)
assert server._ws_session_is_orphaned(inflight, "inflight-sid") is False

pending_event = threading.Event()
server._pending["approval-1"] = ("prompt-sid", pending_event)
server._pending_prompt_payloads["approval-1"] = ("approval.request", {})
try:
waiting = _session(transport=server._detached_ws_transport, running=False)
assert server._ws_session_is_orphaned(waiting, "prompt-sid") is False
finally:
server._pending.pop("approval-1", None)
server._pending_prompt_payloads.pop("approval-1", None)


def test_ws_orphan_reap_disabled_when_grace_zero(monkeypatch):
"""Grace=0 disables the reaper entirely (pre-fix park-forever behaviour)."""
fired = {"timer": False}
Expand Down
14 changes: 11 additions & 3 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,17 +493,25 @@ def _close_session_by_id(sid: str, *, end_reason: str = "tui_close") -> bool:



def _ws_session_is_orphaned(session: dict | None) -> bool:
def _ws_session_is_orphaned(session: dict | None, sid: str | None = None) -> bool:
"""True if a WS session has no live transport and no in-flight turn.

After ``handle_ws`` detaches a disconnected client it points the session at
``_detached_ws_transport``. A session left on that transport (and not
mid-turn) is genuinely orphaned and safe to reap.
mid-turn / mid-build / waiting on input) is genuinely orphaned and safe to
reap.
"""
if not session or session.get("_finalized"):
return False
if session.get("running"):
return False
if sid and _session_pending_kind(sid):
return False
if session.get("inflight_turn") is not None:
return False
ready = session.get("agent_ready")
if ready is not None and not ready.is_set() and session.get("agent_build_started"):
return False
return session.get("transport") is _detached_ws_transport


Expand All @@ -528,7 +536,7 @@ def _reap() -> None:
# guard with _sessions_lock). _sessions_lock is an RLock and the global
# ordering is always resume_lock -> sessions_lock, so nesting is safe.
with _session_resume_lock:
if not _ws_session_is_orphaned(_sessions.get(sid)):
if not _ws_session_is_orphaned(_sessions.get(sid), sid):
return
_close_session_by_id(sid, end_reason="ws_orphan_reap")

Expand Down
Loading