Skip to content
Open
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
49 changes: 29 additions & 20 deletions hermes_cli/browser_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,34 +564,43 @@ def _secure_snapshot_root(path: str) -> None:
"Cookies", "Login Data", "Login Data For Account", "Web Data",
})

# Total time budget for one auth-DB online-backup before falling through to
# the raw-copy path. A running Chrome holds Login Data / Web Data with
# exclusive SQLite locks, and CPython's backup() retries SQLITE_BUSY forever
# without a bound — 10 s matches the backup walker's default (#96646).
_AUTH_BACKUP_TIMEOUT_SECONDS = 10.0


def _copy_auth_file(src_file: str, dst_file: str) -> bool:
"""Copy one auth file, lock-aware. Returns True on success.

For SQLite DBs (Cookies/Login Data/…), use the online-backup API so the
copy works even while the browser holds the file's write lock (Windows).
Everything else is a plain copy. A DB whose backup fails falls through to a
raw copy attempt; only if BOTH fail do we report failure to the caller.
For SQLite DBs (Cookies/Login Data/…), use a deadline-bounded online
backup so the copy works even while the browser holds the file's write
lock (Windows) and never hangs on a wedged source (POSIX, running
Chrome). Everything else is a plain copy. A DB whose backup fails falls
through to a raw copy attempt; only if BOTH fail do we report failure to
the caller.
"""
os.makedirs(os.path.dirname(dst_file), exist_ok=True)
if os.path.basename(src_file) in _SQLITE_AUTH_DBS:
try:
import sqlite3

# Read-only URI + immutable-free: we want a consistent committed
# snapshot, not to fight the writer. Short busy timeout so a truly
# wedged DB fails fast rather than hanging the launch.
source = sqlite3.connect(f"file:{src_file}?mode=ro", uri=True, timeout=5)
try:
out = sqlite3.connect(dst_file)
try:
with out:
source.backup(out)
finally:
out.close()
finally:
source.close()
return True
# The online-backup API must be bounded: CPython's backup()
# retries SQLITE_BUSY forever unless a progress callback
# interrupts it, and a running Chrome holds Login Data / Web
# Data exclusively on POSIX too — so a plain source.backup(out)
# hangs the launch until the tool times out (#96646). Reuse the
# bounded family helper from the backup walker
# (#82042/#92495/#84475): read-only connect, deadline-bounded
# progress callback, fail-closed False. On failure the raw-copy
# fallback below runs — file-level copies don't need SQLite's
# cross-process locks on POSIX.
from hermes_cli.backup import _safe_copy_db

if _safe_copy_db(Path(src_file), Path(dst_file),
timeout_seconds=_AUTH_BACKUP_TIMEOUT_SECONDS):
return True
logger.debug("real-profile: bounded sqlite-backup of %s failed; trying raw copy",
src_file)
except Exception as e:
logger.debug("real-profile: sqlite-backup of %s failed (%s); trying raw copy",
src_file, e)
Expand Down
44 changes: 44 additions & 0 deletions tests/tools/test_browser_real_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -929,3 +929,47 @@ def test_fail_closed_when_db_unreadable(self, tmp_path, monkeypatch):
dst, err = bc.snapshot_real_profile("chrome", src=str(root))
assert dst is None
assert err and "login data" in err.lower() and "close" in err.lower()

def test_copy_auth_file_bounded_when_locked_falls_back_to_raw(self, tmp_path, monkeypatch):
"""A wedged source DB (running Chrome holds Login Data exclusively on
POSIX too) must abort the online backup within the deadline and fall
through to the raw copy instead of hanging the launch forever (#96646).

Regression: CPython's sqlite3.Connection.backup() retries SQLITE_BUSY
indefinitely when no progress callback bounds it, so the old unbounded
source.backup(out) never returned and the raw-copy fallback was
unreachable — this test times out rather than failing fast on the
old code.
"""
import hermes_cli.browser_connect as bc
import sqlite3
import time
monkeypatch.setattr(bc, "_AUTH_BACKUP_TIMEOUT_SECONDS", 0.5)
src = str(tmp_path / "Login Data")
con = sqlite3.connect(src)
con.execute("create table logins(x)")
con.execute("insert into logins values(1)")
con.commit()
# Hold the exclusive SQLite lock the way a running Chrome does.
con.execute("BEGIN EXCLUSIVE")
try:
dst = str(tmp_path / "out" / "Login Data")
started = time.monotonic()
ok = bc._copy_auth_file(src, dst)
elapsed = time.monotonic() - started
assert elapsed < 5.0, (
f"_copy_auth_file took {elapsed:.1f}s on a locked source — "
"the backup is no longer deadline-bounded"
)
# On POSIX the raw-copy fallback needs no SQLite locks, so the
# copy still lands and the launch can proceed.
assert ok is True
assert os.path.isfile(dst)
check = sqlite3.connect(dst)
try:
assert check.execute("select count(*) from logins").fetchone()[0] == 1
finally:
check.close()
finally:
con.rollback()
con.close()
Loading