Files
hermes-agent/tests/agent/test_live_db_preview_locks.py
kshitijk4poor fc53717f72 fix(files): one live-DB refusal source; hold the lock through /api/files/read
Gate r2 Low cleanups (house rule: no aliases/shims):
- Drop the is_live_database_file alias; its point-in-time caveat now lives on
  has_live_connection.
- _refuse_live_database reuses offline_file_access's message (via _serve_offline),
  so a download 409 on state.db-shm names the main database like the read path;
  the verb is "serve" so it fits read/download/stream.
- /api/files/read reads whole files in-process, so _read_base64_file now holds
  offline_file_access through close (409 on a live DB; OSError stays 500). Only
  the streamed FileResponse routes keep the point-in-time check.
- That check takes the global _live_lock, which other threads hold across
  whole-file reads, so fs_download and the managed stream routes run it via
  asyncio.to_thread instead of stalling the event loop.
- _managed_readable_file docstring no longer claims a size cap;
  _read_file_reference returns (early, text) instead of a str|Expansion union
  sniffed with isinstance.

Co-authored-by: Benjamin PERRY <benjaminperry6@yahoo.fr>
2026-09-27 20:45:56 +05:30

118 lines
5.4 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
@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 posix_locks(file):
inode = file.stat().st_ino
return sorted(line.split(": ", 1)[1] for line in Path("/proc/locks").read_text(encoding="utf-8").splitlines()
if f":{inode} " in line and "POSIX ADVISORY" in line
and f" {os.getpid()} " in line)
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 = (posix_locks(path), 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 (posix_locks(path), 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)