Files
LibreMediaConverter/tools/local-emulator/run-e2e.sh
T
JMR-dev 5a8aedf53d Read sdkmanager's status, not the status of the yes feeding it
run-e2e.sh installs a missing system image with

  yes | sdkmanager --install "$pkg" > /dev/null 2>&1 || { echo "  FAILED to install"; ... }

`yes` never ends. The moment sdkmanager exits and closes the pipe, `yes` dies of SIGPIPE
with 141, and this script runs under `pipefail`, which takes the rightmost non-zero status.
So a package that installed perfectly reported "FAILED to install $pkg" and returned 1.

R32 filed this PLAUSIBLE on shell semantics, unexecuted. It is demonstrated now:

  set -o pipefail; yes | true             -> 141   (three runs, three times)
  set -o pipefail; yes | sh -c 'exit 3'   -> 3
  ${PIPESTATUS[1]} for those two          -> 0 and 3

The pipeline status genuinely cannot tell a clean install from a broken one; PIPESTATUS
can. That is the whole change -- no restructuring of the licence flow, so a fresh SDK still
gets its licences accepted exactly as before.

`echo no | avdmanager` eleven lines below is deliberately left alone, and the comment says
so. One line fits the pipe buffer, so echo has already exited before the close and there is
no signal to receive: `echo no | true` measured 0 on five consecutive runs against `yes |
true`'s 141 on three. Only an unbounded producer is exposed. Someone reading this fix later
would otherwise "fix" the echo too and change a line that was never wrong.

Why it went unnoticed: it only misfires when the image is ABSENT, and every existing
checkout already has the images. R32 noted the branch that made this the normal path. The
failure is also silent in the worst way -- the install succeeds, the script says it failed,
and the AVD is then created from a package that is really there.

shellcheck clean at the pinned digest (0.11.0, the version CI runs), bash -n clean.

Closes #41.
2026-08-25 00:16:27 -05:00

644 lines
30 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Runs the instrumented suite on a local emulator, on this workstation, for one or more
# API levels.
#
# Usage: tools/local-emulator/run-e2e.sh [API ...] # default: 33 34 35 36 37
#
# API levels are the labels below, not SDK ints: 33-36, plus `37` (= `37.0`) and `37.1`.
#
# GPU_MODE= force one renderer on every level; unset means per-API (gpu_for_api)
# EMULATOR_PORT=5560 console port, so the serial is deterministic
# BOOT_TIMEOUT=300 seconds to wait for sys.boot_completed
# KEEP_AVD=1 do not delete an AVD this script created
#
# EXIT CODE: 0 only if every level was green; 1 if any level failed, wedged or could not be
# set up; 2 if it refused to start at all. **A bare `run-e2e.sh` therefore exits 1 by design.**
# API 37 is in the default list on purpose -- leaving it out is what left the level unlooked-at
# for as long as it was -- and it is permanently short of green, on the emulator image rather
# than on anything this app does. Since 2026-08-24 it does not even FINISH: one of its expected
# failures kills the framework, so the totals come back short with an arbitrary tail. The summary
# names every failure it expects, so an unnamed one is visibly new, and the last line printed
# says the same thing. Anything that reads a non-zero exit as breakage should name the levels it
# wants: `run-e2e.sh 33 34 35 36` is the sweep that can be green.
# docs/api-37-emulator-crash.md has the measurements.
#
# WHY THIS EXISTS, AND WHAT IT DELIBERATELY DOES NOT DO
#
# It is a *launcher*, not a second test harness. The diagnostics -- the FAILED-vs-WEDGED
# split, the SIGQUIT thread dump, the streamed logcat, `|| true` on every probe -- already
# exist in .github/scripts/e2e-run.sh and are the valuable part. That script assumes only
# an already-booted emulator and an `adb` that resolves to it, so it runs here unmodified;
# this file boots the emulator, points adb and Gradle at it, and calls it. Forking it would
# have produced two copies that drift, and the CI copy is the one exercised every day.
#
# Its GitHub-isms (`::group::`, `::error::`) are harmless noise in a local terminal, and
# RUNNER_TEMP already falls back to /tmp.
#
# THE ONE THING THIS HOST NEEDS THAT CI DOES NOT: a renderer that is not SwiftShader's
# GLES. Fedora's SELinux policy denies `execheap` to unconfined processes
# (`selinuxuser_execheap` is off), SwiftShader's Reactor JIT emits code onto the heap and
# mprotects it executable, the mprotect is refused, and the emulator segfaults the moment
# it calls into the generated routine -- exit 139, every time, before boot completes.
#
# That makes CI's own `-gpu swiftshader_indirect` exactly wrong here, and -- less obviously
# -- so are `auto`, `off` and `guest`, which all resolve to SwiftShader GLES under
# `-no-window` on this host. The refusal list below is not a style preference; each of
# those modes was measured crashing. docs/local-emulator.md has the backtrace, the faulting
# page's RW-without-E segment flags, and the full mode matrix.
#
# AND THE ONE THING API 37 NEEDS THAT 33-36 DO NOT: the opposite renderer. On the API 37
# images the guest's Gralloc5 mapper aborts surfaceflinger from RegionSamplingThread
# (`Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma`). Under `-gpu host` that
# repeats every few seconds and the device never boots; under ANGLE it fires a handful of
# times and the boot survives. So `host` is required below 37 and forbidden at 37, which is
# why the renderer is chosen per level in gpu_for_api rather than set once.
# docs/api-37-emulator-crash.md has that matrix.
#
# THE OTHER LOCAL-ONLY HAZARD: a physical Pixel is usually plugged into this machine, so
# `adb` is ambiguous in a way it never is on a runner, and an unpinned run would install
# and execute this suite on the phone. Every path below pins the emulator serial.
#
# Gradle is pinned with ANDROID_SERIAL and NOT with the `--serial` task option, which reads
# like the better tool -- it is documented to fail when the device is missing, where the env
# var just falls back. It is unusable: `--serial` is broken in AGP 9.3.1, which calls
# `remove()` on an ImmutableList and dies before it reaches any device.
#
# java.lang.UnsupportedOperationException
# at com.google.common.collect.ImmutableCollection.remove(ImmutableCollection.java:280)
# at DeviceProviderInstrumentTestTask.getFilteredDevices(...:519)
#
# (Measured with two devices attached and one filtered out, which is the case that reaches
# the `remove()`. A single matching device probably never does -- untested, and irrelevant
# here, since two devices is exactly the situation that needed pinning.)
#
# ANDROID_SERIAL takes an entirely different path -- ConnectedDeviceProvider splits it and
# keeps devices by `Set.contains(serial)`, building a new list rather than mutating one --
# so it works. summarise_results below re-checks the device from the report afterwards,
# since the env var offers no up-front guarantee.
#
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$REPO_ROOT" || exit 1
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
# Empty means "let each level pick" -- see gpu_for_api. Setting GPU_MODE forces one renderer
# on every level, which is what you want when measuring a mode, not when running the suite.
GPU_MODE="${GPU_MODE:-}"
EMULATOR_PORT="${EMULATOR_PORT:-5560}"
BOOT_TIMEOUT="${BOOT_TIMEOUT:-300}"
SERIAL="emulator-${EMULATOR_PORT}"
APIS=("$@")
[ "${#APIS[@]}" -eq 0 ] && APIS=(33 34 35 36 37)
# Matches CI. `disk-size: 8G` because the FFmpeg libraries do not fit the default userdata
# partition; `ram-size: 2560M` because the emulator's own floor varies by API level and
# 2560 is the highest of them, so no level ends up with less than it had. Both are
# explained at length in status_check.yml -- keep them in step with it.
DISK_SIZE_BYTES=8589934592
RAM_SIZE_MB=2560
RESULTS_DIR="app/build/outputs/androidTest-results"
LOG_DIR="${TMPDIR:-/tmp}/lmc-local-e2e"
mkdir -p "$LOG_DIR"
# The two things that outlive a level, declared here rather than where they are first
# assigned, because the cleanup trap below can fire before either has been reached.
EMU_PID=""
CREATED_AVDS=()
# ---------------------------------------------------------------- renderer preflight ---
if [ -n "$GPU_MODE" ]; then
case "$GPU_MODE" in
swiftshader_indirect | auto | off | guest)
echo "REFUSING to launch with -gpu $GPU_MODE."
echo "On this host that resolves to SwiftShader's GLES, whose JIT is denied execheap by"
echo "SELinux; the emulator segfaults (exit 139) before boot. See docs/local-emulator.md."
echo "Working modes: host, angle_indirect, swangle_indirect."
exit 2
;;
host | angle_indirect | swangle_indirect) ;;
*)
echo "Unrecognised GPU_MODE '$GPU_MODE'. Known-good: host, angle_indirect, swangle_indirect."
exit 2
;;
esac
fi
# A courtesy, not a gate: the boolean being on means SwiftShader would work too, and the
# refusal list above could be relaxed. It is off on a stock Fedora.
if command -v getsebool > /dev/null 2>&1; then
if [ "$(getsebool selinuxuser_execheap 2> /dev/null | awk '{print $3}')" = "on" ]; then
echo "note: selinuxuser_execheap is ON, so SwiftShader modes would work here too."
fi
fi
# --------------------------------------------------------------------------- helpers ---
emu_adb() { adb -s "$SERIAL" "$@"; }
# The two probes that turned the original diagnosis around. CI has no use for them -- a
# runner has neither systemd-coredump nor SELinux in enforcing mode -- but on this host they
# are the difference between "it failed" and knowing why.
host_forensics() {
local since="$1"
echo "--- host: qemu coredumps since $since ---"
coredumpctl list --since "$since" --no-pager 2> /dev/null | grep -i qemu || echo " (none)"
echo "--- host: SELinux denials since $since ---"
journalctl --since "$since" --no-pager 2> /dev/null | grep -E 'avc: .*denied' | tail -10 || echo " (none)"
}
# The API 37 counterpart of host_forensics. `-gpu host` there aborts surfaceflinger in a loop
# and the device never boots; the working renderers abort it a few times and survive. Either way
# the count is the number to look at, and the crash buffer is where it lives -- so print it on
# every 37 level, not only on the failure path, because a level that passed with 40 aborts is
# telling you something a level that passed with 1 is not.
guest_forensics() {
local api="$1" n
case "$api" in 37 | 37.*) ;; *) return 0 ;; esac
n="$(emu_adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma')"
echo " surfaceflinger hasReadColorBufferDma aborts: ${n:-?} (docs/api-37-emulator-crash.md)"
}
# API label -> system image. API 33-36 are plain integers with a `google_apis` image. API 37
# is not: its SDK directories are dotted minor versions (`android-37.0`, `android-37.1`), there
# is no `android-37`, and from 37.1 onwards Google ships only 16 KB-page (`ps16k`) images for
# x86_64. `37` is accepted as a spelling of `37.0` because that is what people type.
image_pkg_for_api() {
case "$1" in
37 | 37.0) echo "system-images;android-37.0;google_apis;x86_64" ;;
37.1) echo "system-images;android-37.1;google_apis_ps16k;x86_64" ;;
*) echo "system-images;android-$1;google_apis;x86_64" ;;
esac
}
# The renderer requirement is per-API and the two levels want OPPOSITE things, which is why this
# is a function and not a constant.
#
# 33-36: must NOT be SwiftShader GLES (host-side SELinux/execheap segfault) -- `host` is right.
# 37.x: must NOT be the host GL translator. With `-gpu host` the guest's Gralloc5 mapper
# aborts surfaceflinger in a loop and the device never boots; under ANGLE the same
# assertion fires a handful of times and the boot survives it. Measured, not guessed --
# docs/api-37-emulator-crash.md has the matrix.
#
# `swangle_indirect` rather than `angle_indirect` for 37: both boot, and swangle names its
# renderer outright instead of resolving through `auto`'s path.
gpu_for_api() {
if [ -n "$GPU_MODE" ]; then
echo "$GPU_MODE"
return
fi
case "$1" in
37 | 37.*) echo "swangle_indirect" ;;
*) echo "host" ;;
esac
}
# `lmc_e2e_api37.0` would be a legal AVD name but an awkward one to type and to grep for.
# `37` and `37.0` therefore give two AVD names (`lmc_e2e_api37`, `lmc_e2e_api37_0`) for the one
# image. Harmless -- two AVDs off the same system image cost only disk -- and deliberately not
# normalised, so that `run-e2e.sh 37 37.0` does not have both levels fight over one AVD.
avd_for_api() { echo "lmc_e2e_api${1//./_}"; }
# Where avdmanager actually put the AVD. `$HOME/.android/avd` is only the default:
# ANDROID_AVD_HOME, ANDROID_USER_HOME and ANDROID_SDK_HOME each move it, and hardcoding the
# default meant a machine that sets any of them silently ran every level at stock RAM and
# userdata size. Rather than encode a precedence that cannot be verified from here, look in
# every location avdmanager honours and let the existence check pick.
avd_config_path() {
local avd="$1" base cfg
for base in "${ANDROID_AVD_HOME:-}" \
"${ANDROID_USER_HOME:+$ANDROID_USER_HOME/avd}" \
"${ANDROID_SDK_HOME:+$ANDROID_SDK_HOME/.android/avd}" \
"$HOME/.android/avd"; do
[ -n "$base" ] || continue
cfg="$base/${avd}.avd/config.ini"
if [ -f "$cfg" ]; then
echo "$cfg"
return 0
fi
done
return 1
}
ensure_avd() {
local api="$1" avd="$2"
local pkg
pkg="$(image_pkg_for_api "$api")"
local img_dir="$ANDROID_HOME/system-images/${pkg#system-images;}"
img_dir="${img_dir//;//}"
if avdmanager list avd -c 2> /dev/null | grep -qx "$avd"; then
echo " reusing existing AVD $avd"
else
if [ ! -d "$img_dir" ]; then
echo " installing $pkg"
# Read sdkmanager's own status, not the pipeline's. `yes` never ends, so the moment
# sdkmanager exits and closes the pipe, `yes` dies of SIGPIPE with 141 -- and this
# script runs under `pipefail`, which takes the rightmost NON-ZERO status. A package
# that installed perfectly therefore reported "FAILED to install".
#
# Measured rather than reasoned: under `set -o pipefail`, `yes | true` exits 141 on
# every run, and `yes | sh -c 'exit 3'` exits 3 -- so the pipeline status cannot tell
# a clean install from a broken one, while ${PIPESTATUS[1]} reports 0 and 3.
#
# The `echo no | avdmanager` below is deliberately NOT changed. One line fits the pipe
# buffer, so echo has already exited before the close and there is no signal to
# receive; `echo no | true` measured 0 on every run. Only an unbounded producer is
# exposed to this.
yes | sdkmanager --install "$pkg" > /dev/null 2>&1
if [ "${PIPESTATUS[1]}" -ne 0 ]; then
echo " FAILED to install $pkg"
return 1
fi
fi
echo " creating AVD $avd from $pkg"
echo no | avdmanager create avd -n "$avd" -k "$pkg" -d pixel_6 --force > /dev/null 2>&1 || {
echo " FAILED to create $avd"
return 1
}
CREATED_AVDS+=("$avd")
fi
# Written into config.ini rather than passed on the command line, which is how
# reactivecircus/android-emulator-runner applies the same two settings in CI. On the reuse
# path too, so an AVD left over from an older run gets today's pins.
#
# A level that cannot be pinned FAILS rather than running at the defaults. Unpinned, it
# dies much later with "not enough space", which reads as a device problem -- CI's own
# history is where that lesson comes from -- and nothing points back to a `sed` that
# edited a path this script guessed wrong.
local cfg
if ! cfg="$(avd_config_path "$avd")"; then
echo " FAILED: no config.ini for $avd in any directory avdmanager uses"
echo " (ANDROID_AVD_HOME=${ANDROID_AVD_HOME:-unset}, ANDROID_USER_HOME=${ANDROID_USER_HOME:-unset},"
echo " ANDROID_SDK_HOME=${ANDROID_SDK_HOME:-unset}, HOME=$HOME)"
return 1
fi
if ! sed -i -e '/^disk\.dataPartition\.size=/d' -e '/^hw\.ramSize=/d' "$cfg"; then
echo " FAILED to rewrite $cfg"
return 1
fi
if ! printf 'disk.dataPartition.size=%s\nhw.ramSize=%s\n' \
"$DISK_SIZE_BYTES" "$RAM_SIZE_MB" >> "$cfg"; then
echo " FAILED to write the RAM/disk pins into $cfg"
return 1
fi
}
boot_emulator() {
local avd="$1" api="$2" gpu="$3"
local boot_log="$LOG_DIR/emulator-api${api}.log"
emulator -avd "$avd" -port "$EMULATOR_PORT" \
-no-window -gpu "$gpu" -noaudio -no-boot-anim -camera-back none -no-snapshot \
> "$boot_log" 2>&1 &
EMU_PID=$!
local waited=0
while [ "$waited" -lt "$BOOT_TIMEOUT" ]; do
sleep 5
waited=$((waited + 5))
if ! kill -0 "$EMU_PID" 2> /dev/null; then
wait "$EMU_PID"
local rc=$?
echo " emulator DIED after ${waited}s (exit $rc)"
[ "$rc" -eq 139 ] && echo " exit 139 is the SwiftShader/execheap segfault -- docs/local-emulator.md"
echo " emulator log: $boot_log"
tail -20 "$boot_log"
return 1
fi
if [ "$(emu_adb shell getprop sys.boot_completed 2> /dev/null | tr -d '\r\n')" = "1" ]; then
echo " booted in ${waited}s"
return 0
fi
done
echo " emulator NEVER BOOTED within ${BOOT_TIMEOUT}s -- log: $boot_log"
return 1
}
# API 37 only, and the reason API 37 can be run at all.
#
# The abort that breaks these images is reached from SurfaceFlinger's RegionSamplingThread,
# which exists only because SystemUI registers a nav-bar luma-sampling listener. Each abort
# kills surfaceflinger, and init responds by SIGKILLing zygote -- so the whole framework
# restarts underneath the test run, which arrives as `Can't find service: package` and
# `INSTRUMENTATION_ABORTED: System has crashed`. Under the host GL renderer that repeats
# forever; under ANGLE it is roughly one every fifteen seconds, which a five-minute suite does
# not survive either.
#
# Removing the listener removes the whole chain. Measured on android-37.0 under
# swangle_indirect: 10-11 aborts per 150 s idle with SystemUI running, and 0 in 180 s with it
# disabled, framework services up throughout.
#
# THIS IS A DEVIATION, and it is deliberately loud rather than silent. The API 37 leg does not
# run the same device configuration as API 33-36 or as the Pixel. It is defensible only
# because nothing in this suite touched SystemUI -- Media3, FFmpeg and WorkManager tests --
# and because the alternative is no API 37 coverage at all. Anything that ever does depend on
# system UI must not trust this leg. docs/api-37-emulator-crash.md explains why.
#
# "Touched", past tense, since 2026-08-24. SafPickerRoundTripTest drives DocumentsUI and rotates
# the display, and both reach the gralloc mapper these images abort in -- disabling SystemUI
# removes the IDLE trigger, not those. Measured per method on android-37.0: the ROTATION test
# takes the framework down (INSTRUMENTATION_ABORTED) and carries @FailsOnEmulatorApi37; the
# picker test passes.
#
# THIS SCRIPT APPLIES NO ANNOTATION FILTER, unlike CI, so a local `run-e2e.sh 37` runs the
# rotation test anyway -- and because that test kills the framework rather than merely failing,
# THE LEVEL DOES NOT FINISH. Its totals come back short and which later tests ran is arbitrary.
# CI's gating leg never sees it.
#
# The retry loop is not defensive padding: at the moment boot_completed flips, the framework
# may be in one of its restarts and `pm` is simply not published yet. The first attempt at this
# failed exactly that way, with `cmd: Can't find service: package`.
#
# The framework restart at the end is not optional, and finding that out cost a run. By the
# time `sys.boot_completed` flips, SystemUI has already registered its region-sampling listener,
# and `pm disable-user` does not retract a registration that already happened -- it only stops
# the package being started again. So the first attempt disabled SystemUI, reported success, and
# then died exactly as before with `Starting 0 tests` and four more aborts. `stop; start` cycles
# zygote deliberately, and the framework that comes back up does not start SystemUI at all.
disable_region_sampling() {
local api="$1" out i before after ready
case "$api" in 37 | 37.*) ;; *) return 0 ;; esac
out=""
for i in $(seq 1 20); do
out="$(emu_adb shell pm disable-user --user 0 com.android.systemui 2>&1 | tr -d '\r')"
case "$out" in
*"new state: disabled"*)
echo " SystemUI disabled on attempt $i"
break
;;
esac
out=""
sleep 5
done
if [ -z "$out" ]; then
echo " WARNING: could not disable SystemUI after 20 attempts."
echo " Expect INSTRUMENTATION_ABORTED -- docs/api-37-emulator-crash.md"
return 0
fi
echo " restarting the framework so the region-sampling listener goes with it"
emu_adb shell stop > /dev/null 2>&1
emu_adb shell start > /dev/null 2>&1
# There is no property worth waiting on here, and an earlier version of this only looked
# like it was waiting on one: `stop` does not clear sys.boot_completed, so it still reads
# `1` throughout the restart and any loop over it returns at once. The loop below is the
# wait -- and it polls the better thing anyway, since `Can't find service: package` is the
# failure it exists to prevent.
ready=0
for i in $(seq 1 30); do
if emu_adb shell service check package 2> /dev/null | grep -q ': found' \
&& emu_adb shell service check activity 2> /dev/null | grep -q ': found'; then
ready=1
break
fi
sleep 5
done
if [ "$ready" -ne 1 ]; then
echo " WARNING: package and activity services still absent 150 s after the restart."
echo " Expect INSTRUMENTATION_ABORTED -- docs/api-37-emulator-crash.md"
fi
# Prove it worked rather than assume it. Zero new aborts over this window is what makes the
# difference between a run that completes and one that reports `Starting 0 tests`.
before="$(emu_adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma')"
emu_adb shell 'sleep 45' > /dev/null 2>&1
after="$(emu_adb logcat -d -b crash 2> /dev/null | grep -c 'hasReadColorBufferDma')"
echo " quiet check: $((after - before)) new surfaceflinger aborts in 45 s (want 0)"
if [ "$((after - before))" -ne 0 ]; then
echo " WARNING: region sampling is still live; the run may not survive."
fi
return 0
}
# CI gets this from the action's `disable-animations: true`.
disable_animations() {
local s
for s in window_animation_scale transition_animation_scale animator_duration_scale; do
emu_adb shell settings put global "$s" 0.0 > /dev/null 2>&1 || true
done
}
# `${EMU_PID:-0}` used to guard these three calls, and it guarded the wrong thing: EMU_PID
# is *empty*, not unset, if the background launch never produced a job, and `kill` reads pid
# 0 as "the sender's whole process group" -- this script and, on a terminal, everything else
# in the foreground group with it. The `kill -0` wait loop had the same shape and would have
# spent its full grace period testing the group. Nothing to stop is now a return, never a
# guess. (boot_emulator's own `kill -0 "$EMU_PID"` is unguarded and cannot reach that form:
# it runs only after the assignment.)
#
# max_wait is a parameter so the interrupt path need not sit through the full grace period.
stop_emulator() {
local max_wait="${1:-30}" waited=0
[ -n "${EMU_PID:-}" ] || return 0
emu_adb emu kill > /dev/null 2>&1
while kill -0 "$EMU_PID" 2> /dev/null && [ "$waited" -lt "$max_wait" ]; do
sleep 2
waited=$((waited + 2))
done
kill -9 "$EMU_PID" 2> /dev/null
wait "$EMU_PID" 2> /dev/null
EMU_PID=""
}
delete_created_avds() {
local avd
[ "${KEEP_AVD:-0}" = "1" ] && return 0
for avd in ${CREATED_AVDS[@]+"${CREATED_AVDS[@]}"}; do
# A SIGKILLed emulator does not get to remove its own lock files, and avdmanager can
# refuse over them. Staying silent there would leak the very thing this exists to clean.
avdmanager delete avd -n "$avd" > /dev/null 2>&1 \
|| echo " WARNING: could not delete AVD $avd -- 'avdmanager delete avd -n $avd' by hand"
done
CREATED_AVDS=()
}
# What an interrupted sweep used to leave behind: a headless emulator holding console port
# $EMULATOR_PORT, and an lmc_e2e_apiNN AVD. The next run's `emulator -port` then collides
# with the orphan, and `emu_adb` can resolve to it -- on a workstation that also has the
# Pixel plugged in, exactly the ambiguity the ANDROID_SERIAL pinning exists to prevent. A
# sweep is up to five boots long, so the window for one Ctrl-C is not small.
#
# Idempotent, and called explicitly on the normal path so its output cannot land after the
# summary; the EXIT trap then finds nothing left to do. The emulator logs are deliberately
# NOT removed -- they live in $LOG_DIR and are the only evidence a failed boot leaves.
CLEANED=0
cleanup() {
[ "$CLEANED" = "1" ] && return 0
CLEANED=1
stop_emulator "${1:-30}"
delete_created_avds
}
# 6 s, not 30: Ctrl-C has already reached the emulator through the foreground process group,
# so this is only waiting for it to finish writing, and `kill -9` follows regardless. The
# EXIT trap is disarmed before exiting so the status below is the one that survives.
#
# bash runs a trap only between commands, so this starts when whatever was in the foreground
# returns -- which for Ctrl-C is immediately, because the same interrupt reached that command
# too. `kill -INT` aimed at this script alone waits for the foreground command to finish.
# Invoked indirectly -- installed as the INT and TERM trap a few lines below. Both codes,
# because shellcheck 0.9.0 reports this as unreachable commands (SC2317) and 0.11.0 as an
# uninvoked function (SC2329); CI pins 0.11.0 but a local install may be either.
# shellcheck disable=SC2317,SC2329
on_signal() {
echo
echo "interrupted (SIG$1) -- stopping the emulator and removing the AVDs this run created"
echo " emulator logs kept in $LOG_DIR"
cleanup 6
trap - EXIT
exit "$2"
}
trap 'on_signal INT 130' INT
trap 'on_signal TERM 143' TERM
trap cleanup EXIT
# The XML is authoritative. The console counter double-counts skips, so a run that reports
# "42 tests" on stdout can be 40 in the report.
#
# It also names the device it ran on, in the file name and in the suite's `hostname`. That is
# printed rather than just counted, because it is the only after-the-fact proof that this
# level ran fresh and ran on the emulator -- see the ANDROID_SERIAL note in the header.
summarise_results() {
local api="$1"
python3 - "$api" "$RESULTS_DIR" << 'PY'
import glob, os, sys, xml.etree.ElementTree as ET
api, results_dir = sys.argv[1], sys.argv[2]
t = f = e = s = 0
devices = set()
files = sorted(glob.glob(os.path.join(results_dir, "**", "TEST-*.xml"), recursive=True))
for path in files:
try:
root = ET.parse(path).getroot()
except ET.ParseError:
continue
suites = [root] if root.tag == "testsuite" else list(root.iter("testsuite"))
for suite in suites:
t += int(suite.get("tests", 0)); f += int(suite.get("failures", 0))
e += int(suite.get("errors", 0)); s += int(suite.get("skipped", 0))
if suite.get("hostname"):
devices.add(suite.get("hostname"))
devices.add(os.path.basename(path))
if not files:
print(f"API {api}: no result XML found under {results_dir}")
else:
print(f"API {api}: tests={t} failures={f} errors={e} skipped={s}")
for d in sorted(devices):
print(f" ran on/report: {d}")
PY
}
# ------------------------------------------------------------------------------ main ---
SUMMARY=()
overall=0
# Whether the red exit is the expected one depends on which level produced it, and only the
# loop knows that -- so it is recorded where `overall` is set rather than guessed from the
# summary afterwards. A note at the end claiming a genuine API 34 failure was "by design"
# would be the same defect it is there to prevent, one layer up.
NON37_RED=0
mark_red() {
overall=1
case "$1" in 37 | 37.*) ;; *) NON37_RED=1 ;; esac
}
for api in "${APIS[@]}"; do
avd="$(avd_for_api "$api")"
gpu="$(gpu_for_api "$api")"
started="$(date '+%Y-%m-%d %H:%M:%S')"
echo "=============================================================="
echo "API $api (avd=$avd gpu=$gpu serial=$SERIAL)"
echo " image: $(image_pkg_for_api "$api")"
echo "=============================================================="
if ! ensure_avd "$api" "$avd"; then
SUMMARY+=("API $api: AVD SETUP FAILED")
mark_red "$api"
continue
fi
if ! boot_emulator "$avd" "$api" "$gpu"; then
host_forensics "$started"
guest_forensics "$api"
SUMMARY+=("API $api: BOOT FAILED")
mark_red "$api"
stop_emulator
continue
fi
disable_region_sampling "$api"
disable_animations
rm -rf "$RESULTS_DIR"
# ANDROID_SERIAL steers both e2e-run.sh's own bare `adb` calls and Gradle's device choice.
# --rerun because each level must actually re-execute: without it a task Gradle considers
# up-to-date would leave the previous level's XML in place, and every row of the summary
# would report the same numbers.
export ANDROID_SERIAL="$SERIAL"
export E2E_EXTRA_GRADLE_ARGS="--rerun"
bash .github/scripts/e2e-run.sh "$api"
rc=$?
unset ANDROID_SERIAL E2E_EXTRA_GRADLE_ARGS
line="$(summarise_results "$api")"
guest_forensics "$api"
# API 37 is in the default list on purpose, and it is expected to be red. Leaving it out would
# put the level back where this whole exercise found it -- untested and unlooked-at -- but a
# summary that just says "N failures" with no explanation trains people to ignore the exit
# code. So the row NAMES the expected ones, and anything else is then obviously new.
#
# The list grew on 2026-08-24 and the shape of the row changed with it. The two
# Media3EngineTest failures are a codec; the third is the gralloc bug reached through system
# UI, and it takes the framework DOWN rather than merely failing -- so the level does not
# finish, and the totals come back SHORT (50 of 59 when this was written) with the later
# tests never run. A run whose totals do not add up is expected here now, which it never
# was before.
case "$api" in
37 | 37.*)
line="$line
expected here: 2 Media3EngineTest failures on c2.goldfish.h264.decoder, plus
SafPickerRoundTripTest.thePickedInputSurvivesARealRotation -- which kills the framework
rather than merely failing, so the run ABORTS partway and the total comes back SHORT with
an arbitrary tail. That is expected here too, and never was before. Anything else is new.
CI's gating leg sees only the first two: the rotation test carries @FailsOnEmulatorApi37
and this script, unlike CI, applies no annotation filter.
docs/api-37-emulator-crash.md"
;;
esac
if [ "$rc" -ne 0 ]; then
line="$line [gradle exit $rc]"
mark_red "$api"
host_forensics "$started"
fi
SUMMARY+=("$line")
stop_emulator
done
cleanup
echo
echo "===================== LOCAL E2E SUMMARY ======================"
printf '%s\n' ${SUMMARY[@]+"${SUMMARY[@]}"}
echo "=============================================================="
# An unexplained red exit trains people to stop reading exit codes, and this one is expected
# whenever API 37 is in the sweep -- which the default list makes the common case. Said here
# rather than only in the docs, because this is where it is actually read. Only when 37.x is
# the ONLY thing that went red: a note calling a real failure elsewhere "by design" would be
# worse than no note at all.
if [ "$overall" -ne 0 ] && [ "$NON37_RED" -eq 0 ]; then
echo "note: the only level that went red is API 37, which exits non-zero by design -- it is"
echo " permanently short of green, and since 2026-08-24 it does not even finish. Confirm"
echo " its row above names every failure it shows; docs/api-37-emulator-crash.md says why"
echo " each of them is the image rather than this app."
fi
exit "$overall"