scripts: emu.sh — reliable emulator lifecycle + on-device debug helpers

Replaces the ad-hoc adb/nohup sequences used throughout verification with
one self-contained script: up/down/status, app start|stop|restart, shot,
log, tap, type, cmd (one-shot editor debug command), perf on|off|pull,
push/pull of Notes test files. Encodes the machine-local paths
(Notes/, PadPerf/ profiler + cmd file) in one auditable place.

Reliability findings baked in (each hit during testing):
- The AVD auto-saves a 'default_boot' instant-boot snapshot on clean
  shutdown; a corrupted one makes the emulator segfault during restore
  (device never comes online). up() detects it (process death / no adb
  device within PAD_ONLINE_TIMEOUT) and falls back to a cold boot with
  -no-snapshot-load; the next clean down() re-saves a good snapshot, so
  this self-heals. down() is the only sanctioned stop (clean kill saves
  the snapshot; SIGKILL corrupts it).
- The emulator process identity is ambiguous (launcher vs
  qemu-system-*-headless), and crashpad/netsimd children carry the AVD
  name in their command lines. Liveness and kill therefore use a tight
  signature: comm matches qemu-system-* AND cmdline contains ' -avd
  <AVD>' (never a broad pkill -f pattern — one matched and killed the
  calling shell during testing).
- Before any launch, orphaned qemu instances are cleared (they hold the
  AVD lock and make new launches fail silently); before starting a
  replacement, the old one must be fully gone (lingering device/lock
  causes false success).
- Timeouts: short online window for the snapshot attempt, full
  BOOT_TIMEOUT for the fallback cold boot (~20 s).

Tested: idempotent up, 10 s snapshot-restore boot, forced fallback
(30 s end-to-end, verified correct instance), orphan cleanup, perf/cmd/
push/pull/shot/log on-device. doc/README.md now documents it.
This commit is contained in:
Greg Pomerantz 2026-08-16 22:00:56 -04:00
parent 3a39fb8126
commit b1a1719f17
2 changed files with 290 additions and 13 deletions

View File

@ -56,28 +56,38 @@ What it does, and why (gogio cannot inject manifest permissions):
5. `apksigner sign` with the debug key → `cmd/pad/pad-emu.apk` 5. `apksigner sign` with the debug key → `cmd/pad/pad-emu.apk`
6. `adb install -r` (skipped with `--no-install`) 6. `adb install -r` (skipped with `--no-install`)
Run the emulator (headless, AVD `pad_avd`, API 35): ## Emulator + on-device debug: `scripts/emu.sh`
``` All emulator lifecycle and on-device debug operations go through
nohup emulator -avd pad_avd -no-window -no-audio -no-boot-anim \ `scripts/emu.sh` (headless AVD `pad_avd`, API 35): `up` / `down` /
-gpu swiftshader_indirect >/tmp/emulator.log 2>&1 & `status`, `app start|stop|restart`, `shot`, `log`, `tap X Y`, `type TEXT`,
``` `cmd <top|bottom|frac F|dp N>` (one-shot editor debug command),
`perf on|off|pull` (in-app profiler), `push`/`pull` (Notes files). See the
script header for the full list and the device paths it encodes.
Snapshot behavior: the AVD auto-saves an instant-boot snapshot on clean
`down` (restore ~10 s vs ~20 s cold boot). A corrupted snapshot makes the
emulator segfault during restore; `up` detects that and falls back to a
cold boot, and the next `down` re-saves a good snapshot (self-healing).
Always stop with `down` — SIGKILLing the emulator corrupts the snapshot.
App package/activity: `pad.pad / org.gioui.GioActivity`. Default root App package/activity: `pad.pad / org.gioui.GioActivity`. Default root
directory: `/storage/emulated/0/Notes` (push test files there with directory: `/storage/emulated/0/Notes`.
`adb push file /storage/emulated/0/Notes/`).
## On-device observation loop (quick reference) ## On-device observation loop (quick reference)
Gio renders into one GL surface, so the authoritative debug signals are Gio renders into one GL surface, so the authoritative debug signals are
data, not pixels: data, not pixels (use `scripts/emu.sh log|shot|perf|cmd` for the first
four):
- `adb logcat -s pad.pad` — app log (IME commits, open-file timing, errors). - `logcat -s pad.pad` — app log (errors, limits, recovery; the normal path
- `adb shell input tap|swipe|text` — drive the UI (see `development_plan.md` is quiet by design).
for the tap coordinates and IME-tap cadence that works). - `input tap|swipe|text` — drive the UI (see `development_plan.md` for the
tap coordinates and IME-tap cadence that works). The first tap right
after launch/open is sometimes swallowed — re-tap.
- Autosave debounce is 1 s: **wait ~1.6 s before reading a file back from - Autosave debounce is 1 s: **wait ~1.6 s before reading a file back from
disk** after typing. disk** after typing.
- Memory: `adb shell dumpsys meminfo pad.pad` (watch PSS/RSS; the 3.8 GB - Memory: `adb shell dumpsys meminfo pad.pad` (watch PSS/RSS; the 3.8 GB
emulator OOMs the app above ~2.5 GB RSS). emulator OOMs the app above ~2.5 GB RSS).
- Screenshots (`adb exec-out screencap -p > /tmp/x.png`) are an auxiliary - Screenshots are an auxiliary check only — state, logcat, and file diffs
check only — state, logcat, and file diffs are authoritative. are authoritative.

267
scripts/emu.sh Executable file
View File

@ -0,0 +1,267 @@
#!/usr/bin/env bash
# Emulator + on-device debug helpers for Pad (machine-local workstation).
#
# Usage:
# scripts/emu.sh up # start headless AVD, wait for boot
# scripts/emu.sh down # stop the emulator (clean; saves snapshot)
# scripts/emu.sh status # adb / boot / top-activity state
# scripts/emu.sh app start|stop|restart
# scripts/emu.sh shot [FILE] # screenshot (default /tmp/pad_shot_<ts>.png)
# scripts/emu.sh log [LINES] # last N lines of app logcat (default 20)
# scripts/emu.sh tap X Y # tap (note: the first tap right after
# # launch/open is sometimes swallowed —
# # re-tap if nothing happens)
# scripts/emu.sh type TEXT # type into the focused field (spaces ok)
# scripts/emu.sh cmd <top|bottom|frac F|dp N> # one-shot editor debug command
# scripts/emu.sh perf on|off # enable/disable the in-app profiler
# scripts/emu.sh perf pull [FILE] # pull logic_frames.csv (default ./logic_frames.csv)
# scripts/emu.sh push FILE # push FILE -> /storage/emulated/0/Notes/
# scripts/emu.sh pull NAME # pull Notes/NAME -> ./
#
# Machine-local facts encoded here (see doc/README.md for context):
# - AVD pad_avd (Pixel 6 profile, API 35, x86_64); headless + swiftshader.
# Boot is fast (snapshot restore ~10 s, cold boot ~20-30 s warm).
# - App pad.pad / org.gioui.GioActivity.
# - Profiler: enable marker + CSV live under /storage/emulated/0/PadPerf/;
# the CSV is truncated on app relaunch, so pull it before restarting the
# app if you are accumulating data.
# - One-shot editor debug commands are read from /storage/emulated/0/PadPerf/cmd
# (scroll: top | bottom | "frac 0.5" | "dp 1234").
# - Test files go under /storage/emulated/0/Notes/.
# - The emulator OOMs above ~2.5 GB RSS on this VM; kill it if it wedges.
#
# Snapshots: the AVD auto-saves a "default_boot" instant-boot snapshot on
# clean shutdown. A corrupted snapshot makes the emulator segfault during
# restore (device never comes online). `up` detects that (process death or
# no adb device within PAD_ONLINE_TIMEOUT seconds) and falls back to a cold
# boot with -no-snapshot-load; the next clean `down` re-saves a healthy
# snapshot, so this self-heals. Always stop with `down` (clean kill saves
# the snapshot) — SIGKILLing the emulator corrupts the snapshot.
#
# Env overrides: PAD_AVD, PAD_ONLINE_TIMEOUT (default 60 s),
# PAD_BOOT_TIMEOUT (default 300 s), EMU_LOG path via ANDROID_HOME-adjacent.
set -euo pipefail
AVD=${PAD_AVD:-pad_avd}
PKG=pad.pad
ACTIVITY=org.gioui.GioActivity
NOTES=/storage/emulated/0/Notes
PERFDIR=/storage/emulated/0/PadPerf
EMU_LOG=${PAD_EMU_LOG:-/tmp/emulator.log}
ONLINE_TIMEOUT=${PAD_ONLINE_TIMEOUT:-60}
BOOT_TIMEOUT=${PAD_BOOT_TIMEOUT:-300}
export ANDROID_HOME=${ANDROID_HOME:-$HOME/android-sdk}
export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator
die() { echo "ERROR: $*" >&2; exit 1; }
adb_state() { adb get-state 2>/dev/null || echo offline; }
booted() {
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]
}
EMU_PID=""
# Find the actual qemu process for this AVD: comm is qemu-system-* and the
# command line contains " -avd $AVD". The comm check excludes the emulator's
# crashpad/netsimd children (their command lines carry the AVD name too) and
# any unrelated process that merely mentions the AVD. Prints the pid.
# Returns 0 if found, 1 otherwise.
find_emu_qemu() {
local p
for p in $(pgrep -f " -avd $AVD" 2>/dev/null); do
case "$(ps -o comm= -p "$p" 2>/dev/null)" in
qemu-system-*) echo "$p"; return 0 ;;
esac
done
return 1
}
start_emulator() {
nohup emulator -avd "$AVD" -no-window -no-audio -no-boot-anim \
-gpu swiftshader_indirect "$@" >"$EMU_LOG" 2>&1 &
EMU_PID=$!
}
# Wait until the adb device is online.
# Returns: 0 online, 1 emulator process died, 2 timeout.
wait_device_online() {
local deadline=${1:-$ONLINE_TIMEOUT} i=0
while [ "$i" -lt "$deadline" ]; do
[ "$(adb_state)" = "device" ] && return 0
# Give the launcher time to exec into qemu before declaring death.
if [ "$i" -ge 10 ] && ! find_emu_qemu >/dev/null; then
return 1
fi
sleep 3
i=$((i + 3))
done
return 2
}
# Wait for boot_completed on an already-online device.
# Returns: 0 booted, 1 not booted (device lost or timeout).
wait_boot_completed() {
local deadline=${1:-$BOOT_TIMEOUT} i=0
while [ "$i" -lt "$deadline" ]; do
booted && return 0
[ "$(adb_state)" = "device" ] || return 1
sleep 5
i=$((i + 5))
done
return 1
}
cmd_up() {
if [ "$(adb_state)" = "device" ] && booted; then
echo "emulator already up and booted"
return 0
fi
if [ "$(adb_state)" = "device" ]; then
echo "emulator present; waiting for boot..."
wait_boot_completed "$BOOT_TIMEOUT" || die "boot failed; see $EMU_LOG"
echo "booted"
return 0
fi
echo "starting AVD $AVD (headless)..."
# Clear any orphaned qemu left by a previously force-killed run: it would
# hold the AVD lock and make the new launch fail silently.
local qp
if qp=$(find_emu_qemu); then
echo "WARN: orphaned emulator (pid $qp) still running; killing it first"
kill -9 "$qp" 2>/dev/null || true
sleep 2
fi
start_emulator
local rc=0
wait_device_online "$ONLINE_TIMEOUT" || rc=$?
if [ "$rc" -ne 0 ]; then
if [ "$rc" -eq 1 ]; then
echo "WARN: emulator process died during startup (corrupt snapshot?) — $EMU_LOG"
else
echo "WARN: device never came online within ${ONLINE_TIMEOUT}s — $EMU_LOG"
fi
echo "retrying with -no-snapshot-load (cold boot; next 'down' re-saves a good snapshot)..."
# Kill the old instance (launcher and/or qemu). SIGKILL: the snapshot
# is already broken, there is nothing to save, and a half-written
# snapshot is harmless (next clean 'down' re-saves).
qp=$(find_emu_qemu || true)
if [ -n "$qp" ]; then kill -9 "$qp" 2>/dev/null || true; fi
kill -9 "$EMU_PID" 2>/dev/null || true
# Wait until it is truly gone: the old device and AVD lock must be
# released before the replacement starts, or the new waits can latch
# onto the dying instance and report false success.
local j=0
while [ "$j" -lt 15 ]; do
find_emu_qemu >/dev/null 2>&1 || break
sleep 1
j=$((j + 1))
done
start_emulator -no-snapshot-load
# Note: full BOOT_TIMEOUT here — a cold boot legitimately takes far
# longer than the ONLINE_TIMEOUT used for the first (snapshot) attempt.
wait_device_online "$BOOT_TIMEOUT" || { tail -n 5 "$EMU_LOG" >&2; die "device never came online (cold boot); see $EMU_LOG"; }
fi
wait_boot_completed "$BOOT_TIMEOUT" || die "boot failed; see $EMU_LOG"
echo "booted"
}
cmd_down() {
adb emu kill 2>/dev/null || die "no emulator running"
echo "stopped"
}
cmd_status() {
echo "adb: $(adb_state)"
if [ "$(adb_state)" = "device" ]; then
echo "boot_completed: $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
local top
top=$(adb shell dumpsys activity activities 2>/dev/null | grep -m1 "topResumedActivity" || true)
echo "top: ${top:-none}"
fi
}
cmd_app() {
case "${1:-}" in
start) adb shell am start -n "$PKG/$ACTIVITY" >/dev/null; echo "started" ;;
stop) adb shell am force-stop "$PKG"; echo "stopped" ;;
restart)
adb shell am force-stop "$PKG"
sleep 1
adb shell am start -n "$PKG/$ACTIVITY" >/dev/null
echo "restarted"
;;
*) die "app: start|stop|restart" ;;
esac
}
cmd_shot() {
local out="${1:-/tmp/pad_shot_$(date +%Y%m%d_%H%M%S).png}"
adb exec-out screencap -p >"$out"
echo "$out"
}
cmd_log() {
adb logcat -d -s "$PKG" | tail -n "${1:-20}"
}
cmd_tap() {
[ $# -eq 2 ] || die "tap X Y"
adb shell input tap "$1" "$2"
}
cmd_type() {
[ $# -ge 1 ] || die "type TEXT"
# input text takes one arg; encode spaces as %s.
local text=${*// /%s}
adb shell input text "$text"
}
cmd_cmd() {
# Commands are constrained (top|bottom|frac F|dp N) — no shell metachars.
[ $# -ge 1 ] || die "cmd <top|bottom|frac F|dp N>"
adb shell "mkdir -p $PERFDIR && printf '%s\n' \"$*\" > $PERFDIR/cmd"
echo "sent editor debug command: $*"
}
cmd_perf() {
case "${1:-}" in
on) adb shell "mkdir -p $PERFDIR && touch $PERFDIR/enable"; echo "profiler on" ;;
off) adb shell "rm -f $PERFDIR/enable"; echo "profiler off" ;;
pull)
local out="${2:-./logic_frames.csv}"
adb pull "$PERFDIR/logic_frames.csv" "$out"
;;
*) die "perf on|off|pull [FILE]" ;;
esac
}
cmd_push() {
[ $# -ge 1 ] || die "push FILE"
adb push "$1" "$NOTES/"
}
cmd_pull() {
[ $# -ge 1 ] || die "pull NAME"
adb pull "$NOTES/$1" "./"
}
[ $# -ge 1 ] || die "no subcommand (see header)"
main="${1}"; shift
case "$main" in
up) cmd_up "$@" ;;
down) cmd_down "$@" ;;
status) cmd_status "$@" ;;
app) cmd_app "$@" ;;
shot) cmd_shot "$@" ;;
log) cmd_log "$@" ;;
tap) cmd_tap "$@" ;;
type) cmd_type "$@" ;;
cmd) cmd_cmd "$@" ;;
perf) cmd_perf "$@" ;;
push) cmd_push "$@" ;;
pull) cmd_pull "$@" ;;
*) die "unknown subcommand: $main" ;;
esac