Files
aiturk-hermes-ide/tests/hermes_state/test_deleted_wal_generation_guard.py
T

199 lines
6.5 KiB
Python

"""Refuse SessionDB open/write when a deleted WAL generation is still held.
A live writer that keeps the unlinked ``state.db-wal`` inode while a second
opener would mint a fresh WAL is the split-brain that produces intermittent
``database disk image is malformed`` / ``disk I/O error``. The store must
fail closed on both the open and write paths instead of creating the second
generation.
"""
import os
import sqlite3
import sys
from pathlib import Path
import pytest
import hermes_state
from hermes_state import (
DeletedWalGenerationError,
SessionDB,
classify_persistence_error,
iter_deleted_sqlite_sidecar_holders,
refuse_deleted_wal_generation,
)
@pytest.fixture
def force_wal(monkeypatch):
"""Pin WAL so this host's vulnerable SQLite still matches production topology."""
monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
)
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "wal")
def _make_db(path: Path, session_id: str, content: str) -> SessionDB:
db = SessionDB(db_path=path)
db.create_session(session_id, "cli")
db.append_message(session_id, role="user", content=content)
return db
def _require_wal(db: SessionDB) -> Path:
if not db._wal_active:
db.close()
pytest.skip("WAL not active on this filesystem")
wal = Path(os.fspath(db.db_path) + "-wal")
if not wal.exists():
db.close()
pytest.skip("WAL sidecar missing after first write")
return wal
def _unlink_sidecars(db_path: Path) -> None:
for suffix in ("-wal", "-shm"):
sidecar = Path(os.fspath(db_path) + suffix)
if sidecar.exists():
os.unlink(sidecar)
def test_classify_deleted_wal_is_replaced_not_disk():
err = DeletedWalGenerationError(
"FATAL: a live process holds a deleted state.db-wal or state.db-shm "
"inode while the path names a different (or missing) generation."
)
assert classify_persistence_error(err) == "replaced"
assert classify_persistence_error(str(err)) == "replaced"
def test_iter_holders_empty_on_non_linux(monkeypatch, tmp_path):
monkeypatch.setattr(hermes_state.sys, "platform", "win32")
assert iter_deleted_sqlite_sidecar_holders(tmp_path / "state.db") == []
def test_clean_open_and_second_open_still_work(tmp_path, force_wal):
path = tmp_path / "state.db"
db = _make_db(path, "s1", "hello")
_require_wal(db)
db.close()
reopened = SessionDB(db_path=path)
try:
reopened.append_message("s1", role="user", content="second-open")
rows = reopened.get_messages("s1")
assert any(m["content"] == "second-open" for m in rows)
finally:
reopened.close()
def test_delete_journal_two_writers_still_work(tmp_path, monkeypatch):
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "delete")
monkeypatch.setattr(
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
)
path = tmp_path / "state.db"
a = _make_db(path, "s", "from-a")
try:
assert not Path(os.fspath(path) + "-wal").exists()
b = SessionDB(db_path=path)
try:
b.append_message("s", role="user", content="from-b")
contents = [m["content"] for m in b.get_messages("s")]
assert "from-a" in contents
assert "from-b" in contents
finally:
b.close()
finally:
a.close()
@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="deleted-WAL /proc scan is Linux-only",
)
def test_iter_finds_self_after_wal_unlink(tmp_path, force_wal):
path = tmp_path / "state.db"
db = _make_db(path, "s", "held")
wal = _require_wal(db)
inode_before = wal.stat().st_ino
_unlink_sidecars(path)
holders = iter_deleted_sqlite_sidecar_holders(path)
try:
assert holders, "expected this process to still hold the deleted WAL inode"
assert any("(deleted)" in target for _pid, target in holders)
assert any(
target.removesuffix(" (deleted)").endswith(("-wal", "-shm"))
for _pid, target in holders
)
assert not wal.exists() or wal.stat().st_ino != inode_before
finally:
db.close()
@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="deleted-WAL /proc scan is Linux-only",
)
def test_second_sessiondb_open_refuses_and_does_not_mint_wal(tmp_path, force_wal):
path = tmp_path / "state.db"
writer = _make_db(path, "s", "before-unlink")
wal = _require_wal(writer)
inode_before = wal.stat().st_ino
_unlink_sidecars(path)
assert not wal.exists()
with pytest.raises(DeletedWalGenerationError, match="deleted state.db-wal"):
SessionDB(db_path=path)
assert not wal.exists(), "open must refuse before sqlite3.connect mints a WAL"
# If a WAL somehow reappeared it must not be a new generation.
if wal.exists():
assert wal.stat().st_ino == inode_before
writer.close()
@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="deleted-WAL write halt uses Linux unlink semantics",
)
def test_writer_halts_after_own_wal_unlinked(tmp_path, force_wal):
path = tmp_path / "state.db"
db = _make_db(path, "s", "before")
_require_wal(db)
recorded = db._db_sidecar_identity.get("-wal")
assert recorded is not None
_unlink_sidecars(path)
with pytest.raises(DeletedWalGenerationError, match="deleted state.db-wal"):
db.append_message("s", role="user", content="after-unlink")
assert db._db_wal_generation_lost is True
with pytest.raises(DeletedWalGenerationError):
db.append_message("s", role="user", content="second-after-halt")
db.close()
@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="deleted-WAL /proc scan is Linux-only",
)
def test_refuse_helper_raises_while_deleted_wal_held(tmp_path, force_wal):
path = tmp_path / "state.db"
raw = sqlite3.connect(str(path))
try:
raw.execute("PRAGMA journal_mode=WAL")
raw.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
raw.execute("INSERT INTO t VALUES (1, 'held')")
raw.commit()
wal = Path(str(path) + "-wal")
assert wal.exists()
os.unlink(wal)
shm = Path(str(path) + "-shm")
if shm.exists():
os.unlink(shm)
with pytest.raises(DeletedWalGenerationError):
refuse_deleted_wal_generation(path)
assert not wal.exists()
finally:
raw.close()