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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user