feat(dev): an Android failure looks exactly like a success

The APK installs and launches. It also dies six milliseconds later, and
finding that out cost a cycle for three reasons that have nothing to do
with the bug itself:

**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go
to /dev/null, so every slog line -- including the one naming the error
the app is about to exit on -- is discarded. `setprop
log.redirect-stdio true` does not help: that redirects the Java
runtime's System.out, and our code is a c-shared native library.

**os.Exit leaves no evidence.** No panic, no AndroidRuntime stack,
nothing in /data/tombstones, nothing in `logcat -b crash` or dropbox.
All three places anyone would look are empty, and the one signal that
is present -- "Zygote: exited due to signal 9" -- reads as "the system
killed it" and sends you after the low-memory killer.

**ActivityManager restarts it faster than you can observe.** pidof
always answers and `am start` always reports Status: ok, so a
crash-looping app looks alive. "Did it start" is the wrong question;
`make android-smoke` asks whether it is the *same pid* N seconds later,
and prints the filtered logcat plus how to read it when it is not.

The tell, once known: "I/WailsBridge: Wails bridge initialized"
followed immediately by a new pid doing the same thing.

scripts/android-emulator.sh follows dev-headless.sh's shape --
background start, saved-PID stop, filtered log tail, never pkill -f.
Two scaffold tasks are deliberately not wrapped: `android:logs` greps
logcat for (Wails|yellowjacket), which catches the WailsBridge tag but
misses the app's own process tag (app.yellowjacket is lowercase) and
misses ActivityManager's "has died" line, which is the one that says it
crashed; and `ensure-emulator` boots whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be sequenced.

One environment note that is not obvious on Arch: Gradle needs a
platform and /opt/android-sdk has none, so ANDROID_SDK defaults to
~/Android/Sdk while ANDROID_NDK points at /opt/android-ndk. Two SDKs,
one for each half of the build.
This commit is contained in:
2026-08-16 15:31:18 -04:00
parent 6fbb62730d
commit 68468e5378
4 changed files with 451 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env bash
#
# The Android tier: an emulator, an APK, and a way to find out why the
# app died.
#
# This is the phone equivalent of `dev-headless.sh`, and it is
# deliberately shaped like it — start in the background and return,
# stop by saved state, tail a log — because the operating pattern is
# the one this repo already has. What is different is *what a failure
# looks like*, and that is the whole reason this script exists rather
# than a paragraph telling you to run adb.
#
# **Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go
# to /dev/null, so every `slog` line the app writes — including the one
# naming the error it is about to exit on — is discarded. There is no
# flag for this: `setprop log.redirect-stdio true` redirects the *Java*
# runtime's System.out and does nothing for a c-shared Go library.
#
# **And `os.Exit` is a silent death.** `main()` ends several failure
# paths in `os.Exit(1)`; from Android's side that is a process that
# vanished, reported as "has died: fg TOP" and signal 9, with no panic,
# no `AndroidRuntime` stack and no tombstone — the three places anyone
# would look. ActivityManager then restarts it, so `pidof` answers with
# a pid and the app looks alive while crash-looping several times a
# second.
#
# `smoke` exists because of those two facts together: the honest test
# is not "did it start" but "is the same pid still there a few seconds
# later", and the useful output is the app's own logcat tags plus a
# named guess at which `os.Exit` it took.
set -euo pipefail
cd "$(dirname "$0")/.."
AVD="${YJ_AVD:-yj-test}"
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}"
PKG="${YJ_ANDROID_PKG:-app.yellowjacket}"
# **Not "$PKG/.MainActivity".** A leading-dot activity is resolved
# relative to the *applicationId*, and the scaffold's activity lives in
# the Java package `com.wails.app`, which is deliberately not the
# applicationId (see app/build.gradle). The short form silently
# resolves to app.yellowjacket.MainActivity, which does not exist, and
# `am start` fails with a class-not-found that reads like a broken
# build rather than a wrong name.
ACTIVITY="${YJ_ANDROID_ACTIVITY:-com.wails.app.MainActivity}"
IMAGE="${YJ_ANDROID_IMAGE:-system-images;android-35;google_apis;x86_64}"
DEVDIR=".dev"
PIDFILE="$DEVDIR/emulator.pid"
LOGFILE="$DEVDIR/emulator.log"
ADB="$SDK/platform-tools/adb"
EMULATOR="$SDK/emulator/emulator"
SDKMANAGER="$SDK/cmdline-tools/latest/bin/sdkmanager"
AVDMANAGER="$SDK/cmdline-tools/latest/bin/avdmanager"
die() { echo "android: $*" >&2; exit 1; }
need_sdk() {
[ -x "$ADB" ] || die "no adb at $ADB — set ANDROID_SDK_ROOT, or run 'make android-setup'"
[ -x "$EMULATOR" ] || die "no emulator at $EMULATOR — run 'make android-setup'"
}
# The emulator is the only long-lived process here, and it is addressed
# by its saved pid. Never by name: `pkill -f emulator` matches this
# script's own command line and kills the shell running it, which is
# the same trap dev-stop.sh documents.
running() {
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
}
cmd_setup() {
[ -x "$SDKMANAGER" ] || die "no sdkmanager at $SDKMANAGER; install the Android command line tools first"
# Each piece is installed only when missing. sdkmanager is itself
# idempotent but still spends minutes verifying, so the guards are
# what make this cheap to re-run.
for want in "platform-tools" "platforms;android-35" "build-tools;34.0.0" "$IMAGE"; do
dir="$SDK/$(printf '%s' "$want" | tr ';' '/')"
if [ -d "$dir" ]; then
echo " $want: present"
else
echo " $want: installing"
yes | "$SDKMANAGER" --install "$want" >/dev/null
fi
done
if "$EMULATOR" -list-avds 2>/dev/null | grep -qx "$AVD"; then
echo " avd $AVD: present"
else
echo " avd $AVD: creating"
echo no | "$AVDMANAGER" create avd -n "$AVD" -k "$IMAGE" -d pixel_6 --force >/dev/null
fi
# A dependency with a requirement, checked like one. Without KVM the
# emulator falls back to full software emulation and a boot that
# takes 30 s takes 20 minutes — which reads as a hung target.
if ! "$EMULATOR" -accel-check 2>&1 | grep -q "is installed and usable"; then
echo
echo " WARNING: KVM is not usable. The emulator will run under software"
echo " emulation and boot times go from ~30s to tens of minutes."
echo " Check /dev/kvm exists and that you are in the kvm group."
fi
}
cmd_start() {
need_sdk
mkdir -p "$DEVDIR"
if running; then
echo "emulator already running (pid $(cat "$PIDFILE"))"
else
"$EMULATOR" -list-avds 2>/dev/null | grep -qx "$AVD" ||
die "no AVD named '$AVD' — run 'make android-setup'"
# -no-window because there is no display and does not need one;
# -no-snapshot so a run starts from the same state every time,
# which is what makes a smoke result mean something.
nohup "$EMULATOR" -avd "$AVD" \
-no-window -no-boot-anim -no-snapshot \
-gpu swiftshader_indirect \
-netdelay none -netspeed full \
>"$LOGFILE" 2>&1 &
echo $! >"$PIDFILE"
echo "emulator starting (pid $(cat "$PIDFILE")), log: $LOGFILE"
fi
echo -n "waiting for boot"
"$ADB" wait-for-device >/dev/null 2>&1 || die "device never appeared; see $LOGFILE"
for _ in $(seq 1 150); do
if [ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then
echo " ok"
"$ADB" shell getprop ro.build.version.release |
sed 's/^/ android /'
return 0
fi
echo -n .
sleep 2
done
echo
die "boot did not complete in 300s; see $LOGFILE"
}
cmd_stop() {
if running; then
pid=$(cat "$PIDFILE")
# The emulator's own console command shuts the guest down
# cleanly; the saved pid is the fallback and the guarantee.
"$ADB" emu kill >/dev/null 2>&1 || true
for _ in $(seq 1 15); do
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
kill -0 "$pid" 2>/dev/null && kill "$pid" 2>/dev/null || true
echo "emulator stopped"
else
echo "emulator not running"
fi
rm -f "$PIDFILE"
}
cmd_install() {
need_sdk
[ -f bin/yellowjacket.apk ] || die "no bin/yellowjacket.apk — run 'make android' first"
"$ADB" get-state >/dev/null 2>&1 || die "no device — run 'make android-emulator' first"
"$ADB" install -r bin/yellowjacket.apk
}
cmd_launch() {
need_sdk
"$ADB" shell am force-stop "$PKG"
"$ADB" logcat -c
"$ADB" shell am start -n "$PKG/$ACTIVITY" >/dev/null
}
cmd_logs() {
need_sdk
# The app's own tags plus the two that report its death. Chasing a
# raw logcat here is hopeless: the emulator emits thousands of lines
# a second, almost all of them WindowManager transitions.
"$ADB" logcat -v time \
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F ActivityManager:I '*:S'
}
# Start the app and assert it is *still the same process* a few seconds
# later. "It started" is not the question — a crash-looping app starts
# continuously.
cmd_smoke() {
need_sdk
local wait_s="${1:-10}"
cmd_launch
sleep 3
local first
first=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}')
sleep "$wait_s"
local second
second=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}')
if [ -n "$first" ] && [ "$first" = "$second" ]; then
echo "PASS: $PKG alive as pid $first after ${wait_s}s"
return 0
fi
echo "FAIL: $PKG is not stable (pid was '${first:-none}', now '${second:-none}')"
if [ -n "$first" ] && [ -n "$second" ]; then
echo " The pid changed: it is crash-looping, not running."
fi
echo
echo "--- last 40 app-relevant logcat lines ---"
"$ADB" logcat -d -v time \
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F '*:S' 2>/dev/null |
tail -40
echo
echo "--- reading this ---"
echo "If the last line is 'Wails bridge initialized' and nothing follows,"
echo "the Go side reached main() and left it. There will be no panic and"
echo "no tombstone, because that is os.Exit, not a crash. Go's stdout"
echo "does not reach logcat, so the slog line naming the error is gone."
echo "Work backwards through main()'s os.Exit(1) paths instead."
return 1
}
case "${1:-}" in
setup) cmd_setup ;;
start) cmd_start ;;
stop) cmd_stop ;;
install) cmd_install ;;
launch) cmd_launch ;;
logs) cmd_logs ;;
smoke) cmd_smoke "${2:-10}" ;;
*)
echo "usage: $0 {setup|start|stop|install|launch|logs|smoke [seconds]}" >&2
exit 2
;;
esac