Files
hermes-agent/tests/hermes_state/test_deleted_wal_generation_guard.py
T
kshitijk4poor e9fa7bc05e fix(state): keep the #94736 teardown self-heal alive under the deleted-WAL guard
Follow-ups on the salvaged #101081 guard:

- A clean close() lets SQLite unlink the WAL sidecars legitimately; the
  guard treated that as a lost generation and permanently halted the
  handle, so the #94736 late-write self-heal reopen dropped transcript
  tails (4 existing tests failed). close() now clears the recorded
  sidecar generation, and _wal_generation_was_lost() re-adopts the
  current sidecars after a clean /proc/self probe instead of relying on
  a stale snapshot.
- Healthy writes no longer walk /proc/self/fd: once a sidecar
  generation is recorded, the stat-based inode check alone detects an
  unlink/replace. The fd probe only runs in the empty-identity state
  (fresh DB, post-close reopen).
- DeletedWalGenerationError now subclasses StateDbReplacedError, so the
  gateway retry queue and run_agent flush divert transcripts to the
  JSONL fallback exactly as they do for a replaced store, instead of
  retrying forever against a halted handle.
- __init__ refuses once (under the startup lock) instead of twice per
  open, halving the system-wide /proc scan; dropped the dead
  include_self parameter and the dead _IS_WINDOWS clause.
- Test fixes: rstrip(' (deleted)') char-set bug -> removesuffix; the
  non-linux test now patches sys.platform (the real gate) instead of
  _IS_WINDOWS.
2026-09-02 18:24:54 +05:30

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()