From 520266996de13033601a09438967a3d9d6ec9009 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Thu, 23 Jul 2026 15:48:16 +0700 Subject: [PATCH 1/4] fix(state): refuse WAL on SQLite builds with the WAL-reset bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On vulnerable SQLite (e.g. 3.50.4), do not enable WAL for fresh/non-WAL shared databases — prefer DELETE instead. Leave existing on-disk WAL alone (no live downgrade under concurrent gateway/cron openers). Surface Python/SQLite version details as a doctor warning (#69784). --- agent/verification_evidence.py | 4 +- cron/executions.py | 4 +- gateway/delivery_ledger.py | 4 +- hermes_cli/doctor.py | 30 ++++++ hermes_state.py | 142 +++++++++++++++++++++++++- plugins/platforms/discord/recovery.py | 4 +- tools/async_delegation.py | 4 +- 7 files changed, 186 insertions(+), 6 deletions(-) diff --git a/agent/verification_evidence.py b/agent/verification_evidence.py index d66a1534045ce..c3154378f5ec0 100644 --- a/agent/verification_evidence.py +++ b/agent/verification_evidence.py @@ -60,10 +60,12 @@ def _db_path() -> Path: def _connect() -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + path = _db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path) - conn.execute("PRAGMA journal_mode=WAL") + apply_wal_with_fallback(conn, db_label="verification_evidence.db") conn.execute("PRAGMA busy_timeout=5000") conn.row_factory = sqlite3.Row _ensure_schema(conn) diff --git a/cron/executions.py b/cron/executions.py index 0abd13b73ef72..6ffe1eef1fc8a 100644 --- a/cron/executions.py +++ b/cron/executions.py @@ -26,11 +26,13 @@ def _connect() -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + EXECUTIONS_FILE.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(EXECUTIONS_FILE, timeout=5) conn.row_factory = sqlite3.Row conn.execute("PRAGMA busy_timeout=5000") - conn.execute("PRAGMA journal_mode=WAL") + apply_wal_with_fallback(conn, db_label="cron/executions.db") conn.execute("PRAGMA synchronous=FULL") conn.execute( """CREATE TABLE IF NOT EXISTS executions ( diff --git a/gateway/delivery_ledger.py b/gateway/delivery_ledger.py index 955e1d1d3e95e..dac168c8572a0 100644 --- a/gateway/delivery_ledger.py +++ b/gateway/delivery_ledger.py @@ -75,10 +75,12 @@ def _db_path(): def _connect() -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + path = _db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path, timeout=10) - conn.execute("PRAGMA journal_mode=WAL") + apply_wal_with_fallback(conn, db_label="state.db (delivery_ledger)") conn.execute( """CREATE TABLE IF NOT EXISTS delivery_obligations ( obligation_id TEXT PRIMARY KEY, diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 68338bfe89e40..3a372cc4ed23f 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -739,6 +739,36 @@ def run_doctor(args): "Upgrade Python to 3.10+", issues, ) + + # Linked SQLite library (issue #69784): version + source id matter independently + # of the Python minor — uv's python-build-standalone can keep a vulnerable + # SQLite across Python upgrades. + try: + import sqlite3 + from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id + + _sqlite_ver = sqlite3.sqlite_version + _sqlite_src = sqlite_source_id() + _sqlite_src_short = ( + (_sqlite_src[:48] + "…") if len(_sqlite_src) > 48 else _sqlite_src + ) + if is_sqlite_wal_reset_vulnerable(): + # Warn-only: Hermes already refuses to enable WAL on fresh DBs. + # Do not append to ``issues`` — users often cannot change the + # SQLite embedded in python-build-standalone via `hermes update`. + check_warn( + f"SQLite {_sqlite_ver} (WAL-reset bug)", + "(new shared DBs use DELETE; prefer 3.51.3+ / 3.50.7 / 3.44.6 — " + "see https://sqlite.org/wal.html#walresetbug)", + ) + if _sqlite_src_short: + check_info(f"SQLite source id: {_sqlite_src_short}") + else: + check_ok(f"SQLite {_sqlite_ver}") + if _sqlite_src_short: + check_info(f"SQLite source id: {_sqlite_src_short}") + except Exception as e: + check_warn(f"SQLite version probe failed: {e}") # Check if in virtual environment in_venv = sys.prefix != sys.base_prefix diff --git a/hermes_state.py b/hermes_state.py index 5ab828fd15087..4fe3a672116da 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -186,6 +186,15 @@ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]: # Instead, fall back to ``journal_mode=DELETE`` (the pre-WAL default) which # works on NFS. Concurrency drops — concurrent readers are blocked during # a write — but the feature works. +# +# Separately, SQLite's WAL-reset bug can corrupt multi-process WAL databases +# on unfixed library builds (issue #69784). See: +# https://sqlite.org/wal.html#walresetbug +# Fixed in 3.51.3+ with backports 3.50.7 and 3.44.6. On vulnerable builds we +# refuse to *enable* WAL for fresh / non-WAL databases (prefer DELETE). We do +# NOT live-downgrade an on-disk WAL database — other gateway/cron/worker +# connections may still hold it open, and flipping journal_mode under them is +# unsafe (same invariant as the NFS path below). _WAL_INCOMPAT_MARKERS = ( "locking protocol", # SQLITE_PROTOCOL on NFS/SMB "not authorized", # Some FUSE mounts block WAL pragma outright @@ -207,6 +216,10 @@ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]: _wal_fallback_warned_paths: set[str] = set() _wal_fallback_warned_lock = threading.Lock() +# Dedup WARNING for the WAL-reset vulnerability fallback (issue #69784). +_wal_reset_bug_warned_paths: set[str] = set() +_wal_reset_bug_warned_lock = threading.Lock() + _FTS_TRIGGERS = ( "messages_fts_insert", "messages_fts_delete", @@ -409,6 +422,51 @@ def _enforce_macos_synchronous_full(conn: sqlite3.Connection) -> None: pass +def is_sqlite_wal_reset_vulnerable( + version_info: Optional[tuple] = None, +) -> bool: + """Return True when the linked SQLite library has the WAL-reset bug. + + Upstream documents the bug in versions 3.7.0 through 3.51.2, fixed in + 3.51.3+, with backports 3.50.7 and 3.44.6: + https://sqlite.org/wal.html#walresetbug + + Pre-WAL libraries (< 3.7.0) cannot hit the race and are treated as safe. + """ + info = version_info if version_info is not None else sqlite3.sqlite_version_info + if len(info) < 3: + # Defensive: treat incomplete tuples as vulnerable once WAL exists. + major = info[0] if info else 0 + minor = info[1] if len(info) > 1 else 0 + patch = info[2] if len(info) > 2 else 0 + info = (major, minor, patch) + if info < (3, 7, 0): + return False + if info >= (3, 51, 3): + return False + # Backports of the same fix on older release lines. + if (3, 50, 7) <= info < (3, 51, 0): + return False + if (3, 44, 6) <= info < (3, 45, 0): + return False + return True + + +def sqlite_source_id() -> str: + """Return ``sqlite_source_id()``, or an empty string when unavailable.""" + try: + conn = sqlite3.connect(":memory:") + try: + row = conn.execute("SELECT sqlite_source_id()").fetchone() + finally: + conn.close() + except sqlite3.Error: + return "" + if not row or row[0] is None: + return "" + return str(row[0]) + + def apply_wal_with_fallback( conn: sqlite3.Connection, *, @@ -423,6 +481,11 @@ def apply_wal_with_fallback( back to DELETE mode — the pre-WAL default, which works on NFS — and log one WARNING explaining why. + On SQLite builds that still contain the WAL-reset corruption bug + (issue #69784), refuse to enable WAL on fresh / non-WAL databases + (prefer DELETE). If the on-disk DB is already WAL, keep WAL and warn + — never live-downgrade under possible concurrent openers. + The WARNING is deduplicated per ``db_label``: repeated connections to the same underlying DB (e.g. kanban_db.connect() which is called on every kanban operation) log once per process, not once per call. @@ -432,8 +495,14 @@ def apply_wal_with_fallback( Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so both databases get identical fallback behavior. - Never downgrades to DELETE if the on-disk DB header reports WAL — see _on_disk_journal_mode. + Never downgrades to DELETE if the on-disk DB header reports WAL — see + _on_disk_journal_mode. That holds for both the NFS path and the + WAL-reset vulnerability path. """ + # Vulnerable SQLite: do not enable WAL on new/non-WAL files. + if is_sqlite_wal_reset_vulnerable(): + return _apply_delete_for_wal_reset_bug(conn, db_label=db_label) + # Read-only probe — no flock, no checkpoint, no WAL/SHM unlink. # Skipping the set-pragma prevents WAL-init from unlinking files other connections hold open. try: @@ -464,6 +533,76 @@ def apply_wal_with_fallback( return "delete" +def _apply_delete_for_wal_reset_bug( + conn: sqlite3.Connection, + *, + db_label: str, +) -> str: + """Avoid enabling WAL when the linked SQLite has the WAL-reset bug. + + - Already-WAL on disk: leave WAL alone (no live downgrade) and warn. + - Otherwise: set DELETE and warn. + """ + current = "" + try: + row = conn.execute("PRAGMA journal_mode").fetchone() + if row and row[0] is not None: + current = str(row[0]).strip().lower() + except sqlite3.OperationalError: + current = "" + + if current == "wal": + # Do not TRUNCATE / journal_mode=DELETE while other processes may + # still hold this WAL DB open — same safety rule as the NFS path. + _log_wal_reset_bug_once(db_label, kept_wal=True) + _apply_macos_checkpoint_barrier(conn) + _enforce_macos_synchronous_full(conn) + return "wal" + + try: + conn.execute("PRAGMA journal_mode=DELETE") + except sqlite3.OperationalError: + # Best-effort: DELETE is usually already the default for new files. + pass + _log_wal_reset_bug_once(db_label, kept_wal=False) + return "delete" + + +def _log_wal_reset_bug_once( + db_label: str, + *, + kept_wal: bool, +) -> None: + """Log once per (process, db_label) about the WAL-reset vulnerability path.""" + with _wal_reset_bug_warned_lock: + if db_label in _wal_reset_bug_warned_paths: + return + _wal_reset_bug_warned_paths.add(db_label) + if kept_wal: + logger.warning( + "%s: linked SQLite %s is vulnerable to the WAL-reset corruption " + "bug (https://sqlite.org/wal.html#walresetbug) and this database " + "is already in WAL mode — leaving WAL in place (no live " + "downgrade under concurrent openers). Upgrade to SQLite 3.51.3+ " + "(or backports 3.50.7 / 3.44.6); `hermes update` alone may not " + "change python-build-standalone's embedded SQLite. See " + "`hermes doctor`. This warning fires once per process per database.", + db_label, + sqlite3.sqlite_version, + ) + return + logger.warning( + "%s: linked SQLite %s is vulnerable to the WAL-reset corruption bug " + "(https://sqlite.org/wal.html#walresetbug) — using journal_mode=DELETE " + "instead of enabling WAL. Upgrade to SQLite 3.51.3+ (or backports " + "3.50.7 / 3.44.6); `hermes update` alone may not change the SQLite " + "embedded in python-build-standalone. This warning fires once per " + "process per database.", + db_label, + sqlite3.sqlite_version, + ) + + def _log_wal_fallback_once(db_label: str, exc: Exception) -> None: """Log a single WARNING per (process, db_label) about WAL fallback. @@ -485,6 +624,7 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None: exc, ) + # --------------------------------------------------------------------------- # Malformed-schema recovery # --------------------------------------------------------------------------- diff --git a/plugins/platforms/discord/recovery.py b/plugins/platforms/discord/recovery.py index 060197e481aa6..97217d76805b2 100644 --- a/plugins/platforms/discord/recovery.py +++ b/plugins/platforms/discord/recovery.py @@ -53,7 +53,9 @@ def call(self, fn: Callable[[sqlite3.Connection], Any], default: Any = None) -> return default def _initialize(self, conn: sqlite3.Connection) -> None: - conn.execute("PRAGMA journal_mode=WAL") + from hermes_state import apply_wal_with_fallback + + apply_wal_with_fallback(conn, db_label="discord_recovery.db") conn.execute(""" CREATE TABLE IF NOT EXISTS discord_messages ( message_id TEXT PRIMARY KEY, diff --git a/tools/async_delegation.py b/tools/async_delegation.py index 5181149c4d3ac..01d6b84ab621a 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -90,10 +90,12 @@ def _db_path(): def _connect() -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + path = _db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path, timeout=10) - conn.execute("PRAGMA journal_mode=WAL") + apply_wal_with_fallback(conn, db_label="state.db (async_delegation)") conn.execute( """CREATE TABLE IF NOT EXISTS async_delegations ( delegation_id TEXT PRIMARY KEY, From d9ecebeb4ea20d249f001b55374e6c0340113ee0 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Thu, 23 Jul 2026 15:48:16 +0700 Subject: [PATCH 2/4] test(state): cover SQLite WAL-reset version gate and doctor probe Assert the version matrix, fresh-DB DELETE fallback, already-WAL left alone (no checkpoint/DELETE), fixed-SQLite WAL path, and warn-only doctor output for vulnerable builds (#69784). --- tests/test_hermes_state.py | 19 ++- tests/test_hermes_state_wal_fallback.py | 11 ++ tests/test_sqlite_wal_reset_gate.py | 190 ++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 tests/test_sqlite_wal_reset_gate.py diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index edeec0332d302..fe984e10e9024 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -3831,9 +3831,15 @@ def test_too_long_title_rejected_by_set(self, db): class TestSchemaInit: def test_wal_mode(self, db): + """Prefer WAL on fixed SQLite; DELETE on WAL-reset-vulnerable builds (#69784).""" + from hermes_state import is_sqlite_wal_reset_vulnerable + cursor = db._conn.execute("PRAGMA journal_mode") - mode = cursor.fetchone()[0] - assert mode == "wal" + mode = cursor.fetchone()[0].lower() + if is_sqlite_wal_reset_vulnerable(): + assert mode == "delete" + else: + assert mode == "wal" def test_foreign_keys_enabled(self, db): cursor = db._conn.execute("PRAGMA foreign_keys") @@ -6023,6 +6029,15 @@ def _flags(conn): class TestApplyWalProbe: """Unit tests for the journal_mode probe in apply_wal_with_fallback.""" + @pytest.fixture(autouse=True) + def _assume_fixed_sqlite(self, monkeypatch): + """These cases cover the fixed-SQLite WAL path (not the #69784 gate).""" + import hermes_state + + monkeypatch.setattr( + hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False + ) + def test_skips_set_pragma_when_already_wal(self, tmp_path): """Already-WAL connection must not trigger the set-pragma.""" import sqlite3 diff --git a/tests/test_hermes_state_wal_fallback.py b/tests/test_hermes_state_wal_fallback.py index 5678e3ff4f115..73141ce438850 100644 --- a/tests/test_hermes_state_wal_fallback.py +++ b/tests/test_hermes_state_wal_fallback.py @@ -71,6 +71,17 @@ def _reset_wal_fallback_warned_paths(): hermes_state._wal_fallback_warned_paths.clear() +@pytest.fixture(autouse=True) +def _assume_fixed_sqlite(monkeypatch): + """NFS-fallback tests assume a SQLite build without the WAL-reset bug.""" + monkeypatch.setattr( + hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False + ) + hermes_state._wal_reset_bug_warned_paths.clear() + yield + hermes_state._wal_reset_bug_warned_paths.clear() + + class TestApplyWalWithFallback: def test_succeeds_on_local_fs(self, tmp_path): """Happy path: WAL works on a normal filesystem.""" diff --git a/tests/test_sqlite_wal_reset_gate.py b/tests/test_sqlite_wal_reset_gate.py new file mode 100644 index 0000000000000..c184a6b9c4038 --- /dev/null +++ b/tests/test_sqlite_wal_reset_gate.py @@ -0,0 +1,190 @@ +"""SQLite WAL-reset vulnerability gate (issue #69784). + +Hermes must not *enable* multi-process WAL on SQLite builds that still contain +the upstream WAL-reset corruption bug: +https://sqlite.org/wal.html#walresetbug + +Existing on-disk WAL databases are left alone (no live downgrade). +""" + +from __future__ import annotations + +import sqlite3 +from types import SimpleNamespace + +import pytest + +import hermes_state +from hermes_state import ( + apply_wal_with_fallback, + is_sqlite_wal_reset_vulnerable, + sqlite_source_id, +) + + +@pytest.fixture(autouse=True) +def _reset_wal_reset_bug_warnings(): + hermes_state._wal_reset_bug_warned_paths.clear() + yield + hermes_state._wal_reset_bug_warned_paths.clear() + + +class TestIsSqliteWalResetVulnerable: + @pytest.mark.parametrize( + "version_info,expected", + [ + ((3, 6, 23), False), # pre-WAL + ((3, 7, 0), True), + ((3, 44, 5), True), + ((3, 44, 6), False), # backport + ((3, 44, 9), False), + ((3, 45, 0), True), + ((3, 46, 1), True), + ((3, 50, 4), True), + ((3, 50, 6), True), + ((3, 50, 7), False), # backport + ((3, 50, 99), False), + ((3, 51, 0), True), + ((3, 51, 2), True), + ((3, 51, 3), False), # fixed line + ((3, 52, 0), False), + ], + ) + def test_version_matrix(self, version_info, expected): + assert is_sqlite_wal_reset_vulnerable(version_info) is expected + + def test_defaults_to_linked_library(self): + assert isinstance(is_sqlite_wal_reset_vulnerable(), bool) + + +class TestApplyWalWalResetGate: + def test_fresh_db_uses_delete_when_vulnerable(self, tmp_path, monkeypatch, caplog): + monkeypatch.setattr( + hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True + ) + conn = sqlite3.connect(str(tmp_path / "fresh.db")) + with caplog.at_level("WARNING", logger="hermes_state"): + mode = apply_wal_with_fallback(conn, db_label="fresh.db") + assert mode == "delete" + assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "delete" + assert any("instead of enabling WAL" in r.getMessage() for r in caplog.records) + conn.close() + + def test_existing_wal_left_alone_when_vulnerable( + self, tmp_path, monkeypatch, caplog + ): + """Already-WAL DBs must not be live-downgraded under concurrent openers.""" + monkeypatch.setattr( + hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True + ) + path = tmp_path / "prior_wal.db" + seed = sqlite3.connect(str(path)) + try: + seed.execute("PRAGMA journal_mode=WAL") + seed.execute("CREATE TABLE t (x INTEGER)") + seed.execute("INSERT INTO t VALUES (42)") + seed.commit() + assert seed.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal" + finally: + seed.close() + + conn = sqlite3.connect(str(path), timeout=30.0) + try: + with caplog.at_level("WARNING", logger="hermes_state"): + mode = apply_wal_with_fallback(conn, db_label="prior_wal.db") + assert mode == "wal" + assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal" + assert conn.execute("SELECT x FROM t").fetchone()[0] == 42 + assert any("already in WAL mode" in r.getMessage() for r in caplog.records) + # Must not attempt a live journal_mode flip. + assert not any( + "instead of enabling WAL" in r.getMessage() for r in caplog.records + ) + finally: + conn.close() + + def test_existing_wal_does_not_run_checkpoint_or_delete( + self, tmp_path, monkeypatch + ): + monkeypatch.setattr( + hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True + ) + + class _TracingConn(sqlite3.Connection): + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self.executed = [] + + def execute(self, sql, params=()): # type: ignore[override] + self.executed.append(sql) + return super().execute(sql, params) + + path = tmp_path / "trace_wal.db" + with sqlite3.connect(str(path)) as seed: + seed.execute("PRAGMA journal_mode=WAL") + + conn = _TracingConn(str(path)) + try: + assert apply_wal_with_fallback(conn, db_label="trace_wal.db") == "wal" + finally: + conn.close() + + joined_lower = "\n".join(conn.executed).lower().replace(" ", "") + assert "wal_checkpoint" not in joined_lower + assert "journal_mode=delete" not in joined_lower + + def test_fixed_sqlite_still_enables_wal(self, tmp_path, monkeypatch): + monkeypatch.setattr( + hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False + ) + conn = sqlite3.connect(str(tmp_path / "fixed.db")) + mode = apply_wal_with_fallback(conn, db_label="fixed.db") + assert mode == "wal" + assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal" + conn.close() + + def test_warning_deduped_per_label(self, tmp_path, monkeypatch, caplog): + monkeypatch.setattr( + hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True + ) + with caplog.at_level("WARNING", logger="hermes_state"): + for name in ("a.db", "a.db", "b.db"): + conn = sqlite3.connect(str(tmp_path / name)) + apply_wal_with_fallback(conn, db_label=name) + conn.close() + warnings = [r for r in caplog.records if "WAL-reset" in r.getMessage()] + assert len(warnings) == 2 + + +def test_sqlite_source_id_non_empty_string(): + src = sqlite_source_id() + assert isinstance(src, str) + assert src + + +def test_doctor_warns_without_adding_issues(monkeypatch, tmp_path, capsys): + """Vulnerable SQLite is warn-only in doctor — not a blocking issues[] entry.""" + from hermes_cli.doctor import run_doctor + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: home) + monkeypatch.setattr( + hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True + ) + monkeypatch.setattr(hermes_state, "sqlite_source_id", lambda: "testid-abc") + monkeypatch.setattr(sqlite3, "sqlite_version", "3.50.4", raising=False) + + args = SimpleNamespace(fix=False, ack=None) + try: + run_doctor(args) + except SystemExit: + pass + + out = capsys.readouterr().out + assert "SQLite" in out + assert "3.50.4" in out + assert "WAL-reset" in out + # No longer appended to the blocking issues summary. + assert "Linked SQLite is vulnerable" not in out From d607c13433b303964ea0f50c48cc286abdb0228e Mon Sep 17 00:00:00 2001 From: kshitij Date: Thu, 23 Jul 2026 16:45:33 +0500 Subject: [PATCH 3/4] refactor: simplify WAL-reset gate warning dedup + tuple handling Consolidate the two near-identical warning strings in _log_wal_reset_bug_once into a single logger.warning call with an action variable. Remove overengineered defensive tuple-length handling in is_sqlite_wal_reset_vulnerable (sqlite3.sqlite_version_info always returns a 3-tuple). Remove extra blank line. Follow-up cleanup for PR #69981. --- hermes_state.py | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 4fe3a672116da..b5d5421184a45 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -434,12 +434,6 @@ def is_sqlite_wal_reset_vulnerable( Pre-WAL libraries (< 3.7.0) cannot hit the race and are treated as safe. """ info = version_info if version_info is not None else sqlite3.sqlite_version_info - if len(info) < 3: - # Defensive: treat incomplete tuples as vulnerable once WAL exists. - major = info[0] if info else 0 - minor = info[1] if len(info) > 1 else 0 - patch = info[2] if len(info) > 2 else 0 - info = (major, minor, patch) if info < (3, 7, 0): return False if info >= (3, 51, 3): @@ -578,28 +572,22 @@ def _log_wal_reset_bug_once( if db_label in _wal_reset_bug_warned_paths: return _wal_reset_bug_warned_paths.add(db_label) - if kept_wal: - logger.warning( - "%s: linked SQLite %s is vulnerable to the WAL-reset corruption " - "bug (https://sqlite.org/wal.html#walresetbug) and this database " - "is already in WAL mode — leaving WAL in place (no live " - "downgrade under concurrent openers). Upgrade to SQLite 3.51.3+ " - "(or backports 3.50.7 / 3.44.6); `hermes update` alone may not " - "change python-build-standalone's embedded SQLite. See " - "`hermes doctor`. This warning fires once per process per database.", - db_label, - sqlite3.sqlite_version, - ) - return + action = ( + "is already in WAL mode — leaving WAL in place (no live " + "downgrade under concurrent openers)" + if kept_wal + else "using journal_mode=DELETE instead of enabling WAL" + ) logger.warning( - "%s: linked SQLite %s is vulnerable to the WAL-reset corruption bug " - "(https://sqlite.org/wal.html#walresetbug) — using journal_mode=DELETE " - "instead of enabling WAL. Upgrade to SQLite 3.51.3+ (or backports " - "3.50.7 / 3.44.6); `hermes update` alone may not change the SQLite " - "embedded in python-build-standalone. This warning fires once per " - "process per database.", + "%s: linked SQLite %s is vulnerable to the WAL-reset corruption " + "bug (https://sqlite.org/wal.html#walresetbug) — %s. " + "Upgrade to SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6); " + "`hermes update` alone may not change python-build-standalone's " + "embedded SQLite. See `hermes doctor`. This warning fires once " + "per process per database.", db_label, sqlite3.sqlite_version, + action, ) @@ -624,7 +612,6 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None: exc, ) - # --------------------------------------------------------------------------- # Malformed-schema recovery # --------------------------------------------------------------------------- From b1216bde94a1bc4cccb1f4b15a052bc1526325e2 Mon Sep 17 00:00:00 2001 From: kshitij Date: Thu, 23 Jul 2026 16:46:40 +0500 Subject: [PATCH 4/4] refactor: dedupe check_info call + fix trailing whitespace in doctor Hoist the duplicated check_info(source_id) call out of both if/else branches into a single call after the branch. Remove trailing whitespace on the blank line after the except block. Follow-up cleanup for PR #69981. --- hermes_cli/doctor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 3a372cc4ed23f..e995178d119aa 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -761,15 +761,12 @@ def run_doctor(args): "(new shared DBs use DELETE; prefer 3.51.3+ / 3.50.7 / 3.44.6 — " "see https://sqlite.org/wal.html#walresetbug)", ) - if _sqlite_src_short: - check_info(f"SQLite source id: {_sqlite_src_short}") else: check_ok(f"SQLite {_sqlite_ver}") - if _sqlite_src_short: - check_info(f"SQLite source id: {_sqlite_src_short}") + if _sqlite_src_short: + check_info(f"SQLite source id: {_sqlite_src_short}") except Exception as e: check_warn(f"SQLite version probe failed: {e}") - # Check if in virtual environment in_venv = sys.prefix != sys.base_prefix if in_venv: