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>
118 lines
5.4 KiB
Python
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)
|