Skip to content

fix(desktop): reclaim pre-update state.db backups even when the backup fails - #91298

Open
jackulau wants to merge 2 commits into
NousResearch:mainfrom
jackulau:fix/91229-emergency-backup-prune
Open

jackulau wants to merge 2 commits into
NousResearch:mainfrom
jackulau:fix/91229-emergency-backup-prune

Conversation

@jackulau

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes the residue half of #91229: state.db.pre-update-emergency-*.bak files that accumulate to gigabytes.

There is already a prune. The bug is where it sits. In preflightStateDb:

try {
  fs.copyFileSync(stateDbPath, emergencyPath)   // <- new backup
  ...
  // Prune to the 2 most recent emergency backups.
  ...                                            // <- sweep, INSIDE this try
} catch (copyErr) {
  rememberLog(`[updates] emergency state.db backup failed: ${copyErr.message}`)
}

Reclaiming old backups is nested inside the success path of writing a new one. So every path that stops the copy also skips the cleanup:

what stops the copy why it matters here
ENOSPC — disk full the disk is full partly because these backups were never reclaimed. The cleanup is disabled by the condition it exists to prevent.
EBUSY / EPERM — another process holds state.db on Windows this is the ordinary state during a failed self-update, i.e. the exact scenario this issue is filed about
state.db missing, or ≤100 bytes both return before the copy is attempted
statSync throws lands in the outer catch

The janitor only ran on the days nothing needed cleaning. With a ~650-750 MB database and a self-update that fails repeatedly (the reporter lists 8/15, 8/17, 8/20), that is exactly the reported 2.5-3 GB.

The fix

Hoist the sweep into pruneEmergencyStateDbBackups and call it unconditionally at the top of preflightStateDb — before any early return, before the copy. Both update entry points (main.ts:3565 and main.ts:4034) funnel through that function, so both reclaim.

Selection logic moves to emergency-backup-retention.ts so it can be tested without booting Electron, matching how the other extracted electron helpers (profile-delete-routing.ts, bundle-skew.ts, …) are structured. main.ts keeps the I/O.

Two behaviours are preserved deliberately:

  • The sweep still never throws. This is disk hygiene on the update path; failing to reclaim must not be able to abort an update. One unremovable file doesn't stop the rest.
  • Retention stays at three. See below — this one is a decision, not an oversight.

A maintainer decision I did not make for you

The old comment said "Prune to the 2 most recent emergency backups". The code kept three: the filter excluded the just-written backup before .slice(2), so the new one plus two older ones survived.

At ~700 MB apiece that is ~1.4 GB versus ~2.1 GB retained, so it isn't a rounding error. I kept the effective behaviour (three) rather than the documented one (two), because lowering it deletes recovery data users currently have, and that is a data-retention call rather than a bug fix. It's now a named constant with the discrepancy written down:

export const EMERGENCY_BACKUP_RETENTION = 3

If maintainers want two, it's a one-line change and the tests read the constant rather than hardcoding counts, so they follow it. I'd rather surface the disagreement than silently pick.

What I deliberately did NOT fix, and why

The issue also reports win-unpacked.bak (~380 MB) as uncleaned residue. It isn't residue. apps/desktop/scripts/before-pack.mjs preserves the previous unpacked tree as <appOutDir>.bak specifically so a corrupt pack can be rolled back — that's #53040's rename-instead-of-delete, and _ensure_desktop_exe_launchable restores from it. It is one copy, replaced on each pack, not an accumulating set.

So "clean up the .bak files" would have deleted a recovery mechanism. Two artifacts that look alike, opposite lifecycles. Flagging it rather than acting on it.

The other two proposals in the issue — staged/atomic update and self-shutdown before replace — are the locking half and are not touched here. Note that #70477 (@JonthanaHanh) is already doing the stop-before-replace work in this same file; its hunks are at ~2494/2687 and never touch the prune at ~3843, so there's no conflict between them. That PR and this one address different halves of #91229 and are complementary.

Related Issue

Fixes #91229 — partially. The residue half is fixed here; the file-locking half remains open and is the larger piece. If maintainers would rather this not auto-close the issue, say so and I'll change the keyword to Related to.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • apps/desktop/electron/emergency-backup-retention.tsnew. isEmergencyBackup, selectEmergencyBackupsToDelete, and the retention constant. Pure, no fs.
  • apps/desktop/electron/main.ts — new pruneEmergencyStateDbBackups (does the I/O, never throws), called unconditionally at the top of preflightStateDb; the inline prune is removed. The post-copy call is kept so the newly written backup counts toward the budget immediately.
  • apps/desktop/electron/emergency-backup-retention.test.tsnew, 14 tests.

How to Test

cd apps/desktop
npx vitest run --project electron emergency-backup-retention    # 14 passed
npx tsc --noEmit -p tsconfig.json                                # clean
npx eslint electron/emergency-backup-retention*.ts               # clean

Full --project electron run, this branch vs its base (533886c8b8), same machine (Windows 11):

base    : 32 failed, 1514 passed, 4 skipped  (111 files)
branch  : 31 failed, 1529 passed, 4 skipped  (112 files)

On the failure delta, stated honestly rather than rounded off. Diffing the sorted FAIL lists gives one branch-only failure — git-worktree-ops.test.ts > ensureGitRepo: inits a plain dir with a root commit — and two base-only ones. None is anywhere near this diff, and I did not want to wave that away, so I re-ran git-worktree-ops three times against unchanged branch code: it passed, then failed, then the run timed out. It is flaky, not a regression. The remaining ~31 are Windows-environment failures (chmod/symlink permission semantics in hardening.test.ts, ssh control sockets in ssh-connection.test.ts) that fail identically on the base commit.

Mutation proof

mutation result
remove the unconditional call, restoring the sweep to the copy's success path (the bug) 1 failed, 13 passed — the wiring test, which is the only one that can see it
exclude the newest backup before slicing, restoring the original off-by-one 7 failed, 7 passed

The first mutation is the important one: it is exactly the state main is in today. Note that it only breaks one test, and that test is a source-level assertion rather than a behavioural one — the selection logic is completely correct in both states, because the defect was never in what to delete, only in when the sweep runs. A purely behavioural test suite would have passed the buggy code. That's why the wiring assertion exists and why it anchors on if (!fileExists(stateDbPath)) rather than on the word return, which appears in the surrounding comments.

Checklist

Code

Documentation & Housekeeping

  • I've updated relevant documentation — the new module's header documents each failure path that used to skip the sweep, and the retention constant documents the comment/code disagreement it inherited
  • N/A — no config keys added or changed
  • N/A — no architecture or workflow change
  • I've considered cross-platform impact — the sweep is plain readdir/unlink with no platform branches and runs on every OS. The bug is worst on Windows (file locking makes the copy fail most often there) but the coupling was never Windows-specific: an ENOSPC on Linux disabled cleanup the same way.

…p fails

`preflightStateDb` copies `state.db` to
`state.db.pre-update-emergency-<ts>.bak` before an update, then prunes older
copies. The prune was nested inside the `try` whose first statement was the
`copyFileSync`, so reclaiming old backups only happened when writing a new
one succeeded.

Every path that prevents the copy therefore also skipped the cleanup:

  * ENOSPC, because the disk is full - and it is full partly because these
    backups were never reclaimed;
  * EBUSY/EPERM from another process holding `state.db`, which on Windows is
    the ordinary state during a failed self-update and is exactly the
    scenario NousResearch#91229 is filed about;
  * `state.db` missing or too small to be a database, both of which `return`
    before the copy is attempted;
  * `statSync` throwing, which lands in the outer catch.

The janitor only ran on the days nothing needed cleaning. NousResearch#91229 reports
2.5-3 GB of accumulated .bak files against a database of ~650-750 MB, which
is what an unbounded count of full-size copies looks like.

Hoist the sweep into `pruneEmergencyStateDbBackups` and call it
unconditionally at the top of `preflightStateDb`, before any early return and
before the copy. Selection moves to `emergency-backup-retention.ts` so it can
be tested without booting Electron, matching how the other extracted electron
helpers are structured.

Two behaviours are preserved on purpose. Retention stays at three files, which
is what the old code actually did (its comment said two, but it excluded the
just-written backup before slicing) - lowering it would delete recovery data
users have today, so the number is now an explicit named constant and the
choice is left to maintainers. And the sweep still never throws: this is disk
hygiene on the update path, and failing to reclaim must not abort an update.

The reported `win-unpacked.bak` is deliberately NOT touched. That is a single
rollback copy preserved by before-pack (NousResearch#53040), replaced on each pack rather
than accumulated, and deleting it would remove a recovery path.
@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) platform/windows Native Windows-specific behavior or breakage area/install-update Installer, updater, packaging, wheels, doctor P2 Medium — degraded but workaround exists sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 21, 2026
…ionist/sort-imports

check:lint enforces perfectionist/sort-imports, and './embed-referer'
sorts before './emergency-backup-retention' (emb < eme). Linting only the
two new files locally missed it because the violation is in main.ts.
@hehehe0803

Copy link
Copy Markdown
Contributor

This improves failed-copy retention handling, but the successful-copy path still uses fs.copyFileSync(stateDbPath, emergencyPath) against a potentially live WAL-mode database. I filed #91636 for that separate consistency problem: committed WAL-only transactions can be absent, and a concurrent checkpoint can make a raw main-file copy unsuitable as a recovery snapshot. SQLite's online backup API (the pattern used by the normal backup path) would address that bug without expanding this PR's retention scope.

@jackulau

Copy link
Copy Markdown
Contributor Author

Agreed on all of it, and thanks for splitting it into #91636 rather than asking
this PR to grow. I checked both halves of your claim and they hold:

  • hermes_cli/config_defaults.py:17 sets "journal_mode": "wal", so this is
    the normal state of the database rather than an edge case.
  • hermes_cli/backup.py:387 does go through conn.backup(...), so the
    "normal backup path uses the online backup API" framing is right.

The strongest corroboration is a comment you did not cite, backup.py:85-95,
which excludes .db-wal / .db-shm from the archive specifically because
the snapshot is taken via sqlite3.backup():

# SQLite sidecar files — the backup takes a consistent snapshot of ``*.db``
# via ``sqlite3.backup()``, so shipping the live WAL / shared-memory /
# rollback-journal alongside would pair a fresh snapshot with stale sidecar
# state and produce a torn restore on the next open.

So the codebase has already reasoned about exactly this and landed on your
conclusion. The Electron path at main.ts:3838 is the one place that copies
state.db as a plain file and inherits none of that.

One thing worth carrying into #91636, since it sits ten lines above the copy:
the pre-flight check reads the first 16 bytes of the main file and, on a
mismatch, logs "this indicates pre-existing corruption or a concurrent write
issue". In WAL mode a perfectly healthy database always has a valid main-file
header no matter how far behind the main file is, so that check cannot detect
the failure mode you are describing. It is not wrong, but it should not be read
as evidence that the file about to be copied is complete, and I suspect it is
part of why this looked safe.

On the fix: copyFileSync plus the sidecars is not equivalent, per the comment
above. The two options that look right to me are shelling out to the CLI, which
the desktop already spawns and which has a backup subcommand
(hermes_cli/main.py:5620), or VACUUM INTO if you want to stay in-process
without a Python round trip. I do not have a strong preference and it is your
issue, so I will stay out of the way unless you want a second pair of eyes.

This PR stays as-is. Its claim is that the retention sweep was nested inside
the copy's success path, so a failure that filled the disk also skipped the
cleanup, and that is orthogonal to snapshot consistency. Expanding it would mean
merging two independent fixes behind one review, which is what you were right to
avoid.

This branch has not been deployed

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

Labels

area/install-update Installer, updater, packaging, wheels, doctor comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: desktop self-update frequently fails — running hermes.exe locked (os error 32); needs staged update or self-shutdown before replace

3 participants