fix(security): harden app-lock recovery restart (self-restart race + lost syncNow enqueue) #138

Merged
JMR-dev merged 3 commits from fix-applock-restart-recovery into main 2026-07-02 16:10:29 +00:00
JMR-dev commented 2026-07-02 15:11:59 +00:00 (Migrated from github.com)

What & why

Closes #99. The app-lock key-invalidation recovery ("clear + re-sync, never corrupt") restart was unreliable in two ways, both in AppLockViewModel:

1. Same-process self-restart race (restartProcess)

context.startActivity(launchIntent) was immediately followed by Runtime.getRuntime().exit(0) in the same process. ActivityManager can schedule the relaunch into the process being killed, so the restart was intermittently dropped and the app just closed — recovery then only happened on the next manual launch (a one-time silent app close), though CLEAR_PENDING kept the wipe pending.

Fix: a ProcessPhoenix-style separate-process trampoline, implemented manually (no new dependency):

  • restart/RestartActivity is declared in the manifest with android:process=":restart", so it runs in its own process. It kills the original (main) process by PID first, then starts the launcher activity, then exits its own process. Because the relaunch is issued from a process that survives the main-process kill, it can't be dropped.
  • restart/ProcessRestarter (injected into the ViewModel) starts that trampoline, passing the main PID.
  • LibreMailApplication.onCreate() early-returns when running in the :restart process, so the short-lived trampoline process runs none of the app's normal startup work (crash reporting, WorkManager scheduling, IDLE push). The main process has no :restart suffix, so normal launch is unaffected (E2E startup path untouched).

2. Lost syncNow() enqueue (clearCacheAndRestart)

syncScheduler.syncNow() was fire-and-forget. WorkManager persists the WorkSpec asynchronously on its serial task executor, so exiting raced that insert and could drop the post-wipe re-sync. This is now user-visible after #118 (accounts survive the wipe): a lost re-sync leaves an empty mailbox until the next periodic sync.

Fix: SyncScheduler.syncNow() now returns its enqueue Operation, and clearCacheAndRestart awaits it (operation.result.get(...), bounded by a 5s timeout) before handing off to the restart, so the WorkSpec is durably persisted first. A timeout/failure is logged and we restart anyway (periodic sync still eventually refills the cache) so a stuck insert can never wedge recovery.

The CLEAR_PENDING recovery-flag semantics are preserved and the cache wipe still happens at cold start in DatabaseModule (unchanged — out of scope per #93/#103/#118).

Testing

Fast CI gate is green locally: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt, plus compileDebugAndroidTestKotlin.

New JVM unit tests:

  • AppLockViewModelTest: the enqueue Operation is awaited before the restart is triggered (asserts order across setClearPending → syncNow → operation.result.get → ProcessRestarter.restart); and a timed-out enqueue still restarts (a stuck insert doesn't wedge recovery). The dispatcher is made injectable (@VisibleForTesting) so the off-main recovery flow runs deterministically on the test scheduler.
  • SyncSchedulerTest: syncNow() returns the enqueue Operation so callers can await durable persistence.

Not covered here (needs maintainer on-device run)

The separate-process kill/relaunch is integration-level and can't be exercised in JVM. End-to-end wipe + resync verification (trigger a key invalidation, confirm the process bounces, the cache is wiped at cold start, accounts survive, and the mailbox re-syncs) remains for an on-device run.

🤖 Generated with Claude Code

## What & why Closes #99. The app-lock key-invalidation recovery ("clear + re-sync, never corrupt") restart was unreliable in two ways, both in `AppLockViewModel`: ### 1. Same-process self-restart race (`restartProcess`) `context.startActivity(launchIntent)` was immediately followed by `Runtime.getRuntime().exit(0)` **in the same process**. ActivityManager can schedule the relaunch into the process being killed, so the restart was intermittently dropped and the app just closed — recovery then only happened on the next manual launch (a one-time silent app close), though `CLEAR_PENDING` kept the wipe pending. **Fix:** a ProcessPhoenix-style separate-process trampoline, implemented **manually (no new dependency)**: - `restart/RestartActivity` is declared in the manifest with `android:process=":restart"`, so it runs in its own process. It kills the original (main) process **by PID first**, then starts the launcher activity, then exits its own process. Because the relaunch is issued from a process that survives the main-process kill, it can't be dropped. - `restart/ProcessRestarter` (injected into the ViewModel) starts that trampoline, passing the main PID. - `LibreMailApplication.onCreate()` early-returns when running in the `:restart` process, so the short-lived trampoline process runs none of the app's normal startup work (crash reporting, WorkManager scheduling, IDLE push). The main process has no `:restart` suffix, so **normal launch is unaffected** (E2E startup path untouched). ### 2. Lost `syncNow()` enqueue (`clearCacheAndRestart`) `syncScheduler.syncNow()` was fire-and-forget. WorkManager persists the WorkSpec **asynchronously** on its serial task executor, so exiting raced that insert and could drop the post-wipe re-sync. This is now user-visible after #118 (accounts survive the wipe): a lost re-sync leaves an empty mailbox until the next periodic sync. **Fix:** `SyncScheduler.syncNow()` now returns its enqueue `Operation`, and `clearCacheAndRestart` **awaits it** (`operation.result.get(...)`, bounded by a 5s timeout) before handing off to the restart, so the WorkSpec is durably persisted first. A timeout/failure is logged and we restart anyway (periodic sync still eventually refills the cache) so a stuck insert can never wedge recovery. The `CLEAR_PENDING` recovery-flag semantics are preserved and the cache wipe still happens at cold start in `DatabaseModule` (unchanged — out of scope per #93/#103/#118). ## Testing Fast CI gate is green locally: `assembleDebug` + `testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt`, plus `compileDebugAndroidTestKotlin`. New JVM unit tests: - `AppLockViewModelTest`: the enqueue `Operation` is awaited **before** the restart is triggered (asserts order across `setClearPending` → `syncNow` → `operation.result.get` → `ProcessRestarter.restart`); and a **timed-out** enqueue still restarts (a stuck insert doesn't wedge recovery). The dispatcher is made injectable (`@VisibleForTesting`) so the off-main recovery flow runs deterministically on the test scheduler. - `SyncSchedulerTest`: `syncNow()` returns the enqueue `Operation` so callers can await durable persistence. ## Not covered here (needs maintainer on-device run) The separate-process kill/relaunch is integration-level and can't be exercised in JVM. **End-to-end wipe + resync verification** (trigger a key invalidation, confirm the process bounces, the cache is wiped at cold start, accounts survive, and the mailbox re-syncs) remains for an on-device run. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.