Conversation
…ssionDB x thread)
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing the thread-local lifetime and the tracked-close path; current main still has the reported premise at hermes_state.py:1855-1865, 2080-2115, and 2647-2656.
Problems
hermes_state.py:2179-2181uses a nonblocking idle-cache checkout. When N readers arrive while the pool is empty, all N can open a connection;maxsize=8only limits later returns at2201-2205. This does not bound peak live descriptors, so a sufficiently concurrent burst can still hit EMFILE.tests/test_session_db_read_conn_pool.py:65-76checks counts only after joining workers, so it cannot detect that peak.
Suggested changes
- Bound open plus checked-out readers (for example, a nonblocking permit with locked-writer fallback once exhausted), and add a barrier-based peak-registry test.
Automated hermes-sweeper review.
| if not self._wal_active or self.read_only: | ||
| return None | ||
| try: | ||
| return self._read_pool.get_nowait() |
There was a problem hiding this comment.
maxsize bounds only idle entries. If many readers reach this empty queue together, each falls through to _get_read_conn() and opens a connection, so peak tracked connections/descriptors remain unbounded. Please cap checked-out plus idle connections too, with fallback rather than an additional open once the cap is reached.
| for t in threads: | ||
| t.start() | ||
| for t in threads: | ||
| t.join() |
There was a problem hiding this comment.
This assertion runs after every worker has returned its connection, so it cannot prove the live descriptor bound during the concurrent interval. Use a barrier to hold more than maxsize readers inside _read_ctx, then assert the registry count before releasing them.
…urns
Review of this PR was right that maxsize=8 bounds the wrong thing. The
LifoQueue caps how many connections are RETURNED; _checkout_read_conn opened
unconditionally on a miss, so N readers arriving on a cold pool all missed, all
opened, and peaked at N. The surplus was closed on release, so nothing
accumulated forever -- but EMFILE is a peak-instant condition and the burst
that empties the pool is exactly the burst that exhausts the fd table, so the
original wedge was still reachable. Measured on the previous commit: 64
concurrent readers held 64 live connections at once.
A connection now holds a permit for its whole lifetime -- acquired in
_get_read_conn() before the open, released in _close_read_conn() after the
close -- so open+checked-out is bounded together. A pool hit costs no permit
because the connection it hands back already holds one, which leaves
_get_read_conn() as the only place that can open. The acquire is non-blocking:
past the ceiling readers fall back to the locked writer connection rather than
queueing, since blocking would convert descriptor exhaustion into a stall,
which is the same outage with a different stack trace. Same burst now peaks at
8. BoundedSemaphore rather than Semaphore so an unpaired release raises instead
of silently widening the ceiling.
Two latent leaks in the same function, found while doing this:
- a CJK extension load that failed after a successful open returned None
without closing the connection, leaking a descriptor the tracking registry
still counted -- the same leak shape one level down;
- any non-sqlite3.Error between open and return stranded a permit
permanently, which would ratchet the ceiling down to zero and silently
demote every later read to the writer lock.
On the test: the existing one joins every worker before counting, so it
measures the pool at rest and structurally cannot observe peak -- which is why
this got through. The new one uses a barrier so all 64 workers hold their
connections until every worker has checked out, making the count taken at that
moment the actual simultaneous peak. Verified it fails against the previous
commit (64 checked out, 65 live) and passes at 8/9. Also covers the
writer-connection fallback, permit recovery after a failed open, and that
close() releases exactly the permits it drained.
|
Pushed in 5b78c93 — you were right, and the test is why I could not see it. The flaw. The fix. A connection now holds a permit for its whole lifetime — acquired in
On the test. Agreed it structurally could not see this: the existing one joins every worker before counting, so it measures the pool at rest by construction. The new one uses a barrier — all 64 workers hold their connections until every worker has checked out, so the count taken at that moment is the actual simultaneous peak. Verified it fails against the previous commit and passes now: Also added coverage for the writer-connection fallback, permit recovery after a failed open, and that Two latent leaks turned up in the same function while doing this, both fixed here:
208 tests pass across the state suite. Rebase status: the branch is on |
|
Production validation found two shutdown races in this bounded-pool design:
A robust fix registers both in-flight opens and active leases under one One additional hardening: if |
|
Merged via PR #83406 — your commits were cherry-picked onto current main with your authorship preserved in git log (rebase merge). Thank you for the contribution! The omnibus combined the WAL read-connection pool (#76700), the configurable nofile floor (#77587), the parent-death watchdog (#73066), the Desktop-boot orphan serve reap incl. the backend.lock.json spare-guard (#78873), and the orphan gateway reap (#78312). All pieces were live-tested end-to-end: real orphaned processes reaped on actual Desktop boot with a lock-owned backend surviving, 151→9 connections under 150 reader threads, and zero fd growth over 800 requests against a live serve. |
Problem.
SessionDB._get_read_conn(hermes_state.py:2065) opens its read-onlyconnection without
check_same_thread=False, unlike both writer opens(
hermes_state.py:1909,:1957). It caches that connection in athreading.local()(
:1855) and pins it in a strong setself._read_conns(:1860) that is drained onlyby
close().Starlette dispatches sync routes on anyio worker threads, so every distinct thread
that ever performs a read pins a new connection — and two fds, the database and its
-wal— for the life of the process. Nothing prunes the set. The growth is unboundedin practice on SessionDBs that are never closed: the dashboard's module-global
_db(
tui_gateway/server.py:148, assigned:1170, never closed) and each_sessions[sid]["session_db"].Two independent consequences:
fd exhaustion. The process walks into its
RLIMIT_NOFILE(256 soft, which iswhat a launchd-managed service gets on macOS), after which every request fails with
OSErrorEMFILE while the process stays alive — so a process supervisor'srestart-on-exit never fires and the service hangs in a broken-but-running state
rather than restarting.
The leaked connections are permanently unclosable. Because they lack
check_same_thread=False,conn.close()raisessqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread.close()swallowsit in a bare
except Exception: pass(hermes_state.py:2651-2655), so the fdssurvive
close()too, andhas_live_connection()keeps reporting them — whichpermanently refuses the byte-probe-guarded raw-copy backup in
_backup_db_file(hermes_state.py:1009) for that path.Note this is not the sibling registry-drift bug — that one is already fixed
upstream by [Bug]: sqlite_safe_read untracks a connection before closing it, so a failed close leaves the byte-probe guard believing nothing is live #75629, which reordered
_TrackingMixin.close()(
hermes_cli/sqlite_safe_read.py:154-164) to untrack only after a successfulsuper().close(). Post-[Bug]: sqlite_safe_read untracks a connection before closing it, so a failed close leaves the byte-probe guard believing nothing is live #75629 the registry is accurate; it reports these connectionsas live because they genuinely are. The count is correct and the leak is still there.
This bug class was already fixed once in this repo:
gateway/readiness.py:35-40carries a
closing(...)guard citing #69678 / #69567. The SessionDB read path wasmissed.
Change. Three parts, in
hermes_state.pyonly:check_same_thread=Falseto the read-only open (:2132post-patch),matching the writer opens. Required for (b): pooled connections are handed between
threads, which is exactly what the default thread check forbids — including on
close().threading.local()+ unbounded strong set with a boundedqueue.LifoQueue(maxsize=8)(:1872;import queueadded at:23, previouslyabsent). Acquisition goes through one seam,
_checkout_read_conn()(:2166), whichapplies the WAL/
read_onlygate, borrows withget_nowait(), and opens a freshconnection only on
queue.Empty;_read_ctx(:2183) calls it and in afinally:returns the connection with
put_nowait()— closing it instead when the pool is fullor when
close()has already drained. Checkout/return gives exclusive ownership forthe duration of the block, so no two threads ever touch one connection concurrently.
close()(:2726-2733) drains the queue instead of the set. LIFO keeps the workingset hot and lets idle connections age out at the tail.
except Exception: passwith a logged failure in_close_read_conn(:2152). A close that fails leaks a tracked fd; it must not beinvisible.
_get_read_conn's "read-only opens don't work here" memo moves from the thread-local toan instance-wide backoff timestamp
_read_open_failed_at(:1893), gated on_READ_OPEN_RETRY_SECONDS(60 s,:242). With a shared pool the open is no longer aper-thread event, and the locked writer connection still serves reads while the backoff
holds.
It must be a timestamp rather than a sticky boolean, and this is worth stating explicitly
because the boolean is the obvious implementation: the likeliest trigger for a failed
read-only open is transient fd pressure — the very condition this patch exists to
prevent — and a
SessionDBis commonly shared process-wide (the gateway hands oneinstance to every agent). A permanent flag would therefore convert one momentary EMFILE
blip into a permanent global convoy, every reader serialized behind the writer lock for
the life of the process. That is a worse failure than the leak being fixed. The expiring
stamp lets the read path self-heal.
The non-WAL /
read_onlyfallback to the locked writer connection is unchanged.Evidence/Repro. 25 short-lived reader threads per round against a temp DB, joined
each round, measuring pinned connections and the live-connection registry:
Measured on this branch's base (
cd6585ab) vs. this branch:Pre-patch grows +25 pinned connections and +50 descriptors per round without bound — two
fds per connection, the database and its
-wal. 509 open descriptors on one temp DBafter 250 threads; a 256-fd soft limit is crossed at roughly 125. Post-patch is flat.
After
db.close(): pre-patch the registry still reports 250 live connections (everycross-thread
conn.close()raisedProgrammingErrorinto the bareexcept, so those250 fds are still open — the registry is right); post-patch it reports 0.
The post-patch fd count plateaus above the 9 live connections because SQLite's unix VFS
parks a closed descriptor on a per-inode reuse list while any connection still holds
POSIX locks on that inode — the same
close()-cancels-all-locks hazard documented inhermes_cli/sqlite_safe_read.py. Those descriptors are handed back to later opens ratherthan accumulating: the plateau tracks peak concurrency, not cumulative threads. This
is also why the tests assert on pool/registry counts and never on
lsof.Tests. Added
tests/test_session_db_read_conn_pool.py(9 tests): pool stays boundedacross 150 short-lived threads; connections are returned and reused; a pooled connection
is usable off-thread;
close()drains the pool and returns the registry to zero for aconnection opened on a since-dead thread; a read racing
close()does not repopulate thepool; concurrent reads stay correct; the backoff stamp expires; the checkout seam is the
only acquisition point; and the locked-writer fallback still serves reads.
8 of those 9 fail against the unpatched tree (the ninth is the
reads-stay-correct-under-concurrency invariant, which must hold either way).
Regression sweep: 28 state/sqlite files (331 passed, 0 failed) and 631
gateway/dashboard/tui-gateway files (4892 passed). Two gateway failures in that sweep —
test_systemd_notify.py::test_notify_supports_systemd_abstract_socketandtest_shutdown_forensics.py::TestSpawnAsyncDiagnostic::test_spawns_subprocess_and_writes_output— reproduce identically on unpatched
cd6585abon the same machine (Linux-only abstractsockets; a subprocess spawn) and are unrelated to this change.
One existing test encodes the old contract and is updated with this patch:
tests/test_session_db_read_path_split.py:42_get_read_connis now an opener, not a per-thread cache, so it returns a freshconnection each call. It is replaced in place by
test_read_conn_reused_via_pool, whichpins the equivalent post-patch contract — two successive
_read_ctx()blocks yield thesame object — rather than deleting the coverage. The file's module docstring is updated
to describe the pool rather than the thread-local.
Two other tests in that file were checked and deliberately left alone.
test_read_conn_is_per_thread(:29) still passes and still pins a real contract:concurrent readers never share a connection. That guarantee simply moves from
threading.local()to checkout/return exclusivity.test_read_conn_open_failure_marks_thread(
:89) also still passes — the "don't retry the failed open on every query" property itasserts is exactly what the backoff stamp preserves; only the scope of the memo changed
(per-thread → per-instance, with expiry), and the expiry half is covered by the new
test_read_open_failure_backs_off_but_recovers.One note on a test that no longer exists.
test_fallback_when_read_conn_unavailablemonkeypatched
_get_read_connto returnNoneto exercise the locked-writer fallback.It was removed from
tests/test_session_db_read_path_split.pyupstream (along withtest_title_resolution_does_not_take_writer_lock) some time beforecd6585ab, so thereis nothing to repoint. The coverage is restored in the new file as
test_fallback_to_locked_writer_when_read_conn_unavailable, patched at the_checkout_read_connseam rather than at_get_read_conn— because once acquisitionchecks the pool first, a patched
_get_read_connis never reached while the pool is warmand the test would keep passing while exercising nothing.
test_checkout_seam_is_the_single_acquisition_pointis added to keep it that way: itfails if a future edit re-inlines the pool checkout into
_read_ctxand silentlyre-orphans the fallback test.
Alternatives considered.
check_same_thread=Falsealone (a, without b). Makesclose()work, but leavesthe set growing one connection per thread for the process's life. The production
failure is a dashboard that runs for days without ever calling
close(), so thisfixes only the shutdown symptom, not the outage.
reaper, and still peaks at the number of live worker threads. A bounded pool caps
the connection count directly and is far less machinery.
weakref.WeakSetfor_read_conns. Would let dead threads' connections becollected, but relies on GC timing for fd release under an fd-exhaustion failure mode,
and connections are still closed from the wrong thread. Deterministic return-to-pool
is strictly better.
was added to remove (a measured 0.2s FTS query stretched to 112s).
maxsize. 8 covers the observed concurrent-read fan-out with headroom; above it,surplus connections are closed on release rather than queued, so the pool degrades to
open/close per read instead of blocking or leaking.