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>
170 lines
6.9 KiB
Python
170 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""fetchgate.py -- the debug FETCH_GATE pause-hook helpers.
|
|
|
|
Thin, testable wrapper around the debug-only ``FetchGateReceiver`` broadcast hook
|
|
(issues #393/#395) that lets the perf harness pause/resume the app's *proactive* body-fetch
|
|
activities (``backfill`` + ``prefetch``) so a genuinely uncached message-open can be measured.
|
|
Everything goes through the guarded :class:`adb.Adb` wrapper, so the device-safety guardrails
|
|
apply unchanged (the broadcast targets our own package/component; nothing destructive).
|
|
|
|
The broadcast the harness sends (delivered *ordered*, targeted by ``-n`` so it needs no
|
|
``<intent-filter>``)::
|
|
|
|
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
|
|
|
|
Because ``am broadcast`` delivers ordered, the receiver returns the resulting gate state as
|
|
result data, which ``am`` prints, e.g.::
|
|
|
|
Broadcast completed: result=0, data="paused=[backfill,prefetch]"
|
|
|
|
:func:`parse_broadcast_result` turns that into a typed :class:`BroadcastResult` for a
|
|
synchronous, race-free read-back. **This hook only exists in a debug build** -- it is declared
|
|
solely in ``app/src/debug`` and R8-stripped from release -- so the cold-fetch scenario needs a
|
|
debug APK installed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Callable, FrozenSet, Optional
|
|
|
|
# Wire constants -- mirror FetchGateReceiver / FetchScope in the app source.
|
|
FETCH_GATE_ACTION = "org.libremail.debug.FETCH_GATE"
|
|
FETCH_GATE_RECEIVER = "org.libremail.debug.FetchGateReceiver"
|
|
|
|
ACTION_PAUSE = "pause"
|
|
ACTION_RESUME = "resume"
|
|
ACTION_QUERY = "query"
|
|
|
|
# The two proactive scopes the harness pauses to keep bodies uncached (FetchScope wire names).
|
|
SCOPE_BACKFILL = "backfill"
|
|
SCOPE_PREFETCH = "prefetch"
|
|
DEFAULT_SCOPE = f"{SCOPE_BACKFILL},{SCOPE_PREFETCH}"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Read-back parsing (pure; unit-tested)
|
|
# --------------------------------------------------------------------------- #
|
|
# `am broadcast` prints e.g. `Broadcast completed: result=0, data="paused=[backfill,prefetch]"`.
|
|
# Parsed in two steps so a trailing `, extras: ...` (absent here, but possible) can't be
|
|
# swallowed into the data payload: match the result code, then the quoted data separately.
|
|
_RESULT_RE = re.compile(r"Broadcast completed:\s*result=(?P<code>-?\d+)")
|
|
_DATA_RE = re.compile(r'data="(?P<data>[^"]*)"')
|
|
# The receiver's result-data payload: `paused=[<comma-list>]`.
|
|
_PAUSED_RE = re.compile(r"paused=\[(?P<scopes>[^\]]*)\]")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BroadcastResult:
|
|
"""The parsed outcome of one FETCH_GATE ``am broadcast`` read-back.
|
|
|
|
* ``result_code`` -- the ordered-broadcast result code (``0`` from the receiver), or
|
|
``None`` if the ``Broadcast completed:`` line was absent (e.g. dry-run / no read-back).
|
|
* ``data`` -- the raw result-data payload (``paused=[backfill,prefetch]``) or ``None``.
|
|
* ``paused`` -- the scope wire-names parsed out of ``data`` (``frozenset``; empty when the
|
|
gate is clear or unparseable).
|
|
"""
|
|
|
|
result_code: Optional[int]
|
|
data: Optional[str]
|
|
paused: FrozenSet[str]
|
|
|
|
@property
|
|
def read_back(self) -> bool:
|
|
"""True iff a real ``Broadcast completed: ... data=...`` read-back was parsed."""
|
|
return self.data is not None
|
|
|
|
def is_paused(self, scope: str) -> bool:
|
|
return scope in self.paused
|
|
|
|
|
|
def parse_paused_scopes(data: Optional[str]) -> FrozenSet[str]:
|
|
"""Parse ``paused=[backfill,prefetch]`` -> ``{"backfill", "prefetch"}`` (empty if none)."""
|
|
if not data:
|
|
return frozenset()
|
|
m = _PAUSED_RE.search(data)
|
|
if not m:
|
|
return frozenset()
|
|
inner = m.group("scopes").strip()
|
|
if not inner:
|
|
return frozenset()
|
|
return frozenset(tok.strip() for tok in inner.split(",") if tok.strip())
|
|
|
|
|
|
def parse_broadcast_result(output: str) -> BroadcastResult:
|
|
"""Parse ``am broadcast`` stdout into a :class:`BroadcastResult`.
|
|
|
|
Tolerant of surrounding lines (the ``Broadcasting: Intent {...}`` echo) and of a missing
|
|
``data=`` (a non-read-back send); returns an all-empty result when no completion line is
|
|
present (e.g. dry-run stdout is empty).
|
|
"""
|
|
if not output:
|
|
return BroadcastResult(result_code=None, data=None, paused=frozenset())
|
|
code: Optional[int] = None
|
|
data: Optional[str] = None
|
|
for line in output.splitlines():
|
|
m = _RESULT_RE.search(line)
|
|
if not m:
|
|
continue
|
|
code = int(m.group("code"))
|
|
dm = _DATA_RE.search(line)
|
|
if dm:
|
|
data = dm.group("data")
|
|
break
|
|
return BroadcastResult(result_code=code, data=data, paused=parse_paused_scopes(data))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The pause-hook helper (drives the guarded adb wrapper)
|
|
# --------------------------------------------------------------------------- #
|
|
def _noop(_msg: str) -> None:
|
|
pass
|
|
|
|
|
|
class FetchGate:
|
|
"""Pause/resume/query the debug FETCH_GATE through a guarded :class:`adb.Adb`.
|
|
|
|
Construct with the harness's ``Adb`` (its ``package`` fixes the broadcast component and is
|
|
enforced by the safety guard). Each call sends the broadcast and returns the parsed
|
|
:class:`BroadcastResult` read-back. In dry-run the underlying ``adb`` prints the command
|
|
plan and returns empty stdout, so the result is an all-empty (no read-back) record.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
adb,
|
|
receiver: str = FETCH_GATE_RECEIVER,
|
|
action: str = FETCH_GATE_ACTION,
|
|
log: Optional[Callable[[str], None]] = None,
|
|
) -> None:
|
|
self.adb = adb
|
|
self.action = action
|
|
# `-n <package>/<receiver>` (assert_safe requires the component package == adb.package).
|
|
self.component = f"{adb.package}/{receiver}"
|
|
self._log = log or _noop
|
|
|
|
def _send(self, gate_action: str, scope: str) -> BroadcastResult:
|
|
res = self.adb.broadcast(
|
|
self.action, self.component, {"action": gate_action, "scope": scope}
|
|
)
|
|
parsed = parse_broadcast_result(res.stdout)
|
|
shown = parsed.data if parsed.read_back else "(no read-back)"
|
|
self._log(f"fetch-gate {gate_action} scope={scope} -> {shown}")
|
|
return parsed
|
|
|
|
def pause(self, scope: str = DEFAULT_SCOPE) -> BroadcastResult:
|
|
"""Pause the given proactive-fetch scope(s); returns the read-back gate state."""
|
|
return self._send(ACTION_PAUSE, scope)
|
|
|
|
def resume(self, scope: str = DEFAULT_SCOPE) -> BroadcastResult:
|
|
"""Resume (clear) the given scope(s); returns the read-back gate state."""
|
|
return self._send(ACTION_RESUME, scope)
|
|
|
|
def query(self, scope: str = DEFAULT_SCOPE) -> BroadcastResult:
|
|
"""Query the current gate state without changing it; returns the read-back."""
|
|
return self._send(ACTION_QUERY, scope)
|