Pad/scripts/profile_emulator.sh
Greg Pomerantz 8bdf916d8a Restrict the frame-regression release gate to the emulator
The on-device profile is demoted to a diagnostic: the run force-stops
the app and seeds/removes a file in its storage, and the developer may
be using the phone while a release runs. The release gate is now
explicitly (1) TestNoFramesWhileIdle in the go-test suite and (2)
scripts/profile_emulator.sh on the EMULATOR.

- script: auto-select picks an emulator only (previously any connected
  device); no emulator -> clear error; a physical device passed via -s
  prints a DIAGNOSTIC (non-gating) banner and a heads-up.
- doc: architecture.md §11 states the two gate tiers and the diagnostic
  use of the physical-device run.
2026-08-20 14:40:24 -04:00

169 lines
6.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# Pre-release frame-regression profile for the Pad editor.
#
# RELEASE GATE ON THE EMULATOR ONLY. The frame-regression guard has two
# tiers (architecture.md §11):
# 1. TestNoFramesWhileIdle (internal/test/e2e) — headless, in the regular
# go-test suite, runs on every build;
# 2. this script, run on the EMULATOR, as the pre-release checklist item.
# Runs on a PHYSICAL device are DIAGNOSTIC only — never a gate. The script
# force-stops the app and seeds/removes a file in its storage; the developer
# may be using the phone while a release runs, so physical devices are
# never auto-selected. Pass -s <phone serial> explicitly for a diagnostic
# run (a banner reminds you the result is informational).
#
# Measures whether the app generates frames WITHOUT a cause. Frame emission
# is event-driven (architecture.md §2), so a correct app emits a small
# number of frames per user action and NOTHING while idle. This script
# drives a fixed sequence of actions and checks the in-app profiler's CSV
# for phases that exceed their frame budget.
#
# Usage:
# scripts/profile_emulator.sh [-s SERIAL] [-o OUT_DIR]
#
# Driven sequence (one app run):
# launch -> 8s idle (browser) -> open seeded file (debug cmd) -> 16s (settle +
# editor idle) -> scroll -> 2.5s -> scroll -> 2.5s -> scroll
#
# Analysis: the profiler marks rows whose previous frame is >= 100ms away
# as gaps, and flushes the CSV when a burst ends, so the CSV is complete
# and self-anchoring (no clock correlation). Rows are grouped into PHASES
# by splitting at gaps >= 2s (the script's action windows are all >= 2s
# apart; late async frames — list load, line-index build — land 100-200ms
# after their action and stay in the phase). Expected phases:
# 1. browser startup (+ 8s idle) budget 10 frames
# 2. open (+ settle + 16s editor idle) budget 20 frames
# 3..N. one per driven scroll budget 8 frames each
# A phase over budget means frames were emitted without (enough) cause —
# a ticker, a re-emission loop, a feedback ping-pong; a 1/s spinner alone
# exceeds a 16s idle phase's budget. The PERF / PERF-PRESENT logcat lines
# are printed for a human to eyeball live fps and latency.
#
# Requirements: adb, the app installed, and (Android 11+) the All-files
# access appop (set automatically when possible).
set -euo pipefail
SERIAL=""
OUT="/tmp/pad-prof"
while getopts ":s:o:h" flag; do
case "$flag" in
s) SERIAL="$OPTARG" ;;
o) OUT="$OPTARG" ;;
h) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option" >&2; exit 2 ;;
esac
done
PKG=pad.pad
ACT=pad.pad/org.gioui.GioActivity
PERFDIR=/storage/emulated/0/PadPerf
# The browser opens at the app's start path (cmd/pad/impl_android.go);
# seed a known scrollable file there so the tap opens real content — it is
# the newest file and the list is date-desc, so it is the first row.
NOTES=/storage/emulated/0/Notes
PROFILE_FILE=padprofile.txt
# Frame budgets per phase index (1-based): startup, open, then per scroll.
BUDGET_STARTUP="${BUDGET_STARTUP:-10}"
BUDGET_OPEN="${BUDGET_OPEN:-20}"
BUDGET_SCROLL="${BUDGET_SCROLL:-8}"
if [ -z "$SERIAL" ]; then
# Auto-select an EMULATOR only: physical devices are diagnostic runs and
# must be requested explicitly (the developer may be using the phone).
SERIAL=$(adb devices | awk 'NR>1 && $2=="device" && $1 ~ /^emulator-/ {print $1; exit}')
if [ -z "$SERIAL" ]; then
echo "no connected emulator; start one (adb emulator / AVD Manager)" >&2
echo "or pass -s <phone serial> for a DIAGNOSTIC (non-gating) run" >&2
exit 1
fi
fi
adb() { command adb -s "$SERIAL" "$@"; }
case "$SERIAL" in
emulator-*) echo "== release-gate profile on emulator $SERIAL ==" ;;
*) echo "== DIAGNOSTIC (non-gating) profile on physical device $SERIAL =="
echo " (release gate = emulator; this run force-stops the app and"
echo " seeds a file in its storage — do not run on a phone in use)" ;;
esac
adb shell "mkdir -p $PERFDIR $NOTES"
adb shell "touch $PERFDIR/enable"
adb shell appops set "$PKG" MANAGE_EXTERNAL_STORAGE allow 2>/dev/null || true
mkdir -p "$OUT"
CSV="$OUT/logic_frames.csv"
awk 'BEGIN { for (i = 1; i <= 4000; i++) printf "profile line %04d: the quick brown fox jumps over the lazy dog\n", i }' > "$OUT/$PROFILE_FILE"
adb push "$OUT/$PROFILE_FILE" "$NOTES/$PROFILE_FILE" >/dev/null
cleanup() {
adb shell "rm -f $NOTES/$PROFILE_FILE" 2>/dev/null || true
}
trap cleanup EXIT
# parse_csv prints one line per phase: frame count, max in-phase (non-gap)
# delta (ms), and the pages covered. Phases are the frame-runs split at
# gaps >= 2s (the rows themselves are still counted in their phase).
parse_csv() {
awk -F, '
function emit() {
if (n > 0) printf "frames=%-4d maxDelta=%7.1f ms page=%s\n", n, md, pg
n = 0; md = 0; pg = ""
}
{
if ($10 == "true" && $3 + 0 >= 2000) emit()
if (n == 0) pg = $4; else if ($4 != pg) pg = pg "+" $4
n++
if ($10 != "true" && $3 + 0 > md) md = $3 + 0
}
END { emit() }
' "$1"
}
fail=0
adb shell am force-stop "$PKG"
adb shell "rm -f $PERFDIR/logic_frames.csv $PERFDIR/cmd"
adb logcat -c
adb shell am start -n "$ACT" >/dev/null
echo "-- app launched; driving: 8s idle, open (debug cmd), 16s, scroll x3"
sleep 8
adb shell "echo 'open $NOTES/$PROFILE_FILE' > $PERFDIR/cmd"
sleep 16
adb shell "echo 'frac 0.5' > $PERFDIR/cmd"; sleep 2.5
adb shell "echo 'frac 0.2' > $PERFDIR/cmd"; sleep 2.5
adb shell "echo 'frac 0.4' > $PERFDIR/cmd"; sleep 2
adb shell am force-stop "$PKG"
adb pull "$PERFDIR/logic_frames.csv" "$CSV" >/dev/null
echo "-- logcat PERF lines (live view; fps < 1 means no continuous redraw):"
adb logcat -d | grep "I $PKG.*PERF" | sed 's/^/ /' || true
echo "-- phases (split at idle gaps >= 2s):"
PHASES=$(parse_csv "$CSV")
echo "$PHASES" | sed 's/^/ /'
# The seeded file is 4000 lines: a frame with TotalLines >= 4000 proves the
# editor actually opened it (the 'open' cmd goes through the same OpenFile
# path as a browser tap).
if ! awk -F, '$7 >= 4000 { found = 1 } END { exit !found }' "$CSV"; then
echo "FAIL: the seeded file was never opened (no frame with" \
"TotalLines >= 4000) — check the debug 'open' command in logcat." >&2
fail=1
fi
i=1
while IFS= read -r line; do
n=$(echo "$line" | sed 's/.*frames=//; s/ .*//')
if [ "$i" -eq 1 ]; then budget="$BUDGET_STARTUP"
elif [ "$i" -eq 2 ]; then budget="$BUDGET_OPEN"
else budget="$BUDGET_SCROLL"; fi
if [ "$n" -gt "$budget" ]; then
echo "FAIL: phase $i has $n frames (budget $budget) — frames are" \
"being generated without a cause: $line" >&2
fail=1
fi
i=$((i + 1))
done <<< "$PHASES"
echo
if [ "$fail" -ne 0 ]; then
echo "RESULT: FAIL — unnecessary frame generation detected (details above)." >&2
exit 1
fi
echo "RESULT: PASS — every phase is within its frame budget."
echo "CSV saved: $CSV"