fix(sync): gate PruneWorker & BackfillWorker on the encrypted-cache lock #229

Merged
JMR-dev merged 2 commits from fix-prune-backfill-cache-lock-gate into main 2026-07-03 16:14:49 +00:00
4 changed files with 153 additions and 15 deletions
@@ -5,9 +5,11 @@ import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.Lazy
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import org.libremail.data.security.EncryptedCacheGuard
/**
* Runs one bounded slice of the full-history backfill (issue #12). Cancellable (WorkManager stops it
@@ -19,16 +21,26 @@ import kotlinx.coroutines.CancellationException
class BackfillWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val backfiller: MailBackfiller,
// Lazy: resolving MailBackfiller builds the Room DB graph, whose first query blocks while the
// encrypted cache is locked. Resolve it only after the cache-lock check passes, so a locked run
// fails fast instead of parking this thread on an unsatisfiable passphrase await (mirrors SyncWorker).
private val backfiller: Lazy<MailBackfiller>,
private val cacheGuard: EncryptedCacheGuard,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result = runCatching {
// Chain bounded slices back-to-back while history remains, so a large mailbox isn't limited to
// one slice per periodic run. runBackfill() returns true while any folder still has pages left;
// isStopped lets WorkManager end a long run gracefully (the periodic schedule resumes it).
while (backfiller.runBackfill() && !isStopped) { /* page the next slice */ }
}.fold(
onSuccess = { Result.success() },
onFailure = { error -> if (error is CancellationException) throw error else Result.retry() },
)
override suspend fun doWork(): Result {
// Can't open the encrypted DB without the user present — retry later rather than parking a
// WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await.
if (cacheGuard.isCacheLocked()) return Result.retry()
return runCatching {
// Chain bounded slices back-to-back while history remains, so a large mailbox isn't limited
// to one slice per periodic run. runBackfill() returns true while any folder still has pages
// left; isStopped lets WorkManager end a long run gracefully (the periodic schedule resumes).
val mailBackfiller = backfiller.get()
while (mailBackfiller.runBackfill() && !isStopped) { /* page the next slice */ }
}.fold(
onSuccess = { Result.success() },
onFailure = { error -> if (error is CancellationException) throw error else Result.retry() },
)
}
}
@@ -5,8 +5,10 @@ import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.Lazy
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.libremail.data.security.EncryptedCacheGuard
/**
* Enforces device-only retention (issue #13) by running [MailPruner]. Purely local — it never
@@ -16,11 +18,20 @@ import dagger.assisted.AssistedInject
class PruneWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val pruner: MailPruner,
// Lazy: resolving MailPruner builds the Room DB graph, whose first query blocks while the encrypted
// cache is locked. Resolve it only after the cache-lock check passes, so a locked run fails fast
// instead of parking this thread on an unsatisfiable passphrase await (mirrors SyncWorker/SendWorker).
private val pruner: Lazy<MailPruner>,
private val cacheGuard: EncryptedCacheGuard,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result = runCatching { pruner.prune() }.fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
)
override suspend fun doWork(): Result {
// Can't open the encrypted DB without the user present — retry later rather than parking a
// WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await.
if (cacheGuard.isCacheLocked()) return Result.retry()
return runCatching { pruner.get().prune() }.fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
)
}
}
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import androidx.work.ListenableWorker.Result
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.data.security.EncryptedCacheGuard
import kotlin.test.assertEquals
/**
* [BackfillWorker] must defer while the encrypted cache is locked rather than park this WorkManager
* thread opening the DB. It gates on [EncryptedCacheGuard], resolving the (`Lazy`) [MailBackfiller]
* only once unlocked — the same pre-auth invariant `SyncWorker`/`SendWorker` enforce.
*/
class BackfillWorkerTest {
private val backfiller = mockk<MailBackfiller>()
private val lazyBackfiller = mockk<Lazy<MailBackfiller>> { every { get() } returns backfiller }
private val cacheGuard = mockk<EncryptedCacheGuard>()
private fun worker() = BackfillWorker(mockk(relaxed = true), mockk(relaxed = true), lazyBackfiller, cacheGuard)
@Test
fun `retries without resolving the backfiller when the cache is locked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
assertEquals(Result.retry(), worker().doWork())
verify(exactly = 0) { lazyBackfiller.get() }
coVerify(exactly = 0) { backfiller.runBackfill(any()) }
}
@Test
fun `chains slices to completion and succeeds when the cache is unlocked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
// true then false: one slice still has pages, the next reports done — the worker loops until false.
coEvery { backfiller.runBackfill(any()) } returnsMany listOf(true, false)
assertEquals(Result.success(), worker().doWork())
coVerify(exactly = 2) { backfiller.runBackfill(any()) }
}
@Test
fun `retries when backfilling throws`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { backfiller.runBackfill(any()) } throws IllegalStateException("boom")
assertEquals(Result.retry(), worker().doWork())
}
}
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import androidx.work.ListenableWorker.Result
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.data.security.EncryptedCacheGuard
import kotlin.test.assertEquals
/**
* [PruneWorker] must not touch the database while the encrypted cache is locked: opening it would park
* this WorkManager thread on an unsatisfiable passphrase await (and wedge the shared serial executor).
* It gates on [EncryptedCacheGuard] and only resolves the (`Lazy`) [MailPruner] once unlocked — the
* invariant every pre-auth DB entry point shares with `SyncWorker`/`SendWorker`.
*/
class PruneWorkerTest {
private val pruner = mockk<MailPruner>()
private val lazyPruner = mockk<Lazy<MailPruner>> { every { get() } returns pruner }
private val cacheGuard = mockk<EncryptedCacheGuard>()
private fun worker() = PruneWorker(mockk(relaxed = true), mockk(relaxed = true), lazyPruner, cacheGuard)
@Test
fun `retries without resolving the pruner when the cache is locked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
assertEquals(Result.retry(), worker().doWork())
// The whole point of the guard: the DB-backed dependency is never even resolved while locked.
verify(exactly = 0) { lazyPruner.get() }
coVerify(exactly = 0) { pruner.prune(any()) }
}
@Test
fun `prunes and succeeds when the cache is unlocked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { pruner.prune(any()) } returns 0
assertEquals(Result.success(), worker().doWork())
coVerify(exactly = 1) { pruner.prune(any()) }
}
@Test
fun `retries when pruning throws`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { pruner.prune(any()) } throws IllegalStateException("boom")
assertEquals(Result.retry(), worker().doWork())
}
}