Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,63 @@ def _db_opens_cleanly(db_path: Path) -> Optional[str]:
return "; ".join(problems[:3])
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()

# FTS5 read probe: run a representative MATCH query against the
# messages_fts* virtual tables. The FTS *write* probe below catches
# the corruption class where base tables read fine but writes fail
# through the triggers (#50502). It does NOT catch partial FTS5
# index corruption — bad shadow-table segments where reads still
# parse but MATCH / snippet / rank queries error out with
# "database disk image is malformed" (a `sqlite3.DatabaseError`,
# not `OperationalError`). session_search, /resume title resolution,
# and any feature relying on FTS5 discovery then break silently
# because the official repair tool's check-only path reports the
# DB as healthy. #66724.
# Catch the full sqlite3 exception hierarchy (not just
# OperationalError) so the malformed-shadow-table class is reported
# rather than letting it crash the caller.
for fts_table in ("messages_fts", "messages_fts_trigram"):
try:
# No-op queries against the actual FTS5 APIs the search
# tools use. The trigram table is included because it backs
# the title-resolution path; either corruption mode would
# break session recall without this probe. MATCH '""' is
# the empty phrase-token probe — FTS5 rejects MATCH ''
# outright ("fts5: syntax error"), but a quoted empty
# phrase parses, scans zero rows, and exercises the same
# shadow-table read path the search tools use.
conn.execute(
f"SELECT 1 FROM {fts_table} WHERE {fts_table} MATCH '\"\"' LIMIT 1"
).fetchone()
except sqlite3.OperationalError as exc:
# Use the canonical capability classifier instead of a
# hand-rolled substring check. On SQLite builds without the
# fts5 module, the legacy messages_fts table may exist on
# disk (from a prior build that had FTS5) and MATCH queries
# against it raise OperationalError("no such module: fts5");
# the substring check below would misclassify that as
# corruption and send the DB into the repair path, whose
# final fallback deletes the messages_fts% schema
# (hermes_state.py:645-723). The supported degraded-runtime
# path (SessionDB._is_fts5_unavailable_error + the
# regression suite in tests/test_hermes_state.py:600-632)
# treats both "no such module: fts5" and
# "no such tokenizer: trigram" as the capability error.
if SessionDB._is_fts5_unavailable_error(exc):
# Degraded runtime — not the corruption class we probe.
continue
msg = str(exc).lower()
if "no such table" in msg or "no such column" in msg:
# FTS5 not built yet (brand new file mid-init) — not the
Comment thread
Enough1122 marked this conversation as resolved.
# corruption class we probe.
continue
return f"fts5 read probe failed on {fts_table}: {exc}"
except sqlite3.DatabaseError as exc:
# This is the corruption class #66724 actually wants caught:
# partial shadow-table damage where MATCH / snippet / rank
# queries raise DatabaseError("database disk image is malformed")
# while reads of the FTS5 table itself parse fine.
return f"fts5 read probe failed on {fts_table}: {exc}"

# FTS write probe: drive a row through the messages_fts* triggers in a
# transaction that is always rolled back, so a corrupt FTS index that
# rejects writes is caught even though reads look healthy. The probe is
Expand Down
154 changes: 154 additions & 0 deletions tests/test_state_db_malformed_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,160 @@ def test_repair_on_clean_db_is_noop(tmp_path):
conn.close()


# ── FTS read-corruption class (#66724) ───────────────────────────────────
# Even when writes succeed, partial FTS5 shadow-table damage makes MATCH /
# snippet / rank queries fail with DatabaseError("database disk image is
# malformed") while plain reads of the FTS5 table still parse. The read
# probe in _db_opens_cleanly must surface this corruption class as a reason
# so the repair path triggers, but it must NOT misclassify the supported
# degraded-runtime path (no fts5 module / no trigram tokenizer) as
# corruption — doing so would route a healthy degraded DB through the
# repair fallback that deletes the messages_fts% schema.


def _corrupt_fts_shadow_segments(db_path: Path) -> None:
"""Overwrite the FTS5 shadow b-tree blocks for ``messages_fts`` only.

Distinct from ``_corrupt_fts_index_data`` which targets the writes-side
trigger path; this targets the MATCH query path so the read probe is
what fires.
"""
conn = sqlite3.connect(str(db_path), isolation_level=None)
conn.execute("UPDATE messages_fts_data SET block = X'BADC0FFEE0DDF00D'")
conn.close()


def test_fts_read_corruption_detected_by_read_probe(tmp_path):
"""Partial shadow-table damage is caught by the FTS5 read probe.

Without the read probe, ``_db_opens_cleanly`` reports the DB healthy
even though ``session_search`` and ``/resume`` title resolution fail
with ``database disk image is malformed`` — the exact silent-fail
behavior reported in #66724.
"""
from hermes_state import _db_opens_cleanly

db_path = tmp_path / "state.db"
_build_healthy_db(db_path)
assert _db_opens_cleanly(db_path) is None

_corrupt_fts_shadow_segments(db_path)

reason = _db_opens_cleanly(db_path)
assert reason is not None
assert "messages_fts" in reason
assert "malformed" in reason.lower() or "database disk image" in reason.lower()


def test_fts_read_corruption_repaired_in_place(tmp_path):
"""``repair_state_db_schema`` rebuilds the FTS index so reads resume."""
from hermes_state import _db_opens_cleanly

db_path = tmp_path / "state.db"
_build_healthy_db(db_path)
_corrupt_fts_shadow_segments(db_path)

assert _db_opens_cleanly(db_path) is not None # unhealthy before

report = repair_state_db_schema(db_path)
assert report["repaired"] is True
assert _db_opens_cleanly(db_path) is None # healthy after rebuild

# Search back online.
db = SessionDB(db_path=db_path)
try:
hits = db._conn.execute(
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH 'pizza'"
).fetchone()[0]
assert hits >= 5
finally:
db.close()


# ── Degraded-runtime compatibility (regression for #66906 review) ────────
# The read probe must NOT misclassify a supported degraded runtime (no
# fts5 module / no trigram tokenizer) as corruption. If it did, a healthy
# degraded DB would be sent into the repair path, whose final fallback
# deletes the messages_fts% schema — breaking the very FTS tables that
# may have been inherited from a prior build that did have FTS5.


class _NoFts5RuntimeCursor(sqlite3.Cursor):
"""Simulate a runtime without the fts5 module: fts5 table exists but
MATCH queries raise the canonical capability error."""

def execute(self, sql, parameters=()):
probe = sql.strip()
if "MATCH" in probe and '""' in probe and "messages_fts " in probe:
raise sqlite3.OperationalError("no such module: fts5")
return super().execute(sql, parameters)


class _NoFts5RuntimeConnection(sqlite3.Connection):
def cursor(self, factory=None):
return super().cursor(factory or _NoFts5RuntimeCursor)


class _NoTrigramRuntimeCursor(sqlite3.Cursor):
"""Simulate a runtime with FTS5 but without the trigram tokenizer."""

def execute(self, sql, parameters=()):
probe = sql.strip()
if "MATCH" in probe and '""' in probe and "messages_fts_trigram" in probe:
raise sqlite3.OperationalError("no such tokenizer: trigram")
return super().execute(sql, parameters)


class _NoTrigramRuntimeConnection(sqlite3.Connection):
def cursor(self, factory=None):
return super().cursor(factory or _NoTrigramRuntimeCursor)


def test_fts_read_probe_returns_none_when_fts5_module_missing(tmp_path, monkeypatch):
"""Capability error on MATCH must not surface as corruption.

Simulates a healthy DB on a SQLite build without the fts5 module:
the messages_fts table exists (from a previous init on a build with
fts5) and MATCH queries raise the canonical "no such module: fts5".
_db_opens_cleanly must NOT classify this as corruption — otherwise
repair would be triggered and its final fallback would delete the
messages_fts% schema, breaking the search feature entirely.
"""
from hermes_state import _db_opens_cleanly

db_path = tmp_path / "state.db"
_build_healthy_db(db_path)

real_connect = sqlite3.connect

def connect_no_fts5(*args, **kwargs):
kwargs["factory"] = _NoFts5RuntimeConnection
return real_connect(*args, **kwargs)

monkeypatch.setattr("hermes_state.sqlite3.connect", connect_no_fts5)

# Healthy degraded DB → probe returns None. Repair path must NOT fire.
assert _db_opens_cleanly(db_path) is None


def test_fts_read_probe_returns_none_when_trigram_missing(tmp_path, monkeypatch):
"""Capability error on trigram MATCH must not surface as corruption."""
from hermes_state import _db_opens_cleanly

db_path = tmp_path / "state.db"
_build_healthy_db(db_path)

real_connect = sqlite3.connect

def connect_no_trigram(*args, **kwargs):
kwargs["factory"] = _NoTrigramRuntimeConnection
return real_connect(*args, **kwargs)

monkeypatch.setattr("hermes_state.sqlite3.connect", connect_no_trigram)

assert _db_opens_cleanly(db_path) is None


# ── FTS write-corruption class (#50502) ──────────────────────────────────
# A readable state.db can still reject every message write through the
# messages_fts* triggers when the FTS index is corrupt. Plain
Expand Down