mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
`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.
204 lines
6.6 KiB
Python
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()
|