Conversation
|
Thanks for tracing the pre-transfer resume paths; the leak premise is confirmed on current main: Problems
Suggested changes
Automated hermes-sweeper review. |
Follow-up to the review on the session.resume ownership fix. Closing the
pre-transfer early returns left two gaps, both real.
1. The transfer had no owner on the other side. Once ownership moved to the
agent, teardown ran AIAgent.close() (via _teardown_session on session.close
and the orphaned-session reaper), which called session_db.end_session() —
that finalizes the session ROW, not the connection. A successfully resumed
profile session kept its dedicated handle, its db/-wal/-shm fds and its
background token-writer thread for the life of the gateway.
AIAgent now carries an explicit _owns_session_db, defaulting False so the
SHARED launch handle — which outlives every agent and backs every other live
session — is still never closed there. Only the dedicated-open sites set it,
at the point ownership actually changes hands.
2. session.resume was not the only profile-scoped open with no close on its
failure paths. Covered here with the same flag, via a _transfer_db_to_agent
helper that refuses the transfer unless the agent really holds that handle:
- the deferred builder (_start_agent_build), including the session-reaped-
mid-build case, where the built agent is discarded and never torn down, so
transferring to it would leak exactly as before;
- session.branch's branch_db;
- the compute host's per-profile open;
- AIAgent's own lazy open in _get_session_db_for_recall, which no other
object ever references and so was unconditionally abandoned.
Where a handle has already reached a registered session, the drop is
unconditional and the transfer is best-effort on top: a refused transfer leaves
the old leak, which is survivable, whereas closing under a live session is the
permanent "Cannot operate on a closed database" break the original patch exists
to avoid.
Tests: tests/tui_gateway/test_session_db_ownership_teardown.py (new, 14).
11 of the 14 fail without this change; the 3 that pass are the "must NOT close"
guards, which hold in both directions by design.
183a906 to
9c62a1b
Compare
|
Both confirmed, both fixed in Teardown. Right: after the transfer, The reason The other pre-transfer paths. Fixed with the same flag, via a small
One deliberate asymmetry, in case it looks inconsistent on review: where a Tests. 11 of the 14 fail without the change. The 3 that pass are the "must NOT close" |
|
Merged via #81071 — both your commits were cherry-picked onto current main with your authorship preserved (rebase-merge; commit 1 re-applied onto main's drifted session.resume with the hunk set verified identical), plus one follow-up test pinning the raising-close swallow/no-retry contract. Your analysis of the atexit token-writer pinning and the ten pre-transfer early returns was exactly right and is what made this the canonical fix for the fd-leak class. Thanks! |
Problem. In app-global remote mode,
session.resumeresumes a session thatlives in another local profile's
state.db. For that case it opens adedicated writer handle (
tui_gateway/methods_session.py:320-327):That handle belongs to the caller until it is handed to the long-lived agent —
_init_sessionexplicitly does not close a caller-suppliedsession_db(
_init_owns_dbstays False,tui_gateway/server.py:6366-6368). But thehandler returns on ten paths before that transfer and closes it on none of
them: session-not-found (
:350), the live-session fast path (:397), thelazy/watch failure, concurrent-winner and success returns (
:422,:436,:454), the cold-resume failure, concurrent-winner and success returns(
:501,:527,:553), the eager "resume failed" return (:608), and thedouble-checked-locking discard that throws away a just-built agent (
:637).Two of those are not edge cases:
:397is every reconnect/tile-paint resume ofan already-live chat, and
:553is the default cold resume —eager_buildis off unless a caller asks for it, so an ordinary "switch to this chat" in a
profile scope goes down a path that never transfers the handle at all.
The codebase already has the right pattern twice —
_profile_db(
tui_gateway/server.py:1208-1222) and_session_db(
tui_gateway/server.py:2637-2662) are contextmanagers that close a dedicatedprofile handle and leave the shared one alone.
session.resumeis the oneprofile-aware open that does neither.
Why this is worth fixing: an abandoned
SessionDBis released only when thegarbage collector gets to it, and as soon as anything holds a strong reference
it is pinned for the life of the process —
SessionDBpins itself the firsttime its background token writer starts, via
atexit.register(self._drain_token_queue_at_exit)(hermes_state.py:4081),which only
close()unregisters (hermes_state.py:2639). The running writerthread holds a second strong reference.
Change. Give the handler explicit ownership, in the style of the two
existing contextmanagers: an
owns_dbflag set only on the profile-scoped open,a
try/finallyaround the whole body, and a close in thefinally.The one subtlety is where ownership ends. It is cleared on exactly one line —
immediately after
_init_session(...)returns — because that is the point theregistered session's agent takes the handle for its lifetime. Clearing it
earlier, at the
_make_agent(..., session_db=db)call (:595-602), would bewrong: the double-checked-locking branch below can still discard that agent and
return, and that path must close. Clearing it later would be wrong too —
closing after a successful transfer hands the live session a dead connection and
faults every subsequent turn with "Cannot operate on a closed database". The
in-code comment at
:320-321("the agent OWNS a long-lived db handle … do NOTauto-close") describes only the post-transfer state; it is reworded to say when
ownership actually changes hands.
The shared launch handle keeps its existing semantics:
owns_dbstays False, soit is never closed here.
One case the
finallyalone gets wrong, and must be handled with it._init_sessionregisters_sessions[sid](tui_gateway/server.py:6334)before its first read through the handle (
:6381). If that read raises —database is lockedis the realistic trigger — ownership has not transferred,so the new
finallycorrectly closes the handle; but the half-built session isalready registered and holds a reference to it through its agent. The
live-session fast path (
methods_session.py:397) then serves that dead sessionon every later resume of the same id, and the chat is permanently broken with
AttributeError: 'NoneType' object has no attribute 'execute'. Before thispatch the same failure merely leaked the handle and the session kept working, so
adding the
finallyin isolation converts a leak into a hard, stickyuser-visible break.
The fix is to undo the registration on that path: in the
except(
methods_session.py:680),if owns_db: with _sessions_lock: _sessions.pop(sid, None)before returning the error.owns_dbstill being True is precisely thesignal that the transfer did not happen and the registration is ours to remove.
_sessions_lockis a reentrantRLock(tui_gateway/server.py:152), so takingit there is safe.
Evidence/Repro. Drive the profile-scoped
session not foundearly returnand count the handles the handler opened vs. closed:
What an abandoned handle costs once it is pinned rather than collected — same 25
iterations, with the token writer started per handle (
queue_token_counts, thecall
agent/conversation_loop.py:3331makes on every turn), fds counted after aforced
gc.collect():Being explicit about the measurement: on the early-return paths as they exist
today none of those calls start the token writer, so the abandoned handles are
reclaimed by the cycle collector — 14 fds were still open against the profile's
state.dbwhen the 25-iteration loop finished, and a forcedgc.collect()drovethat to 0. So this patch removes a latent contract violation, moves release from
the collector's schedule to the return itself, and makes the pinned case
unreachable; it is not a fix for an observed fd exhaustion.
Tests.
tests/tui_gateway/test_session_resume_db_ownership.py(new, 7tests) pins both directions: the pre-transfer early returns (session-not-found,
"resume failed", the live-session fast path, the default deferred cold resume)
each close the dedicated handle; a resume that completes the transfer leaves it
open and asserts the agent and the live session both received that handle; an
_init_sessionthat raises after registering leaves nothing behind in_sessions; and the shared launch handle is never closed.Without the change, 5 of the 7 fail (the two "must NOT close" tests pass, as
they should). With the
finallybut without the_sessions.pop, exactly thehalf-built-registration test fails.
Alternatives considered. Wrapping the open in the existing
_profile_dbcontextmanager is the obvious move and does not work here:
_profile_dbclosesunconditionally on exit, but this handler must keep the handle open on the one
path that transfers it to the agent. A
contextlib.ExitStackwithpop_all()on the transfer would express that, but it is heavier than a boolean for a
single conditional close and reads less like the surrounding code. Making
_init_sessiontake ownership of the handle it is passed was rejected as awider behavioural change — its other callers pass the shared handle.