From 11710c51fc3d2232d32562c8a6e9ab943c6b14b0 Mon Sep 17 00:00:00 2001 From: Frowtek Date: Fri, 17 Jul 2026 19:54:38 +0300 Subject: [PATCH] fix(state): self-heal FTS corruption on the SessionDB search path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements #66296 (self-heal on the write path): search_messages()'s main FTS5 MATCH query caught only sqlite3.OperationalError (a query-syntax error → return empty). A corrupt FTS index raises the malformed / "fts5: corrupt structure record" class, which is a sqlite3.DatabaseError — the parent of OperationalError, so it was NOT caught and propagated straight out of search_messages, crashing session/history search. The write path now rebuilds and retries on that class, but a read-only session (cron/CLI history search, or a search issued before any write) never triggers a write, so its search stayed broken until the next process restart ran the offline repair. Catch the DatabaseError corruption class on the search MATCH read too and route it through the existing one-shot _try_runtime_fts_rebuild(), then retry the query. The catch is moved outside `with self._lock` so rebuild_fts() can re-acquire the lock (mirrors _execute_write). The one-shot guard is shared with the write path, so a single instance never loops on a genuinely unrecoverable index. OperationalError syntax handling is unchanged (caught first). Adds a regression test: with a corrupted messages_fts and no post-corruption write, search_messages() rebuilds in place and returns the match; without the fix it raises DatabaseError. --- hermes_state.py | 25 +++++++++++++++++++------ tests/state/test_fts_runtime_rebuild.py | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index b25bd609783a..07fbcbcd5d28 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -5758,13 +5758,26 @@ class SessionDB: like_cursor = self._conn.execute(like_sql, like_params) matches = [dict(row) for row in like_cursor.fetchall()] else: - with self._lock: - try: + try: + with self._lock: + cursor = self._conn.execute(sql, params) + matches = [dict(row) for row in cursor.fetchall()] + except sqlite3.OperationalError: + # FTS5 query syntax error despite sanitization — return empty + return [] + except sqlite3.DatabaseError as exc: + # A corrupt FTS index raises the malformed / "fts5: corrupt + # structure record" class on the MATCH read, the same class the + # write path self-heals (#66296). OperationalError (query + # syntax) is a subclass caught above; this arm is the corruption + # parent. Rebuild the index in place once — the lock is released + # here, so rebuild_fts() can re-acquire it — and retry, so + # search self-heals for read-only sessions (cron/CLI history + # search) that never trigger a write to repair it first. + if not self._try_runtime_fts_rebuild(exc): + raise + with self._lock: cursor = self._conn.execute(sql, params) - except sqlite3.OperationalError: - # FTS5 query syntax error despite sanitization — return empty - return [] - else: matches = [dict(row) for row in cursor.fetchall()] # Add surrounding context (1 message before + after each match). diff --git a/tests/state/test_fts_runtime_rebuild.py b/tests/state/test_fts_runtime_rebuild.py index 45c7a7c53b40..cf91b474e12a 100644 --- a/tests/state/test_fts_runtime_rebuild.py +++ b/tests/state/test_fts_runtime_rebuild.py @@ -93,6 +93,29 @@ class TestRuntimeFtsRebuild: raw.close() assert len(hits) == 1 + def test_search_messages_self_heals_after_fts_corruption(self, db, tmp_path): + """A read-only session that only SEARCHES (no write after corruption) + must self-heal too. The MATCH read raises the corruption class + (DatabaseError / 'fts5: corrupt structure record'), NOT the + OperationalError that search_messages caught — so before this fix the + search crashed until a write or restart rebuilt the index. + """ + if not db._fts_enabled: + pytest.skip("FTS5 unavailable in this build") + db.create_session("s1", source="test") + db.append_message("s1", "user", "a searchable needle here") + + _corrupt_fts(tmp_path / "state.db") + # Injected via a raw connection, so no write on THIS instance has + # consumed the one-shot rebuild yet. + assert db._fts_runtime_rebuild_attempted is False + + results = db.search_messages("needle") + + assert db._fts_runtime_rebuild_attempted is True # the search rebuilt it + assert results # non-empty: the rebuilt index matched the query + assert any("needle" in (r.get("snippet") or "") for r in results) + def test_rebuild_is_one_shot_per_instance(self, db, tmp_path): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build")