Bakes the proven 2026-07-06 cold-vs-warm pause-hook flow into scripts/device-testing/ as a first-class, reproducible `cold-fetch-ab` scenario, upstreaming the scratchpad driver. - fetchgate.py: FETCH_GATE pause/resume/query helpers through the guarded adb wrapper, with ordered-broadcast read-back parsing (paused=[...]). - scenarios.cold_fetch_ab: pre-arm halt -> detect sign-in (sync all breadcrumb) -> confirm halt (prefetch skipped) -> wait for header sync -> measure cold opens -> resume -> measure warm opens. ALWAYS resumes on exit (finally), even on error -- never leaves fetch paused. - report.render_cold_fetch_ab: gate summary, cold/warm tables, cold-vs-warm delta, connect=0ms reuse proof, throttle signature. - Portability (subsumes #392): file-based uiautomator dump (not /dev/tty), UTF-8 adb decode + PYTHONUTF8/console I/O, openMessage-breadcrumb readiness, row-selection hardening (skip non-message rows). The pause hook is debug-build-only (#393/#395), so the scenario needs a debug APK. Automated validation: mocked unittest coverage (adb/breadcrumbs/gate) for the helpers and the A/B scenario incl. restore-on-error, plus a --dry-run path exercised end-to-end through perf_harness.main. A full on-device run is a follow-up. Dev-tooling only; no app/src changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LibreMail device-testing perf harness
A cross-platform, standard-library-only Python tool that replicates LibreMail's
on-device performance-test scenarios and logging capture. It codifies the methodology that
was first run by hand (2026-07-05, Pixel 10 Pro XL) and written up in
scratchpad/perf/perf_summary.md, causation-report.md, and ab-run/timing-tables.md.
Everything runs against LibreMail only plus read-only system-log/settings/dumpsys collection, behind hard device-safety guardrails (see Device safety).
Requirements
- Python 3.8+ (standard library only — no
pip install, no third-party deps). adbonPATH(or pass--adb /path/to/adb).- A connected device with:
- LibreMail installed as a debuggable build (the cache-clear uses
run-as, which only works on debuggable APKs), and - at least one account signed in with some not-yet-cached messages in the inbox (message bodies are fetched on first open), and
- the screen unlocked (the harness keeps it awake during a run and guards every step against the keyguard, but it cannot get you past a secure lock screen).
- LibreMail installed as a debuggable build (the cache-clear uses
No build step. Run it straight from the repo.
Usage
python scripts/device-testing/perf_harness.py <scenario> [options]
Scenarios (each independently selectable):
| Scenario | What it does |
|---|---|
cold-open |
Force-stop LibreMail, clear only its cache/, am start -W ×N, parse TotalTime/WaitTime. |
message-open |
Open N distinct uncached messages one at a time; time spinner→content from the breadcrumbs. |
back-nav |
Time reader→mailbox back transitions ×N (dump-latency-bound; see caveat in the report). |
prefetch-ab |
Run message-open under Fetch all on Wi-Fi (prefetch ON) vs Always on-demand (prefetch OFF), cache cleared between conditions. |
cross-provider |
Open N messages from the (unified) inbox and tabulate per provider (imap:… vs outlook:…) from the breadcrumb account refs. |
cold-fetch-ab |
Pause-hook cold-vs-warm A/B (debug build only). Pre-arm the FETCH_GATE halt, detect sign-in, confirm the halt, let headers sync, measure genuine cold opens, resume, then measure warm (cached) re-opens — reports the delta, the connect=0ms reuse proof, and any throttle signature. |
Common options:
| Option | Default | Meaning |
|---|---|---|
--serial <id> |
auto (if exactly one device) | choose the device |
-n, --count <N> |
per-scenario | samples / runs (per condition for prefetch-ab) |
--out <dir> |
./device-perf-runs |
output root; a timestamped subdir is created per run |
--package <pkg> |
org.libremail.app |
target package |
--component <c> |
org.libremail.app/org.libremail.MainActivity |
launcher component |
--adb <path> |
adb |
path to the adb executable |
--dry-run |
off | print the exact command plan without changing device state |
Always start with --dry-run to review the command plan a scenario will issue:
python scripts/device-testing/perf_harness.py prefetch-ab --dry-run
python scripts/device-testing/perf_harness.py cold-open -n 5 --serial 5C310DLCQ000G3
What each scenario measures
Timing comes primarily from the on-device breadcrumbs (PII-free), with uiautomator used only as a "content is ready" signal so the driver knows when to move on:
MailReader: openMessage <acctRef> folder=<label> fetchedBody=<bool> took=<ms>ms— end-to-end reader open.fetchedBody=true⇒ a real network body fetch (uncached).Reader: reader ready took=<ms>ms html=<bool> inline=<n>— spinner→content.ImapPerf: <op> connect=<ms>ms work=<ms>ms live=<N>andImapPerf: body-fetch select=<ms>ms body=<ms>ms flag=<ms>ms rfc822=<n>B chars=<n> att=<n>— connection + phase split;body KB/sisrfc822 / body_ms.MailBackfiller: backfill … pages=<n> complete=<bool>/backfill slice…— backfill activity.
cold-open instead parses am start -W's TotalTime / WaitTime.
Cold-fetch A/B (pause hook) — cold-fetch-ab
Folds in the proven 2026-07-06 cold-vs-warm methodology (issue #405) as a first-class, reproducible scenario so the connection-reuse / throttle A/B can be re-run on demand to catch perf regressions. The flow:
- Pre-arm the halt — broadcast
FETCH_GATE pause backfill,prefetchbefore sign-in, so proactive body fetch is gated the instant sync starts (bodies stay uncached). The receiver echoes its state back as ordered-broadcast result data (data="paused=[backfill,prefetch]"), which the harness parses for a race-free read-back. - Detect sign-in — tail the log for
MailSyncer: sync all: N accounts(sign-in is manual; OAuth can’t be automated, so add the account on the device when prompted). - Confirm the halt — wait for
prefetch skipped: fetch-gate paused(proof the gate held). - Wait for header sync — until uncached message rows appear.
- Measure cold opens — genuine uncached opens (
ImapPerfconnect/work +MailReader openMessage fetchedBody=true). - Resume + measure warm opens —
FETCH_GATE resume, then re-open the same messages (now cached) for the A/B.
It reports the cold-vs-warm median delta, the connect=0ms connection-reuse proof, and any
server-side throttle signature (a very high cold IMAP work). The gate is always cleared
on exit (even on error) — the scenario never leaves a device with fetch paused.
Needs a debug build. The
FETCH_GATEpause hook (FetchGateReceiver/DebugFetchGate, issues #393/#395) is compiled only intosrc/debugand R8-stripped from release, so this scenario requires the debug APK. A full on-device run is a follow-up; the mocked unit tests +--dry-runare the automated validation.
Output
Each run writes a timestamped directory under --out:
device-perf-runs/20260706-131612-cold-open/
├── timing-tables.md # per-scenario tables + aggregates (mirrors the manual timing-tables.md)
├── session-raw.log # the full `adb logcat -b all -v threadtime` stream for the run
├── perf-extract.log # the ImapPerf|MailReader|Reader|MailBackfiller subset of the raw log
└── driver.log # what the harness did, step by step
Device safety
Every device call goes through a guarded adb wrapper (adb.py). Two independent layers
mean a dangerous command cannot be constructed:
- Allow-list of adb subcommands:
devices,get-state,install,shell,logcat,wait-for-device,start-server. Anything else (uninstall,root,remount,reboot,disable-verity,emu,push,pull, …) is refused. - Deny-list + assertions on every
shellcommand: nopm clear/pm uninstall, no reboot/remount/root/verity/factory-reset, no touching the app'sdatabases//files//shared_prefs//datastore/, no output redirects, andrun-as/am force-stop/am startare constrained to the target package.
The only sanctioned mutation of app state is clearing LibreMail's own cache/
(run-as org.libremail.app sh -c 'rm -rf cache/*') — an exact-match allow-list; any other
rm/mv/dd/… is refused. There is no pm clear, uninstall, or data wipe anywhere.
The screen is kept awake (svc power stayon true + KEYCODE_WAKEUP) for the run and
restored afterwards, and every uiautomator/input step is guarded against the keyguard and
against a foreign app being in the foreground — a sample taken against either is skipped,
not measured (the manual run hit exactly these: a lock-screen dump and a deskclock alarm).
Tests
Pure-logic modules (the breadcrumb parser, the uiautomator parser, the safety guardrails,
the report renderers) are unit-tested with the standard-library unittest against the
real captures from the manual run:
python -m unittest discover -s scripts/device-testing/tests -p "test_*.py"
tests/fixtures/perf-extract-sample.log is a verbatim slice of the manual run's
perf-extract-ALL.log, so the parser tests assert the harness reproduces the exact figures
in the hand-written timing-tables.md (e.g. Gmail A1: took=31227 ms, rfc822=60457 B,
4.2 KB/s; Outlook O1: took=2934 ms, 139.5 KB/s). The UI fixtures include the reader,
plus the lockscreen and deskclock-alarm negatives the keyguard/foreground guards must catch.
Validated vs. needs the live run
Validated offline (by the unit tests, no device):
- Breadcrumb parsing + open-correlation reproduce the manual
timing-tables.mdfigures. - The safety guardrails accept the known-good commands and refuse every forbidden one.
- Screen recognition (mailbox rows + cached flag, reader, lockscreen, foreign app).
- The report renders the same tables/aggregates as the manual write-up.
- Pause-hook helpers (
fetchgate.py): theFETCH_GATEpause/resume/query broadcast is the one the safety wrapper accepts, and the ordered-broadcast read-back (paused=[…]) parses. - Cold-fetch A/B flow: sign-in / halt-confirm detection, cold/warm open correlation, the
connect=0msreuse proof + throttle signature, and the always-resume restore on error. --dry-runemits the correct command plan for all six scenarios.
Needs the first monitored live run (a device makes the state real):
- End-to-end timing capture on hardware (streamed logcat → per-sample breadcrumb tailing).
- On-device
cold-fetch-abrun (needs a debug APK for theFETCH_GATEhook): pre-arm → manual sign-in → cold/warm A/B. The pause/resume broadcasts, read-back parsing, and flow are validated offline; the live run confirms the timings on real hardware. - Settings-screen navigation for
prefetch-ab. No uiautomator dump of the settings screen was captured in the manual run, soset_fetch_policynavigates by the on-screen option text ("Fetch all on Wi-Fi","Always on-demand", fromres/values/strings.xml) with a scroll fallback. The bottom-nav "Settings" tap target is confirmed fromui_mailbox.xml; the option rows themselves need one live confirmation. - Row selection under a live, scrolling list and the auto-lock recovery path.
Notes / design decisions
- Uncached opens. Message bodies live in the Room DB (
libremail.db), not incache/, so bodies can't be force-uncached without touchingdatabases/(forbidden). The harness therefore opens naturally-uncached messages and verifies each was a real network fetch via thefetchedBody=truebreadcrumb — exactly as the manual run did. - Cross-provider uses the unified inbox: a single mixed pass yields both providers, and
the harness buckets rows by the breadcrumb account ref (
imap:…vsoutlook:…) — no account switching required. - Back-nav timings are dominated by the ~2.5–3 s uiautomator-dump latency floor; the report labels them accordingly (true in-app back is sub-second and not resolvable via adb UI polling under load).
- Portability (subsumes #392). The UI dump is file-based (
uiautomator dump /sdcard/window_dump.xml+cat), notdump /dev/tty, which interleaves a status banner with the XML and is unreliable across devices/hosts. All adb output is decoded as UTF-8 (and the harness forcesPYTHONUTF8/UTF-8 console I/O) so non-ASCII sender/subject text doesn’t mojibake or crash on a Windows cp1252 console. Reader-open readiness is taken from theMailReader openMessagebreadcrumb (authoritative) rather than UI polling alone, and row selection skips non-message rows (a tappable container with no sender/subject label). - Dev-script convention: Python 3, standard library only, cross-platform (Windows-primary),
matching
.claude/skills/preflight/*.py.