134 lines
4.2 KiB
Python
134 lines
4.2 KiB
Python
"""Regression tests: restore paths must fail closed under an IN-PROCESS
|
|
live connection to the destination database (#90837 / #90950 own-holder gap).
|
|
|
|
The foreign-pid scan (`_foreign_db_holder_pids`) excludes the calling
|
|
process by design, so before the fix both restore paths would unlink the
|
|
destination DB and its -wal/-shm sidecars while THIS process held a tracked
|
|
connection — leaving the process on deleted-inode fds (the split-brain
|
|
fingerprint from issue #90837's field reports).
|
|
"""
|
|
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import backup as backup_mod
|
|
from hermes_cli import update_cmd
|
|
from hermes_cli.sqlite_safe_read import connect_tracked
|
|
|
|
|
|
def _make_db(path, marker):
|
|
conn = sqlite3.connect(str(path))
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("CREATE TABLE t (v TEXT)")
|
|
conn.execute("INSERT INTO t VALUES (?)", (marker,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _read_marker(path):
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
return conn.execute("SELECT v FROM t LIMIT 1").fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _own_deleted_fds(needle):
|
|
"""Paths of this process's fds pointing at deleted files matching needle."""
|
|
if not sys.platform.startswith("linux"):
|
|
return []
|
|
out = []
|
|
fd_dir = f"/proc/{os.getpid()}/fd"
|
|
for fd in os.listdir(fd_dir):
|
|
try:
|
|
target = os.readlink(f"{fd_dir}/{fd}")
|
|
except OSError:
|
|
continue
|
|
if needle in target and "(deleted)" in target:
|
|
out.append(target)
|
|
return out
|
|
|
|
|
|
@pytest.fixture
|
|
def live_held_db(tmp_path):
|
|
"""A WAL-mode dst held open by a tracked connection in this process,
|
|
with a corrupt header so the backup-API restore path genuinely fails
|
|
(which is what routes _safe_restore_db into its unlink+move fallback)."""
|
|
dst = tmp_path / "state.db"
|
|
src = tmp_path / "snap.db"
|
|
_make_db(src, "snapshot-good")
|
|
_make_db(dst, "live-old")
|
|
held = connect_tracked(
|
|
dst, connect_fn=sqlite3.connect, check_same_thread=False
|
|
)
|
|
held.execute("PRAGMA journal_mode=WAL")
|
|
held.execute("INSERT INTO t VALUES ('held-write')")
|
|
held.commit()
|
|
assert (tmp_path / "state.db-wal").exists()
|
|
with open(dst, "r+b") as fh: # offline-fixture mutation, doomed file
|
|
fh.write(b"\x00" * 100)
|
|
try:
|
|
yield src, dst
|
|
finally:
|
|
try:
|
|
held.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def test_safe_restore_fallback_refuses_under_own_live_connection(live_held_db):
|
|
src, dst = live_held_db
|
|
wal = dst.with_name(dst.name + "-wal")
|
|
wal_ino = wal.stat().st_ino
|
|
|
|
assert backup_mod._safe_restore_db(src, dst) is False
|
|
|
|
# The held generation must survive: same WAL inode, no deleted-fd ghosts.
|
|
assert wal.exists() and wal.stat().st_ino == wal_ino
|
|
assert _own_deleted_fds(str(dst.name)) == []
|
|
|
|
|
|
def test_update_autorestore_refuses_under_own_live_connection(
|
|
live_held_db, capsys
|
|
):
|
|
src, dst = live_held_db
|
|
wal = dst.with_name(dst.name + "-wal")
|
|
wal_ino = wal.stat().st_ino
|
|
|
|
assert update_cmd._restore_state_db_from_snapshot(dst, src) is False
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Auto-restore refused" in out
|
|
assert wal.exists() and wal.stat().st_ino == wal_ino
|
|
assert _own_deleted_fds(str(dst.name)) == []
|
|
|
|
|
|
def test_safe_restore_fallback_still_works_without_holder(tmp_path):
|
|
dst = tmp_path / "state.db"
|
|
src = tmp_path / "snap.db"
|
|
_make_db(src, "snapshot-good")
|
|
_make_db(dst, "live-old")
|
|
with open(dst, "r+b") as fh:
|
|
fh.write(b"\x00" * 100)
|
|
|
|
assert backup_mod._safe_restore_db(src, dst) is True
|
|
assert _read_marker(dst) == "snapshot-good"
|
|
|
|
|
|
def test_update_autorestore_still_works_without_holder(tmp_path):
|
|
dst = tmp_path / "state.db"
|
|
src = tmp_path / "snap.db"
|
|
_make_db(src, "snapshot-good")
|
|
_make_db(dst, "live-old")
|
|
# Stale WAL from a crashed writer must still be cleared.
|
|
dst.with_name(dst.name + "-wal").write_bytes(b"\x00" * 1024)
|
|
with open(dst, "r+b") as fh:
|
|
fh.write(b"\x00" * 100)
|
|
|
|
assert update_cmd._restore_state_db_from_snapshot(dst, src) is True
|
|
assert _read_marker(dst) == "snapshot-good"
|
|
assert not dst.with_name(dst.name + "-wal").exists()
|