aad74f26f9
The #102827 corruption is pure zero holes -- frames lost across a WAL generation. SessionDB.close() produces exactly that when it runs against a file another live handle is still writing: PRAGMA wal_checkpoint(PASSIVE), then the connection close that lets SQLite unlink -wal/-shm. The dangerous event is a physical close overlapping any other live physical lifetime for the same path, so both sides of it are closed here. Late write vs. close: a cron watchdog timeout only stops waiting, and ThreadPoolExecutor.shutdown(wait=False) cannot interrupt a worker already inside run_conversation. The agent and its registry reference are now held until that worker's Future completes, so its last frames land before any checkpoint. Close vs. open: the per-path barrier now COUNTS admitted teardowns. A path can own several closes at once -- the current generation's final release and a retired generation's drain are admitted independently under the registry lock, and the per-path mutex only serializes teardowns that already entered it. With one bare event per path, a releasing thread descheduled between generation removal and the mutex let the next teardown to settle remove and signal the shared event: close_all() returned over a pending close and acquire() published a replacement writer on top of a handle still inside checkpoint/unlink. _TeardownBarrier tracks event + pending count, _admit_teardown_locked registers each close in the same lock section that removes the generation, and only the last settled teardown lifts the barrier. Physical I/O stays outside the registry lock and unrelated paths still progress independently. The auto-archive sweep called release_or_close in its finally while the import was local to a different function, so every eligible sweep raised NameError, the outer except Exception swallowed it at debug level, and the borrowed registry reference was never returned -- a holder leak that pins a retired generation open. The helper is now bound in the calling scope. Remaining in-process writable SessionDB() call sites (trace upload, the API-server profile cache, the web-server writable paths, startup schema reconcile) go through the canonical registry acquire/release_or_close, and gateway maintenance borrows pinned handles instead of iterating an unpinned snapshot. Regressions: overlapping final releases of the current and retired generations in both orderings with the first paused before the lifecycle mutex, teardown-error settlement, an unrelated-path control, and refcount assertions for the auto-archive sweep on success, on failure, across repeated sweeps and with auto-archive disabled. Fixes #102827 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BzxCWw6SuHXhXMdkiEwMa2
287 lines
10 KiB
Python
287 lines
10 KiB
Python
"""File-identity guard on SessionDB writes (#89332).
|
|
|
|
When state.db is replaced out-of-band under a live handle, in-place FTS
|
|
rebuild / fail-open cannot help: they operate on a generation mismatch.
|
|
The store must fail loudly instead of limping.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_state import (
|
|
SessionDB,
|
|
StateDbReplacedError,
|
|
classify_persistence_error,
|
|
divert_session_transcript_jsonl,
|
|
)
|
|
|
|
|
|
def _make_db(path: Path, session_id: str, content: str) -> SessionDB:
|
|
db = SessionDB(db_path=path)
|
|
db.create_session(session_id, "cli")
|
|
db.append_message(session_id, role="user", content=content)
|
|
return db
|
|
|
|
|
|
def _require_identity(db: SessionDB) -> None:
|
|
if db._db_file_identity is None:
|
|
pytest.skip("filesystem does not expose st_dev/st_ino for identity checks")
|
|
|
|
|
|
def _mark_replaced_handle(monkeypatch, db: SessionDB) -> None:
|
|
"""Exercise the replacement guard without unlinking an open DB on Windows."""
|
|
monkeypatch.setattr(db, "_db_file_was_replaced", lambda: True)
|
|
|
|
|
|
def test_replace_with_new_inode_fails_loudly_without_fts_repair(tmp_path, monkeypatch):
|
|
live = tmp_path / "state.db"
|
|
db = _make_db(live, "live-sess", "original")
|
|
_require_identity(db)
|
|
_mark_replaced_handle(monkeypatch, db)
|
|
|
|
with pytest.raises(StateDbReplacedError, match="replaced underneath"):
|
|
db.append_message("live-sess", role="user", content="after-replace")
|
|
|
|
assert db._db_replaced is True
|
|
# No FTS surgery ran: fail-open never detached the indexes.
|
|
assert db._fts_enabled is True
|
|
assert db._fts_stale is False
|
|
db.close()
|
|
|
|
|
|
def test_second_write_after_halt_does_not_attempt_repair(tmp_path, monkeypatch):
|
|
live = tmp_path / "state.db"
|
|
db = _make_db(live, "s", "a")
|
|
_require_identity(db)
|
|
_mark_replaced_handle(monkeypatch, db)
|
|
with pytest.raises(StateDbReplacedError):
|
|
db.append_message("s", role="user", content="first")
|
|
with pytest.raises(StateDbReplacedError):
|
|
db.append_message("s", role="user", content="second")
|
|
assert db._fts_enabled is True
|
|
assert db._fts_stale is False
|
|
db.close()
|
|
|
|
|
|
def test_same_file_fts_corruption_still_fails_open(tmp_path):
|
|
"""Identity guard must not disable genuine in-file FTS recovery.
|
|
|
|
Since 18ac3c4fb6 the live write path never rebuilds FTS in place; the
|
|
recovery contract is the fail-open detach (stale marker + triggers
|
|
dropped) followed by a successful canonical retry.
|
|
"""
|
|
db = _make_db(tmp_path / "state.db", "s1", "hello world")
|
|
_require_identity(db)
|
|
identity = db._db_file_identity
|
|
raw = sqlite3.connect(str(tmp_path / "state.db"))
|
|
raw.execute(
|
|
"UPDATE messages_fts_data SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'"
|
|
)
|
|
raw.commit()
|
|
raw.close()
|
|
db.append_message("s1", role="user", content="healed append")
|
|
assert db._db_file_identity == identity
|
|
assert db._db_replaced is False
|
|
# Fail-open detach ran: canonical write landed, FTS marked stale.
|
|
assert db._fts_stale is True
|
|
assert db._fts_enabled is False
|
|
db.close()
|
|
|
|
|
|
def test_classify_replaced_is_not_disk_or_fts_repair():
|
|
err = StateDbReplacedError(
|
|
"FATAL: state.db was replaced underneath the gateway; refusing further writes"
|
|
)
|
|
assert classify_persistence_error(err) == "replaced"
|
|
assert classify_persistence_error(str(err)) == "replaced"
|
|
|
|
|
|
def test_new_sessiondb_on_replaced_path_records_new_identity(tmp_path):
|
|
live = tmp_path / "state.db"
|
|
other = tmp_path / "other.db"
|
|
db = _make_db(live, "s", "a")
|
|
old_id = db._db_file_identity
|
|
_require_identity(db)
|
|
db.close()
|
|
alt = _make_db(other, "t", "b")
|
|
alt.close()
|
|
os.replace(other, live)
|
|
reopened = SessionDB(db_path=live)
|
|
try:
|
|
assert reopened._db_file_identity != old_id
|
|
reopened.append_message("t", role="user", content="adopted after reopen")
|
|
assert reopened._db_replaced is False
|
|
finally:
|
|
reopened.close()
|
|
|
|
|
|
def test_fts_scoped_error_on_replaced_file_skips_fts_fail_open(tmp_path, monkeypatch):
|
|
"""Even FTS-provenance corruption must not authorize surgery on a
|
|
replaced file. (A generic malformed error never reaches fail-open at
|
|
all since the provenance classifier of #99652 rejects it earlier.)"""
|
|
live = tmp_path / "state.db"
|
|
db = _make_db(live, "s", "a")
|
|
_require_identity(db)
|
|
_mark_replaced_handle(monkeypatch, db)
|
|
|
|
with pytest.raises(StateDbReplacedError):
|
|
db._enter_fts_fail_open(
|
|
sqlite3.DatabaseError(
|
|
'fts5: corrupt structure record for table "messages_fts"'
|
|
)
|
|
)
|
|
assert db._fts_enabled is True
|
|
assert db._fts_stale is False
|
|
db.close()
|
|
|
|
|
|
def test_copyfile_same_inode_fails_loudly_without_fts_repair(tmp_path):
|
|
"""``cp`` keeps st_ino; generation stamp must still halt (#89332)."""
|
|
live = tmp_path / "state.db"
|
|
other = tmp_path / "other.db"
|
|
db = _make_db(live, "live-sess", "original")
|
|
alt = _make_db(other, "other-sess", "replacement")
|
|
live_app = db._db_file_application_id
|
|
other_app = alt._db_file_application_id
|
|
if not live_app or not other_app:
|
|
alt.close()
|
|
db.close()
|
|
pytest.skip("generation stamp not recorded on this filesystem")
|
|
assert live_app != other_app
|
|
recorded = db._db_file_identity
|
|
alt.close()
|
|
shutil.copyfile(other, live)
|
|
if recorded is not None:
|
|
st = os.stat(live)
|
|
assert (st.st_dev, st.st_ino) == recorded
|
|
with pytest.raises(StateDbReplacedError, match="replaced underneath"):
|
|
db.append_message("live-sess", role="user", content="after-cp")
|
|
assert db._db_replaced is True
|
|
assert db._fts_enabled is True
|
|
assert db._fts_stale is False
|
|
db.close()
|
|
|
|
|
|
def test_divert_session_transcript_jsonl_appends(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
path = divert_session_transcript_jsonl(
|
|
"sess-jsonl",
|
|
[{"role": "user", "content": "hello-jsonl"}],
|
|
)
|
|
assert path == tmp_path / "sessions" / "sess-jsonl.jsonl"
|
|
lines = path.read_text(encoding="utf-8").strip().splitlines()
|
|
assert json.loads(lines[-1])["content"] == "hello-jsonl"
|
|
assert divert_session_transcript_jsonl("sess-jsonl", []) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lock safety of the identity probe itself (#100368 / howtocorrupt §2.2).
|
|
#
|
|
# _read_sqlite_application_id runs on EVERY write against the LIVE state.db.
|
|
# Before the _pread_db_header fix it did open("rb")/read/close, and that
|
|
# close() cancelled every POSIX advisory lock this process held on the file
|
|
# — including the WAL-mode DMS shared lock of the writer connection. These
|
|
# tests measure the actual kernel lock table (/proc/locks), so they are
|
|
# Linux-only; the hazard itself is POSIX-only.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _posix_locks_on(paths):
|
|
"""Set of (inode, type, mode, start, end) locks held by this pid."""
|
|
import sys as _sys
|
|
if not _sys.platform.startswith("linux"):
|
|
pytest.skip("lock-table probe requires /proc/locks (Linux)")
|
|
inodes = {}
|
|
for p in paths:
|
|
try:
|
|
inodes[os.stat(p).st_ino] = str(p)
|
|
except OSError:
|
|
continue
|
|
pid = os.getpid()
|
|
held = set()
|
|
for line in Path("/proc/locks").read_text().splitlines():
|
|
parts = line.split()
|
|
try:
|
|
lpid = int(parts[4])
|
|
ino = int(parts[5].split(":")[2])
|
|
except (IndexError, ValueError):
|
|
continue
|
|
if lpid == pid and ino in inodes:
|
|
held.add((ino, parts[1], parts[3], parts[6], parts[7]))
|
|
return held
|
|
|
|
|
|
def test_identity_probe_does_not_cancel_live_posix_locks(tmp_path):
|
|
"""The on-write header probe must not drop the writer's DMS lock."""
|
|
from hermes_state import _read_sqlite_application_id
|
|
|
|
live = tmp_path / "state.db"
|
|
db = _make_db(live, "probe-sess", "seed")
|
|
try:
|
|
sidecars = [live, Path(str(live) + "-shm")]
|
|
# Hold an open write transaction: that is when the connection holds
|
|
# POSIX range locks on the main db file, and exactly the state a
|
|
# concurrent _raise_if_db_replaced probe (another thread, same
|
|
# process) can destroy.
|
|
db._conn.execute("BEGIN IMMEDIATE")
|
|
db._conn.execute(
|
|
"UPDATE sessions SET source = source WHERE id = 'probe-sess'"
|
|
)
|
|
before = _posix_locks_on(sidecars)
|
|
assert before, "expected in-transaction WAL connection to hold POSIX locks"
|
|
|
|
for _ in range(3):
|
|
_read_sqlite_application_id(live)
|
|
|
|
after = _posix_locks_on(sidecars)
|
|
db._conn.rollback()
|
|
lost = before - after
|
|
assert not lost, (
|
|
"identity probe cancelled POSIX locks held by the live "
|
|
f"connection (howtocorrupt §2.2): {lost}"
|
|
)
|
|
# The decisive check: the WAL DMS shared lock on the MAIN db file
|
|
# must survive. With the pre-fix open/read/close probe the close()
|
|
# cancels it (it is already gone by the time the connection has run
|
|
# its first identity check in __init__), leaving other processes
|
|
# free to treat this writer as dead and rerun WAL-index recovery
|
|
# underneath it.
|
|
db_ino = os.stat(live).st_ino
|
|
main_db_locks = {lk for lk in after if lk[0] == db_ino}
|
|
assert main_db_locks, (
|
|
"live writer connection holds no POSIX lock on state.db itself — "
|
|
"the WAL DMS lock was cancelled by a raw open/close probe "
|
|
"(howtocorrupt §2.2)"
|
|
)
|
|
# The connection must still be able to commit.
|
|
db.append_message("probe-sess", role="user", content="post-probe")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_identity_probe_still_detects_replacement_after_fd_cache(tmp_path):
|
|
"""The cached-fd probe rebinds when the path names a new inode."""
|
|
from hermes_state import _read_sqlite_application_id
|
|
|
|
live = tmp_path / "state.db"
|
|
other = tmp_path / "other.db"
|
|
db = _make_db(live, "live-sess", "original")
|
|
_require_identity(db)
|
|
first = _read_sqlite_application_id(live) # populates the fd cache
|
|
db.close()
|
|
|
|
alt = _make_db(other, "other-sess", "replacement")
|
|
alt.close()
|
|
os.replace(other, live)
|
|
|
|
second = _read_sqlite_application_id(live)
|
|
assert second is not None
|
|
assert second != first, (
|
|
"probe kept reading the retired inode instead of rebinding to the "
|
|
"replacement file"
|
|
)
|