fix(state): close the read-only connection when the FTS probe fails

The RO branch's new FTS capability probe raises sqlite3.DatabaseError on
a malformed store (the probe itself only catches OperationalError). The
outer __init__ handler re-raises without closing self._conn, leaking a
tracked connection for the process lifetime — which makes
_backup_db_file refuse its raw-copy, so the writable heal that follows
(web_server's stale-schema/malformed reopen) repairs the store WITHOUT
the forensic backup repair_state_db_schema promises. Close-then-reraise
on any probe failure, mirroring _open_probed's cleanup discipline.

Regression test: corrupt sqlite_master (duplicate messages_fts row),
assert the failed RO open leaves no live tracked connection and the
subsequent writable heal creates its malformed-backup file. Mutation-
checked: no-oping the cleanup handler makes the test fail.
This commit is contained in:
kshitijk4poor 2026-08-02 21:26:42 +05:30 committed by kshitij
parent 9bcc326207
commit e38055a85e
2 changed files with 71 additions and 11 deletions

View File

@ -1914,18 +1914,32 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
# FTS capability flags normally come from writable schema
# initialisation. Probe existing virtual tables with SELECTs
# only so read-only search keeps its FTS and trigram paths.
cursor = self._conn.cursor()
self._fts_enabled = (
self._fts_table_probe(cursor, "messages_fts") is True
)
if self._fts_enabled:
self._trigram_available = (
self._fts_table_probe(
cursor,
"messages_fts_trigram",
)
is True
# Close the connection on ANY probe failure (e.g. malformed
# schema raises DatabaseError, not the OperationalError the
# probe handles): the outer except re-raises without cleanup,
# and a leaked tracked connection blocks _backup_db_file's
# raw-copy for the rest of the process — the writable heal
# that follows would then repair WITHOUT its forensic backup.
try:
cursor = self._conn.cursor()
self._fts_enabled = (
self._fts_table_probe(cursor, "messages_fts") is True
)
if self._fts_enabled:
self._trigram_available = (
self._fts_table_probe(
cursor,
"messages_fts_trigram",
)
is True
)
except BaseException:
conn, self._conn = self._conn, None
try:
conn.close()
except Exception:
pass
raise
return
self.db_path.parent.mkdir(parents=True, exist_ok=True)

View File

@ -134,6 +134,52 @@ class TestConnectionLifecycle:
"fts-read-only"
]
def test_failed_read_only_open_does_not_leak_tracked_connection(
self, tmp_path
):
"""A malformed store makes the RO FTS probe raise DatabaseError.
The connection must be closed on that failure path: a leaked tracked
connection blocks _backup_db_file's raw-copy for the process
lifetime, so the writable heal that follows would repair WITHOUT its
forensic backup."""
import sqlite3
from hermes_cli.sqlite_safe_read import has_live_connection
db_path = tmp_path / "state.db"
writable = SessionDB(db_path=db_path)
writable.create_session("s1", source="cli")
writable.append_message("s1", role="user", content="leak probe")
writable.close()
# Corrupt sqlite_master: duplicate messages_fts definition. Any
# statement on a fresh connection then raises "malformed database
# schema" (DatabaseError, not the OperationalError the probe eats).
conn = sqlite3.connect(str(db_path), isolation_level=None)
conn.execute("PRAGMA writable_schema=ON")
row = conn.execute(
"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_master "
"WHERE name='messages_fts'"
).fetchone()
assert row is not None
conn.execute(
"INSERT INTO sqlite_master (type,name,tbl_name,rootpage,sql) "
"VALUES (?,?,?,?,?)",
row,
)
conn.execute("PRAGMA writable_schema=OFF")
conn.close()
with pytest.raises(sqlite3.DatabaseError):
SessionDB(db_path=db_path, read_only=True)
assert has_live_connection(db_path) is False
# The writable heal must still take its forensic backup.
healed = SessionDB(db_path=db_path, read_only=False)
healed.close()
assert list(tmp_path.glob("*malformed-backup*"))
# =========================================================================
# Session lifecycle