fix(state): close the SessionDB lock gate's blind spot on its own mixin files
test_no_locked_readers_gate.py (#97676) parses hermes_state.py's SessionDB class body with ast and flags any method that holds the writer lock around a pure-read query — Pattern C, where every concurrent turn's persistence convoys behind an unrelated read. #97676 converted 39 such methods and closed detection blind spots for alias/variable-SQL readers. But SessionDB is declared as `class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)`, and the gate only ever opened hermes_state.py — it never parsed the three mixin files those base classes are defined in, so a locked reader declared there was structurally invisible to the scanner regardless of how good the alias/variable-SQL detection got. Applying the gate's exact scanning logic to the three mixin files directly turns up 9 genuine pure-read methods still holding the writer lock, none in #97676's converted list: - hermes_state_search.py: _fts_teardown_trash_step, fts_optimize_available, optimize_fts_storage, list_recent_user_messages - hermes_state_portability.py: distinct_session_cwds, list_cron_job_runs, _get_session_rich_rows_batch, list_skill_scaffolded_sessions, get_first_assistant_text _get_session_rich_rows_batch is a hot path: it backs list_sessions_rich's compression-tip resolution and the web server's session-search hydration across every gateway install — its own docstring already claims "same read-your-writes guarantee as list_sessions_rich", but list_sessions_rich was already using _read_ctx() (its guarantee comes from flush_token_counts() before the read, not from holding the writer lock) while this method's implementation never caught up to match. Converted all 9 to `with self._read_ctx() as conn:`, the exact pattern #97676 used, verified each is a genuine pure read with no hidden writes by tracing every helper call it makes. Extended the gate itself (_ALL_STATE_SOURCES) to scan all three mixin files under their own class names, plus hermes_state.py, so this blind spot can't silently reopen. Added a regression test (test_scan_all_state_sources_visits_every_mixin_file) that plants a synthetic violation in a mixin-shaped file and asserts the scanner still catches it — a change that reverts the file list back to one file passes the existing sabotage test but fails this one. Mutation-verified: with the gate's new scope but the old (unconverted) mixin sources, test_no_locked_pure_readers fails and names all 9 real violations with correct file/line. Restored the fix; it passes clean.
This commit is contained in:
+10
-10
@@ -54,8 +54,8 @@ class SessionPortabilityMixin:
|
||||
where = "cwd IS NOT NULL AND TRIM(cwd) != ''"
|
||||
if not include_archived:
|
||||
where += " AND archived = 0"
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
with self._read_ctx() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT cwd AS cwd, COUNT(*) AS sessions, "
|
||||
"MAX(COALESCE(ended_at, started_at, 0)) AS last_active "
|
||||
f"FROM sessions WHERE {where} GROUP BY cwd"
|
||||
@@ -119,8 +119,8 @@ class SessionPortabilityMixin:
|
||||
ORDER BY s.started_at DESC, s.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(query, (prefix, prefix_hi, limit, offset))
|
||||
with self._read_ctx() as conn:
|
||||
cursor = conn.execute(query, (prefix, prefix_hi, limit, offset))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
runs: List[Dict[str, Any]] = []
|
||||
@@ -202,8 +202,8 @@ class SessionPortabilityMixin:
|
||||
{prompt_join}
|
||||
WHERE s.id IN ({placeholders})
|
||||
"""
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(query, ids)
|
||||
with self._read_ctx() as conn:
|
||||
cursor = conn.execute(query, ids)
|
||||
rows = cursor.fetchall()
|
||||
result: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
@@ -229,8 +229,8 @@ class SessionPortabilityMixin:
|
||||
Returns ``id``, ``title``, and the full first-turn ``content`` so a
|
||||
caller can re-derive what the user typed. Newest first.
|
||||
"""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
with self._read_ctx() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT s.id, s.title, m.content
|
||||
FROM sessions s
|
||||
@@ -254,8 +254,8 @@ class SessionPortabilityMixin:
|
||||
Pairs with :meth:`list_skill_scaffolded_sessions` so a re-title can feed
|
||||
the titler the same (request, reply) shape the live path uses.
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
with self._read_ctx() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content FROM messages "
|
||||
"WHERE session_id = ? AND role = 'assistant' AND content IS NOT NULL "
|
||||
"ORDER BY timestamp, id LIMIT 1",
|
||||
|
||||
Reference in New Issue
Block a user