fix(db): honour require_wal on the probe-unknown path; touch nothing at all
Follow-up to the probe-unknown guard: - `require_wal=True` now raises WalUnsupportedError when the on-disk mode cannot be read instead of reporting an unverified "wal" (the function's contract is "the mode actually set"). - Drop `_apply_wal_companions` from the branch: journal_size_limit / synchronous pragmas were being applied to a file whose mode is unknown, contradicting the "touch nothing" rule the branch exists for. - Cut the retracted #104596 mechanism narrative from comments, log text and docstrings; the guard is hardening (same rule the DELETE branch already applied), not a root-cause fix. - Tests: one binding test per file (no pragma of any kind reaches the connection; require_wal raises); drop the log-dedupe test (`_log_once` is already covered). test_captures_cause_on_failed_init's double now fails only set-pragmas, matching the read-only-mount it simulates.
This commit is contained in:
@@ -33,8 +33,7 @@ _WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
|
||||
_wal_fallback_warned_paths: set[str] = set()
|
||||
_wal_fallback_warned_lock = threading.Lock()
|
||||
|
||||
# Dedup WARNING for the #104596 probe-unknown guard (WAL path touching nothing
|
||||
# while ownership is not provably exclusive).
|
||||
# Dedup for the probe-unknown WARNING (on-disk journal mode unreadable, nothing touched).
|
||||
_wal_probe_unknown_paths: set[str] = set()
|
||||
_wal_probe_unknown_lock = threading.Lock()
|
||||
_wal_reset_bug_warned_paths: set[str] = set()
|
||||
@@ -165,7 +164,8 @@ def resolve_journal_mode() -> str:
|
||||
class WalUnsupportedError(sqlite3.OperationalError):
|
||||
"""Raised by :func:`apply_wal_with_fallback` under ``require_wal=True`` when
|
||||
the filesystem cannot provide WAL (SQLITE_PROTOCOL raised, or macOS-NFS silent
|
||||
refusal). Subclasses ``OperationalError`` so DB-init handlers still catch it."""
|
||||
refusal) or the on-disk mode cannot be verified (probe blocked by a concurrent
|
||||
opener). Subclasses ``OperationalError`` so DB-init handlers still catch it."""
|
||||
|
||||
|
||||
def _verify_configured_delete(actual: str) -> str:
|
||||
@@ -178,8 +178,9 @@ def _verify_configured_delete(actual: str) -> str:
|
||||
def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.db", require_wal: bool = False) -> str:
|
||||
"""Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
|
||||
|
||||
Returns the mode actually set. Shared by :class:`SessionDB` and ``hermes_cli.kanban_db_connect.connect``.
|
||||
WAL-incompatible filesystems either raise ``OperationalError`` ("locking protocol" / "disk I/O error") or —
|
||||
Returns the mode actually set — or, when the read-only mode probe is blocked by a concurrent opener, ``"wal"``
|
||||
as the assumed mode with nothing touched (``require_wal=True`` raises instead). Shared by :class:`SessionDB`
|
||||
and ``hermes_cli.kanban_db_connect.connect``. WAL-incompatible filesystems either raise ``OperationalError`` ("locking protocol" / "disk I/O error") or —
|
||||
macOS NFS / SMB / AgentFS — silently refuse and stay in DELETE; either way log ERROR once per process per
|
||||
``db_label`` and fall back. ``require_wal=True`` raises :class:`WalUnsupportedError` instead. WAL-reset-bug
|
||||
builds (https://sqlite.org/wal.html#walresetbug) never enable WAL on non-WAL files; an already-WAL DB keeps WAL
|
||||
@@ -220,17 +221,13 @@ def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.
|
||||
raise sqlite3.OperationalError(_CANNOT_VERIFY_DELETE_MSG)
|
||||
return _verify_configured_delete(_set_journal_mode_no_wait(conn, "DELETE"))
|
||||
if current_mode is None:
|
||||
# #104596: the probe failed (locked/busy under load) and the on-disk mode is unknown. A fresh 0-page
|
||||
# DB probes "delete" cleanly, so None here means the probe could not read a real file — most often
|
||||
# because a sibling connection (same process or another) holds it, in WAL, with live -wal/-shm sidecars.
|
||||
# Emitting the set-pragma inside _enable_wal would re-run WAL-init and UNLINK those sidecars under the
|
||||
# holder: two -shm generations that cannot see each other's locks -> split-brain corruption
|
||||
# (btreeInitPage error 11). Ownership is not provably exclusive, so touch nothing: an already-WAL DB
|
||||
# keeps working (this connection inherits the mode from the on-disk header, exactly like the early
|
||||
# return above), the rare true-DELETE file degrades to DELETE with the warning below, and a new DB
|
||||
# reaches _enable_wal on its next open. Mirrors the vulnerable-gate path's indeterminate handling.
|
||||
# Probe failed (locked/busy): ownership not provably exclusive, same as the DELETE branch above. A fresh
|
||||
# 0-page DB probes "delete" cleanly, so this is a real file some other connection holds — running WAL-init
|
||||
# would unlink its -wal/-shm sidecars. Touch nothing: the connection inherits whatever mode the header has.
|
||||
if require_wal:
|
||||
raise WalUnsupportedError("could not verify the on-disk journal mode (database is locked — possible "
|
||||
"concurrent openers); cannot guarantee WAL")
|
||||
_log_once("wal_probe_unknown", db_label)
|
||||
_apply_wal_companions(conn)
|
||||
return "wal"
|
||||
return _enable_wal(conn, db_label, require_wal, current_mode)
|
||||
|
||||
@@ -408,16 +405,11 @@ _ONCE_LOGS = {
|
||||
"this DB and run a one-time offline 'PRAGMA journal_mode=DELETE' on the file. This message fires once per "
|
||||
"process per database."),
|
||||
"wal_probe_unknown": (_wal_probe_unknown_lock, "_wal_probe_unknown_paths", logging.WARNING,
|
||||
# #104596: the read-only probe failed (locked/busy under load). A fresh 0-page DB probes "delete" cleanly,
|
||||
# so None means the probe could not read a real file — most often because a sibling connection holds it, in
|
||||
# WAL, with live -wal/-shm sidecars. Emitting the set-pragma here re-runs WAL-init and unlinks those
|
||||
# sidecars under the holder: two -shm generations that cannot see each other's locks -> split-brain
|
||||
# corruption. Keep the WARNING (not ERROR): the connection still inherits WAL from the on-disk header, so
|
||||
# the common case is harmless — only the rare true-DELETE file degrades (silently, to DELETE).
|
||||
"%s: could not verify the on-disk journal mode (database is locked / busy under load); refusing to issue a "
|
||||
"journal-mode set-pragma while ownership is not provably exclusive (it could unlink the -wal/-shm sidecars "
|
||||
"a sibling connection still holds open — split-brain corruption, #104596). Assuming the configured "
|
||||
"journal_mode=wal and leaving the file untouched. This message fires once per process per database."),
|
||||
# WARNING, not ERROR: the connection inherits the header's mode, so an already-WAL file (the common case)
|
||||
# keeps working; only a true-DELETE file stays DELETE for this connection.
|
||||
"%s: could not verify the on-disk journal mode (database is locked / busy); not issuing a journal-mode "
|
||||
"set-pragma while another connection may hold the file (it could unlink the -wal/-shm sidecars that "
|
||||
"connection still uses). Leaving the file untouched; this connection inherits the on-disk mode. This message fires once per process per database."),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -428,7 +428,8 @@ class TestGetLastInitError:
|
||||
"""When SessionDB() raises, the cause is preserved for slash commands.
|
||||
|
||||
Simulates a filesystem where BOTH WAL and DELETE journal modes fail —
|
||||
e.g. a read-only mount where no ``PRAGMA journal_mode=X`` works. The
|
||||
e.g. a read-only mount where no ``PRAGMA journal_mode=X`` works (the
|
||||
read-only mode probe still succeeds, as on a real mount). The
|
||||
fallback tries DELETE and also gets rejected; the exception bubbles
|
||||
out of ``SessionDB.__init__`` and the cause is captured.
|
||||
"""
|
||||
@@ -437,7 +438,7 @@ class TestGetLastInitError:
|
||||
|
||||
class _BothPragmasFailConnection(sqlite3.Connection):
|
||||
def execute(self, sql, *args, **kwargs): # type: ignore[override]
|
||||
if "journal_mode" in sql.lower():
|
||||
if "journal_mode=" in sql.lower().replace(" ", ""):
|
||||
raise sqlite3.OperationalError(
|
||||
"locking protocol: read-only filesystem"
|
||||
)
|
||||
|
||||
@@ -43,19 +43,13 @@ def _reset_configured_delete_override_warned_paths():
|
||||
|
||||
|
||||
def test_wal_probe_unknown_never_emits_set_pragma(monkeypatch, tmp_path, caplog):
|
||||
"""#104596: probe failure (None) must not reach the WAL set-pragma.
|
||||
|
||||
The DELETE branch refuses to flip modes when the on-disk mode cannot be
|
||||
verified (ownership not provably exclusive). The configured-WAL branch
|
||||
used to fall through to ``_enable_wal`` and emit ``PRAGMA
|
||||
journal_mode=WAL`` anyway; when a sibling connection holds the DB in WAL
|
||||
with live -wal/-shm sidecars, WAL-init unlinks those sidecars under the
|
||||
holder -> two -shm generations -> split-brain corruption (btreeInitPage
|
||||
error 11). The guard must touch nothing and assume the configured mode.
|
||||
"""
|
||||
"""Probe failure (None) on the configured-WAL path must not reach any journal-mode
|
||||
pragma: the file may be held by a sibling whose -wal/-shm sidecars WAL-init would
|
||||
unlink. The DELETE branch already refused; this binds the WAL branch to the same rule,
|
||||
and ``require_wal=True`` must raise instead of reporting an unverified "wal"."""
|
||||
import logging
|
||||
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
from hermes_state_wal import WalUnsupportedError, apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -64,80 +58,34 @@ def test_wal_probe_unknown_never_emits_set_pragma(monkeypatch, tmp_path, caplog)
|
||||
class _SpyConnection(sqlite3.Connection):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.emitted: list[str] = []
|
||||
self.pragmas: list[str] = []
|
||||
|
||||
def execute(self, sql, *args, **kwargs): # type: ignore[override]
|
||||
if "journal_mode" in sql.lower() and "=" in sql.lower():
|
||||
self.emitted.append(str(sql))
|
||||
if str(sql).lstrip().lower().startswith("pragma"):
|
||||
self.pragmas.append(str(sql))
|
||||
return super().execute(sql, *args, **kwargs)
|
||||
|
||||
db_path = tmp_path / "probe-unknown.db"
|
||||
sibling = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
# A DB that is genuinely WAL on disk; the sibling holds the sidecars.
|
||||
assert (
|
||||
sibling.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower()
|
||||
== "wal"
|
||||
)
|
||||
# The probe fails under load (locked/busy) -> on-disk mode unknown.
|
||||
monkeypatch.setattr(
|
||||
"hermes_state_wal._on_disk_journal_mode", lambda _conn: None
|
||||
)
|
||||
assert sibling.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal"
|
||||
monkeypatch.setattr("hermes_state_wal._on_disk_journal_mode", lambda _conn: None)
|
||||
conn = sqlite3.connect(str(db_path), factory=_SpyConnection)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_state_wal"):
|
||||
result = apply_wal_with_fallback(
|
||||
conn, db_label="probe-unknown.db"
|
||||
)
|
||||
assert result == "wal" # assumed configured mode
|
||||
assert conn.emitted == [] # no set-pragma while ownership unproven
|
||||
# The sibling's on-disk WAL state is untouched.
|
||||
assert (
|
||||
sibling.execute("PRAGMA journal_mode").fetchone()[0].lower()
|
||||
== "wal"
|
||||
)
|
||||
assert any(
|
||||
"could not verify the on-disk journal mode" in r.getMessage()
|
||||
for r in caplog.records
|
||||
)
|
||||
assert apply_wal_with_fallback(conn, db_label="probe-unknown.db") == "wal"
|
||||
assert conn.pragmas == [] # nothing touched while ownership is unproven
|
||||
assert sibling.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
||||
assert any("could not verify the on-disk journal mode" in r.getMessage() for r in caplog.records)
|
||||
with pytest.raises(WalUnsupportedError, match="could not verify the on-disk journal mode"):
|
||||
apply_wal_with_fallback(conn, db_label="probe-unknown.db", require_wal=True)
|
||||
assert conn.pragmas == []
|
||||
finally:
|
||||
conn.close()
|
||||
finally:
|
||||
sibling.close()
|
||||
|
||||
|
||||
def test_wal_probe_unknown_warns_once_per_database(monkeypatch, tmp_path, caplog):
|
||||
"""Dedupe contract: one WARNING per (process, db_label)."""
|
||||
import logging
|
||||
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
hermes_state_wal._wal_probe_unknown_paths.clear()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_state_wal._on_disk_journal_mode", lambda _conn: None
|
||||
)
|
||||
db_path = tmp_path / "warn.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_state_wal"):
|
||||
assert apply_wal_with_fallback(conn, db_label="warn.db") == "wal"
|
||||
assert apply_wal_with_fallback(conn, db_label="warn.db") == "wal"
|
||||
assert (
|
||||
apply_wal_with_fallback(conn, db_label="other.db") == "wal"
|
||||
)
|
||||
hits = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if "could not verify the on-disk journal mode" in r.getMessage()
|
||||
]
|
||||
assert len(hits) == 2 # warn.db once, other.db once
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_database_journal_mode_has_a_canonical_default():
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
|
||||
@@ -351,20 +351,9 @@ class TestNoDowngradeUnderConcurrentOpeners:
|
||||
check.close()
|
||||
|
||||
def test_probe_unreadable_touches_nothing(self, tmp_path, monkeypatch, caplog):
|
||||
"""#104596: when the on-disk mode cannot be verified (possible
|
||||
concurrent openers — same-process siblings holding live -wal/-shm
|
||||
sidecars), the WAL path must not emit the set-pragma at all.
|
||||
|
||||
Previously it fell through to ``PRAGMA journal_mode=WAL``; on an
|
||||
incompatible filesystem that raised, but on a WAL DB whose sidecars
|
||||
a sibling connection still holds, WAL-init silently unlinked them ->
|
||||
two -shm generations -> split-brain corruption (btreeInitPage error
|
||||
11). The guard now refuses to touch anything while ownership is
|
||||
unproven: it assumes the configured WAL mode, logs one warning per
|
||||
database, and returns without issuing the set-pragma — strictly more
|
||||
conservative than the old raise (which still required emitting the
|
||||
dangerous pragma first).
|
||||
"""
|
||||
"""Non-vulnerable runtime, probe blocked ("database is locked"): the WAL path
|
||||
must not emit the set-pragma (here it would raise "locking protocol"); it
|
||||
warns once and reports the inherited mode instead."""
|
||||
import logging
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
||||
Reference in New Issue
Block a user