diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 432e028467a2..35f7cabc575b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7f7109d10121..d5b39866daf9 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 @@ -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")