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 clearCacheAndRestartawaits 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.
## 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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 byRuntime.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), thoughCLEAR_PENDINGkept the wipe pending.Fix: a ProcessPhoenix-style separate-process trampoline, implemented manually (no new dependency):
restart/RestartActivityis declared in the manifest withandroid: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:restartprocess, 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:restartsuffix, 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 enqueueOperation, andclearCacheAndRestartawaits 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_PENDINGrecovery-flag semantics are preserved and the cache wipe still happens at cold start inDatabaseModule(unchanged — out of scope per #93/#103/#118).Testing
Fast CI gate is green locally:
assembleDebug+testDebugUnitTest+lintDebug+ktlintCheck+detekt, pluscompileDebugAndroidTestKotlin.New JVM unit tests:
AppLockViewModelTest: the enqueueOperationis awaited before the restart is triggered (asserts order acrosssetClearPending→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 enqueueOperationso 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