fix(sync): gate PruneWorker & BackfillWorker on EncryptedCacheGuard (defer when the cache is locked) #224

Closed
opened 2026-07-03 15:42:17 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-03 15:42:17 +00:00 (Migrated from github.com)

Source: "same-class crash-state" audit (follow-up to the SQLCipher cold-start crash, #210).

PruneWorker and BackfillWorker are the only two pre-auth background DB entry points that do NOT gate on EncryptedCacheGuard.isCacheLocked() before opening the database — unlike SyncWorker, SendWorker, and IdleService, which inject their DB deps as dagger.Lazy and bail (Result.retry() / stopSelf()) when the cache is locked.

Trigger: encryptCache = true AND appLock = true (auth-sealed passphrase) on a headless cold start where the user hasn't authenticated (WorkManager wakes the process after reboot, or the periodic backfill (30 min) / prune (12 h) window fires while locked):

  • PruneWorker.kt:16-26 injects MailPruner directly (not Lazy) and calls pruner.prune() with no guard → accountDao.getAll() (MailPruner.kt:41) → runBlocking { prepareCache() } (DatabaseModule.kt:84) → resolvePassphrase → session.await() (PassphraseSession.kt:60) parks the worker thread until unlock.
  • BackfillWorker.kt:19-34 same via MailBackfiller.runBackfill() → accountDao.getAll() (MailBackfiller.kt:70).

Blast radius: wasted wakelock/battery each cycle while locked, zero prune/backfill progress during the locked window, and the held prepareCache mutex serializes other openers behind the parked thread. Self-heals on unlock (not a crash / data-loss), but it's exactly the thread-park SyncWorker.kt:24-26 documents its guard to prevent.

Fix: switch MailPruner / MailBackfiller injection in both workers to dagger.Lazy, and add if (cacheGuard.isCacheLocked()) return Result.retry() before resolving the dep — mirroring SyncWorker / SendWorker. Test coverage tracked in the sibling unit + E2E tickets.

**Source:** "same-class crash-state" audit (follow-up to the SQLCipher cold-start crash, #210). `PruneWorker` and `BackfillWorker` are the **only** two pre-auth background DB entry points that do NOT gate on `EncryptedCacheGuard.isCacheLocked()` before opening the database — unlike `SyncWorker`, `SendWorker`, and `IdleService`, which inject their DB deps as `dagger.Lazy` and bail (`Result.retry()` / `stopSelf()`) when the cache is locked. **Trigger:** `encryptCache = true` AND `appLock = true` (auth-sealed passphrase) on a headless cold start where the user hasn't authenticated (WorkManager wakes the process after reboot, or the periodic backfill (30 min) / prune (12 h) window fires while locked): - `PruneWorker.kt:16-26` injects `MailPruner` directly (not `Lazy`) and calls `pruner.prune()` with no guard → `accountDao.getAll()` (`MailPruner.kt:41`) → `runBlocking { prepareCache() }` (`DatabaseModule.kt:84`) → `resolvePassphrase` → `session.await()` (`PassphraseSession.kt:60`) **parks the worker thread until unlock**. - `BackfillWorker.kt:19-34` same via `MailBackfiller.runBackfill()` → `accountDao.getAll()` (`MailBackfiller.kt:70`). **Blast radius:** wasted wakelock/battery each cycle while locked, zero prune/backfill progress during the locked window, and the held `prepareCache` mutex serializes other openers behind the parked thread. Self-heals on unlock (not a crash / data-loss), but it's exactly the thread-park `SyncWorker.kt:24-26` documents its guard to prevent. **Fix:** switch `MailPruner` / `MailBackfiller` injection in both workers to `dagger.Lazy`, and add `if (cacheGuard.isCacheLocked()) return Result.retry()` before resolving the dep — mirroring `SyncWorker` / `SendWorker`. Test coverage tracked in the sibling unit + E2E tickets.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#224