fix(tui): reap session kernels when a TUI-owned session ends

TUI/Desktop sessions that end via _finalize_session never cleared the
approval session state, so session-persistent execute_code kernels
(owner = approval session key) stayed alive until the NEXT execute_code
in the same process ran the lazy idle sweep in _acquire_kernel.

The gateway's /stop and /new paths already call approval.clear_session
(approval.py:264) at the same boundary, killing the owner's kernels with
the session. Align the TUI path: when the TUI owns the session lifecycle
(not a viewer tab over a gateway-owned session), clear_session(session_key)
on finalize so a finished conversation cannot leak a live interpreter.

Fixes the class: any end_reason that tears down a TUI-owned session
(tui_close, ws_orphan_reap, idle_timeout, lru_evict, tui_shutdown) now
reaps its kernels.
This commit is contained in:
cmyyy
2026-09-08 00:44:46 +08:00
committed by brooklyn!
parent 0f4a98f87c
commit 7fa45eb349
2 changed files with 83 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
"""Session finalize disposes the owner's execute_code kernels (TUI-owned only).
Regression for #105213. The gateway's /stop and /new paths call ``approval.clear_session``,
which tears down the session-persistent kernels owned by that session key (#88637). A
TUI/Desktop session ending via ``_finalize_session`` never did, so each finished conversation
left a live interpreter behind until the idle reaper's ``kernel_idle_timeout`` expired.
Contract: when the TUI owns the lifecycle, finalize disposes that session's kernels and approval
state; when the messaging gateway owns the session (the TUI is a viewer), both are left alone.
"""
import contextlib
import threading
import pytest
from tools import approval, code_kernel
from tui_gateway import server
SESSION_KEY = "20260907_120000_abcdef"
def _session() -> dict:
return {"active_session_lease": None, "agent": None, "history": [], "history_lock": threading.Lock(),
"profile_home": None, "session_key": SESSION_KEY, "slash_worker": None, "source": "desktop"}
@pytest.fixture
def live_kernel(monkeypatch):
"""A registered (unspawned) kernel owned by SESSION_KEY, plus per-session YOLO state."""
kernel = code_kernel.SessionKernel((SESSION_KEY, "project", "python", "/tmp", ()))
monkeypatch.setitem(code_kernel._REGISTRY.kernels, kernel.key, kernel)
approval.enable_session_yolo(SESSION_KEY)
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **k: None)
monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr(server, "_load_cfg", lambda: {})
yield kernel
approval.clear_session(SESSION_KEY)
def _stored_source(monkeypatch, source):
class _FakeDB:
def get_session(self, target):
return None if source is None else {"id": target, "source": source}
def end_session(self, *_a, **_k):
pass
@contextlib.contextmanager
def _profile_db(_session):
yield _FakeDB()
monkeypatch.setattr(server, "_session_db", _profile_db)
@pytest.mark.parametrize("source, end_reason", [
("desktop", "tui_close"), ("desktop", "ws_orphan_reap"), (None, "idle_timeout")])
def test_tui_owned_finalize_disposes_the_sessions_kernels(monkeypatch, live_kernel, source, end_reason):
_stored_source(monkeypatch, source)
server._finalize_session(_session(), end_reason=end_reason)
assert live_kernel.key not in code_kernel._REGISTRY.kernels
assert live_kernel.stop_event.is_set()
assert not approval.is_session_yolo_enabled(SESSION_KEY)
def test_viewer_finalize_keeps_a_gateway_owned_sessions_kernels(monkeypatch, live_kernel):
_stored_source(monkeypatch, "telegram")
server._finalize_session(_session(), end_reason="tui_shutdown")
assert code_kernel._REGISTRY.kernels.get(live_kernel.key) is live_kernel
assert not live_kernel.stop_event.is_set()
assert approval.is_session_yolo_enabled(SESSION_KEY)

View File

@@ -433,6 +433,14 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
interrupt_for_session(
session_key=str(session_key or "") if _tui_owns_lifecycle else "",
origin_ui_session_id=_lifecycle_own_sid(session), reason=end_reason)
# Session-persistent code kernels (execute_code) share this owner key and die at the same boundary, like the
# gateway's /stop and /new (approval.clear_session); otherwise each finished conversation keeps a live
# interpreter until kernel_idle_timeout. Only when the TUI owns the lifecycle: a viewer tab over a
# gateway-owned session must not kill the gateway's kernels.
if _tui_owns_lifecycle and session_key:
with contextlib.suppress(Exception):
from tools.approval import clear_session
clear_session(str(session_key))
# Close the slash-worker in this single ``_finalized``-guarded chokepoint (a direct caller can't leak it); idempotent.
with contextlib.suppress(Exception):
if worker := session.get("slash_worker"):