Skip to content

fix(tui): close profile-owned SessionDB handles on teardown - #78970

Open
SilentKnight87 wants to merge 1 commit into
NousResearch:mainfrom
SilentKnight87:fix/tui-profile-db-close
Open

fix(tui): close profile-owned SessionDB handles on teardown#78970
SilentKnight87 wants to merge 1 commit into
NousResearch:mainfrom
SilentKnight87:fix/tui-profile-db-close

Conversation

@SilentKnight87

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes a file-descriptor leak in the dashboard/TUI gateway: every session scoped to a non-launch profile builds a dedicated SessionDB for that profile's state.db and hands it to its AIAgent — but nothing ever closed it. AIAgent.close() intentionally leaves _session_db open (its last step is end_session() through that handle, and on ordinary sessions the handle is the process-shared _get_db() that must stay open), so the dedicated profile handles had no owner. Each one holds ~5 fds (db + WAL + SHM + read conn), pinned by the token-writer atexit registration, so session churn (desktop chat switching + the TTL/LRU reapers) grows the fd table monotonically. A long-lived dashboard under app-global remote mode exhausts the macOS launchd 256-fd soft limit and dies in an EMFILE storm (auth reads, cron jobs, skills, log writes, socket.accept). Observed in production: 255/256 fds, ~224 SQLite-related, repeated handles on the same profile state.db files.

This is the same leak class as #69678 (fixed for the delivery/delegation/verification ledgers) on a surface that fix didn't cover. It's also distinct from the WAL-reader-per-thread work in #75269/#75546/#74304 (read-pool internals) and from #78500 (notification-poller profile scoping): those bound other fd sources; this one gives the agent-held profile handle an owner.

The ownership contract: the shared _get_db() launch handle is borrowed and never closed; a dedicated profile handle is owned by exactly one session record (under _owned_session_db) and closed exactly once at teardown, strictly after agent.close() (which still writes end_session through it). This extends the (db, owns_handle) idiom the file already uses for scoped handles (_db_for_profile / _profile_db / _session_db) across time instead of scope.

Origin: the leak arrived with 6f6eb87 (#39993, deferred builds), and the same unowned pattern was repeated by the eager resume path (02d6bf1), session.branch (29dd621), and the compute host (7d27a31). The teardown fix in 6da970f predates profile DBs and closed only the agent.

Related Issue

Related: #69678 (same fd-leak class; closed — its fix covered the ledgers but not this tui_gateway surface). No open issue tracks this specific leak.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Review tip: git diff -w — the session.resume non-eager phase was mechanically reindented into a try/finally, which inflates the raw line count.

Changes Made

  • tui_gateway/server.py
    • New _close_session_agent_and_owned_db(): single teardown disposal helper. Claims the agent and the owned db atomically under _sessions_lock (a concurrent claimant takes both resources or neither, so the db can never be closed out from under agent.close()'s final end_session write), then closes agent first, db second. _teardown_session routes through it.
    • _start_agent_build: publishes agent + profile db to the live session atomically iff the sid still maps to this record; a build that lost the publish race disposes of its own locals with _end_session_on_close=False (a deferred resume's row belongs to the still-resumable conversation, not the discarded agent); a _make_agent failure closes the db in the finally. The build thread deliberately does not also close published resources when it later finds the session detached — publish and pop are totally ordered by _sessions_lock, so the closer that popped the session always tears it down itself, and a second closer would race _finalize_session's unflushed-message persist and end_session writes.
    • _init_session grows owned_session_db= so eager construction sites can record ownership at registration time (same lock as registration, so a concurrent close always finds the handle it must close). Registration refuses to displace a live record, so a closer's pop-claim is the only way a registered record leaves the slot (two compute-host turns racing one sid can no longer strand the loser's resources). A failed _init_session no longer strands a half-initialized registered session (nobody ever receives its sid, so nothing could close it until the idle reaper hours later): it reclaims its record with the same atomic identity-checked pop teardown uses and re-raises — unmarked when the pop proves no concurrent closer touched the record (agent/db revert to the caller's locals, which every caller disposes of), marked _resources_claimed_by_closer when a concurrent close won the pop (that closer closes both resources; callers must not double-close). Callers mutate the record _init_session returns instead of re-looking up the registry, so a concurrent pop can't divert them into the local-close path.
  • tui_gateway/methods_session.py
    • Eager session.resume (eager_build): ownership transferred via _init_session; the double-checked-locking race loser is released with _end_session_on_close=False (it must not end_session the durable row the winner owns) and closes its profile db after its agent; build/init failures close the local agent + db unless the exception is marked claimed-by-closer. The stale "the agent OWNS a long-lived db handle" comment now documents the actual contract.
    • session.resume's whole non-eager phase runs under one try/finally, closing its transient profile read handle on every exit — not-found, fast live reuse, lazy watch, deferred, and any mid-read exception. TrackedConnection untracks a path only on close(), so dropping the handle for GC frees the fds but permanently inflates _live_connections and disables byte-probe recovery for that state.db.
    • session.branch: branch_db ownership transferred via _init_session; the failure path disposes of the built agent + db under the same claimed-by-closer guard.
  • tui_gateway/compute_host.py
    • _ensure_server_session: closes the profile db if _make_agent raises; transfers ownership via _init_session; the minimal fallback record is built complete (ownership included) and published atomically under _sessions_lock only into an empty slot; a claimed failure re-raises instead of resurrecting closed handles, and losing the slot to a concurrent initializer disposes of the losing build's resources with _end_session_on_close=False so the winner's durable row is never finalized by the loser.
  • tests/test_tui_gateway_server.py — 20 regression tests (see below).

How to Test

  1. scripts/run_tests.sh tests/test_tui_gateway_server.py — 536 passed (517 existing + 19 new, plus two strengthened). New coverage: owned profile db closed exactly once at teardown (deferred build, eager resume, branch); shared launch handle never closed; _make_agent failure closes the db; close-during-build and close-after-publish races close each resource exactly once; repeated teardown is idempotent; the agent closes while its db is still open (so end_session still lands); init-failure unregisters the record and returns ownership to the caller (no ghost session); init-failure with a concurrent close marks the exception and nothing double-closes or resurrects; registration refuses to displace a live record; every discarded duplicate agent (resume loser, compute-host slot loser, detached deferred build, failed eager-resume init) releases without end_session; the transient read handle closes on every non-eager exit including mid-read exceptions; and test_profile_session_churn_keeps_fd_count_stable, which churns 13 sessions with real SessionDB instances and asserts the process fd table stays flat (the incident regression — it fails by ~40+ fds without the fix). All were written red-first against the unfixed code.
    Note: while validating, test_write_json_serializes_concurrent_writes was observed to flake (~1 in 4 under heavy machine load) on the unmodified base as well — an extra event line lands in its patched stdout from a thread leaked by an earlier test in the file. Pre-existing, unrelated to this change; happy to file it separately.
  2. scripts/run_tests.sh tests/test_tui_gateway_server.py tests/test_hermes_state.py tests/test_session_db_read_path_split.py tests/hermes_state tests/state — 801 passed, 0 failed.
  3. Full suite (controlled comparison, same machine + venv): 25,211 passed on this branch vs 25,200 on a pristine base checkout — byte-identical failing set of 72 (all pre-existing environment-dependent tests: missing optional extras like anthropic/daytona/fal), delta exactly the new tests. Re-run on the final commit posted as a PR comment.
  4. Live validation on the affected deployment: before the fix the dashboard sat at 255/256 fds and EMFILE'd twice; after cutover fd count settled at 38 and stayed flat under health-check load, with no further EMFILE.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run the test suite via scripts/run_tests.sh and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Apple Silicon)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — docstrings/comments updated in the touched functions
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — the fd-count regression test skips where /dev/fd is unavailable
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

Second-incident evidence (before fix): dashboard process at 255 open fds against the 256 launchd soft limit, ~224 SQLite-related, repeated open handles on the same profile state.db/WAL/SHM paths, EMFILE spreading from platform probes to auth reads, cron, skills, log writes, and socket.accept. After fix: fd count stable at 38 across session churn and sustained health checks.

Every gateway session built for a non-launch profile constructs a
dedicated SessionDB (that profile's state.db) and hands it to its
AIAgent. AIAgent.close() deliberately leaves _session_db open (it
finalizes the session row through it as its last step), and nothing else
owned the handle — so each profile chat leaked its db+WAL+SHM fds (kept
alive by the token-writer atexit registration) until the dashboard
process hit the 256-fd launchd soft limit and started failing with
EMFILE ([Errno 24] Too many open files) on auth reads, cron jobs,
skills, log files, and socket.accept. Same leak class as NousResearch#69678, on the
tui_gateway surface.

The ownership contract this change introduces: the shared _get_db()
launch handle is borrowed and never closed; a dedicated profile handle
is owned by exactly one session record under _owned_session_db and
closed exactly once at teardown, strictly after agent.close() (which
still writes end_session through it); and a handle that was only read
from is closed on every exit — return or raise — of the non-eager
resume phase (TrackedConnection untracks a path only on close(), so a
handle dropped for GC would permanently inflate _live_connections and
disable byte-probe recovery for that state.db).

- _close_session_agent_and_owned_db(): single teardown helper. Claims
  the agent and the owned db atomically under _sessions_lock (a
  concurrent claimant takes both or neither, so the db can never be
  closed out from under agent.close()'s final end_session write),
  then closes agent first, db second.
- _start_agent_build: publishes agent + profile db to the live session
  atomically iff the sid still maps to this record; a build that lost
  the publish race disposes of its own locals; a _make_agent failure
  closes the db in the finally. The build thread deliberately does NOT
  also close published resources when it later finds the session
  detached: publish and pop are totally ordered by _sessions_lock, so
  the closer that popped the session always tears it down itself, and a
  second closer would race _finalize_session's unflushed-message
  persist and end_session writes.
- _init_session grows owned_session_db so the eager construction sites
  record ownership too: session.resume (eager_build), session.branch
  (branch_db), and the compute-host session bootstrap. Registration
  refuses to displace a live record (two compute-host turns racing one
  sid can no longer strand the loser's resources), so a closer's
  pop-claim is the only way a registered record leaves the slot. A
  failed _init_session no longer strands a half-initialized registered
  session: it reclaims its record with the same atomic pop teardown
  uses and re-raises — unmarked when the pop proves no closer touched
  it (the agent/db revert to the caller's locals, which every caller
  then disposes of), marked _resources_claimed_by_closer when a
  concurrent close won the pop (that closer closes both; callers must
  not double-close). Callers mutate the returned record rather than
  re-looking up the registry, so a concurrent pop can't divert them
  into the local-close path; the compute-host fallback is built
  complete (ownership included) and published atomically only into an
  empty slot.
- Every discarded duplicate agent — the resume double-checked-locking
  loser, the compute-host initializer that lost its sid, and the
  deferred build whose session was closed before publication, and the
  eager resume whose _init_session failed — is
  released with _end_session_on_close=False so it cannot end_session a
  durable row that the winning session (or the still-resumable stored
  conversation) owns.
- session.resume's whole non-eager phase runs under try/finally, so
  the transient profile read handle closes on every exit — not-found,
  fast live reuse, lazy watch, deferred, and any mid-read exception.

Regression tests: owned profile db closed exactly once at teardown
(deferred build, eager resume, branch), shared launch handle never
closed, build-failure and close-during/after-build races, repeated
teardown idempotency, agent-before-db close ordering, init-failure
unregistration with ownership return, init-failure concurrent-close
claim marking (no double-close, no ghost session, no fallback
resurrection), registration displacement refusal, discarded-duplicate
release without end_session (resume loser and detached deferred build),
transient-handle close on the deferred path and on mid-read exceptions,
and a real-SessionDB churn test asserting the process fd table stays
flat.
@SilentKnight87

Copy link
Copy Markdown
Contributor Author

Full-suite re-run on the exact PR commit (a6d80bd), same environment as the controlled comparison in the description: 25,436 passed, 72 failed — the failing set is byte-identical to the pristine-base baseline (all pre-existing environment-dependent tests; missing optional extras). Zero regressions attributable to this change.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/tui Terminal UI (ui-tui/ + tui_gateway/) area/sessions Session lifecycle, resume, persistence, history area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 5, 2026
@riyaazd29

Copy link
Copy Markdown

Independent validation + production incident evidence for this fix (we hit this bug for real):

Production repro (this PR fixes a real failure): a long-lived hermes serve backend (the desktop app's per-profile backend) running the current main leaked one state.db connection per desktop session open via session.resume. Over ~22h it accumulated 226 fds in the profile dir, crossed the soft limit, and every tool call died with Errno 24. The gateway process also hit its own fd ceiling from per-thread reader connections (that part is #76700's territory — no overlap here).

Independent A/B verification (close-count instrumentation on SessionDB.__init__/close, profile-scoped resume + teardown cycles):

  • Unpatched main: 6 resume cycles → 6 dedicated SessionDBs created, 6 never closed (1 leaked per resume, exactly the hot path this PR fixes).
  • Patched (equivalent changes): 0 leaked; only the shared launch handle stays open by design.
  • Fast-path live-reuse, session-not-found, _make_agent failure, and branch-failure exits all verified closed.

Test evidence: full tui_gateway + state suites green on the patched tree (1049 passed, 0 failed).

Review note — no gaps found. I specifically looked for: not-found return, fast-path reuse, eager _make_agent/_init_session exception handlers, branch failure, teardown double-close, and the shared-handle guard. The handed_off flag + single finally covers every exit; the _end_session_on_close = False on discarded agents and the _resources_claimed_by_closer guard are both correct and cover cases my own first pass missed. The atomic pop-under-lock in _close_session_agent_and_owned_db is the right shape.

Happy to help review the next revision or test anything specific. One suggestion: this deserves a mention in the PR description that the desktop serve backend (hermes serve + desktop session browsing) is the primary user-visible victim — it's what makes the blast radius concrete for reviewers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping area/sessions Session lifecycle, resume, persistence, history comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants