Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
"""``session.resume`` must not abandon the profile-scoped SessionDB it opens.
|
||||
|
||||
In app-global remote mode a resume for another local profile opens a DEDICATED
|
||||
``SessionDB(db_path=<profile>/state.db)`` handle (the ``session.resume`` handler
|
||||
in tui_gateway/methods_session.py). That handle is the caller's to close until
|
||||
it is handed to the long-lived agent by ``_init_session`` — and
|
||||
``_init_session`` never closes a caller-supplied ``session_db`` (its
|
||||
``_init_owns_db`` stays False for that case).
|
||||
|
||||
Every early return before that transfer used to drop the handle on the floor,
|
||||
so its SQLite fds stayed open for as long as anything kept the instance
|
||||
reachable — and a ``SessionDB`` pins ITSELF once its background token writer
|
||||
starts (``atexit.register(self._drain_token_queue_at_exit)``, which only
|
||||
``close()`` unregisters).
|
||||
|
||||
Pinned here, in both directions:
|
||||
|
||||
* the pre-transfer early returns (session-not-found, "resume failed", the
|
||||
live-session fast path, the deferred cold-resume return) all close it;
|
||||
* a resume that COMPLETES the transfer leaves it open — closing there would
|
||||
fault every later turn with "Cannot operate on a closed database";
|
||||
* an ``_init_session`` that raises AFTER registering the session must drop that
|
||||
half-built registration, otherwise the live-session fast path serves a
|
||||
session whose db we just closed on every later resume of the same id;
|
||||
* the shared launch-profile handle (``_get_db()``) is never closed, since it
|
||||
outlives the RPC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from tui_gateway import server
|
||||
|
||||
|
||||
class _RecordingDB:
|
||||
"""Stand-in for ``hermes_state.SessionDB`` that counts ``close()`` calls.
|
||||
|
||||
Implements only the surface ``session.resume`` touches.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path=None, **_kwargs):
|
||||
self.db_path = db_path
|
||||
self.closed = 0
|
||||
self.rows: dict = {}
|
||||
self.reopen_error: Exception | None = None
|
||||
|
||||
def close(self):
|
||||
self.closed += 1
|
||||
|
||||
def get_session(self, target):
|
||||
return self.rows.get(target)
|
||||
|
||||
def get_session_by_title(self, _target):
|
||||
return None
|
||||
|
||||
def resolve_resume_session_id(self, target):
|
||||
return target
|
||||
|
||||
def reopen_session(self, _target):
|
||||
if self.reopen_error is not None:
|
||||
raise self.reopen_error
|
||||
|
||||
def get_resume_conversations(self, _target):
|
||||
return ([], [])
|
||||
|
||||
def get_ancestor_display_prefix(self, _target):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, _target, **_kwargs):
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def profile_dbs(monkeypatch, tmp_path):
|
||||
"""Route profile-scoped opens to _RecordingDB; yield the list of opens.
|
||||
|
||||
``params['profile']`` selects the profile scope; omitting it resolves to
|
||||
the launch profile (``_profile_home`` -> None) and the shared handle.
|
||||
"""
|
||||
opened: list[_RecordingDB] = []
|
||||
profile_home = tmp_path / "work"
|
||||
profile_home.mkdir()
|
||||
|
||||
def _factory(db_path=None, **kwargs):
|
||||
db = _RecordingDB(db_path=db_path, **kwargs)
|
||||
opened.append(db)
|
||||
return db
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
monkeypatch.setattr(
|
||||
server, "_profile_home", lambda profile: profile_home if profile else None
|
||||
)
|
||||
monkeypatch.setattr(server, "_profile_configured_cwd", lambda _home: str(tmp_path))
|
||||
# The handler builds nothing on the paths under test; keep it hermetic and
|
||||
# off the real agent/secret/HERMES_HOME machinery.
|
||||
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
|
||||
monkeypatch.setattr(server, "_find_live_session_by_key", lambda _key, *_a: None)
|
||||
monkeypatch.setattr(server, "_schedule_agent_build", lambda *a, **k: None)
|
||||
monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda *a, **k: None)
|
||||
monkeypatch.setattr(server, "_maybe_schedule_auto_continue", lambda *a, **k: None)
|
||||
monkeypatch.setattr(server, "_default_session_cwd", lambda *a, **k: str(tmp_path))
|
||||
known = set(server._sessions)
|
||||
yield opened
|
||||
with server._sessions_lock:
|
||||
for sid in [s for s in server._sessions if s not in known]:
|
||||
server._sessions.pop(sid, None)
|
||||
|
||||
|
||||
def _resume(**params):
|
||||
return server.handle_request(
|
||||
{"id": "1", "method": "session.resume", "params": params}
|
||||
)
|
||||
|
||||
|
||||
def test_resume_closes_profile_db_when_session_not_found(profile_dbs):
|
||||
"""The 'session not found' early return must not leak the handle.
|
||||
|
||||
The stranded-session adoption fallback (#93296 follow-up) may lazily
|
||||
construct the SHARED launch handle via ``_get_db()`` while probing the
|
||||
default store for a donor row; that handle carries ``db_path=None`` and
|
||||
is never closed by design (see module docstring). Only the dedicated
|
||||
profile-scoped open (``db_path=<profile>/state.db``) is the caller's to
|
||||
close, so the leak assertion filters to path-scoped opens.
|
||||
"""
|
||||
resp = _resume(session_id="missing", profile="work")
|
||||
|
||||
assert resp["error"]["code"] == 4007
|
||||
scoped = [db for db in profile_dbs if db.db_path is not None]
|
||||
assert len(scoped) == 1
|
||||
assert scoped[0].closed == 1
|
||||
|
||||
|
||||
def test_deferred_desktop_resume_keeps_stored_workspace_provenance(
|
||||
profile_dbs, monkeypatch, tmp_path
|
||||
):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
|
||||
def _factory(db_path=None, **kwargs):
|
||||
db = _RecordingDB(db_path=db_path, **kwargs)
|
||||
db.rows["s1"] = {"id": "s1", "cwd": str(workspace)}
|
||||
profile_dbs.append(db)
|
||||
return db
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
|
||||
resp = _resume(session_id="s1", profile="work", source="desktop")
|
||||
session = server._sessions[resp["result"]["session_id"]]
|
||||
|
||||
assert session["cwd"] == str(workspace)
|
||||
assert session["explicit_cwd"] is True
|
||||
assert server._context_cwd_is_launch_artifact(session) is False
|
||||
|
||||
|
||||
def test_resume_closes_profile_db_when_reopen_fails(profile_dbs, monkeypatch):
|
||||
"""The 'resume failed' early return must not leak the handle."""
|
||||
|
||||
def _factory(db_path=None, **kwargs):
|
||||
db = _RecordingDB(db_path=db_path, **kwargs)
|
||||
db.rows["s1"] = {"id": "s1", "cwd": ""}
|
||||
db.reopen_error = RuntimeError("database is locked")
|
||||
profile_dbs.append(db)
|
||||
return db
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
|
||||
resp = _resume(session_id="s1", profile="work")
|
||||
|
||||
assert resp["error"]["code"] == 5000
|
||||
assert "resume failed" in resp["error"]["message"]
|
||||
assert profile_dbs[0].closed == 1
|
||||
|
||||
|
||||
def test_resume_closes_profile_db_on_live_session_fast_path(profile_dbs, monkeypatch):
|
||||
"""Re-resuming an already-live session returns early — and must close.
|
||||
|
||||
This is the hottest leak in practice: every reconnect/tile-paint resume of
|
||||
a chat that is already live takes this path, so the fd growth tracked
|
||||
reconnect count rather than anything rare.
|
||||
"""
|
||||
|
||||
def _factory(db_path=None, **kwargs):
|
||||
db = _RecordingDB(db_path=db_path, **kwargs)
|
||||
db.rows["s1"] = {"id": "s1", "cwd": ""}
|
||||
profile_dbs.append(db)
|
||||
return db
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
live_session = {}
|
||||
with server._sessions_lock:
|
||||
server._sessions["live-sid"] = live_session
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_find_live_session_by_key",
|
||||
lambda _key, *_a: ("live-sid", live_session),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_live_session_payload",
|
||||
lambda sid, session, **_kwargs: {"session_id": sid},
|
||||
)
|
||||
monkeypatch.setattr(server, "_child_run_active", lambda _key: False)
|
||||
|
||||
resp = _resume(session_id="s1", profile="work")
|
||||
|
||||
assert resp["result"]["resumed"] == "s1"
|
||||
assert profile_dbs[0].closed == 1
|
||||
|
||||
|
||||
def test_resume_closes_profile_db_on_deferred_cold_resume(profile_dbs, monkeypatch):
|
||||
"""The DEFAULT resume path returns before any transfer — and must close.
|
||||
|
||||
A cold resume without ``eager_build`` registers a deferred session record
|
||||
(no agent, no db reference) and builds the agent later off the response
|
||||
path, so the handle opened here is never handed to anyone.
|
||||
"""
|
||||
|
||||
def _factory(db_path=None, **kwargs):
|
||||
db = _RecordingDB(db_path=db_path, **kwargs)
|
||||
db.rows["s1"] = {"id": "s1", "cwd": ""}
|
||||
profile_dbs.append(db)
|
||||
return db
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {})
|
||||
|
||||
resp = _resume(session_id="s1", profile="work")
|
||||
|
||||
assert resp["result"]["session_key"] == "s1"
|
||||
assert resp["result"]["status"] == "idle"
|
||||
assert profile_dbs[0].closed == 1
|
||||
|
||||
|
||||
def test_resume_hands_profile_db_to_deferred_history_worker(profile_dbs, monkeypatch):
|
||||
"""Incremental hydration owns the profile handle until its read completes."""
|
||||
history_started = threading.Event()
|
||||
release_history = threading.Event()
|
||||
close_completed = threading.Event()
|
||||
|
||||
class _BlockingDB(_RecordingDB):
|
||||
def close(self):
|
||||
super().close()
|
||||
close_completed.set()
|
||||
|
||||
def get_resume_conversations(self, _target):
|
||||
history_started.set()
|
||||
assert release_history.wait(timeout=2.0)
|
||||
assert self.closed == 0
|
||||
return ([], [])
|
||||
|
||||
def _factory(db_path=None, **kwargs):
|
||||
db = _BlockingDB(db_path=db_path, **kwargs)
|
||||
db.rows["s1"] = {"id": "s1", "cwd": "", "message_count": 0}
|
||||
profile_dbs.append(db)
|
||||
return db
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {})
|
||||
monkeypatch.setattr(server, "_start_agent_build", lambda *_args, **_kwargs: None)
|
||||
|
||||
try:
|
||||
resp = _resume(
|
||||
session_id="s1", profile="work", defer_history=True
|
||||
)
|
||||
sid = resp["result"]["session_id"]
|
||||
db = profile_dbs[0]
|
||||
assert history_started.wait(timeout=1.0)
|
||||
assert db.closed == 0
|
||||
|
||||
release_history.set()
|
||||
assert server._sessions[sid]["resume_history_ready"].wait(timeout=1.0)
|
||||
assert close_completed.wait(timeout=1.0)
|
||||
assert db.closed == 1
|
||||
finally:
|
||||
release_history.set()
|
||||
|
||||
|
||||
def test_resume_keeps_profile_db_open_after_ownership_transfer(profile_dbs, monkeypatch):
|
||||
"""A COMPLETED resume transfers the handle to the agent — do not close it.
|
||||
|
||||
Guards the other direction: closing here would hand the live session a dead
|
||||
connection and fault every subsequent turn.
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
def _factory(db_path=None, **kwargs):
|
||||
db = _RecordingDB(db_path=db_path, **kwargs)
|
||||
db.rows["s1"] = {"id": "s1", "cwd": ""}
|
||||
profile_dbs.append(db)
|
||||
return db
|
||||
|
||||
def _fake_make_agent(sid, key, session_db=None, **_kwargs):
|
||||
captured["agent_db"] = session_db
|
||||
return types.SimpleNamespace(model="test")
|
||||
|
||||
def _fake_init_session(sid, key, agent, history, session_db=None, **_kwargs):
|
||||
captured["init_db"] = session_db
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
monkeypatch.setattr(server, "_make_agent", _fake_make_agent)
|
||||
monkeypatch.setattr(server, "_init_session", _fake_init_session)
|
||||
monkeypatch.setattr(server, "_set_session_context", lambda _target: [])
|
||||
monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None)
|
||||
monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {})
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent, *a: {"model": "test"})
|
||||
|
||||
resp = _resume(session_id="s1", profile="work", eager_build=True)
|
||||
|
||||
assert resp["result"]["session_key"] == "s1"
|
||||
db = profile_dbs[0]
|
||||
# The agent and the live session both took THIS handle...
|
||||
assert captured["agent_db"] is db
|
||||
assert captured["init_db"] is db
|
||||
# ...so the handler must have released ownership instead of closing it.
|
||||
assert db.closed == 0
|
||||
|
||||
|
||||
def test_resume_drops_half_built_session_when_init_session_raises(
|
||||
profile_dbs, monkeypatch
|
||||
):
|
||||
"""Closing the handle is only safe if the failed registration goes with it.
|
||||
|
||||
``_init_session`` publishes ``_sessions[sid]`` BEFORE its first read through
|
||||
the handle. If that read raises, the handle is still ours (and gets closed),
|
||||
so the half-built session must not stay registered — otherwise the
|
||||
live-session fast path serves that dead session on every later resume of the
|
||||
same id, forever, with "'NoneType' object has no attribute 'execute'".
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
def _factory(db_path=None, **kwargs):
|
||||
db = _RecordingDB(db_path=db_path, **kwargs)
|
||||
db.rows["s1"] = {"id": "s1", "cwd": ""}
|
||||
profile_dbs.append(db)
|
||||
return db
|
||||
|
||||
def _fake_init_session(sid, key, agent, history, session_db=None, **_kwargs):
|
||||
# Same ordering as the real one: register, THEN read through the db.
|
||||
captured["sid"] = sid
|
||||
with server._sessions_lock:
|
||||
server._sessions[sid] = {"agent": agent, "session_key": key}
|
||||
raise RuntimeError("database is locked")
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
monkeypatch.setattr(
|
||||
server, "_make_agent", lambda *a, **k: types.SimpleNamespace(model="test")
|
||||
)
|
||||
monkeypatch.setattr(server, "_init_session", _fake_init_session)
|
||||
monkeypatch.setattr(server, "_set_session_context", lambda _target: [])
|
||||
monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None)
|
||||
monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {})
|
||||
|
||||
resp = _resume(session_id="s1", profile="work", eager_build=True)
|
||||
|
||||
assert resp["error"]["code"] == 5000
|
||||
assert profile_dbs[0].closed == 1
|
||||
assert captured["sid"] not in server._sessions
|
||||
|
||||
|
||||
def test_resume_never_closes_shared_launch_db(profile_dbs, monkeypatch):
|
||||
"""No profile scope -> the shared ``_get_db()`` handle, which we never close."""
|
||||
shared = _RecordingDB(db_path="launch")
|
||||
monkeypatch.setattr(server, "_get_db", lambda: shared)
|
||||
|
||||
resp = _resume(session_id="missing")
|
||||
|
||||
assert resp["error"]["code"] == 4007
|
||||
assert profile_dbs == [] # no dedicated handle was opened
|
||||
assert shared.closed == 0
|
||||
|
||||
|
||||
def test_resume_eager_never_transfers_shared_launch_db(profile_dbs, monkeypatch):
|
||||
"""Regression #91610: an eager resume in the LAUNCH profile resolves the
|
||||
shared ``_get_db()`` handle and used to transfer ownership to the agent
|
||||
unconditionally — session.close() then closed the process-wide database
|
||||
under every unrelated session. The transfer must be gated on owns_db."""
|
||||
shared = _RecordingDB(db_path="launch")
|
||||
shared.rows["s1"] = {"id": "s1", "cwd": ""}
|
||||
monkeypatch.setattr(server, "_get_db", lambda: shared)
|
||||
|
||||
def _fake_make_agent(sid, key, session_db=None, **_kwargs):
|
||||
agent = types.SimpleNamespace(model="test")
|
||||
agent._session_db = session_db # the agent IS holding the shared handle
|
||||
agent._owns_session_db = False
|
||||
return agent
|
||||
|
||||
def _fake_init_session(sid, key, agent, history, session_db=None, **_kwargs):
|
||||
with server._sessions_lock:
|
||||
server._sessions[sid] = {"agent": agent, "session_key": key}
|
||||
|
||||
monkeypatch.setattr(server, "_make_agent", _fake_make_agent)
|
||||
monkeypatch.setattr(server, "_init_session", _fake_init_session)
|
||||
monkeypatch.setattr(server, "_set_session_context", lambda _target: [])
|
||||
monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None)
|
||||
monkeypatch.setattr(
|
||||
server, "_stored_session_runtime_overrides", lambda _found: {}
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent, *a: {"model": "test"})
|
||||
|
||||
resp = _resume(session_id="s1", eager_build=True)
|
||||
|
||||
assert resp["result"]["session_key"] == "s1"
|
||||
agent = server._sessions.get(resp["result"]["session_id"], {}).get("agent")
|
||||
assert agent is not None
|
||||
# Ownership never transferred: closing this one session must not own the
|
||||
# process-wide handle, and the shared handle stays open for others.
|
||||
assert agent._owns_session_db is False
|
||||
assert shared.closed == 0
|
||||
Reference in New Issue
Block a user