Files
hermes-agent/tests/hermes_state/test_wal_lock_guard.py
T
teknium1 3e43cee505 fix(state): refcount guard locks shared by several handles in one process
Two SessionDB handles on one state.db in one process see the same
descriptors; the first release must not drop the range the second still
needs. Test covers the same-process sibling and the foreign-process close.
2026-09-14 05:28:22 -07:00

51 lines
2.3 KiB
Python

"""A live writer's WAL generation survives lock cancellation and sibling closes.
SQLite guards a WAL generation with per-PROCESS POSIX locks, so any raw ``open()``/``close()`` of
``state.db`` or ``-shm`` inside the holder (howtocorrupt.html §2.2) silently drops them; the next
last-connection close anywhere then checkpoints and unlinks ``-wal``/``-shm`` and the holder
sticky-halts with ``DeletedWalGenerationError`` (#109727, #110042, #110276, Desktop "chat fails
after update"). ``hermes_state_lockguard`` re-holds the same ranges as OFD locks that a stray
close cannot cancel. Linux-only: the scenario needs ``/proc``-visible POSIX lock semantics.
"""
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
from hermes_state import SessionDB
from tests.hermes_state._wal_generation_harness import make_db, pin_wal, require_wal
pytestmark = pytest.mark.linux_only
def _foreign_open_close(db_path: Path) -> None:
"""Another process opens state.db, reads, closes: SQLite's last-connection WAL reset runs there."""
subprocess.run([sys.executable, "-c",
f"import sqlite3; c = sqlite3.connect({str(db_path)!r}); "
"c.execute('select count(*) from messages').fetchone(); c.close()"], check=True)
def test_holder_survives_stray_close_then_sibling_closes(tmp_path, monkeypatch):
pin_wal(monkeypatch)
db = make_db(tmp_path / "state.db", "s", "seed")
wal = require_wal(db)
wal_inode = wal.stat().st_ino
try:
for name in ("state.db", "state.db-shm"): # the §2.2 bug, e.g. a raw header probe
os.close(os.open(tmp_path / name, os.O_RDONLY))
# Same-process second handle (Desktop backend / Herder tab shape) closing...
sibling = SessionDB(db_path=db.db_path)
sibling.append_message("s", role="user", content="tab")
sibling.close()
# ...and a foreign process (cron worker / one-shot CLI shape) closing.
_foreign_open_close(db.db_path)
assert wal.exists() and wal.stat().st_ino == wal_inode, "a sibling close unlinked the live WAL"
db.append_message("s", role="user", content="after") # would raise DeletedWalGenerationError
finally:
db.close()
assert sqlite3.connect(db.db_path).execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 3