Skip to content

fix(backup): bound unbounded SQLite busy-retry + stop misclassifying transient files as hard errors - #82042

Open
EvgenyMettsler wants to merge 4 commits into
NousResearch:mainfrom
EvgenyMettsler:recovery/t_b64c0694-sqlite-busy-retry-fix
Open

fix(backup): bound unbounded SQLite busy-retry + stop misclassifying transient files as hard errors#82042
EvgenyMettsler wants to merge 4 commits into
NousResearch:mainfrom
EvgenyMettsler:recovery/t_b64c0694-sqlite-busy-retry-fix

Conversation

@EvgenyMettsler

Copy link
Copy Markdown

What does this PR do?

Fixes two related bugs in hermes backup that could either hang indefinitely or wrongly report a clean backup as "Backup incomplete":

  1. Unbounded SQLite busy-retry. _safe_copy_db() copies each .db file via sqlite3.Connection.backup(). That call has no bounded retry: when the source is SQLITE_BUSY/SQLITE_LOCKED (another process holds a write transaction), it retries via sqlite3_sleep() indefinitely until the lock clears — the connection's timeout= parameter only bounds acquiring the initial connection, not this internal retry loop. Reproduced live against a real, independently-locked chrome-debug/first_party_sets.db (a small SQLite file Chrome itself uses internally): unpatched, the copy blocked for 61.4 seconds.
  2. Transient chrome-debug files misclassified as hard errors. Chrome's own transient session/tab files (Sessions/, shared_proto_db/, BrowserMetrics-spare.pma) can vanish mid-backup as Chrome's own housekeeping runs. A scan-then-archive race treated any such vanish as a fatal error, flipping the whole backup summary to "Backup incomplete" even though the resulting archive was complete and restorable.

Related Issue

No existing issue — found as a side-effect while live-verifying a separate backup fix, documented in the two RCA docs included in this PR.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • hermes_cli/backup.py:
    • _safe_copy_db() now bounds the SQLite busy/locked retry via conn.backup(progress=<callback>). The callback fires after every sqlite3_backup_step() call (including busy/locked ones) and raises past a deadline (default 30s, overridable via HERMES_BACKUP_SQLITE_BUSY_DEADLINE_SECONDS, non-positive disables bounding). The deadline check is gated on the status code (SQLITE_BUSY/SQLITE_LOCKED), not raw elapsed time, so a large-but-healthy copy is never wrongly aborted mid-flight.
    • Both connections in the bounded path use timeout=0sqlite3.connect()'s default timeout=5.0 installs SQLite's own internal busy-handler that silently retries inside a single backup_step() call for up to 5s before the progress= callback ever runs, which coarsens the deadline to ~5-8s regardless of the configured value. This is documented at length in code comments since it's easy to reintroduce by "simplifying."
    • New _safe_copy_db_ex() reports why a copy failed (busy-timeout abort vs. anything else) so callers can route a busy-timeout into the same harmless "transient/skipped" bucket already used for vanished-mid-backup files, instead of the hard errors list. Both hermes backup call sites (the CLI path and the automatic _write_full_zip_backup_locked path) updated to use it.
    • The transient chrome-debug file walk now excludes known-transient paths (Sessions/, shared_proto_db/, BrowserMetrics-spare.pma) and reclassifies a benign vanished-file skip separately from a genuine failure.
  • docs/rca-backup-sqlite-busy-retry-unbounded.md / docs/fix-backup-sqlite-busy-retry-unbounded.md — root cause (CPython C-source level) and fix write-up for the busy-retry bug.
  • docs/rca-chrome-debug-transient-files-backup.md / docs/fix-chrome-debug-transient-files-backup.md — root cause and fix write-up for the transient-file misclassification.
  • tests/hermes_cli/test_backup.py — 590 new lines: busy-bounded abort, short-lock-still-succeeds, timeout= insufficiency documented as a regression guard, large-non-busy-copy unaffected by a tight deadline, negative-deadline escape hatch, busy-timeout correctly reported as transient (not a hard error) at both call sites, transient chrome-debug ENOENT races tolerated.

How to Test

  1. pytest tests/hermes_cli/test_backup.py -q — 58 tests, all pass.
  2. Live repro of the original bug: hold a real BEGIN EXCLUSIVE lock on a throwaway SQLite file in one process, run _safe_copy_db_ex() against it from another with HERMES_BACKUP_SQLITE_BUSY_DEADLINE_SECONDS=2 — returns cleanly in ~2.1s instead of hanging.
  3. hermes backup against a real ~/.hermes with the Hermes-managed debug Chrome actively running — prints "Backup complete", exit code 0, restore hint present, zipfile.testzip() == None on the resulting archive.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 26 (Apple Silicon)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A (no new config keys; the deadline is env-var-overridable only)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A (pure sqlite3/stdlib, no platform-specific paths)
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A (CLI-only, no model tool schema touched)

Screenshots / Logs

Live pre-fix repro against a real locked file:

_safe_copy_db() against a genuinely SQLITE_BUSY chrome-debug/first_party_sets.db
-> blocked for 61.4s (unpatched)

Post-fix, same locked file, 10s deadline override:

_safe_copy_db_ex() -> aborted cleanly at 10.1s, classified as transient (not a hard error)

Root cause (docs/rca-chrome-debug-transient-files-backup.md): hermes
backup scans then archives ~/.hermes in two phases seconds-to-minutes
apart. The Hermes-managed debug Chrome profile (chrome-debug/)
continuously rewrites its own Sessions/, shared_proto_db/, and
BrowserMetrics state while running, so a file that existed at scan
time can vanish before the archive-write phase reaches it, raising a
benign FileNotFoundError that the existing per-file catch already
handled -- but the finalize/summary step treated ANY non-empty
errors list as a fatal-looking 'Backup incomplete', even though the
produced archive was always complete and restorable.

Two complementary fixes, both in hermes_cli/backup.py:

1. Exclude known-transient chrome-debug subpaths (Sessions/,
   shared_proto_db/, BrowserMetrics/, BrowserMetrics-spare.pma) from
   the backup walk outright, scoped to the top-level chrome-debug/
   dir so same-named directories elsewhere under HERMES_HOME are
   unaffected. Eliminates the race in the common case.

2. Reclassify vanished-mid-backup skips (FileNotFoundError, and a
   vanished source detected post-hoc in the .db safe-copy path) into
   a separate transient_skipped bucket, distinct from errors. Only
   genuine failures (permissions, disk errors, a .db that's present
   but fails to copy) still flip the summary to 'Backup incomplete'
   and suppress the restore hint; transient skips print as a
   clearly-labeled harmless note instead.

Also fixes a more severe variant of the same bug in
_write_full_zip_backup_locked (the shared helper behind hermes
update's pre-update backup and hermes claw migrate's pre-migration
backup): a vanished .db file there previously raised
_SQLiteSnapshotError and aborted the ENTIRE archive via the outer
except, discarding every file scanned before and after it. Now a
vanished file (plain or .db) is skipped via continue and the archive
completes with everything else intact; a .db that's present but
genuinely fails to copy still aborts+preserves the previous archive
as before.

Live-verified: ran hermes backup against the real ~/.hermes while
the Hermes-launched debug Chrome (launched via the actual
browser_connect.launch_chrome_debug code path) was active and being
navigated via CDP, producing genuine Session_*/Tabs_* file churn
mid-backup. Result: "Backup complete" with a valid, restore-hinted
4170-file/543MB archive (testzip()==None) in 28.9s, chrome-debug
Sessions/shared_proto_db listed as excluded directories, and zero
chrome-debug warnings -- vs. the original ticket's "Backup
incomplete" with 6 ENOENT warnings and a suppressed restore hint on
the same class of scenario.

Added 9 new tests (TestChromeDebugTransientRace + 2 exclusion-scope
tests) covering: exclusion scoping, vanished-file-is-warning-not-
fatal in both backup paths, vanished .db is skipped not reported as
a copy failure, and genuine (non-vanished) failures still correctly
flip the summary/abort the archive in both paths. All 65 backup-
related tests pass (46 pre-existing + 9 new + 10 curator_backup).
Root cause (docs/rca-backup-sqlite-busy-retry-unbounded.md):
sqlite3.Connection.backup() retries SQLITE_BUSY/SQLITE_LOCKED with
time.sleep() inside CPython's C implementation with no overall
deadline, and the connection's timeout= parameter has no effect on
this loop (only bounds the initial lock acquisition for ordinary
statements). A source .db that stays locked -- observed in the wild
against Chrome's own first_party_sets.db under chrome-debug/, and
live-reproduced against the user's actual, currently-locked file
with zero test interaction (61.4s real stall) -- could previously
stall hermes backup for as long as the lock was held.

Fix: bound the retry via backup()'s progress= callback, which fires
on every step including busy ones and can raise to abort cleanly
(CPython already treats this as a normal abort, calling
sqlite3_backup_finish() internally). The deadline check is gated on
the BUSY/LOCKED status code, not raw elapsed time, so a large but
uncontended copy is never wrongly aborted just for taking a while.
Both connections in the bounded path use timeout=0 -- load-bearing,
not cosmetic: the connect() default (5.0) lets SQLite's own internal
busy-handler silently absorb the wait inside a single backup_step()
call before the progress callback ever runs, which was measured to
make an earlier prototype's deadline check unreliable (coarse ~5-8s
granularity regardless of the configured deadline).

Default deadline 30s, overridable via
HERMES_BACKUP_SQLITE_BUSY_DEADLINE_SECONDS; a non-positive value
disables bounding entirely (restores the exact pre-fix behavior).
The raised timeout is a plain Exception subclass caught by
_safe_copy_db's existing except Exception clause, so both call sites
(interactive and automatic backup paths) already handle it via the
vanished-vs-genuine-failure distinction from the chrome-debug ENOENT
fix -- a file still present but past its busy deadline is a genuine
errors entry, not a benign transient skip.

Added TestSafeCopyDbBusyBounding (6 tests): busy source is bounded,
short busy window still succeeds normally, connection timeout=
documented as insufficient (regression guard against re-introducing
that approach), large non-busy copy unaffected by an absurdly tight
deadline, negative deadline disables bounding. All 146 tests across
the backup-adjacent surface pass (test_backup.py, test_backup_stability.py,
test_curator_backup.py, test_execution_ledger.py, test_state_db_guard.py,
test_sizefmt.py, and the update_cmd test files that import backup
helpers) -- no regressions. ruff clean.

Live end-to-end verification: at the time of this fix the user's
actual chrome-debug/first_party_sets.db was independently found
SQLITE_BUSY with zero interaction from this session. Before the fix,
running the real _safe_copy_db() against it blocked 61.4s. After the
fix, with a 10s deadline override, the same live locked file was
aborted cleanly at 10.1s as designed.
…timeout

_safe_copy_db's SQLITE_BUSY/LOCKED retry was bounded by commit 30fdaab42
(default 30s deadline) to stop indefinite hangs against a live Chrome
profile's own small housekeeping databases (first_party_sets.db,
declarative_performance_observer.db under chrome-debug/). That fix
correctly stopped the hang, but the resulting clean abort was still
classified identically to genuine corruption/permission failures --
so 'hermes backup' summary flipped to 'Backup incomplete' again, just
via different files than the original ENOENT-based bug report
(t_6f9583fe), which t_c753bb8d/d47696346 already fixed for the
vanished-file case.

Add _safe_copy_db_ex(), a superset of _safe_copy_db() that also
reports whether a failure was specifically a busy-timeout abort (vs.
any other cause). Both backup call sites (the primary 'hermes backup'
path and the automatic pre-update/pre-migration _write_full_zip_backup_locked
path) now route a busy-timeout into the same harmless
transient/skipped bucket already used for vanished-mid-backup files,
instead of the hard errors list -- matching the existing convention
and keeping 'Backup complete' + the restore hint for an otherwise
fully valid archive.

Live-verified end-to-end against the real ~/.hermes with the actual
Hermes-launched chrome-debug Chrome running: pre-fix, 'hermes backup'
printed 'Backup incomplete' due to first_party_sets.db /
declarative_performance_observer.db SQLITE_BUSY aborts; post-fix,
two consecutive runs both print 'Backup complete', exit 0, restore
hint present, both busy files correctly listed under the harmless
Note instead of Warnings, and zipfile.testzip() == None on the
resulting archive.

4 new regression tests (_safe_copy_db_ex busy-timeout reporting,
busy-timeout not misclassifying genuine corruption, and end-to-end
transient-not-incomplete behavior for both backup paths); fixed 2
existing tests that monkeypatched the now-superseded _safe_copy_db at
these call sites. 58/58 tests pass in test_backup.py; 92/92 across
the wider backup-adjacent suite. ruff clean.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 8, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #68868 and #72966 independently bound SQLite backup stalls. This PR also handles transient Chrome profile files, so it is a competing different-scope repair rather than a duplicate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants