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

Merged
JMR-dev merged 2 commits from feat-393-debug-fetch-gate into main 2026-07-07 07:46:15 +00:00
JMR-dev commented 2026-07-07 01:14:17 +00:00 (Migrated from github.com)

What & why (#393)

The on-device perf harness can't force a genuinely uncached body fetch — proactive
full-history backfill (#12) and post-sync body prefetch (#88/#89) warm the cache before a
test can open a message. This adds a debug-only, adb-reachable hook to pause proactive
fetch so the harness can add an account, let headers sync, then trigger a real uncached open
and measure it (needed for the Gmail body-fetch-throttle repro and the connection-reuse
ON/OFF A/B, #368/#357 Part 2). Mirrors the ColdOpenCacheProbe (#221) debug-source-set
precedent.

Components

  • DebugFetchGate (src/main, data.sync): thread-safe in-memory holder of paused
    FetchScopes (BACKFILL, PREFETCH, plus an all wire alias). Defaults to not
    paused
    . Only the two proactive activities are gateable — HEADER_SYNC and on-demand
    OPEN are structurally never gated. Lock-free volatile-snapshot reads; writes swap under a
    lock.
  • FetchGateReceiver (src/debug only, org.libremail.debug): BroadcastReceiver
    declared solely in app/src/debug/AndroidManifest.xml. Driven by:
    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 it ordered, so it returns the state as result data
    (data=paused=[backfill,prefetch]) for a synchronous, race-free read-back. query reports
    without mutating.

Enforcement (each read behind if (BuildConfig.DEBUG && …) so R8 strips it)

  • BackfillWorker.doWork() entry → skip-and-reschedule (Result.retry()) when BACKFILL
    is paused, byte-for-byte the existing cacheGuard.isCacheLocked() deferral. Covers periodic
    • backfillNow() (both route through the worker).
  • MailSyncer.prefetchIfEnabled / MailBackfiller.prefetchIfEnabled → early-return when
    PREFETCH is paused (the single SyncResourcePolicy.shouldPrefetchContent decision funnels
    through these two; SyncResourcePolicy stays pure/Android-free). Header sync keeps flowing.
  • Deliberately NOT gated: openMessage / fetchBodyMarkingSeen / on-demand
    fetchAttachment — uncached open stays live.

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

Debug-only gating — release-exclusion proof (hard requirement)

assembleRelease (R8 minify on), then byte-scan of the packaged release DEX + mapping.txt
(original names) + the merged release manifest:

Symbol Release APK (app-release.apk) Debug APK (control)
DebugFetchGate 0 19
FetchGateReceiver 0 6
FetchScope 0 —
fetch-gate paused (log string) 0 2
FETCH_GATE 0 —
org.libremail.debug 0 —
  • mapping.txt (case-sensitive, original names on the left): org.libremail.data.sync.DebugFetchGate ->,
    org.libremail.data.sync.FetchScope ->, org.libremail.debug.FetchGateReceiver -> — 0 lines each
    (R8 removed DebugFetchGate/FetchScope; FetchGateReceiver was never compiled into release).
  • Merged release manifest (processReleaseMainManifest/AndroidManifest.xml): FetchGateReceiver = 0,
    FETCH_GATE = 0.

Tests

  • Unit (testDebugUnitTest, green): DebugFetchGateTest (defaults not-paused, per-scope
    pause/resume/query, all alias + scope parsing, paused=[…] read-back format, thread-safe
    holder); enforcement cases in BackfillWorkerTest (BACKFILL-paused → retry, backfiller never
    resolved; prefetch-only pause doesn't defer the worker) and MailSyncerTest /
    MailBackfillerTest (PREFETCH-paused → header sync/paging still runs, prefetchMessage never
    called). jacocoTestCoverageVerification floor holds.
  • Instrumented (FetchGateReceiverInstrumentedTest): ordered broadcast → gate → result-data
    read-back (pause/resume/query), a BACKFILL-paused BackfillWorker defers without resolving the
    backfiller, and a PREFETCH-only pause leaves the worker running. Ran green on a cold-booted
    API 36 emulator
    (5/5, via local_instrumented.py, isolated to the emulator with
    ANDROID_SERIAL).
  • API 37 preview: deferred to CI's e2e-preview job — api37_e2e.py runs the full
    ~114-test suite unfiltered and doesn't isolate from the attached physical device, so a local
    run would wedge / hit the physical Pixel. CI covers it in isolation.

🤖 Generated with Claude Code

## What & why (#393) The on-device perf harness can't force a genuinely **uncached** body fetch — proactive full-history backfill (#12) and post-sync body prefetch (#88/#89) warm the cache before a test can open a message. This adds a **debug-only, adb-reachable** hook to pause proactive fetch so the harness can add an account, let headers sync, then trigger a real uncached open and measure it (needed for the Gmail body-fetch-throttle repro and the connection-reuse ON/OFF A/B, #368/#357 Part 2). Mirrors the `ColdOpenCacheProbe` (#221) debug-source-set precedent. ## Components - **`DebugFetchGate`** (`src/main`, `data.sync`): thread-safe in-memory holder of paused `FetchScope`s (`BACKFILL`, `PREFETCH`, plus an `all` wire alias). Defaults to **not paused**. Only the two proactive activities are gateable — `HEADER_SYNC` and on-demand `OPEN` are structurally never gated. Lock-free volatile-snapshot reads; writes swap under a lock. - **`FetchGateReceiver`** (`src/debug` **only**, `org.libremail.debug`): `BroadcastReceiver` declared solely in `app/src/debug/AndroidManifest.xml`. Driven by: ``` 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 it ordered, so it returns the state as **result data** (`data=paused=[backfill,prefetch]`) for a synchronous, race-free read-back. `query` reports without mutating. ## Enforcement (each read behind `if (BuildConfig.DEBUG && …)` so R8 strips it) - **`BackfillWorker.doWork()` entry** → skip-and-reschedule (`Result.retry()`) when `BACKFILL` is paused, byte-for-byte the existing `cacheGuard.isCacheLocked()` deferral. Covers periodic + `backfillNow()` (both route through the worker). - **`MailSyncer.prefetchIfEnabled` / `MailBackfiller.prefetchIfEnabled`** → early-return when `PREFETCH` is paused (the single `SyncResourcePolicy.shouldPrefetchContent` decision funnels through these two; `SyncResourcePolicy` stays pure/Android-free). Header sync keeps flowing. - **Deliberately NOT gated:** `openMessage` / `fetchBodyMarkingSeen` / on-demand `fetchAttachment` — uncached open stays live. PII-free `AppLog` breadcrumbs on pause/resume/query and on each gate-triggered defer/skip (scope names only). ## Debug-only gating — release-exclusion proof (hard requirement) `assembleRelease` (R8 minify on), then byte-scan of the packaged release DEX + `mapping.txt` (original names) + the merged release manifest: | Symbol | Release APK (`app-release.apk`) | Debug APK (control) | |---|---|---| | `DebugFetchGate` | **0** | 19 | | `FetchGateReceiver` | **0** | 6 | | `FetchScope` | **0** | — | | `fetch-gate paused` (log string) | **0** | 2 | | `FETCH_GATE` | **0** | — | | `org.libremail.debug` | **0** | — | - `mapping.txt` (case-sensitive, original names on the left): `org.libremail.data.sync.DebugFetchGate ->`, `org.libremail.data.sync.FetchScope ->`, `org.libremail.debug.FetchGateReceiver ->` — **0 lines each** (R8 removed `DebugFetchGate`/`FetchScope`; `FetchGateReceiver` was never compiled into release). - Merged release manifest (`processReleaseMainManifest/AndroidManifest.xml`): `FetchGateReceiver` = 0, `FETCH_GATE` = 0. ## Tests - **Unit** (`testDebugUnitTest`, green): `DebugFetchGateTest` (defaults not-paused, per-scope pause/resume/query, `all` alias + scope parsing, `paused=[…]` read-back format, thread-safe holder); enforcement cases in `BackfillWorkerTest` (BACKFILL-paused → retry, backfiller never resolved; prefetch-only pause doesn't defer the worker) and `MailSyncerTest` / `MailBackfillerTest` (PREFETCH-paused → header sync/paging still runs, `prefetchMessage` never called). `jacocoTestCoverageVerification` floor holds. - **Instrumented** (`FetchGateReceiverInstrumentedTest`): ordered broadcast → gate → result-data read-back (pause/resume/query), a BACKFILL-paused `BackfillWorker` defers without resolving the backfiller, and a PREFETCH-only pause leaves the worker running. **Ran green on a cold-booted API 36 emulator** (5/5, via `local_instrumented.py`, isolated to the emulator with `ANDROID_SERIAL`). - **API 37 preview:** deferred to CI's `e2e-preview` job — `api37_e2e.py` runs the full ~114-test suite unfiltered and doesn't isolate from the attached physical device, so a local run would wedge / hit the physical Pixel. CI covers it in isolation. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.