feat(debug): dev-only pause/halt mail-fetch hook for test harnesses (#393)

The on-device perf harness cannot force a genuinely uncached body fetch: proactive
backfill (#12) and post-sync body prefetch (#88/#89) warm the cache before a test can
open a message. Add a debug-only, adb-reachable hook to pause proactive fetch so a real
uncached open can be measured.

Components:
- DebugFetchGate (src/main): thread-safe in-memory holder of paused FetchScopes
  (BACKFILL, PREFETCH; `all` alias). Defaults to not-paused; HEADER_SYNC and on-demand
  OPEN are never gateable.
- FetchGateReceiver (src/debug only): BroadcastReceiver registered in the debug manifest,
  driven by `adb shell am broadcast -a org.libremail.debug.FETCH_GATE -n .../FetchGateReceiver
  --es action <pause|resume|query> --es scope <backfill,prefetch|all>`. Returns the state as
  ordered-broadcast result data (paused=[...]) for a synchronous read-back.

Enforcement (each read guarded by BuildConfig.DEBUG so R8 strips it from release):
- BackfillWorker.doWork() entry -> skip-and-reschedule when BACKFILL is paused, mirroring
  the existing cache-lock deferral (covers periodic + backfillNow()).
- MailSyncer/MailBackfiller.prefetchIfEnabled -> early-return when PREFETCH is paused.
  openMessage / fetchBodyMarkingSeen / fetchAttachment are deliberately NOT gated.

Debug-only: receiver + <receiver> live wholly in src/debug; every gate read in main is
behind BuildConfig.DEBUG. Verified on assembleRelease that R8 strips DebugFetchGate /
FetchScope / FetchGateReceiver and the log strings from the release APK, and the merged
release manifest has no FETCH_GATE receiver.

PII-free AppLog breadcrumbs on pause/resume/query and on each gate-triggered defer/skip
(scope names only).

Tests: DebugFetchGateTest, BackfillWorkerTest / MailSyncerTest / MailBackfillerTest
enforcement cases, and FetchGateReceiverInstrumentedTest (ordered-broadcast -> gate ->
read-back; gated worker defers while an un-gated path runs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:13:22 -05:00
co-authored by Claude Opus 4.8
parent cfd433af6f
commit 8f8608a430
12 changed files with 646 additions and 3 deletions
+4
View File
@@ -343,6 +343,10 @@ val jacocoNonJvmTestableSurface = listOf(
"**/di/**",
// --- src/debug cold-open probe (issue #221) ---
"**/data/local/coldopen/**",
// --- src/debug fetch-gate receiver (issue #393): a BroadcastReceiver that only runs on-device
// --- (adb-driven), covered by an instrumented test, never packaged into a release build. Its
// --- pure collaborators DebugFetchGate/FetchScope stay IN scope (unit-tested by DebugFetchGateTest).
"**/debug/FetchGateReceiver*",
)
// Classes = the debug variant's compiled Kotlin (AGP 9 built-in Kotlin output), with the generated
@@ -0,0 +1,182 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.debug
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.ListenableWorker.Result
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.data.security.PassphraseSession
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.BackfillWorker
import org.libremail.data.sync.DebugFetchGate
import org.libremail.data.sync.FetchScope
import org.libremail.data.sync.MailBackfiller
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* On-device proof of the debug-only fetch gate (issue #393): the adb-reachable [FetchGateReceiver]
* updates [DebugFetchGate] and returns the resulting state as ordered-broadcast result data (exactly
* what `adb shell am broadcast ... FETCH_GATE` prints back to the harness), and a gated proactive path
* ([BackfillWorker]) genuinely defers while an un-gated path keeps running. The broadcast is sent
* ordered — the same delivery mode `am broadcast` uses — so [BroadcastReceiver.getResultData] on the
* final receiver reads back what the gate set, with no logcat race.
*
* The worker-deferral cases reuse `WorkerCacheLockDeferralInstrumentedTest`'s approach: build a
* [BackfillWorker] with a real, never-unlocked-or-off [EncryptedCacheGuard] and a `Lazy` [MailBackfiller]
* whose resolution is observable, so "the gate deferred before touching the DB" is proven by the `Lazy`
* never being resolved.
*/
@RunWith(AndroidJUnit4::class)
class FetchGateReceiverInstrumentedTest {
private val context: Context = ApplicationProvider.getApplicationContext()
// A fresh, real, never-unlocked session per test — so the real guard reports UNLOCKED only because
// app-lock is off (see [unlockedGuard]), never because of leftover auth state.
private val session = PassphraseSession()
@Before
@After
fun resetGate() {
DebugFetchGate.reset()
unmockkAll()
}
@Test
fun pauseUpdatesTheGateAndReturnsTheReadBack() {
val data = sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill,prefetch")
assertEquals("paused=[backfill,prefetch]", data)
assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL))
assertTrue(DebugFetchGate.isPaused(FetchScope.PREFETCH))
}
@Test
fun resumeAllClearsTheGateAndReturnsAnEmptyReadBack() {
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "all")
val data = sendGateBroadcast(FetchGateReceiver.ACTION_RESUME, "all")
assertEquals("paused=[]", data)
assertFalse(DebugFetchGate.isPaused(FetchScope.BACKFILL))
assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH))
}
@Test
fun queryReadsBackTheStateWithoutMutatingIt() {
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill")
val data = sendGateBroadcast(FetchGateReceiver.ACTION_QUERY, scope = null)
assertEquals("paused=[backfill]", data)
assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL))
assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH))
}
@Test
fun aBackfillPausedGateDefersTheBackfillWorkerWithoutResolvingTheBackfiller() = runBlocking<Unit> {
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill")
val lazyBackfiller = mockk<Lazy<MailBackfiller>>()
val worker = TestListenableWorkerBuilder<BackfillWorker>(context)
.setWorkerFactory(backfillWorkerFactory(lazyBackfiller, unlockedGuard()))
.build()
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
assertEquals(Result.retry(), result)
// The gate deferred BEFORE any DB-backed work — the Lazy was never resolved.
verify(exactly = 0) { lazyBackfiller.get() }
}
@Test
fun aPrefetchOnlyPauseLeavesTheBackfillWorkerRunning() = runBlocking<Unit> {
// The worker gate honours BACKFILL only; pausing PREFETCH must NOT defer history paging — the
// on-device analogue of "on-demand open and header sync stay live while prefetch is paused".
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "prefetch")
val backfiller = mockk<MailBackfiller> { coEvery { runBackfill(any()) } returns false }
val lazyBackfiller = mockk<Lazy<MailBackfiller>> { every { get() } returns backfiller }
val worker = TestListenableWorkerBuilder<BackfillWorker>(context)
.setWorkerFactory(backfillWorkerFactory(lazyBackfiller, unlockedGuard()))
.build()
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
assertEquals(Result.success(), result)
verify { lazyBackfiller.get() }
}
/**
* Sends the [FetchGateReceiver.ACTION] broadcast to the receiver by explicit component (mirroring
* `am broadcast -n`), ordered, and returns the result data the receiver set (the harness read-back).
*/
private fun sendGateBroadcast(action: String, scope: String?): String {
val latch = CountDownLatch(1)
val readBack = arrayOfNulls<String>(1)
val intent = Intent(FetchGateReceiver.ACTION).apply {
component = ComponentName(context, FetchGateReceiver::class.java)
putExtra(FetchGateReceiver.EXTRA_ACTION, action)
if (scope != null) putExtra(FetchGateReceiver.EXTRA_SCOPE, scope)
}
context.sendOrderedBroadcast(
intent,
null,
object : BroadcastReceiver() {
override fun onReceive(c: Context, i: Intent) {
readBack[0] = resultData
latch.countDown()
}
},
null,
0,
null,
null,
)
assertTrue("gate broadcast timed out", latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS))
return requireNotNull(readBack[0]) { "receiver set no result data" }
}
/** A real [EncryptedCacheGuard] reporting UNLOCKED (app-lock off) — so only the gate can defer. */
private fun unlockedGuard(): EncryptedCacheGuard {
val settingsRepository = mockk<SettingsRepository>()
every { settingsRepository.settings } returns flowOf(AppSettings(appLock = false, encryptCache = true))
return EncryptedCacheGuard(settingsRepository, session)
}
private fun backfillWorkerFactory(lazyBackfiller: Lazy<MailBackfiller>, cacheGuard: EncryptedCacheGuard) =
object : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
) = BackfillWorker(appContext, workerParameters, lazyBackfiller, cacheGuard)
}
private companion object {
const val TIMEOUT_MS = 5_000L
}
}
+17 -1
View File
@@ -1,5 +1,6 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
Debug-only test harness for issue #221 (never merged into a release APK — this manifest belongs to
@@ -16,6 +17,21 @@
android:authorities="${applicationId}.coldopen"
android:exported="false"
android:process=":coldopen" />
<!--
Debug-only fetch-gate receiver (issue #393; also never merged into a release APK — this
manifest belongs to the debug source set). Lets the on-device perf harness pause proactive
fetch (backfill + body prefetch) via `adb shell am broadcast` so a genuine uncached
message-open can be measured. Must be exported="true" so the adb `shell` UID can reach it by
explicit component (`-n`); it targets the debug BuildConfig.DEBUG-guarded DebugFetchGate only,
carries no PII, and — being debug-only — can never ship. tools:ignore suppresses the
exported-without-permission lint note: a signature permission would (by design) also lock out
the shell UID this hook exists to serve.
-->
<receiver
android:name="org.libremail.debug.FetchGateReceiver"
android:exported="true"
tools:ignore="ExportedReceiver" />
</application>
</manifest>
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.debug
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.libremail.data.sync.DebugFetchGate
import org.libremail.data.sync.FetchScope
import org.libremail.reporting.AppLog
/**
* Debug-only [BroadcastReceiver] (issue #393) that lets an adb-driven perf harness pause/resume the
* proactive-fetch activities tracked by [DebugFetchGate], so a genuinely uncached message-open can be
* measured (add an account, let headers sync, then open a message that must hit the network instead of a
* warmed cache). Declared **only** in `app/src/debug/AndroidManifest.xml`, so it is physically absent
* from every release APK — the same source-set guarantee `ColdOpenCacheProbe` (#221) relies on.
*
* Driven by (component targeted with `-n`, so it needs no `<intent-filter>`):
* ```
* adb shell am broadcast -a org.libremail.debug.FETCH_GATE \
* -n org.libremail.app/org.libremail.debug.FetchGateReceiver \
* --es action <pause|resume|query> --es scope <backfill,prefetch|all>
* ```
* `am broadcast` delivers this **ordered**, so the receiver returns the resulting state as result data
* (e.g. `data=paused=[backfill,prefetch]`) which `am` prints — a synchronous, race-free read-back for
* the harness. A `query` reports the current state without changing it. The new state is logged via the
* PII-free [AppLog] (scope names only — never an email, host, or message content).
*/
class FetchGateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.getStringExtra(EXTRA_ACTION)?.trim()?.lowercase()
val scopes = FetchScope.parse(intent.getStringExtra(EXTRA_SCOPE))
when (action) {
ACTION_PAUSE -> {
DebugFetchGate.pause(scopes)
AppLog.i(TAG, "fetch gate pause -> ${DebugFetchGate.pausedResult()}")
}
ACTION_RESUME -> {
DebugFetchGate.resume(scopes)
AppLog.i(TAG, "fetch gate resume -> ${DebugFetchGate.pausedResult()}")
}
ACTION_QUERY -> AppLog.i(TAG, "fetch gate query -> ${DebugFetchGate.pausedResult()}")
else -> AppLog.w(TAG, "fetch gate: unknown action")
}
// Return the gate state as ordered-broadcast result data for a synchronous read-back. Guarded so
// a non-ordered send (which has no result receiver) can't crash the receiver.
if (isOrderedBroadcast) {
resultCode = RESULT_CODE
resultData = DebugFetchGate.pausedResult()
}
}
companion object {
/** The broadcast action the harness sends (kept for parity with the adb command; delivery is by `-n`). */
const val ACTION = "org.libremail.debug.FETCH_GATE"
/** `--es action <pause|resume|query>`. */
const val EXTRA_ACTION = "action"
/** `--es scope <comma-list|all>` (see [FetchScope.parse]). */
const val EXTRA_SCOPE = "scope"
const val ACTION_PAUSE = "pause"
const val ACTION_RESUME = "resume"
const val ACTION_QUERY = "query"
private const val TAG = "FetchGateReceiver"
private const val RESULT_CODE = 0
}
}
@@ -9,6 +9,7 @@ import dagger.Lazy
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import org.libremail.BuildConfig
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.reporting.AppLog
@@ -30,6 +31,16 @@ class BackfillWorker @AssistedInject constructor(
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
// Debug-only fetch gate (issue #393): a test harness can pause backfill via an adb broadcast so a
// genuinely uncached message-open can be measured (proactive backfill would otherwise warm the
// cache first). Skip-and-reschedule exactly like the cache-lock deferral below; WorkManager
// retries and picks up from the persisted per-folder boundary once the gate resumes. The whole
// branch is compiled out of release: BuildConfig.DEBUG is a compile-time `false` there, so R8
// strips it (and DebugFetchGate with it).
if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.BACKFILL)) {
AppLog.i(TAG, "backfill deferred: fetch-gate paused")
return Result.retry()
}
// 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()) {
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
/**
* The proactive-fetch activities a debug harness can pause via [DebugFetchGate] (issue #393). Only the
* two *proactive* activities are gateable: full-history [BACKFILL] paging ([BackfillWorker]) and the
* post-sync body [PREFETCH] ([MailSyncer]/[MailBackfiller] `prefetchIfEnabled`). Header sync and the
* on-demand message open are deliberately absent — they are **never** gated, so a paused gate can defer
* background caching without ever blocking new mail arriving or a user-triggered (uncached) open. The
* `all` wire alias ([ALL_ALIAS]) expands to every entry here.
*/
enum class FetchScope(val wireName: String) {
BACKFILL("backfill"),
PREFETCH("prefetch"),
;
companion object {
/** The `all` scope alias accepted on the adb wire — expands to every [FetchScope]. */
const val ALL_ALIAS = "all"
/**
* Parses the comma-separated `scope` extra of the debug broadcast (e.g. `"backfill,prefetch"`
* or `"all"`) into the set of scopes it names. Case- and whitespace-insensitive; the [ALL_ALIAS]
* expands to every scope; unrecognised or blank tokens are ignored; a null/blank input yields
* the empty set. Declaration order is preserved so the read-back string is stable.
*/
fun parse(raw: String?): Set<FetchScope> {
if (raw.isNullOrBlank()) return emptySet()
val tokens = raw.split(',').map { it.trim().lowercase() }.filter { it.isNotEmpty() }
if (tokens.contains(ALL_ALIAS)) return entries.toSet()
return entries.filterTo(LinkedHashSet()) { it.wireName in tokens }
}
}
}
/**
* In-memory, thread-safe holder of the currently-paused proactive-fetch [FetchScope]s — a **debug-only**
* test hook (issue #393) that lets an adb-driven perf harness pause background body caching so a genuine
* uncached message-open can be measured (the harness adds an account, lets headers sync, then opens a
* message that must hit the network rather than a warmed cache).
*
* Lives in `src/main` so the workers/syncer/backfiller can reference it, but **every read is wrapped in
* `if (BuildConfig.DEBUG && ...)`**. `BuildConfig.DEBUG` is a compile-time `false` in release, so R8
* dead-code-eliminates each such branch, leaving this object unreferenced and stripping it (and
* [FetchScope]) from the release APK entirely — verified by issue #393's release-exclusion check. The
* writer, `FetchGateReceiver`, lives wholly in `src/debug` and is never packaged into release either;
* this is the same source-set guarantee `ColdOpenCacheProbe` (#221) relies on.
*
* Defaults to **nothing paused**, so the gate is inert until a debug broadcast pauses a scope. Reads are
* lock-free (a `@Volatile` snapshot of an immutable set, cheap enough for the fetch hot path); the rare
* writes swap the reference under a lock.
*/
object DebugFetchGate {
private val writeLock = Any()
@Volatile
private var paused: Set<FetchScope> = emptySet()
/** Whether [scope]'s proactive fetch is currently paused. */
fun isPaused(scope: FetchScope): Boolean = scope in paused
/** The scopes currently paused, in [FetchScope] declaration order. */
fun pausedScopes(): Set<FetchScope> {
val snapshot = paused
return FetchScope.entries.filterTo(LinkedHashSet()) { it in snapshot }
}
/** Pauses [scopes] (union with whatever is already paused). No-op for an empty set. */
fun pause(scopes: Set<FetchScope>) {
if (scopes.isEmpty()) return
synchronized(writeLock) { paused = paused + scopes }
}
/** Resumes [scopes] (removes them from the paused set). No-op for an empty set. */
fun resume(scopes: Set<FetchScope>) {
if (scopes.isEmpty()) return
synchronized(writeLock) { paused = paused - scopes }
}
/** Clears every pause, restoring the default not-paused state. Used to isolate tests. */
fun reset() {
synchronized(writeLock) { paused = emptySet() }
}
/**
* The synchronous read-back string the debug receiver returns as ordered-broadcast result data —
* e.g. `"paused=[backfill,prefetch]"` (declaration order) or `"paused=[]"` when nothing is paused.
*/
fun pausedResult(): String {
val snapshot = paused
return FetchScope.entries.filter { it in snapshot }.joinToString(
separator = ",",
prefix = "paused=[",
postfix = "]",
) { it.wireName }
}
}
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.libremail.BuildConfig
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.BackfillProgressDao
import org.libremail.data.local.dao.MessageDao
@@ -224,6 +225,14 @@ class MailBackfiller @Inject constructor(
* fetched is filled in lazily when the message is opened.
*/
private suspend fun prefetchIfEnabled(ids: List<String>) {
// Debug-only fetch gate (issue #393): pause proactive body prefetch so a later open is a genuine
// uncached fetch. Header paging above is untouched (its own gate is the BackfillWorker entry), so
// history still lands; a skipped body is filled in lazily on open. Compiled out of release
// (BuildConfig.DEBUG is a compile-time false, so R8 drops the branch).
if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.PREFETCH)) {
AppLog.i(TAG, "prefetch skipped: fetch-gate paused")
return
}
val shouldPrefetch = SyncResourcePolicy.shouldPrefetchContent(
policy = settingsRepository.fetchPolicy(),
unmetered = { context.isActiveNetworkUnmetered() },
@@ -9,6 +9,7 @@ import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.libremail.BuildConfig
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.toDomain
@@ -166,6 +167,14 @@ class MailSyncer @Inject constructor(
* and is cancellable between messages so an IDLE renewal stops it promptly.
*/
private suspend fun prefetchIfEnabled(account: Account, folder: String) {
// Debug-only fetch gate (issue #393): a test harness pauses proactive body prefetch so a later
// open does a genuine uncached fetch. Header sync above already ran, so mail still arrives; the
// skipped prefetch is filled in lazily on open, exactly as the low-battery pause behaves. Compiled
// out of release (BuildConfig.DEBUG is a compile-time false, so R8 drops the branch).
if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.PREFETCH)) {
AppLog.i(TAG, "prefetch skipped: fetch-gate paused")
return
}
val shouldPrefetch = SyncResourcePolicy.shouldPrefetchContent(
policy = settingsRepository.fetchPolicy(),
unmetered = { context.isActiveNetworkUnmetered() },
@@ -52,7 +52,10 @@ class BackfillWorkerTest {
}
@After
fun tearDown() = unmockkAll()
fun tearDown() {
unmockkAll()
DebugFetchGate.reset() // the gate is a process-global object; don't leak a pause to other tests
}
@Test
fun `retries without resolving the backfiller when the cache is locked`() = runTest {
@@ -122,4 +125,44 @@ class BackfillWorkerTest {
assertTrue(entry.message.contains("IllegalStateException"), entry.message)
assertFalse(entry.message.contains("a@example.org"), entry.message)
}
// --- issue #393: debug-only fetch gate ------------------------------------------------------
@Test
fun `defers without resolving the backfiller when the fetch gate pauses backfill`() = runTest {
// Cache unlocked, so the ONLY reason to defer is the gate. (BuildConfig.DEBUG is true under
// testDebugUnitTest, so the gate branch is live.)
coEvery { cacheGuard.isCacheLocked() } returns false
DebugFetchGate.pause(setOf(FetchScope.BACKFILL))
assertEquals(Result.retry(), worker().doWork())
// Same invariant as the cache-lock deferral: never resolve the DB-backed collaborator.
verify(exactly = 0) { lazyBackfiller.get() }
coVerify(exactly = 0) { backfiller.runBackfill(any()) }
}
@Test
fun `pausing only prefetch does NOT defer the backfill worker`() = runTest {
// The worker gate honours BACKFILL only — a PREFETCH pause must leave history paging running.
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { backfiller.runBackfill(any()) } returns false
DebugFetchGate.pause(setOf(FetchScope.PREFETCH))
assertEquals(Result.success(), worker().doWork())
coVerify(exactly = 1) { backfiller.runBackfill(any()) }
}
@Test
fun `logs a deferred breadcrumb when the fetch gate pauses backfill`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
DebugFetchGate.pause(setOf(FetchScope.BACKFILL))
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('I', entry.level)
assertEquals("backfill deferred: fetch-gate paused", entry.message)
}
}
@@ -0,0 +1,164 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Unit tests for the debug-only fetch gate (issue #393): its default not-paused state, pause/resume/query
* per [FetchScope], the `all` alias + scope-string parsing the receiver relies on, the ordered-broadcast
* read-back string, and thread-safety of the in-memory holder. [DebugFetchGate] is a process-global
* object, so each test resets it to isolate from the others.
*/
class DebugFetchGateTest {
@Before
@After
fun resetGate() = DebugFetchGate.reset()
@Test
fun `defaults to nothing paused for every scope`() {
FetchScope.entries.forEach { scope ->
assertFalse(DebugFetchGate.isPaused(scope), "$scope must default to not-paused")
}
assertTrue(DebugFetchGate.pausedScopes().isEmpty())
assertEquals("paused=[]", DebugFetchGate.pausedResult())
}
@Test
fun `pausing one scope leaves the other live`() {
DebugFetchGate.pause(setOf(FetchScope.BACKFILL))
assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL))
assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH))
assertEquals(setOf(FetchScope.BACKFILL), DebugFetchGate.pausedScopes())
assertEquals("paused=[backfill]", DebugFetchGate.pausedResult())
}
@Test
fun `pausing both scopes reports both, in declaration order`() {
DebugFetchGate.pause(setOf(FetchScope.PREFETCH, FetchScope.BACKFILL))
assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL))
assertTrue(DebugFetchGate.isPaused(FetchScope.PREFETCH))
// Declaration order (BACKFILL before PREFETCH), regardless of the set's insertion order.
assertEquals("paused=[backfill,prefetch]", DebugFetchGate.pausedResult())
}
@Test
fun `pause is additive across calls`() {
DebugFetchGate.pause(setOf(FetchScope.BACKFILL))
DebugFetchGate.pause(setOf(FetchScope.PREFETCH))
assertEquals(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH), DebugFetchGate.pausedScopes())
}
@Test
fun `resume clears only the named scope`() {
DebugFetchGate.pause(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH))
DebugFetchGate.resume(setOf(FetchScope.BACKFILL))
assertFalse(DebugFetchGate.isPaused(FetchScope.BACKFILL))
assertTrue(DebugFetchGate.isPaused(FetchScope.PREFETCH))
assertEquals("paused=[prefetch]", DebugFetchGate.pausedResult())
}
@Test
fun `resume of an unpaused scope is a no-op`() {
DebugFetchGate.resume(setOf(FetchScope.BACKFILL))
assertEquals("paused=[]", DebugFetchGate.pausedResult())
}
@Test
fun `empty pause and resume are no-ops`() {
DebugFetchGate.pause(emptySet())
assertEquals("paused=[]", DebugFetchGate.pausedResult())
DebugFetchGate.pause(setOf(FetchScope.PREFETCH))
DebugFetchGate.resume(emptySet())
assertEquals("paused=[prefetch]", DebugFetchGate.pausedResult())
}
@Test
fun `reset clears every pause`() {
DebugFetchGate.pause(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH))
DebugFetchGate.reset()
assertTrue(DebugFetchGate.pausedScopes().isEmpty())
}
// --- FetchScope.parse (the scope-string contract the receiver depends on) -------------------------
@Test
fun `parse maps a comma list of known scopes`() {
assertEquals(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH), FetchScope.parse("backfill,prefetch"))
assertEquals(setOf(FetchScope.BACKFILL), FetchScope.parse("backfill"))
}
@Test
fun `parse expands the all alias to every scope`() {
assertEquals(FetchScope.entries.toSet(), FetchScope.parse("all"))
// The alias wins even mixed with other tokens.
assertEquals(FetchScope.entries.toSet(), FetchScope.parse("backfill,all"))
}
@Test
fun `parse is case- and whitespace-insensitive`() {
assertEquals(setOf(FetchScope.BACKFILL, FetchScope.PREFETCH), FetchScope.parse(" BACKFILL , Prefetch "))
}
@Test
fun `parse ignores unknown and blank tokens`() {
assertEquals(setOf(FetchScope.PREFETCH), FetchScope.parse("prefetch,bogus,,"))
assertTrue(FetchScope.parse("nope").isEmpty())
}
@Test
fun `parse of null or blank is the empty set`() {
assertTrue(FetchScope.parse(null).isEmpty())
assertTrue(FetchScope.parse("").isEmpty())
assertTrue(FetchScope.parse(" ").isEmpty())
}
// --- thread-safety --------------------------------------------------------------------------------
@Test
fun `concurrent pause and resume never corrupts the holder`() {
val threads = 8
val iterations = 2_000
val pool = Executors.newFixedThreadPool(threads)
val start = CountDownLatch(1)
try {
val futures = (0 until threads).map { index ->
pool.submit {
start.await()
val scope = FetchScope.entries[index % FetchScope.entries.size]
repeat(iterations) {
DebugFetchGate.pause(setOf(scope))
DebugFetchGate.isPaused(scope)
DebugFetchGate.pausedResult()
DebugFetchGate.resume(setOf(scope))
}
}
}
start.countDown()
futures.forEach { it.get(30, TimeUnit.SECONDS) }
} finally {
pool.shutdownNow()
}
// No exception above (a data race on a plain HashSet would throw), and the holder is consistent:
// pausedScopes() only ever reports declared scopes.
assertTrue(DebugFetchGate.pausedScopes().all { it in FetchScope.entries })
}
}
@@ -107,6 +107,7 @@ class MailBackfillerTest {
fun tearDown() {
greenMail.stop()
unmockkAll()
DebugFetchGate.reset() // the gate is a process-global object; don't leak a pause to other tests
}
private fun params() = ImapConnectionParams(
@@ -234,6 +235,23 @@ class MailBackfillerTest {
coVerify(exactly = 0) { requireNotNull(lastMailRepository).prefetchMessage(any()) }
}
// --- issue #393: debug-only fetch gate ------------------------------------------------------
@Test
fun `a paused prefetch gate skips body prefetch but still pages history headers`() = runTest {
appendMessages(60)
seedForegroundWindow()
DebugFetchGate.pause(setOf(FetchScope.PREFETCH))
// ALWAYS + healthy battery: without the gate this WOULD prefetch, so the gate is the only cause.
backfiller(AccountSettings("acct"), fetchPolicy = FetchPolicy.ALWAYS).runBackfill()
// The gate stops only the body/attachment prefetch; the history headers still page in.
assertEquals(60, distinctCachedUids().size, "header paging itself is not gated")
coVerify(exactly = 0) { requireNotNull(lastMailRepository).prefetchMessage(any()) }
assertTrue(logBuffer.snapshot().any { it.message == "prefetch skipped: fetch-gate paused" })
}
@Test
fun `charging at low percent keeps the backfill content prefetch running`() = runTest {
appendMessages(60)
@@ -73,7 +73,10 @@ class MailSyncerTest {
}
@After
fun tearDown() = unmockkAll()
fun tearDown() {
unmockkAll()
DebugFetchGate.reset() // the gate is a process-global object; don't leak a pause to other tests
}
/** The IMAP client of the most recently built [syncer], for verifying the fetch window size. */
private lateinit var lastImapClient: ImapClient
@@ -228,6 +231,22 @@ class MailSyncerTest {
coVerify(exactly = 0) { repo.prefetchMessage(any()) }
}
// --- issue #393: debug-only fetch gate ------------------------------------------------------
@Test
fun `a paused prefetch gate skips prefetch while the header sync still runs`() = runTest {
val repo = mockk<MailRepository>(relaxed = true)
DebugFetchGate.pause(setOf(FetchScope.PREFETCH))
// ALWAYS + healthy battery: without the gate this WOULD prefetch, so the gate is the only cause.
val result = syncer(FetchPolicy.ALWAYS, repo).syncFolder("acct", "INBOX")
assertEquals(0, result.getOrNull()) // header sync still ran and succeeded
coVerify { lastImapClient.fetchRecent(any(), "INBOX", any()) } // headers still fetched
coVerify(exactly = 0) { repo.prefetchMessage(any()) }
assertTrue(logBuffer.snapshot().any { it.message == "prefetch skipped: fetch-gate paused" })
}
@Test
fun `at exactly the 20 percent threshold prefetch is paused`() = runTest {
val repo = mockk<MailRepository>(relaxed = true)