fix(doctor): name the processes holding a retired WAL generation instead of a green state.db
After the deleted-WAL guard fires, every SessionDB open raises DeletedWalGenerationError, yet `hermes doctor` printed "✓ state.db exists" and "0 process(es) holding the DB open" with rc 0 (#110054). It only counted holders of the CURRENT state.db inode; the processes wedging the store are the ones holding the RETIRED -wal/-shm inodes, and nothing in-product named them — while the chat explainer sends users to exactly this doctor. Ask iter_deleted_sqlite_sidecar_holders (the helper the guard itself uses) first. When it names holders: warn with their PIDs, record a stop-them issue line pinned to the active profile, and return before the health/stats/WAL probes — a read-only connect on the new generation is another opener, and --fix must not checkpoint under them. No kill path, no new module.
This commit is contained in:
@@ -344,11 +344,32 @@ def _state_db_wal(f: Finding, should_fix: bool, state_db_path: Path) -> None:
|
||||
check_info(f"WAL file is {size // (1024*1024)} MB (normal for active sessions)")
|
||||
|
||||
|
||||
def _retired_wal_holders(f: Finding, state_db_path: Path, _DHH: str) -> bool:
|
||||
"""Name the processes holding a retired -wal/-shm generation (#110054). Every SessionDB open is
|
||||
refused while they live, and the current inode has no holders, so the plain holder count says
|
||||
"0 holding the DB open" beside a green state.db line — the opposite of the truth."""
|
||||
from hermes_constants import profile_cli_selector
|
||||
from hermes_state_dbfile import iter_deleted_sqlite_sidecar_holders
|
||||
pids = list(dict.fromkeys(pid for pid, _ in iter_deleted_sqlite_sidecar_holders(state_db_path)))
|
||||
if not pids:
|
||||
return False
|
||||
rendered = ", ".join(f"pid {pid}" for pid in pids)
|
||||
check_warn(f"{_DHH}/state.db: {len(pids)} process(es) still hold a retired WAL generation ({rendered})",
|
||||
"(every new session refuses to open until they exit; health/stats probes skipped)")
|
||||
f.issues.append(f"state.db retired WAL generation held by {rendered} — stop them: "
|
||||
f"'hermes {profile_cli_selector()}gateway stop', quit the Desktop app / dashboard, "
|
||||
"then rerun 'hermes doctor'")
|
||||
return True
|
||||
|
||||
|
||||
@doctor_check()
|
||||
def _check_state_db(should_fix: bool, f: Finding) -> None:
|
||||
"""state.db session count, FTS write health, schema repair, stats snapshot, WAL size."""
|
||||
from hermes_cli.doctor import HERMES_HOME, _DHH
|
||||
state_db_path = HERMES_HOME / "state.db"
|
||||
# A read-only connect on the new generation is itself another opener, so nothing below may run.
|
||||
if _retired_wal_holders(f, state_db_path, _DHH):
|
||||
return
|
||||
if state_db_path.exists():
|
||||
_state_db_health(f, should_fix, state_db_path, _DHH)
|
||||
_state_db_stats(f.issues, state_db_path)
|
||||
|
||||
@@ -49,3 +49,29 @@ def test_wal_checkpoint_skipped_while_live_writer_holds_db(tmp_path):
|
||||
|
||||
assert finding.fixed == 0
|
||||
assert any("gateway" in issue for issue in finding.issues)
|
||||
|
||||
|
||||
def test_doctor_names_retired_wal_holders_instead_of_healthy_state_db(tmp_path, monkeypatch, capsys):
|
||||
"""After the deleted-WAL guard fires (#110054), doctor must name the PIDs holding the retired
|
||||
generation, must not print a healthy state.db line, and must not open the store itself (the
|
||||
health probe is another opener) nor checkpoint under --fix."""
|
||||
import hermes_cli.doctor as doctor
|
||||
import hermes_cli.doctor_state as doctor_state
|
||||
import hermes_state_dbfile
|
||||
|
||||
db = tmp_path / "state.db"
|
||||
db.write_bytes(b"")
|
||||
monkeypatch.setattr(doctor, "HERMES_HOME", tmp_path)
|
||||
monkeypatch.setattr(hermes_state_dbfile, "iter_deleted_sqlite_sidecar_holders",
|
||||
lambda path: [(4242, f"{path}-wal"), (4242, f"{path}-shm")])
|
||||
probed = []
|
||||
monkeypatch.setattr(doctor_state, "_state_db_health", lambda *a, **k: probed.append(a))
|
||||
monkeypatch.setattr(doctor_state, "_state_db_wal", lambda *a, **k: probed.append(a))
|
||||
|
||||
finding = doctor_state._check_state_db(True)
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "4242" in out and "retired WAL" in out
|
||||
assert "✓" not in out
|
||||
assert probed == [] and finding.fixed == 0
|
||||
assert any("4242" in issue and "gateway stop" in issue for issue in finding.issues)
|
||||
|
||||
Reference in New Issue
Block a user