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/debugonly, 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).
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).
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.
## 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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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-setprecedent.
Components
DebugFetchGate(src/main,data.sync): thread-safe in-memory holder of pausedFetchScopes (BACKFILL,PREFETCH, plus anallwire alias). Defaults to notpaused. Only the two proactive activities are gateable —
HEADER_SYNCand on-demandOPENare structurally never gated. Lock-free volatile-snapshot reads; writes swap under alock.
FetchGateReceiver(src/debugonly,org.libremail.debug):BroadcastReceiverdeclared solely in
app/src/debug/AndroidManifest.xml. Driven by:am broadcastdelivers it ordered, so it returns the state as result data(
data=paused=[backfill,prefetch]) for a synchronous, race-free read-back.queryreportswithout mutating.
Enforcement (each read behind
if (BuildConfig.DEBUG && …)so R8 strips it)BackfillWorker.doWork()entry → skip-and-reschedule (Result.retry()) whenBACKFILLis paused, byte-for-byte the existing
cacheGuard.isCacheLocked()deferral. Covers periodicbackfillNow()(both route through the worker).MailSyncer.prefetchIfEnabled/MailBackfiller.prefetchIfEnabled→ early-return whenPREFETCHis paused (the singleSyncResourcePolicy.shouldPrefetchContentdecision funnelsthrough these two;
SyncResourcePolicystays pure/Android-free). Header sync keeps flowing.openMessage/fetchBodyMarkingSeen/ on-demandfetchAttachment— uncached open stays live.PII-free
AppLogbreadcrumbs 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:
app-release.apk)DebugFetchGateFetchGateReceiverFetchScopefetch-gate paused(log string)FETCH_GATEorg.libremail.debugmapping.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;FetchGateReceiverwas never compiled into release).processReleaseMainManifest/AndroidManifest.xml):FetchGateReceiver= 0,FETCH_GATE= 0.Tests
testDebugUnitTest, green):DebugFetchGateTest(defaults not-paused, per-scopepause/resume/query,
allalias + scope parsing,paused=[…]read-back format, thread-safeholder); enforcement cases in
BackfillWorkerTest(BACKFILL-paused → retry, backfiller neverresolved; prefetch-only pause doesn't defer the worker) and
MailSyncerTest/MailBackfillerTest(PREFETCH-paused → header sync/paging still runs,prefetchMessagenevercalled).
jacocoTestCoverageVerificationfloor holds.FetchGateReceiverInstrumentedTest): ordered broadcast → gate → result-dataread-back (pause/resume/query), a BACKFILL-paused
BackfillWorkerdefers without resolving thebackfiller, 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 withANDROID_SERIAL).e2e-previewjob —api37_e2e.pyruns 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