Files
hermes-agent/tests/agent/test_live_db_preview_locks.py
teknium1 35272ce28b fix(state): /proc/locks readers no longer lose entries to other processes' lock churn
/proc/locks is host-wide and the kernel serves it over several read() calls,
resuming by record index. When other processes add or drop locks between
those reads the table shifts and entries are skipped, so a lock that is
still held can read as gone.

- tests: the live-DB preview and header-probe lock tests read their own
  process's locks from /proc/locks and flaked on busy CI hosts (1 in 12 on
  a quiet box; 9 in 12 with six processes churning locks). A shared
  tests/posix_lock_probe.own_posix_locks() reads /proc/self/fdinfo instead,
  which lists this process's locks per open file in one read.
- hermes_state_lockowners: state_db_write_lock_holders() names the writer
  in the "state.db write lock unavailable" warning. One pass could skip the
  holder and log "no write-class lock held at the deadline". It now unions
  three passes; a skip rarely repeats.
2026-09-28 02:26:21 -07:00

113 lines
5.2 KiB
Python

"""File previews may not cancel SQLite's live POSIX locks (including WAL sidecars)."""
import asyncio
import os
from pathlib import Path
import sqlite3
import subprocess
import sys
import pytest
from fastapi import HTTPException
from agent.context_references import _expand_path_reference, parse_context_references
from hermes_state import SessionDB
from hermes_cli.web_routers.files import fs_download, fs_read_text
from tests.posix_lock_probe import own_posix_locks
@pytest.mark.platforms("linux")
@pytest.mark.requires_wal
@pytest.mark.parametrize("route,target_kind", [
("file", "main"), ("folder", "directory"), ("desktop", "main"), ("desktop", "shm"),
])
def test_preview_preserves_live_database_locks(tmp_path, route, target_kind):
path = tmp_path / "state.db"
text = tmp_path / "normal.txt"
text.write_text("ordinary readable text", encoding="utf-8")
db = SessionDB(path)
lock_fd = None
try:
db.create_session("preview-test", "cli")
db.append_message("preview-test", "user", "before preview")
shm = Path(str(path) + "-shm")
target = {"main": path, "shm": shm, "directory": tmp_path}[target_kind]
conn = db._conn
assert isinstance(conn, sqlite3.Connection)
conn.execute("CREATE TABLE preview_markers (value TEXT)")
conn.commit()
conn.execute("BEGIN IMMEDIATE")
conn.execute("INSERT INTO preview_markers VALUES ('first process')")
# WAL writers reliably hold -shm locks, but not always a main-file lock.
# Hold our own POSIX main-file lock to prove that preview close() does
# not cancel any locks on that inode, regardless of SQLite's WAL timing.
import fcntl
lock_fd = os.open(path, os.O_RDONLY)
fcntl.lockf(lock_fd, fcntl.LOCK_SH, 1, 4096)
def rival_locked():
code = ("import sqlite3,sys; c=sqlite3.connect(sys.argv[1], timeout=0); "
"c.execute('BEGIN IMMEDIATE'); c.rollback(); c.close()")
result = subprocess.run([sys.executable, "-c", code, str(path)],
capture_output=True, text=True, timeout=10)
return result.returncode != 0 and "database is locked" in result.stderr
before = (own_posix_locks(path), own_posix_locks(shm))
assert all(before), "fixture must hold POSIX main and WAL-sidecar locks"
assert rival_locked(), "second process must be excluded before the preview"
if route == "desktop":
# FileResponse opens/closes in-process too, so a download must be refused as well,
# with the same (sidecar-aware) refusal text as the read.
for route_fn in (fs_read_text, fs_download):
with pytest.raises(HTTPException) as refused:
asyncio.run(route_fn(str(target)))
assert refused.value.status_code == 409
if target_kind == "shm":
assert "main database" in refused.value.detail
assert asyncio.run(fs_read_text(str(text)))["text"] == "ordinary readable text"
else:
ref = parse_context_references(f"@{route}:{target}")[0]
warning, block = _expand_path_reference(ref, tmp_path.parent)
assert warning is None
assert block is not None
if route == "file":
assert "not previewed" in block
ordinary = parse_context_references(f"@file:{text}")[0]
warning, block = _expand_path_reference(ordinary, tmp_path.parent)
assert warning is None and block is not None and "ordinary readable text" in block
assert (own_posix_locks(path), own_posix_locks(shm)) == before
assert rival_locked(), "second process entered a still-open write transaction"
conn.commit()
code = (
"import sqlite3,sys; c=sqlite3.connect(sys.argv[1], timeout=2); "
"assert c.execute('SELECT value FROM preview_markers').fetchall() == [('first process',)]; "
"c.execute(\"INSERT INTO preview_markers VALUES ('second process')\"); "
"c.commit(); c.close()"
)
rival = subprocess.run([sys.executable, "-c", code, str(path)],
capture_output=True, text=True, timeout=10)
assert rival.returncode == 0, rival.stderr
assert [row[0] for row in conn.execute(
"SELECT value FROM preview_markers ORDER BY rowid"
)] == ["first process", "second process"]
db.append_message("preview-test", "assistant", "after preview")
assert len(db.get_messages("preview-test")) == 2
finally:
db.close()
if lock_fd is not None:
os.close(lock_fd)
def test_closed_database_can_still_be_previewed(tmp_path):
path = tmp_path / "offline.db"
db = SessionDB(path)
db.create_session("offline", "cli")
db.close()
ref = parse_context_references(f"@file:{path}")[0]
warning, block = _expand_path_reference(ref, tmp_path.parent)
assert warning is None and block is not None and "binary file" in block
preview = asyncio.run(fs_read_text(str(path)))
assert preview["binary"] is True and preview["byteSize"] == path.stat().st_size
assert asyncio.run(fs_download(str(path))).path == str(path)