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:
parent
9bcc326207
commit
e38055a85e
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue