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`
6. `adb install -r` (skipped with `--no-install`)
Run the emulator (headless, AVD `pad_avd`, API 35):
## Emulator + on-device debug: `scripts/emu.sh`
```
nohup emulator -avd pad_avd -no-window -no-audio -no-boot-anim \
-gpu swiftshader_indirect >/tmp/emulator.log 2>&1 &
```
All emulator lifecycle and on-device debug operations go through
`scripts/emu.sh` (headless AVD `pad_avd`, API 35): `up` / `down` /
`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
directory: `/storage/emulated/0/Notes` (push test files there with
`adb push file /storage/emulated/0/Notes/`).
directory: `/storage/emulated/0/Notes`.
## On-device observation loop (quick reference)
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).
- `adb shell input tap|swipe|text` — drive the UI (see `development_plan.md`
for the tap coordinates and IME-tap cadence that works).
- `logcat -s pad.pad` — app log (errors, limits, recovery; the normal path
is quiet by design).
- `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
disk** after typing.
- Memory: `adb shell dumpsys meminfo pad.pad` (watch PSS/RSS; the 3.8 GB
emulator OOMs the app above ~2.5 GB RSS).
- Screenshots (`adb exec-out screencap -p > /tmp/x.png`) are an auxiliary
check only — state, logcat, and file diffs are authoritative.
- Screenshots are an auxiliary check only — state, logcat, and file diffs
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