From b1a1719f171af394c26b2c5aeaa56a21d94a38e0 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Sun, 16 Aug 2026 22:00:56 -0400 Subject: [PATCH] =?UTF-8?q?scripts:=20emu.sh=20=E2=80=94=20reliable=20emul?= =?UTF-8?q?ator=20lifecycle=20+=20on-device=20debug=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' (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. --- doc/README.md | 36 ++++--- scripts/emu.sh | 267 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+), 13 deletions(-) create mode 100755 scripts/emu.sh diff --git a/doc/README.md b/doc/README.md index 8ef478b..bf3c8f5 100644 --- a/doc/README.md +++ b/doc/README.md @@ -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 ` (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. diff --git a/scripts/emu.sh b/scripts/emu.sh new file mode 100755 index 0000000..1075b7b --- /dev/null +++ b/scripts/emu.sh @@ -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_.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 # 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 " + 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