Files
LibreMail/scripts/device-testing/breadcrumbs.py
T
JMR-devandClaude Opus 4.8 55f1f59e3d refactor(scripts): fold cold-fetch pause-hook A/B into device-testing harness (#405)
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>
2026-07-07 05:07:11 -05:00

463 lines
16 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""breadcrumbs.py -- pure parser for LibreMail on-device perf breadcrumbs.
This module is the *core* of the device-testing harness and is intentionally free of any
I/O, ``adb``, or device dependency so it can be unit-tested against the saved logcat
captures from the manual perf run (see ``tests/test_breadcrumbs.py`` and
``tests/fixtures/perf-extract-sample.log``).
It parses two things:
1. A raw ``adb logcat -v threadtime`` line into its fields (:func:`parse_logcat_line`).
2. The message payload of the four perf tags into typed events
(:func:`parse_breadcrumb` and the per-tag helpers).
Breadcrumb formats (verbatim from the app source, all PII-free):
* ``ImapPerf: <op> connect=<ms>ms work=<ms>ms live=<N>``
-- ``ImapClient`` generic per-operation timing; ``op`` is a short label
(``prefetch-body``, ``backfill-page``, ``body-fetch``, ``imap`` ...).
* ``ImapPerf: body-fetch select=<ms>ms body=<ms>ms flag=<ms>ms rfc822=<n>B chars=<n> att=<n>``
-- ``ImapClient`` detailed body-fetch phase split (emitted just before the generic
``body-fetch connect=.. work=.. live=..`` line for the same fetch).
* ``MailReader: openMessage <acctRef> folder=<label> fetchedBody=<bool> took=<ms>ms``
-- ``MailRepositoryImpl`` reader-open; ``fetchedBody=true`` means a real network body
fetch happened (i.e. the message was *not* cached).
* ``Reader: reader ready took=<ms>ms html=<bool> inline=<n>``
-- ``ReaderViewModel`` spinner-to-content time.
* ``MailBackfiller: backfill <acctRef> folder=<label> pages=<n> complete=<bool>``
* ``MailBackfiller: backfill slice: maxBatches=<n>``
* ``MailBackfiller: backfill slice done: moreWork=<bool>``
The threadtime tag column is space-padded by logcat (e.g. ``Reader :``); the parser
tolerates that and reports the trimmed tag (``Reader``).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Iterable, Iterator, List, Optional, Union
# Tags that make up the filtered perf extract (mirrors the manual
# `grep -E 'ImapPerf|MailReader|Reader|MailBackfiller'`). Matched on the *tag* column so a
# stray mention of one of these words inside another tag's message is not miscounted.
PERF_TAGS = ("ImapPerf", "MailReader", "Reader", "MailBackfiller")
# --------------------------------------------------------------------------- #
# Raw logcat line (``-v threadtime``)
# --------------------------------------------------------------------------- #
_THREADTIME_RE = re.compile(
r"^(?P<date>\d{2}-\d{2})\s+"
r"(?P<time>\d{2}:\d{2}:\d{2}\.\d{3})\s+"
r"(?P<pid>\d+)\s+"
r"(?P<tid>\d+)\s+"
r"(?P<level>[VDIWEFS])\s+"
r"(?P<tag>[^:]+?)\s*:\s?"
r"(?P<message>.*)$"
)
@dataclass(frozen=True)
class LogLine:
"""A single ``adb logcat -v threadtime`` line, split into fields."""
date: str
time: str
pid: int
tid: int
level: str
tag: str
message: str
raw: str
@property
def timestamp(self) -> str:
"""`MM-DD HH:MM:SS.mmm` -- the wall-clock stamp (no year in logcat)."""
return f"{self.date} {self.time}"
def parse_logcat_line(line: str) -> Optional[LogLine]:
"""Parse one threadtime logcat line; return ``None`` if it is not one."""
m = _THREADTIME_RE.match(line.rstrip("\r\n"))
if not m:
return None
return LogLine(
date=m.group("date"),
time=m.group("time"),
pid=int(m.group("pid")),
tid=int(m.group("tid")),
level=m.group("level"),
tag=m.group("tag").strip(),
message=m.group("message").strip(),
raw=line.rstrip("\r\n"),
)
def is_perf_line(line: str) -> bool:
"""True if ``line`` is a threadtime line whose tag is one of :data:`PERF_TAGS`."""
parsed = parse_logcat_line(line)
return parsed is not None and parsed.tag in PERF_TAGS
# --------------------------------------------------------------------------- #
# Control-signal line matchers (not perf metrics -- flow signals for the harness)
# --------------------------------------------------------------------------- #
# These are *not* perf breadcrumbs (they carry no timing), so they are deliberately kept out
# of PERF_TAGS / the Event dispatch. The cold-fetch scenario tails the live log for them to
# know when sign-in/sync has started and whether the pre-armed fetch-gate halt took effect.
_SYNC_ALL_RE = re.compile(r"^sync all:\s+(?P<n>\d+)\s+accounts?$")
# Verbatim from MailSyncer.kt / MailBackfiller.kt when a paused FETCH_GATE skips prefetch.
FETCH_GATE_SKIP_MESSAGE = "prefetch skipped: fetch-gate paused"
def match_sync_all(line: str) -> Optional[int]:
"""If ``line`` is the ``MailSyncer: sync all: N accounts`` breadcrumb, return N.
N is the account count logged at the start of a sync pass -- the harness's sign-in /
sync-start signal (a fresh sign-in triggers the first ``syncAll``). Returns ``None`` for
any other line.
"""
log = parse_logcat_line(line)
if log is None or log.tag != "MailSyncer":
return None
m = _SYNC_ALL_RE.match(log.message)
return int(m.group("n")) if m else None
def is_fetch_gate_skip(line: str) -> bool:
"""True if ``line`` is the debug ``prefetch skipped: fetch-gate paused`` breadcrumb.
Emitted by ``MailSyncer``/``MailBackfiller`` when a pre-armed FETCH_GATE pause is honoured
-- the harness's proof the halt actually took effect (bodies will stay uncached).
"""
log = parse_logcat_line(line)
return (
log is not None
and log.tag in ("MailSyncer", "MailBackfiller")
and log.message == FETCH_GATE_SKIP_MESSAGE
)
# --------------------------------------------------------------------------- #
# Typed breadcrumb events
# --------------------------------------------------------------------------- #
class Breadcrumb:
"""Base type: every event optionally carries its source :class:`LogLine`.
A plain (non-dataclass) base so each subclass can declare its own fields with ``line``
last -- this keeps the generated ``__init__`` valid on Python 3.7+ without the
3.10-only ``kw_only`` field option (a base dataclass field with a default would force a
"non-default argument follows default argument" error in the subclasses).
"""
line: Optional[LogLine] = None
@property
def timestamp(self) -> Optional[str]:
return self.line.timestamp if self.line else None
@dataclass
class ImapPerfOp(Breadcrumb):
"""``ImapPerf: <op> connect=<ms>ms work=<ms>ms live=<N>``."""
op: str
connect_ms: int
work_ms: int
live: int
line: Optional[LogLine] = None
@dataclass
class BodyFetch(Breadcrumb):
"""``ImapPerf: body-fetch select=.. body=.. flag=.. rfc822=..B chars=.. att=..``."""
select_ms: int
body_ms: int
flag_ms: int
rfc822_bytes: int
chars: int
att: int
line: Optional[LogLine] = None
@property
def body_kb_per_s(self) -> Optional[float]:
"""Body-download throughput in decimal KB/s; ``None`` if body_ms==0.
``bytes/ms`` is already KB/s (``bytes/ms * 1000 ms/s / 1000 B/KB``), so this mirrors
the manual ``timing-tables.md`` "body KB/s" column exactly (e.g. 60457/14253 = 4.2).
"""
if self.body_ms <= 0:
return None
return self.rfc822_bytes / self.body_ms
@dataclass
class OpenMessage(Breadcrumb):
"""``MailReader: openMessage <ref> folder=<label> fetchedBody=<bool> took=<ms>ms``."""
account_ref: str
folder: str
fetched_body: bool
took_ms: int
line: Optional[LogLine] = None
@dataclass
class ReaderReady(Breadcrumb):
"""``Reader: reader ready took=<ms>ms html=<bool> inline=<n>``."""
took_ms: int
html: bool
inline: int
line: Optional[LogLine] = None
@dataclass
class BackfillProgress(Breadcrumb):
"""``MailBackfiller: backfill <ref> folder=<label> pages=<n> complete=<bool>``."""
account_ref: str
folder: str
pages: int
complete: bool
line: Optional[LogLine] = None
@dataclass
class BackfillSliceStart(Breadcrumb):
"""``MailBackfiller: backfill slice: maxBatches=<n>``."""
max_batches: int
line: Optional[LogLine] = None
@dataclass
class BackfillSliceDone(Breadcrumb):
"""``MailBackfiller: backfill slice done: moreWork=<bool>``."""
more_work: bool
line: Optional[LogLine] = None
Event = Union[
ImapPerfOp,
BodyFetch,
OpenMessage,
ReaderReady,
BackfillProgress,
BackfillSliceStart,
BackfillSliceDone,
]
# --------------------------------------------------------------------------- #
# Message-payload parsers (pure; operate on the trimmed message string)
# --------------------------------------------------------------------------- #
def _to_bool(text: str) -> bool:
return text == "true"
_IMAP_OP_RE = re.compile(
r"^(?P<op>\S+)\s+connect=(?P<connect>\d+)ms\s+work=(?P<work>\d+)ms\s+live=(?P<live>\d+)$"
)
_BODY_FETCH_RE = re.compile(
r"^body-fetch\s+select=(?P<select>\d+)ms\s+body=(?P<body>\d+)ms\s+flag=(?P<flag>\d+)ms\s+"
r"rfc822=(?P<bytes>\d+)B\s+chars=(?P<chars>\d+)\s+att=(?P<att>\d+)$"
)
_OPEN_MSG_RE = re.compile(
r"^openMessage\s+(?P<ref>\S+)\s+folder=(?P<folder>\S+)\s+"
r"fetchedBody=(?P<fetched>true|false)\s+took=(?P<took>\d+)ms$"
)
_READER_READY_RE = re.compile(
r"^reader ready took=(?P<took>\d+)ms\s+html=(?P<html>true|false)\s+inline=(?P<inline>\d+)$"
)
_BACKFILL_PROGRESS_RE = re.compile(
r"^backfill\s+(?P<ref>\S+)\s+folder=(?P<folder>\S+)\s+pages=(?P<pages>\d+)\s+"
r"complete=(?P<complete>true|false)$"
)
_BACKFILL_SLICE_START_RE = re.compile(r"^backfill slice:\s+maxBatches=(?P<n>\d+)$")
_BACKFILL_SLICE_DONE_RE = re.compile(r"^backfill slice done:\s+moreWork=(?P<more>true|false)$")
def parse_imap_perf(message: str) -> Optional[Union[ImapPerfOp, BodyFetch]]:
"""Parse an ``ImapPerf`` message (either the detailed body-fetch or the generic op)."""
m = _BODY_FETCH_RE.match(message)
if m:
return BodyFetch(
select_ms=int(m.group("select")),
body_ms=int(m.group("body")),
flag_ms=int(m.group("flag")),
rfc822_bytes=int(m.group("bytes")),
chars=int(m.group("chars")),
att=int(m.group("att")),
)
m = _IMAP_OP_RE.match(message)
if m:
return ImapPerfOp(
op=m.group("op"),
connect_ms=int(m.group("connect")),
work_ms=int(m.group("work")),
live=int(m.group("live")),
)
return None
def parse_open_message(message: str) -> Optional[OpenMessage]:
"""Parse a ``MailReader`` ``openMessage`` message."""
m = _OPEN_MSG_RE.match(message)
if not m:
return None
return OpenMessage(
account_ref=m.group("ref"),
folder=m.group("folder"),
fetched_body=_to_bool(m.group("fetched")),
took_ms=int(m.group("took")),
)
def parse_reader_ready(message: str) -> Optional[ReaderReady]:
"""Parse a ``Reader`` ``reader ready`` message."""
m = _READER_READY_RE.match(message)
if not m:
return None
return ReaderReady(
took_ms=int(m.group("took")),
html=_to_bool(m.group("html")),
inline=int(m.group("inline")),
)
def parse_backfill(
message: str,
) -> Optional[Union[BackfillProgress, BackfillSliceStart, BackfillSliceDone]]:
"""Parse any of the three ``MailBackfiller`` messages."""
m = _BACKFILL_PROGRESS_RE.match(message)
if m:
return BackfillProgress(
account_ref=m.group("ref"),
folder=m.group("folder"),
pages=int(m.group("pages")),
complete=_to_bool(m.group("complete")),
)
m = _BACKFILL_SLICE_START_RE.match(message)
if m:
return BackfillSliceStart(max_batches=int(m.group("n")))
m = _BACKFILL_SLICE_DONE_RE.match(message)
if m:
return BackfillSliceDone(more_work=_to_bool(m.group("more")))
return None
# Dispatch table keyed by the trimmed logcat tag.
_TAG_PARSERS = {
"ImapPerf": parse_imap_perf,
"MailReader": parse_open_message,
"Reader": parse_reader_ready,
"MailBackfiller": parse_backfill,
}
def parse_breadcrumb(line: str) -> Optional[Event]:
"""Parse a full threadtime logcat line into a typed :data:`Event` (or ``None``).
The returned event carries its source :class:`LogLine` on ``.line`` so callers can
order and time-box events. Lines that are not perf breadcrumbs return ``None``.
"""
log = parse_logcat_line(line)
if log is None:
return None
parser = _TAG_PARSERS.get(log.tag)
if parser is None:
return None
event = parser(log.message)
if event is None:
return None
event.line = log
return event
def iter_events(lines: Iterable[str]) -> Iterator[Event]:
"""Yield every parseable breadcrumb :data:`Event` from an iterable of logcat lines."""
for line in lines:
event = parse_breadcrumb(line)
if event is not None:
yield event
# --------------------------------------------------------------------------- #
# Correlation -- group the breadcrumbs of one message-open together
# --------------------------------------------------------------------------- #
@dataclass
class OpenSample:
"""One reader-open, correlating the breadcrumbs the app emits for a single open.
Mirrors a row of the manual ``timing-tables.md``: the ``openMessage`` marker plus the
``body-fetch`` detail/op that preceded it and the ``reader ready`` that followed.
"""
open_message: OpenMessage
body_fetch: Optional[BodyFetch] = None
body_fetch_op: Optional[ImapPerfOp] = None
reader_ready: Optional[ReaderReady] = None
@property
def account_ref(self) -> str:
return self.open_message.account_ref
@property
def cached(self) -> bool:
"""A cached open did not fetch the body over the network."""
return not self.open_message.fetched_body
@property
def took_ms(self) -> int:
return self.open_message.took_ms
@property
def rfc822_bytes(self) -> Optional[int]:
return self.body_fetch.rfc822_bytes if self.body_fetch else None
@property
def body_kb_per_s(self) -> Optional[float]:
return self.body_fetch.body_kb_per_s if self.body_fetch else None
def correlate_opens(events: Iterable[Event]) -> List[OpenSample]:
"""Group a flat, time-ordered event stream into :class:`OpenSample` records.
Strategy (matches how the breadcrumbs interleave in a real capture): keep the most
recent ``body-fetch`` detail and generic ``body-fetch`` op seen; when an
``openMessage`` marker arrives, attach those (consuming them so they are not reused);
the first ``reader ready`` after the marker attaches to it.
"""
samples: List[OpenSample] = []
pending_detail: Optional[BodyFetch] = None
pending_op: Optional[ImapPerfOp] = None
open_awaiting_reader: Optional[OpenSample] = None
for event in events:
if isinstance(event, BodyFetch):
pending_detail = event
elif isinstance(event, ImapPerfOp) and event.op == "body-fetch":
pending_op = event
elif isinstance(event, OpenMessage):
sample = OpenSample(open_message=event)
if event.fetched_body:
sample.body_fetch = pending_detail
sample.body_fetch_op = pending_op
pending_detail = None
pending_op = None
samples.append(sample)
open_awaiting_reader = sample
elif isinstance(event, ReaderReady):
if open_awaiting_reader is not None and open_awaiting_reader.reader_ready is None:
open_awaiting_reader.reader_ready = event
open_awaiting_reader = None
return samples