hermes-agent/tests/test_sqlite_lock_safe_inspection.py
teknium1 fbd5e5772b fix(state): stop cancelling our own POSIX locks on live SQLite databases
`hermes sessions optimize` could corrupt state.db. Root cause is Hermes,
not the SQLite WAL-reset bug (#69784).

close() on ANY file descriptor for a SQLite database cancels every POSIX
advisory lock the process holds on that file, including a running VACUUM's
EXCLUSIVE lock (sqlite.org/howtocorrupt.html section 2.2). Hermes byte-probed
live databases in several hot paths: the zeroed-state.db detector runs on every
SessionDB construction (and the gateway builds those constantly), and kanban's
post-commit invariant check ran after every COMMIT. While VACUUM rewrote the
file, those probes dropped its lock and let other processes write into it.

A/B against the real code, only variable being the raw read:

  SQLite 3.50.4, VACUUM + concurrent writers, DELETE mode
    raw open/close during VACUUM   8 vacuums, 319 vacuum errors, 2/2 corrupt
    no raw read (control)        229 vacuums,   0 vacuum errors, 0/2 corrupt

  SQLite 3.53.1 (WAL-reset FIXED) reproduces identically: 2/2 corrupt.
  After this change: 0/4 corrupt, 0 vacuum errors.

Because the upgraded runtime corrupts too, replacing the embedded SQLite does
not fix this class; and because DELETE is where it reproduces, #70055's
"force DELETE on vulnerable builds" mitigation steered users into the failing
mode. That gate is reverted here: vulnerable builds get WAL again and still
warn so operators can upgrade.

- add hermes_cli/sqlite_safe_read.py: read page_count via PRAGMA over the
  existing connection instead of open()+seek(28); byte-level probes are
  restricted to before any connection exists and refused once one is live,
  with an explicit force= escape for offline artifacts (snapshots, archives)
- track live connections in SessionDB and kanban's connect so that guard is
  enforced rather than merely documented
- kanban's torn-extend check now only applies under a rollback journal; in WAL
  a committed page may still legitimately sit in the -wal file
- revert the force-DELETE WAL gate and update the tests that pinned it

Regression tests assert the behavioural contract (an external process stays
locked out across Hermes' inspection calls) and were verified to fail when the
old raw-open behaviour is restored.
2026-07-25 21:44:43 -07:00

204 lines
6.6 KiB
Python

"""POSIX advisory locks must survive Hermes' own database inspection.
close() on ANY file descriptor for a SQLite database cancels every POSIX
advisory lock the process holds on that file -- including a running VACUUM's
EXCLUSIVE lock and an in-flight BEGIN IMMEDIATE's RESERVED lock:
https://sqlite.org/howtocorrupt.html#_posix_advisory_locks_canceled_by_a_separate_thread_doing_close_
Hermes used to byte-probe live databases in several places (kanban's
post-commit page-count check, the zeroed-state.db detector run on every
SessionDB construction, backup header verification). Under `hermes sessions
optimize` this let an external process write into a database while VACUUM was
rewriting it, producing "database disk image is malformed".
These tests pin the behavioural contract: an external process must stay locked
out across Hermes' inspection calls.
"""
from __future__ import annotations
import sqlite3
import subprocess
import sys
import textwrap
import pytest
from hermes_cli.sqlite_safe_read import (
file_length_matches_header,
has_live_connection,
page_count_bytes,
read_header_bytes_preopen,
track_connection,
untrack_connection,
)
_INTRUDER = textwrap.dedent(
"""
import sqlite3, sys
conn = sqlite3.connect(sys.argv[1], isolation_level=None, timeout=0)
try:
conn.execute("PRAGMA busy_timeout=0")
conn.execute("BEGIN IMMEDIATE")
conn.execute("INSERT INTO t(v) VALUES ('intruder')")
conn.execute("COMMIT")
print("ACQUIRED")
except sqlite3.OperationalError:
print("BLOCKED")
"""
)
def _external_writer_can_break_in(db_path) -> bool:
"""True when a separate process managed to write to a locked database."""
result = subprocess.run(
[sys.executable, "-c", _INTRUDER, str(db_path)],
capture_output=True,
text=True,
timeout=60,
)
return "ACQUIRED" in result.stdout
def _make_db(path, journal_mode: str) -> None:
conn = sqlite3.connect(str(path), isolation_level=None)
conn.execute(f"PRAGMA journal_mode={journal_mode}")
conn.execute("CREATE TABLE t(v TEXT)")
conn.executemany("INSERT INTO t(v) VALUES (?)", [(f"row{i}",) for i in range(200)])
conn.close()
@pytest.fixture
def clean_registry():
yield
# Keep the module-level registry from leaking across tests.
import hermes_cli.sqlite_safe_read as mod
with mod._live_lock:
mod._live_connections.clear()
@pytest.mark.parametrize("journal_mode", ["DELETE", "WAL"])
def test_write_lock_survives_file_length_check(tmp_path, journal_mode, clean_registry):
"""kanban's post-commit invariant check must not cancel the write lock."""
from hermes_cli.kanban_db import _check_file_length_invariant
db = tmp_path / "kanban.db"
_make_db(db, journal_mode)
holder = sqlite3.connect(str(db), isolation_level=None, timeout=0.5)
holder.execute(f"PRAGMA journal_mode={journal_mode}")
holder.execute("BEGIN IMMEDIATE")
holder.execute("INSERT INTO t(v) VALUES ('held')")
try:
assert not _external_writer_can_break_in(db), (
"precondition failed: the external writer was not locked out "
"before the inspection call"
)
worker = sqlite3.connect(str(db), isolation_level=None, timeout=0.5)
try:
_check_file_length_invariant(worker)
finally:
worker.close()
assert not _external_writer_can_break_in(db), (
"_check_file_length_invariant cancelled this process's POSIX "
"advisory locks -- an external process wrote into a database "
"that a writer still believed it held exclusively"
)
finally:
holder.close()
@pytest.mark.parametrize("journal_mode", ["DELETE", "WAL"])
def test_write_lock_survives_zeroed_state_db_probe(
tmp_path, journal_mode, clean_registry
):
"""SessionDB's zeroed-file detector must not cancel locks once connected."""
from hermes_state import is_zeroed_state_db
db = tmp_path / "state.db"
_make_db(db, journal_mode)
holder = sqlite3.connect(str(db), isolation_level=None, timeout=0.5)
holder.execute(f"PRAGMA journal_mode={journal_mode}")
holder.execute("BEGIN IMMEDIATE")
holder.execute("INSERT INTO t(v) VALUES ('held')")
track_connection(db)
try:
assert not _external_writer_can_break_in(db)
assert is_zeroed_state_db(db) is False
assert not _external_writer_can_break_in(db), (
"is_zeroed_state_db cancelled this process's POSIX advisory "
"locks on a live database"
)
finally:
untrack_connection(db)
holder.close()
def test_preopen_read_refused_while_connection_is_live(tmp_path, clean_registry):
"""The byte-level probe is allowed pre-open and refused once connected."""
db = tmp_path / "state.db"
_make_db(db, "WAL")
assert not has_live_connection(db)
head = read_header_bytes_preopen(db, length=16)
assert head == b"SQLite format 3\x00"
track_connection(db)
try:
assert has_live_connection(db)
assert read_header_bytes_preopen(db, length=16) is None
# An explicit override stays available for offline artifacts.
assert read_header_bytes_preopen(db, length=16, force=True) is not None
finally:
untrack_connection(db)
assert not has_live_connection(db)
assert read_header_bytes_preopen(db, length=16) is not None
def test_page_count_bytes_matches_on_disk_size(tmp_path):
"""The PRAGMA route reports the same size the header field encodes."""
db = tmp_path / "state.db"
_make_db(db, "DELETE")
conn = sqlite3.connect(str(db))
try:
logical = page_count_bytes(conn)
assert logical is not None
assert logical == db.stat().st_size
assert file_length_matches_header(conn) is True
finally:
conn.close()
def test_file_length_check_never_reports_truncated_db_as_healthy(tmp_path):
"""A short file must not come back as a clean 'file length matches'.
On a truncated database SQLite refuses the pragma outright, so the helper
returns None (inconclusive) rather than False. Either way the contract that
matters is the same: a torn file is never reported as healthy.
"""
db = tmp_path / "state.db"
_make_db(db, "DELETE")
conn = sqlite3.connect(str(db))
try:
logical = page_count_bytes(conn)
assert logical is not None
assert file_length_matches_header(conn) is True
# Truncate behind SQLite's back to simulate a torn extend.
with open(db, "r+b") as handle:
handle.truncate(logical // 2)
assert file_length_matches_header(conn) is not True
finally:
conn.close()