Files
hermes-agent/tests/hermes_state/test_wal_lock_guard_lifecycle.py
T
teknium1 274fd56dca fix(state): WAL lock guard follows the handle's lifecycle
Three gaps in the #110544 guard, all reported in its review and reproduced:

- A writer reopened by _reopen_after_close_locked (teardown/worker race,
  #94736) came back with no guard: the next stray close + foreign close
  deleted its WAL again.
- _try_wal_checkpoint refreshed the guard outside self._lock; landing after
  close() it pinned an OFD lock with no connection behind it, so a foreign
  `PRAGMA journal_mode=DELETE` saw `database is locked` forever.
- Refcounts keyed on (fd, inode) treated a recycled fd number as a surviving
  lock: A+B live, close A, C reuses A's fd, close B left C recorded as guarded
  while a foreign EXCLUSIVE succeeded.

The guard now counts handles per inode, re-locks every matching descriptor on
each hold (OFD re-lock is idempotent), and unlocks on the last handle only;
the reopen path holds it; the checkpoint refresh runs under self._lock and
skips a closed handle. The macOS holder scan folds case so a case-only alias
of the sidecar path on APFS still matches.
2026-09-14 06:54:07 -07:00

74 lines
3.0 KiB
Python

"""The WAL lock guard tracks the lifecycle of the handle it protects.
Three shapes the first cut got wrong (review on #110544): a writer reopened after ``close()``
raced a live caller came back unguarded; the periodic checkpoint's guard refresh could land after
``close()`` and pin an OFD lock with no connection behind it; and refcounts keyed on a reusable
descriptor NUMBER mistook a recycled fd for a surviving lock. Linux-only: OFD locks + ``/proc``.
"""
import os
import sqlite3
import subprocess
import sys
import pytest
import hermes_state_lockguard as lg
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_exclusive_ok(path: str) -> bool:
"""Another process tries the EXCLUSIVE a close-time WAL reset needs; True = nothing guards."""
code = (
"import fcntl, os, struct, sys\n"
f"fd = os.open({path!r}, os.O_RDWR)\n"
"lk = struct.pack('@hhqqi', fcntl.F_WRLCK, 0, 0x40000002, 510, 0)\n"
"try:\n fcntl.fcntl(fd, 37, lk); print('EXCLUSIVE_ACQUIRED')\n"
"except BlockingIOError:\n print('REFUSED')\n"
)
return "EXCLUSIVE_ACQUIRED" in subprocess.run(
[sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout
def test_reopened_writer_is_guarded_again(tmp_path, monkeypatch):
pin_wal(monkeypatch)
db = make_db(tmp_path / "state.db", "s", "seed")
require_wal(db)
db.close()
db.append_message("s", role="user", content="after close") # #94736 teardown/worker reopen
try:
assert db._conn is not None and db._wal_lock_guard, "reopen returned an unguarded writer"
assert not _foreign_exclusive_ok(str(db.db_path))
finally:
db.close()
assert _foreign_exclusive_ok(str(db.db_path)) # a true last close lifts the guard
def test_guard_never_outlives_the_handle_under_fd_reuse(tmp_path, monkeypatch):
"""A+B live -> close A (its fd number is recycled by C) -> close B: C must still be guarded,
and once C closes nothing may be left locked."""
pin_wal(monkeypatch)
path = tmp_path / "state.db"
a = make_db(path, "s", "seed")
require_wal(a)
b = SessionDB(db_path=path)
a.close()
c = SessionDB(db_path=path)
b.close()
try:
assert not _foreign_exclusive_ok(str(path)), "C recorded as guarded while nothing locks"
for name in ("state.db", "state.db-shm"): # the stray close the guard exists for
os.close(os.open(tmp_path / name, os.O_RDONLY))
subprocess.run([sys.executable, "-c",
f"import sqlite3; c = sqlite3.connect({str(path)!r}); "
"c.execute('select count(*) from messages').fetchone(); c.close()"], check=True)
c.append_message("s", role="user", content="still writes")
finally:
c.close()
assert _foreign_exclusive_ok(str(path)), "a lock survived the last handle's close"
assert not lg._HANDLES
assert sqlite3.connect(path).execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 2