Files
hermes-agent/tests/hermes_cli/test_doctor_wal_holder_guard.py
Halldrix b02f3aa00b fix(doctor): refuse the WAL checkpoint while a live writer holds state.db (#103339)
doctor --fix ran a raw sqlite3.connect + wal_checkpoint(PASSIVE) with no
holder guard. Under a running gateway that second connection joins the
live WAL and its close-time handling is the second-writer corruption
class #103339 tracks. Route the checkpoint through live_writer_holds_db
and skip with an actionable finding when the database is held.

[Salvage note: the original PR also flipped the repair probe's
DatabaseError branch to fail closed; that hunk is dropped here because
it refuses every header-destroyed DB (the exact class repair exists
for) — the probe's own connect raises before the EXCLUSIVE/BEGIN
statements ever run.]
2026-09-11 06:21:48 -07:00

52 lines
1.9 KiB
Python

"""Regression: ``hermes doctor --fix`` must not checkpoint the live WAL under a running gateway.
Checkpoint-lock premise (#40177): a bare ``sqlite3.connect`` runs WAL recovery and
``PRAGMA wal_checkpoint(PASSIVE)`` joins the live WAL — that second-writer handling
on a gateway-held state.db is the corruption class #103339 tracks. The check must
skip with an actionable finding while a live writer holds the database.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from hermes_cli.doctor_report import Finding
from hermes_cli.doctor_state import _state_db_wal
# NOTE: no ``requires_wal`` marker here on purpose. That gate exists for tests
# that depend on Hermes *choosing* WAL mode (declined on vulnerable SQLite
# builds). This test forces WAL explicitly through raw SQL and asserts only on
# the holder-guard skip, so the probe mechanics work on any build.
def test_wal_checkpoint_skipped_while_live_writer_holds_db(tmp_path):
"""A held database skips the checkpoint; nothing is checkpointed or fixed."""
db = tmp_path / "state.db"
setup = sqlite3.connect(str(db))
try:
setup.execute("CREATE TABLE t(x)")
setup.execute("PRAGMA journal_mode=WAL")
setup.execute("INSERT INTO t VALUES (1)")
setup.commit()
finally:
setup.close()
holder = sqlite3.connect(str(db))
holder.execute("SELECT count(*) FROM t").fetchone()
try:
wal = Path(f"{db}-wal")
assert wal.exists()
# Push past the 50 MB fix threshold without 50 MB of real frames: the
# guard runs before any WAL byte is parsed, so padding is never read.
with open(wal, "ab") as handle:
handle.truncate(51 * 1024 * 1024)
finding = Finding()
_state_db_wal(finding, True, db)
finally:
try:
holder.close()
except Exception:
pass
assert finding.fixed == 0
assert any("gateway" in issue for issue in finding.issues)