fix(hermes_state): bound per-thread read-connection cache to prevent fd leak - #75546
fix(hermes_state): bound per-thread read-connection cache to prevent fd leak#75546RGerrish wants to merge 3 commits into
Conversation
…fd leak SessionDB._get_read_conn() opens a per-thread read-only SQLite connection, caches it in threading.local, and pins it in the strong _read_conns set, which only drains on close(). Long-lived SessionDB handles (dashboard global _get_db(), gateway runner) are never closed for the process lifetime, so every distinct worker thread that ever reads leaves a permanent main+wal+shm connection behind. The dashboard backend leaked ~80 state.db connections (~160 fds) in ~10h, pinned against RLIMIT_NOFILE (256), and every unrelated open started failing with EMFILE/Errno 24 — the desktop session died on os.scandir during /api/profiles/sessions. Fix: cap _read_conns at _MAX_READ_CONNS=32. On overflow, evict+close the whole generation and bump _read_gen; threads holding stale per-thread connections detect the mismatch and lazily reopen. Bounded set, no permanent leak, reopen cost is one connect per evicted thread.
8a4b0fb to
c5e6a4e
Compare
|
Thanks for identifying a real lifetime issue in the WAL read path: current main retains each per-thread reader in Problems
Suggested changes
Automated hermes-sweeper review. |
… bound Addresses reviewer feedback on the generation-eviction bound (PR NousResearch#75546): - Eviction no longer closes other threads' connections from the registering thread without synchronization. Every read connection carries a per-conn RLock held for the whole _read_ctx block, and _close_read_conn acquires that lock before closing, so a connection is never closed mid-statement (check_same_thread=False makes the cross-thread close legal). - A connection is only removed from _read_conns after close() SUCCEEDED. A failed or busy close is re-registered so close() at shutdown retries it — a live fd is never dropped from the registry it is reachable through, establishing a real bound on live descriptors. - Concurrent double-close is impossible (conn._hermes_read_closed guard); sqlite3 does not tolerate racing close() calls on the same connection. - _read_ctx re-verifies the connection is still the current generation after acquiring its lock (eviction can race the handoff) and reopens under a fresh generation, bounded, falling back to the locked writer path under pathological churn. - _TrackingMixin.close() now untracks only after the underlying close succeeds, so a failed close can no longer leave a live fd untracked from the byte-probe guard. Tests: multi-wave WAL test with idle first-wave threads (the evictor, not the owners, must close them), in-flight-read eviction test, failed-close registration + tracking test, shutdown drain + untrack test.
Revision addressing reviewer feedbackThanks for the review — all three points are addressed in the follow-up commit ( 1. Real bound on live descriptors (was: evicted objects orphaned in thread-locals, unreachable by close())The evictor no longer relies on the owner thread to close stale connections. On overflow it closes every evicted connection itself, synchronously, through
So a live fd is never dropped from the registry it is reachable through, and the set size is an honest bound on live descriptors. Added 2. Owner-safe cross-thread close (was: close-from-arbitrary-thread + hidden failures + untrack-before-close)
3. Regression coverageFour new tests in
Verification
|
The bounded read cache (_MAX_READ_CONNS=32) stops runaway growth but a long-lived handle that goes quiet still parks at the cap forever - observed ~50 conns / ~240 FDs on an idle dashboard, 94% of the macOS 256 soft limit. A per-instance daemon sweeper now closes connections idle past _READ_CONN_IDLE_TIMEOUT even under the bound, and owners detect the close via conn._hermes_read_closed and reopen lazily (no generation bump, so active threads keep warm connections). Adds tests/test_session_db_idle_eviction.py covering the sweeper: a quiet connection is closed within one sweep interval, an active one survives the sweep, and the sweeper never runs for read_only instances.
|
Added a third commit to this PR: idle-eviction for the bounded read-conn cache ( Why it's needed: the What it does: a per-instance daemon sweeper ( Design constraints preserved from commits 1–2:
Tests: |
|
Resolved on main by PR #83406 (rebase-merged), which landed a bounded read-connection pool for SessionDB: reads borrow from a LIFO pool (max 8) with a lifetime permit per open connection, bounding PEAK descriptors, and close() drains from any thread. Your diagnosis of the (SessionDB x thread) accumulation was correct — thank you for working on this. The pool approach from #76700 was chosen among the four competing fixes because it bounds peak descriptors rather than reaping after the fact; @Yishova's commits carry the class fix, with credit to all four submitters for converging on the root cause. |
Summary
SessionDB._get_read_conn()opens a per-thread read-only SQLite connection under WAL, caches it inthreading.local, and pins it in the strong setself._read_conns. That set is only drained byclose(). For process-lifetimeSessionDBhandles — the desktop dashboard backend's global_get_db(), and the gateway runner's SessionDB —close()never runs, so every unique worker thread that ever performs a read leaves a permanentstate.db+state.db-wal(+-shm) connection behind. Under sustained polling the thread pool churns, the cache grows unboundedly, the process pins againstRLIMIT_NOFILE, and unrelated opens (evenos.scandiron the skills tree during/api/profiles/sessions) start failing withEMFILE/Errno 24.Reproduction path
hermes dashboard— launchd-managed in our setup,KeepAlive) for several hours. It keeps one process-lifetimeSessionDBvia_get_db()(tui_gateway/server.py).lsof -p <pid> | grep -c 'state.db'grow monotonically. In our case: ~80 leaked connections ≈ 160 fds after ~10h (evening local session + a morning remote session from a second machine).launchctl limit maxfiles= 256, NOT the interactive-shellulimit -nfrom.zshrc). After that, every new open fails — the desktop session dies withOSError: [Errno 24] Too many open fileson a filesystem scan (/api/profiles/sessions→_count_skills→rglob→os.scandir), which is the visible symptom.The broken code:
hermes_state.pySessionDB._get_read_conn()— per-thread connection registered inself._read_conns(strong set), drained only inclose(); the cache has no bound.Fix
Bound the per-thread read-connection cache:
_MAX_READ_CONNS = 32— cap onself._read_conns.self._read_genon the instance._get_read_conn():_read_gen, close the stale connection and reopen (threads whose connection was evicted lazily re-establish).len(_read_conns) >= _MAX_READ_CONNS, evict + close the whole generation (close every tracked connection, clear the set, bump_read_gen).Safety: eviction only closes idle read-only WAL connections; the writer connection and
close()semantics are untouched. The reopen cost is onesqlite3.connectper evicted thread — negligible compared to pinning the whole process against its fd limit.Why a cap instead of just raising RLIMIT_NOFILE
The leak is unbounded (one connection per unique thread), so a higher limit only delays the crash. Bumping the limit would also require launchd plist changes since daemons don't inherit shell ulimits; a cap fixes the root cause for every deployment.
Verification
len(_read_conns)bounded (16 << 32), generation bumped on overflow.scripts/run_tests.sh): 196 passed / 0 failed acrosstest_session_db_read_path_split,test_hermes_state,test_hermes_state_compression_locks,test_hermes_state_readonly_preflight,test_hermes_state_wal_fallback,test_zeroed_state_db,test_pty_session,test_web_server_sessiondb_eventloop.state.dbhandles 160 → 0, stable over repeated checks. Gateways restarted clean with the fix.Related
process_registryPopen/PTY handles). This is the same symptom ("file descriptor limit" / Errno 24) from a different source: SQLite read connections on long-lived handles rather than subprocess pipes.