"""Unopenable admission lock files must fail CLOSED (#100368). `state.db` has two cross-process admission authorities that gate destructive work on a file several Hermes processes share (gateway service, the Desktop app's `hermes serve` backend, CLI sessions, the TUI slash worker): * `hermes_state_common.fts_rebuild_admission` — full structural FTS rebuilds * `hermes_state._cross_process_repair_lock` — writable_schema surgery / VACUUM Both document themselves as fail-closed, and both honoured that only for a *timed-out* acquire. When the lock file could not be `open()`ed at all they yielded True and proceeded "with in-process serialisation only" — which is no cross-process authority whatsoever. That inversion is reachable exactly when it does the most damage. Creating the lock file needs a directory entry and an inode, so on a full disk `open()` raises ENOSPC — while a sibling process that opened ITS handle before the disk filled is still mid-rebuild or mid-surgery. Every process then ran concurrent destructive work on the same live DB, i.e. the precise interleaving PR #93200 added these locks to prevent. #100368 reports that shape: a disk-full trigger, then a fresh corruption on every boot with other writers alive, and no re-corruption on a boot with zero other writers. These tests drive a real unopenable lock path (a directory where the code expects a file, so `open()` raises a genuine OSError from the kernel) rather than monkeypatching the helpers, and assert the deferral is honoured at both the primitive and the behavior level. """ import sqlite3 import sys from pathlib import Path import pytest import hermes_state import hermes_state_common from hermes_state import SessionDB, repair_state_db_schema def _make_unopenable(lock_path: Path) -> None: """Make ``open(lock_path, "a+b")`` raise a real OSError. A directory standing where the code expects a regular file yields IsADirectoryError on POSIX and PermissionError on Windows — both OSError, both raised by the kernel. This stands in for the ENOSPC/EMFILE the field reports hit, without needing to fill a real disk. """ lock_path.unlink(missing_ok=True) lock_path.mkdir(parents=True, exist_ok=True) with pytest.raises(OSError): open(lock_path, "a+b").close() # ── FTS rebuild authority ─────────────────────────────────────────────────── def test_fts_admission_fails_closed_when_lock_file_is_unopenable(tmp_path): """The primitive must refuse admission, not fall back to no authority.""" db_path = tmp_path / "state.db" _make_unopenable(db_path.with_name(db_path.name + ".fts_rebuild.lock")) with hermes_state_common.fts_rebuild_admission(db_path) as admitted: assert admitted is False def test_fts_admission_still_admits_a_pathless_db(tmp_path): """Guardrail: an in-memory store has no cross-process surface at all. The fix must not turn the legitimate no-op case into a permanent deferral. """ with hermes_state_common.fts_rebuild_admission(None) as admitted: assert admitted is True def test_rebuild_fts_defers_when_lock_file_is_unopenable(tmp_path): """Behavior: the rebuild entry point reports no progress and rebuilds nothing.""" db = SessionDB(db_path=tmp_path / "state.db") if not db._fts_enabled: db.close() pytest.skip("FTS5 unavailable in this build") try: db.create_session("s1", source="test") db.append_message("s1", "user", "hello world") # Sanity: with an openable lock the rebuild really runs, so a 0 below # is the deferral and not an unrelated no-op. assert db.rebuild_fts() >= 1 _make_unopenable( db.db_path.with_name(db.db_path.name + ".fts_rebuild.lock") ) assert db.rebuild_fts() == 0 finally: try: db.close() except Exception: pass # ── Schema-surgery authority ──────────────────────────────────────────────── def _build_healthy_db(db_path: Path) -> None: db = SessionDB(db_path=db_path) db.create_session("s1", source="test") db.append_message("s1", "user", "hello world") db.close() def _corrupt_duplicate_fts(db_path: Path) -> None: """Inject a duplicate messages_fts row into sqlite_master. Reproduces 'malformed database schema (messages_fts) - table messages_fts already exists'. """ conn = sqlite3.connect(str(db_path)) conn.execute("PRAGMA writable_schema=ON") conn.execute( "INSERT INTO sqlite_master (type, name, tbl_name, rootpage, sql) " "SELECT type, name, tbl_name, rootpage, sql FROM sqlite_master " "WHERE name='messages_fts'" ) conn.commit() conn.close() def test_repair_lock_fails_closed_when_lock_file_is_unopenable(tmp_path): """The primitive must refuse the repair authority.""" db_path = tmp_path / "state.db" _make_unopenable(db_path.with_name(db_path.name + ".repair.lock")) with hermes_state._cross_process_repair_lock(db_path) as holding: assert holding is False @pytest.mark.skipif(sys.platform == "win32", reason="writable_schema corruption harness") def test_repair_skips_surgery_when_lock_file_is_unopenable(tmp_path): """Behavior: no writable_schema surgery, no forensic backup, DB untouched. A full disk is the worst possible moment to start an unsynchronised VACUUM on a live shared DB, and it is exactly when the lock file cannot be created. """ db_path = tmp_path / "state.db" _build_healthy_db(db_path) _corrupt_duplicate_fts(db_path) assert hermes_state._db_opens_cleanly(db_path) is not None before = db_path.read_bytes() _make_unopenable(db_path.with_name(db_path.name + ".repair.lock")) report = repair_state_db_schema(db_path) assert report["repaired"] is False assert "repair lock" in (report["error"] or "") assert report["backup_path"] is None assert not list(tmp_path.glob("state.db.malformed-backup-*")) # The damaged image is left byte-identical for the next (authorised) pass. assert db_path.read_bytes() == before