diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 90b73049b63..b3d6f6b5980 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -6200,10 +6200,16 @@ def test_close_session_by_id_is_idempotent_and_full(monkeypatch): def close(self): calls["agent"] += 1 - monkeypatch.setattr( - server, "_finalize_session", - lambda s, end_reason="tui_close": calls.__setitem__("finalize", calls["finalize"] + 1), - ) + def _fake_finalize(s, end_reason="tui_close"): + # Real _finalize_session is the single chokepoint that closes the + # slash-worker; mirror that here so the test exercises the actual + # teardown contract (worker close lives in finalize, not the caller). + calls["finalize"] += 1 + w = s.get("slash_worker") + if w: + w.close() + + monkeypatch.setattr(server, "_finalize_session", _fake_finalize) monkeypatch.setattr( "tools.approval.unregister_gateway_notify", lambda key: calls.__setitem__("unreg", calls["unreg"] + 1), raising=False, @@ -6307,6 +6313,9 @@ def test_close_sessions_for_transport_closes_flagged_repoints_rest(monkeypatch): server, "_close_session_by_id", lambda sid, *, end_reason: bool(seen.append((sid, end_reason))) or True, ) + # Detached session "b" would schedule a real grace-reap threading.Timer that + # outlives the test; grace=0 short-circuits it so no thread lingers. + monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0) transport = object() # the disconnecting transport server._sessions.clear() server._sessions["a"] = {"transport": transport, "close_on_disconnect": True} @@ -6314,7 +6323,7 @@ def test_close_sessions_for_transport_closes_flagged_repoints_rest(monkeypatch): try: server._close_sessions_for_transport(transport, end_reason="ws_disconnect") assert seen == [("a", "ws_disconnect")] # only the flagged one closed - assert server._sessions["b"]["transport"] is server._stdio_transport # re-pointed + assert server._sessions["b"]["transport"] is server._detached_ws_transport # re-pointed finally: server._sessions.clear() @@ -6360,7 +6369,7 @@ def _idle_evictable_session(now): return { "running": False, "agent_ready": ready, - "transport": server._stdio_transport, # dead/detached + "transport": server._detached_ws_transport, # dead/detached "last_active": old, "created_at": old, } diff --git a/tests/test_tui_gateway_ws.py b/tests/test_tui_gateway_ws.py index 125cb4ec310..3fd8b404cf6 100644 --- a/tests/test_tui_gateway_ws.py +++ b/tests/test_tui_gateway_ws.py @@ -7,7 +7,23 @@ from tui_gateway import ws as ws_mod def _run_disconnect(monkeypatch, seed): """Drive handle_ws to its disconnect `finally`, seeding sessions against the live WSTransport the moment it exists. Returns nothing; inspect _sessions.""" - monkeypatch.setattr(server, "_finalize_session", lambda s, end_reason="tui_close": None) + # Disable the grace-reap Timer: detached sessions normally schedule a + # threading.Timer via _schedule_ws_orphan_reap, which would outlive the test + # and fire _reap during interpreter teardown — touching _sessions/DB and + # producing spurious post-run errors under the per-file CI runner. Grace=0 + # short-circuits the Timer (see _schedule_ws_orphan_reap) so the test leaves + # no lingering thread. + monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0) + + # Mirror the real _finalize_session chokepoint: it is the single place that + # closes the slash-worker (#38095). Stub it but keep that behavior so the + # disconnect-reap path still exercises worker teardown. + def _fake_finalize(s, end_reason="tui_close"): + w = s.get("slash_worker") + if w: + w.close() + + monkeypatch.setattr(server, "_finalize_session", _fake_finalize) created = [] real_transport = ws_mod.WSTransport @@ -68,6 +84,6 @@ def test_ws_disconnect_preserves_and_repoints_reconnectable_session(monkeypatch) plain={"transport": t, "close_on_disconnect": False, "session_key": "k"} ), ) - assert server._sessions["plain"]["transport"] is server._stdio_transport + assert server._sessions["plain"]["transport"] is server._detached_ws_transport finally: server._sessions.clear() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c7bfd8f84e9..6b43d305a0f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -425,10 +425,10 @@ def _teardown_session(session: dict | None, *, end_reason: str = "tui_close") -> agent.close() except Exception: pass - # NOTE: the slash-worker subprocess is already closed inside - # _finalize_session (the single _finalized-guarded chokepoint on main). - # No second worker.close() here — it would be redundant (poll()-guarded, - # harmless, but dead). + # NOTE: the slash-worker is closed inside _finalize_session (the single + # _finalized-guarded chokepoint that main folded it into), exactly once. + # We deliberately do NOT re-close it here — _teardown_session's job beyond + # finalize is unregistering the notifier and closing the in-process agent. def _attach_worker(sid: str, session: dict, worker) -> None: