"""Bounded FTS-corruption recovery on live SessionDB paths. A corrupted FTS5 shadow table (``messages_fts_data``) makes every message write raise ``sqlite3.DatabaseError: database disk image is malformed`` through the FTS sync triggers, while the canonical ``messages`` rows stay intact. Before this fix the gateway swallowed the failure at debug level and the in-memory session advanced while disk silently fell behind — surfacing later as "Persisted transcript lagged live cached history" amnesia. The fix records a durable stale marker, detaches the FTS sync triggers, and retries the canonical write immediately. Live search degrades to canonical ``LIKE`` queries. The existing guarded stale-open or explicit repair path may rebuild later, outside the failed live write/search operation. """ import json import os import sqlite3 from types import SimpleNamespace import pytest import hermes_state import hermes_state_schema from hermes_state import ( FTS_REBUILD_DEFERRAL_KEY, FTS_STALE_KEY, LEGACY_FTS_SQL, LEGACY_FTS_TRIGRAM_SQL, SCHEMA_SQL, SessionDB, _FTS_TRIGGERS, _concrete_state_db_holder_pids, _is_inactive_orphan_desktop_holder, ) @pytest.fixture def db(tmp_path): d = SessionDB(db_path=tmp_path / "state.db") yield d try: d.close() except Exception: pass def _corrupt_fts(db_path): raw = sqlite3.connect(str(db_path)) raw.execute( "UPDATE messages_fts_data SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'" ) raw.commit() raw.close() def _corrupt_trigram_fts(db_path): raw = sqlite3.connect(str(db_path)) raw.execute( "UPDATE messages_fts_trigram_data " "SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'" ) raw.commit() raw.close() def _message_contents(db_path): raw = sqlite3.connect(str(db_path)) rows = raw.execute("SELECT content FROM messages ORDER BY id").fetchall() raw.close() return [r[0] for r in rows] def _meta_value(db_path, key): raw = sqlite3.connect(str(db_path)) row = raw.execute( "SELECT value FROM state_meta WHERE key = ?", (key,) ).fetchone() raw.close() return None if row is None else row[0] def _base_fts_triggers(db_path): raw = sqlite3.connect(str(db_path)) rows = raw.execute( "SELECT name FROM sqlite_master WHERE type = 'trigger' " f"AND name IN ({','.join('?' for _ in _FTS_TRIGGERS)})", _FTS_TRIGGERS, ).fetchall() raw.close() return {row[0] for row in rows} class TestRuntimeFtsRebuild: def test_reap_candidates_exclude_uninspectable_holder_suspicions( self, tmp_path ): db_path = tmp_path / "state.db" assert _concrete_state_db_holder_pids( db_path, [ (222, "uninspectable holder: python -m hermes_cli.main serve --port 0"), (-1, "open-file scan failed"), ], ) == [] def test_reap_candidates_deduplicate_multiple_proven_watched_fds(self, tmp_path): db_path = tmp_path / "state.db" assert _concrete_state_db_holder_pids( db_path, [ (222, str(db_path)), (222, f"{db_path}-wal"), (222, f"{db_path}-shm (deleted)"), ], ) == [222] def test_inactive_orphan_reap_predicate_preserves_live_or_ambiguous_holders(self): common = { "ppid": 1, "age_seconds": 120.0, "min_age_seconds": 60.0, "ephemeral_backend": True, "connection_statuses": [], } assert _is_inactive_orphan_desktop_holder(**common) assert not _is_inactive_orphan_desktop_holder(**{**common, "ppid": 42}) assert not _is_inactive_orphan_desktop_holder( **{**common, "age_seconds": 10.0} ) assert not _is_inactive_orphan_desktop_holder( **{**common, "ephemeral_backend": False} ) assert not _is_inactive_orphan_desktop_holder( **{ **common, "connection_statuses": ["ESTABLISHED"], } ) def test_foreign_holder_detection_includes_deleted_wal( self, db, tmp_path, monkeypatch ): db_path = tmp_path / "state.db" class FakePsutil: @staticmethod def process_iter(_attrs): return iter( ( SimpleNamespace( info={ "pid": 111, "open_files": [SimpleNamespace(path=str(db_path))], } ), SimpleNamespace( info={ "pid": 222, "open_files": [ SimpleNamespace(path=f"{db_path}-wal (deleted)") ], } ), SimpleNamespace( info={ "pid": 333, "open_files": [SimpleNamespace(path=str(tmp_path / "other.db"))], } ), ) ) monkeypatch.setattr(hermes_state, "psutil", FakePsutil) monkeypatch.setattr(hermes_state, "_IS_WINDOWS", False) monkeypatch.setattr(hermes_state.os, "getpid", lambda: 111) # Force the macOS/psutil path even on Linux test runners monkeypatch.setattr(hermes_state.sys, "platform", "darwin") assert db._foreign_state_db_holders() == [ (222, f"{db_path}-wal (deleted)") ] def test_foreign_holder_detection_proc_readlink_deleted_wal( self, db, tmp_path, monkeypatch ): """Linux /proc//fd readlinks preserve '(deleted)' suffix. psutil.open_files() drops these entries (isfile_strict stats the literal path and fails). The /proc path catches the split-brain holder that psutil silently misses. """ db_path = tmp_path / "state.db" db_path_wal = str(db_path) + "-wal" # Build a fake /proc with two PIDs: self (111) and foreign (222). proc_root = tmp_path / "proc" for pid in (111, 222, 333): fd_dir = proc_root / str(pid) / "fd" fd_dir.mkdir(parents=True) # PID 222 holds the deleted WAL sidecar os.symlink(db_path_wal + " (deleted)", str(proc_root / "222" / "fd" / "3")) # PID 111 (self) holds the db — should be excluded os.symlink(str(db_path), str(proc_root / "111" / "fd" / "3")) # PID 333 holds an unrelated file other = tmp_path / "other.db" other.touch() os.symlink(str(other), str(proc_root / "333" / "fd" / "3")) monkeypatch.setattr(hermes_state, "_IS_WINDOWS", False) monkeypatch.setattr(hermes_state.os, "getpid", lambda: 111) monkeypatch.setattr(hermes_state.sys, "platform", "linux") real_listdir = os.listdir def _listdir(path): if isinstance(path, str): path = path.replace("/proc", str(proc_root)) return real_listdir(path) monkeypatch.setattr(hermes_state.os, "listdir", _listdir) real_readlink = os.readlink def _readlink(path): path = path.replace("/proc", str(proc_root)) return real_readlink(path) monkeypatch.setattr(hermes_state.os, "readlink", _readlink) holders = db._foreign_state_db_holders() assert holders == [(222, db_path_wal + " (deleted)")] def test_foreign_holder_uninspectable_process_cmdline_fallback( self, db, tmp_path, monkeypatch ): """A process whose fd table is unreadable (different user) is still flagged when /proc//cmdline identifies it as a Hermes process.""" db_path = tmp_path / "state.db" proc_root = tmp_path / "proc" for pid in (111, 222): (proc_root / str(pid) / "fd").mkdir(parents=True) # PID 222's fd dir is unreadable (PermissionError) os.chmod(proc_root / "222" / "fd", 0o000) # PID 222's cmdline is world-readable and looks like Hermes cmdline_path = proc_root / "222" / "cmdline" cmdline_path.write_bytes(b"python3\x00hermes_cli.main\x00chat\x00") monkeypatch.setattr(hermes_state, "_IS_WINDOWS", False) monkeypatch.setattr(hermes_state.os, "getpid", lambda: 111) monkeypatch.setattr(hermes_state.sys, "platform", "linux") real_listdir = os.listdir def _listdir(path): if isinstance(path, str): path = path.replace("/proc", str(proc_root)) return real_listdir(path) monkeypatch.setattr(hermes_state.os, "listdir", _listdir) # _read_proc_cmdline opens /proc//cmdline directly; redirect # it to our fake proc tree. def _fake_cmdline(pid): fake_path = str(proc_root / str(pid) / "cmdline") try: with open(fake_path, "rb") as f: raw = f.read() if not raw: return None return raw.replace(b"\x00", b" ").decode("utf-8", "replace").strip() except OSError: return None monkeypatch.setattr(hermes_state, "_read_proc_cmdline", _fake_cmdline) holders = db._foreign_state_db_holders() # Should include PID 222 with the cmdline info assert len(holders) == 1 assert holders[0][0] == 222 assert "hermes_cli.main" in holders[0][1] # Cleanup os.chmod(proc_root / "222" / "fd", 0o755) def test_corruption_error_classification_requires_fts_evidence(self): """Generic structural corruption must not enter live FTS repair. Older SQLite builds may use the generic malformed-image text for an FTS virtual-table failure, but still expose SQLITE_CORRUPT_VTAB. Preserve that route while failing closed for unscoped SQLITE_CORRUPT errors. """ generic = sqlite3.DatabaseError("database disk image is malformed") assert not SessionDB._is_fts_write_corruption_error(generic) structural = sqlite3.DatabaseError("database disk image is malformed") structural.sqlite_errorcode = sqlite3.SQLITE_CORRUPT structural.sqlite_errorname = "SQLITE_CORRUPT" assert not SessionDB._is_fts_write_corruption_error(structural) fts_virtual_table = sqlite3.DatabaseError("database disk image is malformed") fts_virtual_table.sqlite_errorcode = sqlite3.SQLITE_CORRUPT_VTAB fts_virtual_table.sqlite_errorname = "SQLITE_CORRUPT_VTAB" assert SessionDB._is_fts_write_corruption_error(fts_virtual_table) contradictory = sqlite3.IntegrityError( 'fts5: corrupt structure record for table "messages_fts"' ) contradictory.sqlite_errorcode = sqlite3.SQLITE_CONSTRAINT_TRIGGER contradictory.sqlite_errorname = "SQLITE_CONSTRAINT_TRIGGER" assert not SessionDB._is_fts_write_corruption_error(contradictory) assert SessionDB._is_fts_write_corruption_error( sqlite3.DatabaseError( 'fts5: corrupt structure record for table "messages_fts"' ) ) assert not SessionDB._is_fts_write_corruption_error( sqlite3.DatabaseError("no such table: nothing_fts_related") ) def test_structural_corruption_propagates_without_live_fts_mutation( self, db, tmp_path, monkeypatch ): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") rebuild_called = False def _unexpected_rebuild(): nonlocal rebuild_called rebuild_called = True raise AssertionError("structural corruption must not rebuild FTS") monkeypatch.setattr(db, "rebuild_fts", _unexpected_rebuild) structural = sqlite3.DatabaseError("database disk image is malformed") structural.sqlite_errorcode = sqlite3.SQLITE_CORRUPT structural.sqlite_errorname = "SQLITE_CORRUPT" with pytest.raises(sqlite3.DatabaseError) as caught: db._execute_write(lambda _conn: (_ for _ in ()).throw(structural)) # Structural corruption quarantines the handle: the typed error wraps # the original (cause preserved, SQLite result code copied) and the # sticky flag is set, so later writes fail fast. from hermes_state import StateDbCorruptError assert isinstance(caught.value, StateDbCorruptError) assert caught.value.__cause__ is structural assert caught.value.sqlite_errorcode == sqlite3.SQLITE_CORRUPT assert db._db_corrupt is True assert rebuild_called is False assert db._fts_stale is False assert _meta_value(tmp_path / "state.db", FTS_STALE_KEY) is None assert _base_fts_triggers(tmp_path / "state.db") == set(_FTS_TRIGGERS) def test_fts_looking_constraint_error_does_not_mutate_fts( self, db, tmp_path, monkeypatch ): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") rebuild_called = False def _unexpected_rebuild(): nonlocal rebuild_called rebuild_called = True raise AssertionError("contradictory error code must fail closed") monkeypatch.setattr(db, "rebuild_fts", _unexpected_rebuild) contradictory = sqlite3.IntegrityError( 'fts5: corrupt structure record for table "messages_fts"' ) contradictory.sqlite_errorcode = sqlite3.SQLITE_CONSTRAINT_TRIGGER contradictory.sqlite_errorname = "SQLITE_CONSTRAINT_TRIGGER" with pytest.raises(sqlite3.IntegrityError) as caught: db._execute_write(lambda _conn: (_ for _ in ()).throw(contradictory)) assert caught.value is contradictory assert rebuild_called is False assert db._fts_stale is False assert _meta_value(tmp_path / "state.db", FTS_STALE_KEY) is None assert _base_fts_triggers(tmp_path / "state.db") == set(_FTS_TRIGGERS) def test_append_defers_rebuild_after_fts_corruption( self, db, tmp_path, monkeypatch ): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "hello world") _corrupt_fts(db_path) monkeypatch.setattr( db, "rebuild_fts", lambda: pytest.fail("live write must not rebuild the full FTS index"), ) # The canonical write survives without waiting for a full index scan. msg_id = db.append_message("s1", "user", "healed append") assert msg_id is not None assert _message_contents(db_path) == [ "hello world", "healed append", ] assert db._fts_stale is True assert _meta_value(db_path, FTS_STALE_KEY) == "1" assert _base_fts_triggers(db_path) == set() def test_search_works_from_canonical_rows_after_fail_open(self, db, tmp_path): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "before corruption") _corrupt_fts(db_path) db.append_message("s1", "user", "searchable needle text") results = db.search_messages("needle") assert results assert any("needle" in (row.get("snippet") or "") for row in results) assert db._fts_stale is True def test_search_messages_defers_rebuild_after_fts_corruption( self, db, tmp_path, monkeypatch ): """A read-only session that only SEARCHES (no write after corruption) must stay available without starting an unbounded index scan. """ if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "a searchable needle here") _corrupt_fts(db_path) monkeypatch.setattr( db, "rebuild_fts", lambda: pytest.fail("live search must not rebuild the full FTS index"), ) results = db.search_messages("needle") assert db._fts_stale is True assert _meta_value(db_path, FTS_STALE_KEY) == "1" assert _base_fts_triggers(db_path) == set() assert results assert any("needle" in (r.get("snippet") or "") for r in results) def test_trigram_search_defers_rebuild_after_fts_corruption( self, db, tmp_path, monkeypatch ): """The CJK/trigram MATCH branch has the same read-corruption exposure as the main FTS5 branch and must fall back to canonical rows. """ if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") if not db._trigram_available: pytest.skip("trigram tokenizer unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "关于大别山项目的进展报告") _corrupt_trigram_fts(db_path) monkeypatch.setattr( db, "rebuild_fts", lambda: pytest.fail("live search must not rebuild the full FTS index"), ) # >=3 CJK chars per token → routed to the trigram branch. results = db.search_messages("大别山项目") assert db._fts_stale is True assert _meta_value(db_path, FTS_STALE_KEY) == "1" assert _base_fts_triggers(db_path) == set() assert results assert any("大别山项目" in (r.get("snippet") or "") for r in results) def test_corruption_fails_open_and_rebuilds_on_reopen(self, db, tmp_path): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "seed") _corrupt_fts(db_path) db.append_message("s1", "user", "corruption survives") assert _message_contents(db_path) == [ "seed", "corruption survives", ] assert db._fts_stale is True assert _meta_value(db_path, FTS_STALE_KEY) == "1" assert _base_fts_triggers(db_path) == set() # Search remains available from canonical rows while FTS is stale. results = db.search_messages("corruption survives") assert results assert any("corruption survives" in row["snippet"] for row in results) # A later open atomically rebuilds all canonical rows before triggers # return, then clears the durable breadcrumb. db.close() reopened = SessionDB(db_path=db_path) try: assert reopened._fts_stale is False assert _meta_value(db_path, FTS_STALE_KEY) is None assert _base_fts_triggers(db_path) == set(_FTS_TRIGGERS) results = reopened.search_messages("corruption survives") assert results finally: reopened.close() def test_non_fts_write_error_after_fail_open_raises_not_hangs( self, db, tmp_path ): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "seed") _corrupt_fts(db_path) db.append_message("s1", "user", "canonical survives") def _persistent_non_fts_error(conn): raise sqlite3.DatabaseError("routine integrity check failed") with pytest.raises(sqlite3.DatabaseError, match="routine integrity"): db._execute_write(_persistent_non_fts_error) def test_live_write_does_not_scan_foreign_holders( self, db, tmp_path, monkeypatch ): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "seed") _corrupt_fts(db_path) monkeypatch.setattr( db, "_foreign_state_db_holders", lambda: pytest.fail("live fail-open must not enter rebuild admission"), raising=False, ) db.append_message("s1", "user", "canonical survives foreign holder") assert _message_contents(db_path)[-1] == "canonical survives foreign holder" assert db._fts_stale is True assert _meta_value(db_path, FTS_STALE_KEY) == "1" assert _base_fts_triggers(db_path) == set() def test_stale_search_preserves_not_semantics(self, db, tmp_path, monkeypatch): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "python language guide") db.append_message("s1", "user", "python java interoperability") _corrupt_fts(db_path) monkeypatch.setattr( db, "rebuild_fts", lambda: (_ for _ in ()).throw( sqlite3.DatabaseError("rebuild could not read corrupt FTS") ), ) db.append_message("s1", "user", "canonical write survives") assert db._fts_stale is True results = db.search_messages("python NOT java") snippets = [row["snippet"] for row in results] assert any("python language guide" in snippet for snippet in snippets) assert all("java" not in snippet for snippet in snippets) def test_existing_peer_observes_fail_open_marker( self, db, tmp_path, monkeypatch ): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "seed") peer = SessionDB(db_path=db_path) try: _corrupt_fts(db_path) def _failed_rebuild(): raise sqlite3.DatabaseError("rebuild failed") monkeypatch.setattr(db, "rebuild_fts", _failed_rebuild) db.append_message("s1", "user", "visible through canonical search") assert peer._fts_stale is False results = peer.search_messages("canonical search") assert peer._fts_stale is True assert results finally: peer.close() def test_failed_startup_rebuild_keeps_fts_detached( self, db, tmp_path, monkeypatch ): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "seed") _corrupt_fts(db_path) monkeypatch.setattr( db, "rebuild_fts", lambda: (_ for _ in ()).throw(sqlite3.DatabaseError("still corrupt")), ) db.append_message("s1", "user", "before restart") db.close() monkeypatch.setattr( SessionDB, "_recover_stale_fts", lambda self, cursor, legacy: False, ) reopened = SessionDB(db_path=db_path) try: assert reopened._fts_stale is True assert _meta_value(db_path, FTS_STALE_KEY) == "1" assert _base_fts_triggers(db_path) == set() reopened.append_message("s1", "user", "after failed recovery") assert _message_contents(db_path)[-1] == "after failed recovery" assert reopened.search_messages("failed recovery") finally: reopened.close() def test_foreign_holder_defers_startup_stale_rebuild( self, db, tmp_path, monkeypatch ): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "seed") _corrupt_fts(db_path) monkeypatch.setattr( db, "rebuild_fts", lambda: (_ for _ in ()).throw(sqlite3.DatabaseError("still corrupt")), ) db.append_message("s1", "user", "before restart") db.close() monkeypatch.setattr( SessionDB, "_foreign_state_db_holders", lambda self: [(4242, str(db_path) + "-wal")], raising=False, ) reopened = SessionDB(db_path=db_path) try: assert reopened._fts_stale is True assert _meta_value(db_path, FTS_STALE_KEY) == "1" assert _base_fts_triggers(db_path) == set() reopened.append_message("s1", "user", "after deferred recovery") assert _message_contents(db_path)[-1] == "after deferred recovery" finally: reopened.close() def test_repeated_deferrals_reap_inactive_orphan_then_rebuild( self, db, tmp_path, monkeypatch ): if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db_path = tmp_path / "state.db" db.create_session("s1", source="test") db.append_message("s1", "user", "seed") _corrupt_fts(db_path) monkeypatch.setattr( db, "rebuild_fts", lambda: (_ for _ in ()).throw(sqlite3.DatabaseError("still corrupt")), ) db.append_message("s1", "user", "before restart") db.close() raw = sqlite3.connect(str(db_path)) raw.execute( "INSERT INTO state_meta (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", ( FTS_REBUILD_DEFERRAL_KEY, json.dumps({"first_seen": 1.0, "last_seen": 30.0, "attempts": 2}), ), ) raw.commit() raw.close() holder_scans = iter(([(4242, str(db_path) + "-wal")], [])) reaped = [] monkeypatch.setattr( SessionDB, "_foreign_state_db_holders", lambda self: next(holder_scans), ) monkeypatch.setattr( SessionDB, "_reap_inactive_orphan_desktop_holders", lambda self, holders, *, min_age_seconds: reaped.extend(holders) or [4242], ) monkeypatch.setattr(hermes_state_schema.time, "time", lambda: 120.0) reopened = SessionDB(db_path=db_path) try: assert reaped == [(4242, str(db_path) + "-wal")] assert reopened._fts_stale is False assert _meta_value(db_path, FTS_STALE_KEY) is None assert _meta_value(db_path, FTS_REBUILD_DEFERRAL_KEY) is None assert reopened.search_messages("before restart") finally: reopened.close() def test_legacy_inline_fts_fails_open_and_recovers(self, tmp_path, monkeypatch): db_path = tmp_path / "legacy-state.db" raw = sqlite3.connect(str(db_path)) raw.executescript(SCHEMA_SQL) try: raw.executescript(LEGACY_FTS_SQL + LEGACY_FTS_TRIGRAM_SQL) except sqlite3.OperationalError as exc: raw.close() pytest.skip(f"required FTS tokenizer unavailable: {exc}") raw.commit() raw.close() legacy = SessionDB(db_path=db_path) try: assert legacy._db_has_legacy_inline_fts(legacy._conn.cursor()) legacy.create_session("s1", source="test") legacy.append_message("s1", "user", "legacy seed") _corrupt_fts(db_path) monkeypatch.setattr( legacy, "rebuild_fts", lambda: (_ for _ in ()).throw( sqlite3.DatabaseError("legacy rebuild failed") ), ) legacy.append_message("s1", "user", "legacy canonical survives") assert _message_contents(db_path)[-1] == "legacy canonical survives" assert _meta_value(db_path, FTS_STALE_KEY) == "1" finally: legacy.close() recovered = SessionDB(db_path=db_path) try: assert recovered._fts_stale is False assert _meta_value(db_path, FTS_STALE_KEY) is None assert recovered.search_messages("canonical survives") finally: recovered.close() def _corrupt_canonical_btree(db_path): """Physically damage every ``messages`` table B-tree leaf page. Real byte-flip corruption (no mocks): checkpoint the WAL so all pages are in the main file, locate the leaves via ``dbstat``, and clobber each leaf's page-header cell-count bytes. Any subsequent write or read touching the ``messages`` tree raises a genuine bare ``SQLITE_CORRUPT`` from SQLite. """ raw = sqlite3.connect(str(db_path)) try: raw.execute("PRAGMA wal_checkpoint(TRUNCATE)") page_size = raw.execute("PRAGMA page_size").fetchone()[0] try: leaves = [ row[0] for row in raw.execute( "SELECT pageno FROM dbstat " "WHERE name='messages' AND pagetype='leaf'" ).fetchall() ] except sqlite3.Error: pytest.skip("dbstat virtual table unavailable in this build") finally: raw.close() assert leaves, "expected at least one messages leaf page" with open(db_path, "r+b") as f: for leaf in leaves: f.seek((leaf - 1) * page_size + 3) f.write(b"\xff\xff\xff\xff") class TestPhysicalCorruptionAcceptance: """Real-fixture acceptance tests for the fail-closed classifier (#97940). The field incident behind issue #97940: canonical B-trees were physically damaged, SQLite raised the generic ``database disk image is malformed``, and the over-broad classifier routed it into the FTS-only self-heal — logging "canonical message rows are preserved" while transcript writes silently failed for ~10 hours. These tests damage a real database with byte flips (canonical tree) and shadow-table stomps (FTS-only) and assert the two corruption classes are handled differently end to end. """ def test_canonical_btree_corruption_fails_closed( self, tmp_path, caplog ): """Bare SQLITE_CORRUPT from real canonical damage must propagate. No FTS rebuild attempt, no trigger detach, no stale marker, and — critically — no log line claiming canonical rows are preserved. """ db_path = tmp_path / "state.db" seed = SessionDB(db_path=db_path) try: if not seed._fts_enabled: pytest.skip("FTS5 unavailable in this build") seed.create_session("s1", source="test") for i in range(300): seed.append_message("s1", "user", f"canon row {i} " + "y" * 300) finally: seed.close() _corrupt_canonical_btree(db_path) db = SessionDB(db_path=db_path) try: caplog.clear() with caplog.at_level("WARNING", logger="hermes_state"): with pytest.raises(sqlite3.DatabaseError) as caught: db.append_message("s1", "user", "post-corruption write") # The genuine structural error propagated, not an FTS retry result. assert getattr(caught.value, "sqlite_errorcode", None) in ( sqlite3.SQLITE_CORRUPT, None, # very old sqlite3 modules without errorcode attrs ) assert "malformed" in str(caught.value).lower() # The classifier refused the FTS route entirely. assert not SessionDB._is_fts_write_corruption_error(caught.value) assert getattr(db, "_fts_runtime_rebuild_attempted", False) is False assert db._fts_stale is False # The misdiagnosis message from the field incident must be gone. assert "canonical message rows are preserved" not in caplog.text assert "attempting one-shot in-place FTS rebuild" not in caplog.text # Structural damage quarantines the handle: typed error, sticky # flag, later writes fail fast, and close() must not checkpoint # the WAL over a damaged page image (the #90950 page-1 clobber). from hermes_state import StateDbCorruptError assert isinstance(caught.value, StateDbCorruptError) assert db._db_corrupt is True with pytest.raises(StateDbCorruptError): db.append_message("s1", "user", "second write after corruption") caplog.clear() with caplog.at_level("WARNING", logger="hermes_state"): db.close() assert "Skipping the close-time WAL checkpoint" in caplog.text finally: db.close() # Fail-closed also means non-destructive: triggers untouched, no # stale-FTS marker persisted for a structural (non-FTS) failure. assert _base_fts_triggers(db_path) == set(_FTS_TRIGGERS) assert _meta_value(db_path, FTS_STALE_KEY) is None def test_fts_only_corruption_still_self_heals(self, db, tmp_path): """Contrast case: a real FTS shadow-table stomp raises SQLITE_CORRUPT_VTAB, is classified as FTS-scoped, and the write path self-heals with canonical rows intact — proving the narrowed classifier did not break the legitimate FTS repair route.""" if not db._fts_enabled: pytest.skip("FTS5 unavailable in this build") db.create_session("s1", source="test") db.append_message("s1", "user", "before stomp") for i in range(50): db.append_message("s1", "user", f"seed row {i} " + "z" * 200) _corrupt_fts(tmp_path / "state.db") # Prove the fixture produces the FTS-scoped extended code for the # classifier (provenance check via a raw connection so no self-heal # runs). A MATCH read walks the stomped structure record on every # SQLite version; the insert trigger only trips on some versions. raw = sqlite3.connect(str(tmp_path / "state.db")) try: raw.execute( "SELECT rowid FROM messages_fts WHERE messages_fts MATCH 'seed'" ).fetchall() except sqlite3.DatabaseError as exc: assert getattr(exc, "sqlite_errorcode", None) == getattr( sqlite3, "SQLITE_CORRUPT_VTAB", 267 ) assert SessionDB._is_fts_write_corruption_error(exc) else: # pragma: no cover - fixture must corrupt the index pytest.fail("FTS stomp fixture did not corrupt the index") finally: raw.close() # And the app-level write path still succeeds after real FTS-only # damage (self-heals on builds whose sync triggers surface the # corruption; passes through untouched on builds that defer it). msg_id = db.append_message("s1", "user", "healed after stomp") assert msg_id is not None contents = _message_contents(tmp_path / "state.db") assert contents[0] == "before stomp" assert contents[-1] == "healed after stomp" assert len(contents) == 52