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

Closed
opened 2026-07-07 00:35:11 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-07 00:35:11 +00:00 (Migrated from github.com)

Motivation

The on-device perf harness (scripts/device-testing/, #370; portability fixes #392) cannot force a genuine
uncached body fetch
. Proactive fetch — full-history backfill (MailBackfiller / BackfillWorker, #12) and
post-header-sync body prefetch (MailSyncer.prefetchIfEnabled / MailBackfiller.prefetchIfEnabled, #88/#89) —
pulls bodies into Room before a test can open a message, so openMessage finds the body already cached and
does a local read instead of an IMAP fetch. This blocked:

  • the Gmail body-fetch-throttle repro (message-open / cross-provider scenarios), and
  • the connection-reuse ON-vs-OFF A/B the reuse rollout still owes (#368 / #357 Part 2).

The harness needs to pause proactive fetch — add an account, let headers sync but not prefetch bodies or
backfill — then trigger a real uncached open and measure it. Today the closest lever is the prefetch-ab
scenario toggling the persisted fetch-policy setting, which (a) doesn't stop backfill's header paging or the
prefetch already in flight, and (b) navigates the settings UI by on-screen text (fragile). We need a
deterministic, debug-only, adb-reachable pause hook.

This is a PROPOSAL / design ticket — no product code is written here.


Fetch architecture: the distinct activities and their choke points

Activity Path Triggered by Test wants
Header sync (recent window) MailSyncer.syncFolderHeaders -> ImapClient.fetchRecent (op imap) periodic SyncWorker (15m), pull-to-refresh, syncNow(), folder open, IDLE push (syncAccount) LIVE (headers must arrive)
Body prefetch (proactive) MailSyncer.prefetchIfEnabled + MailBackfiller.prefetchIfEnabled -> MailRepository.prefetchMessage -> ImapClient.fetchBodyPeek (op prefetch-body) + fetchAttachment after every header sync and after every backfill page PAUSE
Backfill (history paging) MailBackfiller.backfillFolder -> ImapClient.fetchOlderThan (op backfill-page) periodic BackfillWorker (30m), backfillNow() PAUSE
On-demand open MailRepositoryImpl.openMessage -> ImapClient.fetchBodyMarkingSeen (op body-fetch) user opens an uncached message LIVE (the thing we measure)
On-demand attachment ensureAttachmentFile -> ImapClient.fetchAttachment (op attachment) user opens attachment / inline image LIVE
IDLE push ImapClient.idle / IdleService foreground service independent (leave alone)
Prune / Send PruneWorker / SendWorker periodic / outbox not a fetch — irrelevant

Two facts make this cheap to gate:

  1. Both prefetch paths funnel through one pure decision: SyncResourcePolicy.shouldPrefetchContent(policy, unmetered, battery). The recent-window prefetch (MailSyncer) and the backfill prefetch (MailBackfiller)
    both consult it, so proactive body prefetch has a single logical gate. (Header sync is deliberately never
    gated here — see its kdoc: "a paused prefetch merely defers content caching to the next healthy sync, or to
    on-demand fetch when a message is opened." Exactly the behaviour the harness wants.)
  2. Backfill header paging is gated at the worker/scheduler layer, not by shouldPrefetchContent. It runs in
    backfillFolder's while loop under WorkManager's battery-not-low constraint. The natural single choke
    point is BackfillWorker.doWork() entry — which already has the exact skip-and-reschedule pattern we want
    (if (cacheGuard.isCacheLocked()) { AppLog.i(...); return Result.retry() }).

All fetch runs in the main process (only :coldopen and :restart are separate processes; workers, the
syncer, and IdleService share the default process), so an in-memory gate is visible everywhere it's read.


Options considered

# Mechanism Verdict
1 Debug BroadcastReceiver via adb shell am broadcast RECOMMENDED. The harness already uses am constrained to the target package (adb.py allow-list). Ordered-broadcast result-data gives synchronous read-back; receiver lives in src/debug (physically absent from release). Best fit.
2 Debug DataStore/settings flag Rejected. Persists across runs (state leak between harness runs); the harness's own safety rules forbid touching datastore/, so it would still need a receiver/UI to flip it — no win over #1, plus unwanted persistence.
3 Debug ContentProvider via adb shell content call Runner-up. Mirrors the existing ColdOpenCacheProbe (#221) precedent, but content call has poor Bundle read-back ergonomics across Android versions and is awkward for a simple toggle. Keep as fallback.
4 Instrumentation arg / intent extra Rejected. The harness is an external adb + uiautomator driver, not an instrumented test — no instrumentation to pass args to.
5 Watched file / system prop Rejected. Reading a file/prop on the fetch hot path is untestable and hacky; harness safety rules forbid touching app dirs.

Recommended approach

A debug-only BroadcastReceiver (src/debug) that toggles an in-memory DebugFetchGate holder, which
main-source fetch entry points consult behind a BuildConfig.DEBUG guard.

Harness hook (adb)

# Pause proactive fetch (the driving use case): backfill + prefetch off, header-sync + open stay live.
adb shell am broadcast \
  -a org.libremail.debug.FETCH_GATE \
  -n org.libremail.app/org.libremail.debug.FetchGateReceiver \
  --es action pause --es scope backfill,prefetch
# -> "Broadcast completed: result=0, data=paused=[backfill,prefetch]"

# Resume everything after the measurement.
adb shell am broadcast -a org.libremail.debug.FETCH_GATE \
  -n org.libremail.app/org.libremail.debug.FetchGateReceiver --es action resume --es scope all
# -> "Broadcast completed: result=0, data=paused=[]"

# Query current state without changing it (idempotent read-back).
adb shell am broadcast -a org.libremail.debug.FETCH_GATE \
  -n org.libremail.app/org.libremail.debug.FetchGateReceiver --es action query
# -> "Broadcast completed: result=0, data=paused=[backfill,prefetch]"
  • The receiver is ordered; it calls setResultCode(0) + setResultData("paused=[...]"), which am broadcast
    prints, so the harness gets synchronous confirmation that the gate took effect (no logcat race).
  • Component is named with -n and exported="true" in the debug manifest (adb shell UID must reach it).
    Optional hardening: a signature-level android:permission. It's debug-only regardless, so it can never ship.
  • scope accepts a comma list of backfill, prefetch (add header-sync, idle, attachment if wanted) and
    the alias all. Default harness usage pauses backfill,prefetch.

Granularity

Per-activity scopes, minimally BACKFILL and PREFETCH (an enum FetchScope, plus an all alias). The
driving use case pauses both and leaves HEADER_SYNC + on-demand OPEN live — so: add account -> SyncWorker
runs fetchRecent (INBOX headers land) -> prefetch gated (no bodies cached) -> BackfillWorker gated (no history)
-> open a message -> openMessage sees !routing.bodyFetched -> fetchBodyMarkingSeen does a genuine uncached
fetch
-> measured. Global "halt all" is just scope=all.

Enforcement points + semantics

  • BackfillWorker.doWork() entry (primary backfill gate):
    if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.BACKFILL)) { AppLog.i(TAG, "backfill deferred: fetch-gate paused"); return Result.retry() } — skip-and-reschedule, byte-for-byte the existing cache-lock
    deferral. Covers periodic + backfillNow() (both route through the worker). WorkManager retries later, so on
    resume backfill continues from its persisted per-folder boundary. (Alternative/finer: gate inside
    MailBackfiller.runBackfill(); the worker entry is simpler and sufficient.)
  • MailSyncer.prefetchIfEnabled and MailBackfiller.prefetchIfEnabled (prefetch gate): early-return when
    PREFETCH is paused — no-op-until-resumed, which is already the documented contract (a skipped prefetch is
    retried on the next healthy sync / filled in lazily on open). fetchRecent above is untouched, so headers keep
    flowing. This also neutralises the folder-open-triggered prefetch (syncFolder -> prefetchIfEnabled), so
    merely navigating to the inbox won't warm the body cache.
  • Deliberately NOT gated: openMessage / fetchBodyMarkingSeen / on-demand fetchAttachment -> uncached
    open stays live. Also not gating at ImapClient.withStore op-dispatch: op-label gating would duplicate the
    higher-level gates and risk pausing an op we want live — keep the gate where intent is unambiguous.

Debug-only gating (hard requirement) + release verification

Belt and suspenders:

  1. Writer absent from release. FetchGateReceiver and its <receiver> manifest entry live entirely in
    app/src/debug/ — the same source-set guarantee the repo already relies on for ColdOpenCacheProbe (#221).
    Never compiled into or merged into a release APK.
  2. Reader stripped from release. The DebugFetchGate holder lives in src/main (so main workers/syncer can
    reference it), but every read is guarded by if (BuildConfig.DEBUG && ...). BuildConfig.DEBUG is a
    compile-time constant false in release, so R8 dead-code-eliminates the branch, leaving DebugFetchGate
    unreferenced -> removed from the release APK. In release the gate is not merely inert, it's gone.

Verify release is unaffected:

  • ./gradlew :app:assembleRelease then apkanalyzer dex packages (or dexdump) shows no DebugFetchGate /
    FetchGateReceiver class, and the merged release manifest has no FETCH_GATE receiver.
  • Unit test: DebugFetchGate defaults to not-paused for every scope.
  • Manual: am broadcast ... FETCH_GATE against a release build is a no-op (component absent) and fetch proceeds.

Observability (PII-free AppLog)

  • On toggle: AppLog.i("DebugFetchGate", "fetch gate: paused=[backfill,prefetch]") — scope names only, never PII.
  • On enforcement: "backfill deferred: fetch-gate paused" (mirrors "backfill deferred: cache locked");
    MailSyncer/MailBackfiller log "prefetch skipped: fetch-gate paused".
  • Harness confirmation (three independent signals): (a) the am broadcast result-data read-back above;
    (b) a query action; (c) the logcat breadcrumbs, which breadcrumbs.py already tails. The absence of
    ImapPerf prefetch-body / backfill-page ops while paused is itself corroborating evidence.

Testing

  • JVM unit (branch is live because BuildConfig.DEBUG is true under testDebugUnitTest):
    • DebugFetchGateTest — pause/resume/query/all per scope; default not-paused; thread-safe holder.
    • BackfillWorkerTest — with BACKFILL paused, doWork() returns retry() and MailBackfiller is never
      invoked; unpaused path unchanged.
    • MailSyncerTest / MailBackfillerTest — with PREFETCH paused, fetchRecent / fetchOlderThan still run
      but prefetchMessage is not called; unpaused path unchanged.
    • FetchGateReceiver action/scope parsing (backfill,prefetch / all / unknown -> result-data).
  • Instrumented/E2E (DoD) — debug-only test: send the ordered broadcast, assert result-data and
    DebugFetchGate state; assert a paused-backfill worker defers. Must run and pass on the cold-boot emulator +
    API 37 preflight.
  • End-to-end harness flow: install debug -> am broadcast ... pause backfill,prefetch (confirm read-back) ->
    add Gmail account (headers sync, no prefetch-body/backfill-page ops in logcat) -> open a message
    (MailReader openMessage ... fetchedBody=true + one real body-fetch ImapPerf op) -> measure uncached open
    -> resume all -> repeat for reuse ON vs OFF builds (#368 A/B). Supersedes the fragile settings-nav in
    prefetch-ab.

Scope / notes

  • Product code stays Kotlin; the harness-side change (adding am broadcast FETCH_GATE to adb.py's sanctioned
    am calls and wiring the prefetch-ab / message-open scenarios to it) is a follow-up in scripts/device-testing/.
  • References: #370 (harness), #392 (harness portability), #368 / #357 Part 2 (the reuse ON/OFF A/B that
    needs a cold body), #221 (ColdOpenCacheProbe — the debug-source-set precedent this mirrors), #358
    (MailReader / ImapPerf breadcrumbs used for readiness + measurement).
## Motivation The on-device perf harness (`scripts/device-testing/`, #370; portability fixes #392) **cannot force a genuine uncached body fetch**. Proactive fetch — full-history backfill (`MailBackfiller` / `BackfillWorker`, #12) and post-header-sync body prefetch (`MailSyncer.prefetchIfEnabled` / `MailBackfiller.prefetchIfEnabled`, #88/#89) — pulls bodies into Room *before* a test can open a message, so `openMessage` finds the body already cached and does a local read instead of an IMAP fetch. This blocked: - the Gmail body-fetch-throttle repro (`message-open` / `cross-provider` scenarios), and - the **connection-reuse ON-vs-OFF A/B** the reuse rollout still owes (#368 / #357 Part 2). The harness needs to **pause proactive fetch** — add an account, let headers sync but **not** prefetch bodies or backfill — then trigger a real uncached open and measure it. Today the closest lever is the `prefetch-ab` scenario toggling the persisted fetch-policy setting, which (a) doesn't stop backfill's header paging or the prefetch already in flight, and (b) navigates the settings UI by on-screen text (fragile). We need a deterministic, debug-only, adb-reachable pause hook. This is a **PROPOSAL / design ticket** — no product code is written here. --- ## Fetch architecture: the distinct activities and their choke points | Activity | Path | Triggered by | Test wants | |---|---|---|---| | **Header sync** (recent window) | `MailSyncer.syncFolderHeaders` -> `ImapClient.fetchRecent` (op `imap`) | periodic `SyncWorker` (15m), pull-to-refresh, `syncNow()`, folder open, IDLE push (`syncAccount`) | **LIVE** (headers must arrive) | | **Body prefetch** (proactive) | `MailSyncer.prefetchIfEnabled` + `MailBackfiller.prefetchIfEnabled` -> `MailRepository.prefetchMessage` -> `ImapClient.fetchBodyPeek` (op `prefetch-body`) + `fetchAttachment` | after every header sync **and** after every backfill page | **PAUSE** | | **Backfill** (history paging) | `MailBackfiller.backfillFolder` -> `ImapClient.fetchOlderThan` (op `backfill-page`) | periodic `BackfillWorker` (30m), `backfillNow()` | **PAUSE** | | **On-demand open** | `MailRepositoryImpl.openMessage` -> `ImapClient.fetchBodyMarkingSeen` (op `body-fetch`) | user opens an uncached message | **LIVE** (the thing we measure) | | **On-demand attachment** | `ensureAttachmentFile` -> `ImapClient.fetchAttachment` (op `attachment`) | user opens attachment / inline image | LIVE | | **IDLE push** | `ImapClient.idle` / `IdleService` | foreground service | independent (leave alone) | | Prune / Send | `PruneWorker` / `SendWorker` | periodic / outbox | not a fetch — irrelevant | Two facts make this cheap to gate: 1. **Both prefetch paths funnel through one pure decision:** `SyncResourcePolicy.shouldPrefetchContent(policy, unmetered, battery)`. The recent-window prefetch (`MailSyncer`) and the backfill prefetch (`MailBackfiller`) both consult it, so proactive body prefetch has a single logical gate. (Header sync is deliberately never gated here — see its kdoc: "a paused prefetch merely defers content caching to the next healthy sync, or to on-demand fetch when a message is opened." Exactly the behaviour the harness wants.) 2. **Backfill header paging is gated at the worker/scheduler layer, not by `shouldPrefetchContent`.** It runs in `backfillFolder`'s `while` loop under WorkManager's `battery-not-low` constraint. The natural single choke point is `BackfillWorker.doWork()` entry — which already has the exact skip-and-reschedule pattern we want (`if (cacheGuard.isCacheLocked()) { AppLog.i(...); return Result.retry() }`). All fetch runs in the **main process** (only `:coldopen` and `:restart` are separate processes; workers, the syncer, and `IdleService` share the default process), so an in-memory gate is visible everywhere it's read. --- ## Options considered | # | Mechanism | Verdict | |---|---|---| | **1** | **Debug `BroadcastReceiver` via `adb shell am broadcast`** | **RECOMMENDED.** The harness already uses `am` constrained to the target package (`adb.py` allow-list). Ordered-broadcast result-data gives synchronous read-back; receiver lives in `src/debug` (physically absent from release). Best fit. | | 2 | Debug DataStore/settings flag | Rejected. Persists across runs (state leak between harness runs); the harness's own safety rules **forbid touching `datastore/`**, so it would still need a receiver/UI to flip it — no win over #1, plus unwanted persistence. | | 3 | Debug `ContentProvider` via `adb shell content call` | Runner-up. Mirrors the existing `ColdOpenCacheProbe` (#221) precedent, but `content call` has poor Bundle read-back ergonomics across Android versions and is awkward for a simple toggle. Keep as fallback. | | 4 | Instrumentation arg / intent extra | Rejected. The harness is an **external** adb + uiautomator driver, not an instrumented test — no instrumentation to pass args to. | | 5 | Watched file / system prop | Rejected. Reading a file/prop on the fetch hot path is untestable and hacky; harness safety rules forbid touching app dirs. | --- ## Recommended approach A **debug-only `BroadcastReceiver`** (`src/debug`) that toggles an **in-memory `DebugFetchGate`** holder, which main-source fetch entry points consult behind a `BuildConfig.DEBUG` guard. ### Harness hook (adb) ``` # Pause proactive fetch (the driving use case): backfill + prefetch off, header-sync + open stay live. adb shell am broadcast \ -a org.libremail.debug.FETCH_GATE \ -n org.libremail.app/org.libremail.debug.FetchGateReceiver \ --es action pause --es scope backfill,prefetch # -> "Broadcast completed: result=0, data=paused=[backfill,prefetch]" # Resume everything after the measurement. adb shell am broadcast -a org.libremail.debug.FETCH_GATE \ -n org.libremail.app/org.libremail.debug.FetchGateReceiver --es action resume --es scope all # -> "Broadcast completed: result=0, data=paused=[]" # Query current state without changing it (idempotent read-back). adb shell am broadcast -a org.libremail.debug.FETCH_GATE \ -n org.libremail.app/org.libremail.debug.FetchGateReceiver --es action query # -> "Broadcast completed: result=0, data=paused=[backfill,prefetch]" ``` - The receiver is **ordered**; it calls `setResultCode(0)` + `setResultData("paused=[...]")`, which `am broadcast` prints, so the harness gets **synchronous confirmation** that the gate took effect (no logcat race). - Component is named with `-n` and `exported="true"` in the debug manifest (adb `shell` UID must reach it). Optional hardening: a signature-level `android:permission`. It's debug-only regardless, so it can never ship. - `scope` accepts a comma list of `backfill`, `prefetch` (add `header-sync`, `idle`, `attachment` if wanted) and the alias `all`. Default harness usage pauses `backfill,prefetch`. ### Granularity Per-activity scopes, minimally **`BACKFILL`** and **`PREFETCH`** (an enum `FetchScope`, plus an `all` alias). The driving use case pauses both and leaves `HEADER_SYNC` + on-demand `OPEN` live — so: add account -> `SyncWorker` runs `fetchRecent` (INBOX headers land) -> prefetch gated (no bodies cached) -> `BackfillWorker` gated (no history) -> open a message -> `openMessage` sees `!routing.bodyFetched` -> `fetchBodyMarkingSeen` does a **genuine uncached fetch** -> measured. Global "halt all" is just `scope=all`. ### Enforcement points + semantics - **`BackfillWorker.doWork()` entry** (primary backfill gate): `if (BuildConfig.DEBUG && DebugFetchGate.isPaused(FetchScope.BACKFILL)) { AppLog.i(TAG, "backfill deferred: fetch-gate paused"); return Result.retry() }` — **skip-and-reschedule**, byte-for-byte the existing cache-lock deferral. Covers periodic + `backfillNow()` (both route through the worker). WorkManager retries later, so on resume backfill continues from its persisted per-folder boundary. (Alternative/finer: gate inside `MailBackfiller.runBackfill()`; the worker entry is simpler and sufficient.) - **`MailSyncer.prefetchIfEnabled` and `MailBackfiller.prefetchIfEnabled`** (prefetch gate): early-return when `PREFETCH` is paused — **no-op-until-resumed**, which is already the documented contract (a skipped prefetch is retried on the next healthy sync / filled in lazily on open). `fetchRecent` above is untouched, so headers keep flowing. This also neutralises the folder-open-triggered prefetch (`syncFolder` -> `prefetchIfEnabled`), so merely navigating to the inbox won't warm the body cache. - **Deliberately NOT gated:** `openMessage` / `fetchBodyMarkingSeen` / on-demand `fetchAttachment` -> uncached open stays live. Also not gating at `ImapClient.withStore` op-dispatch: op-label gating would duplicate the higher-level gates and risk pausing an op we want live — keep the gate where intent is unambiguous. ### Debug-only gating (hard requirement) + release verification Belt **and** suspenders: 1. **Writer absent from release.** `FetchGateReceiver` and its `<receiver>` manifest entry live entirely in `app/src/debug/` — the same source-set guarantee the repo already relies on for `ColdOpenCacheProbe` (#221). Never compiled into or merged into a release APK. 2. **Reader stripped from release.** The `DebugFetchGate` holder lives in `src/main` (so main workers/syncer can reference it), but every read is guarded by `if (BuildConfig.DEBUG && ...)`. `BuildConfig.DEBUG` is a compile-time constant `false` in release, so R8 dead-code-eliminates the branch, leaving `DebugFetchGate` unreferenced -> removed from the release APK. In release the gate is not merely inert, it's **gone**. **Verify release is unaffected:** - `./gradlew :app:assembleRelease` then `apkanalyzer dex packages` (or `dexdump`) shows **no** `DebugFetchGate` / `FetchGateReceiver` class, and the merged release manifest has no `FETCH_GATE` receiver. - Unit test: `DebugFetchGate` defaults to not-paused for every scope. - Manual: `am broadcast ... FETCH_GATE` against a release build is a no-op (component absent) and fetch proceeds. ### Observability (PII-free `AppLog`) - On toggle: `AppLog.i("DebugFetchGate", "fetch gate: paused=[backfill,prefetch]")` — scope names only, never PII. - On enforcement: `"backfill deferred: fetch-gate paused"` (mirrors "backfill deferred: cache locked"); `MailSyncer`/`MailBackfiller` log `"prefetch skipped: fetch-gate paused"`. - Harness confirmation (three independent signals): (a) the `am broadcast` **result-data** read-back above; (b) a `query` action; (c) the logcat breadcrumbs, which `breadcrumbs.py` already tails. The absence of `ImapPerf` `prefetch-body` / `backfill-page` ops while paused is itself corroborating evidence. ### Testing - **JVM unit** (branch is live because `BuildConfig.DEBUG` is true under `testDebugUnitTest`): - `DebugFetchGateTest` — pause/resume/query/`all` per scope; default not-paused; thread-safe holder. - `BackfillWorkerTest` — with `BACKFILL` paused, `doWork()` returns `retry()` and `MailBackfiller` is never invoked; unpaused path unchanged. - `MailSyncerTest` / `MailBackfillerTest` — with `PREFETCH` paused, `fetchRecent` / `fetchOlderThan` still run but `prefetchMessage` is not called; unpaused path unchanged. - `FetchGateReceiver` action/scope parsing (`backfill,prefetch` / `all` / unknown -> result-data). - **Instrumented/E2E (DoD)** — debug-only test: send the ordered broadcast, assert result-data and `DebugFetchGate` state; assert a paused-backfill worker defers. Must run and pass on the cold-boot emulator + API 37 preflight. - **End-to-end harness flow:** install debug -> `am broadcast ... pause backfill,prefetch` (confirm read-back) -> add Gmail account (headers sync, no `prefetch-body`/`backfill-page` ops in logcat) -> open a message (`MailReader openMessage ... fetchedBody=true` + one real `body-fetch` `ImapPerf` op) -> measure uncached open -> `resume all` -> repeat for reuse ON vs OFF builds (#368 A/B). Supersedes the fragile settings-nav in `prefetch-ab`. --- ## Scope / notes - Product code stays Kotlin; the harness-side change (adding `am broadcast FETCH_GATE` to `adb.py`'s sanctioned `am` calls and wiring the `prefetch-ab` / `message-open` scenarios to it) is a follow-up in `scripts/device-testing/`. - References: **#370** (harness), **#392** (harness portability), **#368 / #357 Part 2** (the reuse ON/OFF A/B that needs a cold body), **#221** (`ColdOpenCacheProbe` — the debug-source-set precedent this mirrors), **#358** (`MailReader` / `ImapPerf` breadcrumbs used for readiness + measurement).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#393