Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions hermes_cli/doctor_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,24 +298,26 @@ def _state_db_wal(f: Finding, should_fix: bool, state_db_path: Path) -> None:
with warn_on_error(""):
size = wal_size()
if size > 50 * 1024 * 1024: # 50 MB
check_warn(f"WAL file is large ({size // (1024*1024)} MB)", "(may indicate missed checkpoints)")
if not should_fix:
return f.issues.append("Large WAL file — run 'hermes doctor --fix' to checkpoint")
# Checkpoint-lock premise (#40177, #103339): a bare connect runs WAL recovery and the checkpoint
# joins the live WAL — under a running gateway that second-writer handling corrupts state.db.
# Holder scan first (any other process holding the DB, or an unknown, fails closed), then run the
# checkpoint on the exclusive repair guard so an opener arriving in between is refused, not joined.
from hermes_state_holders import live_writer_holds_db
from hermes_state_repair import _connect_repair_durable, _exclusive_repair_db_guard
from hermes_state_repair import _exclusive_repair_db_guard, _live_writer_holds_db
title = f"WAL file is large ({size // (1024*1024)} MB)"
_SKIP = ("Large WAL file — cannot prove state.db is quiet (stop the profile's gateway first, then "
"re-run 'hermes doctor --fix' to checkpoint)")
if live_writer_holds_db(state_db_path, connect_repair_durable=_connect_repair_durable):
# Honest disjunction (gate C1): a True here means "held OR unprovable" — never assert a live
# writer as fact.
check_warn("WAL checkpoint skipped: cannot prove state.db is quiet",
"(another process holds it, or it is unreadable — stop the profile's gateway "
"and re-run 'hermes doctor --fix')")
"run 'hermes doctor --fix' to checkpoint)")
# Honest disjunction (gate C1): a True here means "held OR unprovable" — never assert a live
# writer as fact.
if _live_writer_holds_db(state_db_path):
# A large WAL is normal while Desktop or the gateway is running; a bare "run --fix" here sent
# users straight into the second-writer trap (#110054).
check_warn(title, "(normal while Desktop or the gateway is running, or state.db cannot be "
"inspected — checkpoint only with them stopped)")
return f.issues.append(_SKIP)
check_warn(title, "(may indicate missed checkpoints)")
if not should_fix:
return f.issues.append(
"Large WAL file — stop the profile's gateway, then run 'hermes doctor --fix' to checkpoint")
with _exclusive_repair_db_guard(state_db_path) as (guard, guard_error):
if guard is None:
check_warn("WAL checkpoint skipped: could not take exclusive ownership of state.db",
Expand Down
34 changes: 31 additions & 3 deletions tests/hermes_cli/test_doctor_wal_checkpoint_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,21 @@
from hermes_cli.doctor_state import _state_db_wal


def test_doctor_checkpoint_runs_only_on_the_exclusive_repair_guard(tmp_path, monkeypatch):
def _large_wal_db(tmp_path):
db = tmp_path / "state.db"
setup = sqlite3.connect(str(db))
setup.execute("CREATE TABLE t(x)")
setup.execute("PRAGMA journal_mode=WAL")
setup.execute("INSERT INTO t VALUES (1)")
setup.commit()
setup.close()
wal = Path(f"{db}-wal")
with open(wal, "ab") as handle:
with open(Path(f"{db}-wal"), "ab") as handle:
handle.truncate(51 * 1024 * 1024)
return db


def test_doctor_checkpoint_runs_only_on_the_exclusive_repair_guard(tmp_path, monkeypatch):
db = _large_wal_db(tmp_path)

bare_connects: list[str] = []
real_connect = sqlite3.connect
Expand Down Expand Up @@ -64,3 +68,27 @@ def test_session_count_reads_a_home_with_uri_reserved_characters(tmp_path):
conn.commit()
conn.close()
assert _session_count(db) == 2


def test_large_wal_warning_under_a_live_writer_never_suggests_a_bare_fix(tmp_path, monkeypatch, capsys):
"""`hermes doctor` (no --fix) on a large WAL while Desktop/gateway hold the DB must say it is normal and
order "stop" before any `--fix` — the bare "run 'hermes doctor --fix'" nudge is how users became the
second writer (#110054)."""
import hermes_state_holders

monkeypatch.setattr(hermes_state_holders, "live_writer_holds_db", lambda *a, **k: True)
finding = Finding()
_state_db_wal(finding, False, _large_wal_db(tmp_path))
assert len(finding.issues) == 1 and not finding.fixed
assert "normal while Desktop or the gateway is running, or state.db cannot be inspected" in capsys.readouterr().out
assert finding.issues[0].index("stop the profile's gateway") < finding.issues[0].index("hermes doctor --fix")


def test_large_wal_warning_without_a_holder_still_orders_stop_before_fix(tmp_path, monkeypatch):
import hermes_state_holders

monkeypatch.setattr(hermes_state_holders, "live_writer_holds_db", lambda *a, **k: False)
finding = Finding()
_state_db_wal(finding, False, _large_wal_db(tmp_path))
assert len(finding.issues) == 1 and not finding.fixed
assert finding.issues[0].index("stop the profile's gateway") < finding.issues[0].index("hermes doctor --fix")
Loading