Files
emulator-tools/bin/emu
T
irisandClaude Opus 5 80d8324b4f emu: record the 2026-09-08 host-Vulkan retry, so nobody spends a boot on it
The comment invited a retry whenever mesa moves, and mesa moved
(26.1.7 -> 26.2.2). Retried: byte-identical fatal error, gfxstream still
cannot find a memory type for its ColorBuffers because Venus does not
support exporting VK_FORMAT_R8G8B8A8_UNORM the way it asks. Emulator
37.1.11 is still the newest offered and still ships only OpaqueFd/Metal/
none as external-memory modes, so there is no mode to switch to.

Comment only -- no behaviour change. Also records what the guest does get,
measured inside it: GLES on the host's real GPU through virgl, OpenGL ES
3.1, compute shaders available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:04:07 -04:00

353 lines
16 KiB
Bash
Executable File

#!/bin/bash
# This checkout's emulator: which one it is, whether there is room for it,
# and getting it up and down.
#
# The AVD is named after the enclosing git checkout and nobody has to say so
# (see `project_avd`). That is the whole point: several agent sessions work on
# this machine at once, and an emulator named after the checkout is one that
# cannot be somebody else's to install onto or take the foreground from.
#
# `up` refuses rather than tries when memory is short, which is not caution
# for its own sake. On 2026-08-30 an emulator started with 2.8 GB available
# invoked the OOM killer, and what it took was not the emulator that had just
# started: it walked the user slice and killed pipewire, dbus-broker and
# another session's running emulator first. The cost of one too many lands on
# somebody else's work, minutes later, looking like an unrelated crash.
set -euo pipefail
. "$(dirname "$(readlink -f "$0")")/../lib/project-avd.sh"
# What one costs, measured: 3.8 GB resident for a headless x86_64 AVD, plus
# what it grows into while an app builds and installs.
EMU_EXPECTED_MB=${EMU_EXPECTED_MB:-4200}
# What must be left afterwards. A machine at exactly zero swaps itself to a
# halt rather than failing cleanly.
EMU_HEADROOM_MB=${EMU_HEADROOM_MB:-1200}
# Two are comfortable on 23 GB; three is what invoked the OOM killer.
EMU_MAX_RUNNING=${EMU_MAX_RUNNING:-2}
DEVICE_PROFILE=${DEVICE_PROFILE:-pixel_10}
SYSTEM_IMAGE=${SYSTEM_IMAGE:-system-images;android-36;google_apis;x86_64}
sdk=$(android_sdk) || {
echo "emu: no Android SDK found; set ANDROID_HOME" >&2
exit 127
}
adb=$(android_adb)
avd=$(project_avd)
export ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.android/avd}"
usage() {
cat >&2 <<'USAGE'
usage: emu <command>
name the AVD this directory means
serial its adb serial, if it is running (exit 1 if not)
list every emulator attached, with its AVD and what it is costing
up start it, refusing if the machine has no room
down stop it
check refuse if a command that reaches every attached device would
reach another checkout's emulator from here
Environment: EMU_GPU=software renders on the CPU instead of the host's GPU,
AVD_NAME overrides the name, EMU_FORCE=1 overrides the memory refusal,
EMU_ANY_DEVICE=1 overrides `check`, DEVICE_PROFILE and SYSTEM_IMAGE decide
what `up` creates.
USAGE
exit 2
}
# Every emulator's serial, AVD and resident size -- the last being the number
# the refusal below is about, so it is worth seeing before asking for another.
cmd_list() {
found=false
while IFS=$'\t' read -r serial name; do
found=true
# Matched on the AVD name the emulator was started with, which is
# what its process actually carries -- there is no -port in that
# command line, and the console port in the serial does not appear
# in it. Filtered on the process *name* rather than on the whole
# line, so this cannot match the shell running it: that shell's
# command line contains this pattern, and a `ps | awk` that matches
# itself reports the size of the shell.
# Whole argument rather than a prefix, for the reason `cmd_up`
# gives: with both up, a substring match reported ai-app-2's size
# against ai-app, which is a number that looks entirely plausible.
rss=$(ps -eo rss=,comm=,args= |
awk -v want="-avd $name" '$2 ~ /^qemu-system/ &&
match($0, want "([[:space:]]|$)") {print int($1/1024); exit}')
printf ' %-16s %-24s %s\n' "$serial" "$name" "${rss:+$rss MB}"
done < <(running_avds "$adb")
[ "$found" = true ] || echo " (none attached)"
}
# Whether starting one now is likely to cost somebody else their work.
#
# Reported in full rather than as a bare refusal: the caller cannot see this
# machine, and "not enough memory" without the numbers or a way forward is a
# message that gets overridden blind.
check_room() {
available=$(mem_available_mb)
needed=$((EMU_EXPECTED_MB + EMU_HEADROOM_MB))
running=$(running_avds "$adb" | wc -l)
problem=""
if [ "$running" -ge "$EMU_MAX_RUNNING" ]; then
problem="$running emulator(s) are already running, and $EMU_MAX_RUNNING is as many as this machine takes"
elif [ "$available" -lt "$needed" ]; then
problem="$available MB available, and one of these needs about $EMU_EXPECTED_MB MB plus $EMU_HEADROOM_MB MB of headroom"
fi
[ -z "$problem" ] && return 0
if [ "${EMU_FORCE:-}" = 1 ]; then
echo "emu: $problem -- starting anyway because EMU_FORCE=1" >&2
return 0
fi
{
echo "emu: not starting '$avd' -- $problem."
echo
echo " Emulators attached:"
cmd_list
echo
echo " Biggest processes:"
ps -eo rss,comm --sort=-rss | awk 'NR>1 && NR<=6 {printf " %-24s %6.0f MB\n", $2, $1/1024}'
echo
echo " What usually frees enough:"
echo " gradle --stop (an idle Gradle daemon holds 1-2 GB between builds)"
echo " emu down (in the checkout that owns an emulator you are done with)"
echo " ask the session that owns one -- they are named after their checkout"
echo " EMU_FORCE=1 emu up (if you have decided the numbers above are wrong)"
} >&2
exit 1
}
# Host Vulkan is off, and this is the one flag here that is about a bug
# rather than a preference.
#
# gfxstream asks the host for a memory type it can use for its ColorBuffers,
# and Venus -- the virtio-gpu Vulkan driver this VM sees the real GPU
# through -- does not offer one: "Format VK_FORMAT_R8G8B8A8_UNORM is not
# supported", then "Failed to find memory type for ColorBuffers", then the
# emulator dies before adb ever sees it. GLES on the same driver is fine, and
# is what the guest falls back to, so the only thing lost is Vulkan inside
# the guest. Try dropping this whenever mesa or the emulator package moves:
# the failure is loud and immediate, so it costs one boot to find out.
#
# Retried 2026-09-08 on mesa 26.2.2 (was 26.1.7): identical fatal error, and
# emulator 37.1.11 is still the newest offered, its libgfxstream_backend.so
# still knowing only OpaqueFd/Metal/none as external-memory modes -- so there
# is no mode to switch to and nothing to do here but wait. What the guest
# does get, measured in it the same day: GLES on the host's real GPU through
# virgl, reporting OpenGL ES 3.1 with compute shaders.
GPU_HOST_FEATURES=${GPU_HOST_FEATURES:--feature -Vulkan}
# An X server on this machine's GPU, started once and reused, or nothing.
#
# The emulator's `-gpu host` renderer talks GLX. With no DISPLAY it cannot
# open one -- "GlxEnginegetDefaultDisplay: Failed to open display 0" -- and
# the only other thing it will do is rasterise in software. Measured on this
# VM on 2026-08-30, that is not a small difference: scrolling a list in the
# stock Settings app went from 25% janky frames to 3%, and the emulator's own
# resident size fell from 3.9 GB to 2.9 GB.
#
# So: a headless sway, purely to get an Xwayland whose GLX runs on the
# virtio-gpu render node. It needs no seat, no DRM master and no card node --
# only /dev/dri/renderD128, which is world-readable here. Writes nothing
# outside XDG_RUNTIME_DIR and goes away with the machine.
#
# Prints the DISPLAY it ended up with; exit 1 means the caller should fall
# back to software rather than fail, because none of this is essential.
headless_display() {
command -v sway >/dev/null 2>&1 || return 1
[ -e /dev/dri/renderD128 ] || return 1
run="${XDG_RUNTIME_DIR:-/tmp}/emulator-tools"
mkdir -p "$run"
export SWAYSOCK="$run/sway.sock"
# Named rather than left to sway's pid-based default, which is what makes
# a second `emu up` reuse the first one's compositor instead of starting
# another beside it.
if ! swaymsg -t get_version >/dev/null 2>&1; then
rm -f "$SWAYSOCK"
conf="$(dirname "$(readlink -f "$0")")/../share/emu/sway.conf"
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \
setsid sway -c "$conf" >"$run/sway.log" 2>&1 &
for _ in $(seq 20); do
swaymsg -t get_version >/dev/null 2>&1 && break
sleep 0.5
done
swaymsg -t get_version >/dev/null 2>&1 || {
echo "emu: could not start the headless compositor; see $run/sway.log" >&2
return 1
}
fi
# Asked of the compositor rather than guessed, because Xwayland takes the
# first free display number and this machine may already have one.
rm -f "$run/display"
swaymsg exec -- "sh -c 'printf %s \"\$DISPLAY\" > $run/display'" >/dev/null 2>&1
for _ in $(seq 20); do
[ -s "$run/display" ] && break
sleep 0.5
done
[ -s "$run/display" ] || return 1
cat "$run/display"
}
cmd_up() {
if serial=$(avd_serial "$adb" "$avd"); then
echo "emu: '$avd' is already running ($serial)" >&2
echo "$serial"
return 0
fi
check_room
if [ ! -f "$ANDROID_AVD_HOME/$avd.ini" ]; then
echo "emu: creating AVD '$avd' ($DEVICE_PROFILE, $SYSTEM_IMAGE)" >&2
"$sdk/cmdline-tools/latest/bin/sdkmanager" "emulator" "$SYSTEM_IMAGE" >/dev/null ||
echo "emu: sdkmanager could not confirm the system image; trying anyway" >&2
echo no | "$sdk/cmdline-tools/latest/bin/avdmanager" create avd \
-n "$avd" -k "$SYSTEM_IMAGE" --device "$DEVICE_PROFILE" --sdcard 512M >&2
fi
# The host keyboard, so anything with a text field can be typed into
# rather than tapped out on the on-screen one.
config="$ANDROID_AVD_HOME/$avd.avd/config.ini"
if [ -f "$config" ] && ! grep -qx 'hw.keyboard=yes' "$config"; then
grep -v '^hw\.keyboard=' "$config" >"$config.tmp"
echo "hw.keyboard=yes" >>"$config.tmp"
mv "$config.tmp" "$config"
fi
# A stray process for this AVD that never registered with adb. The
# bracketed first character keeps the pattern from matching the shell
# running this script, which would kill it mid-flight.
#
# The trailing boundary is the other half, and it is not decoration: an
# AVD name is a *prefix* of every longer one, so `-avd ai-app` matched
# `-avd ai-app-2` and starting one checkout's emulator silently killed
# another checkout's. That is the exact interference this whole tool
# exists to prevent, and it looked from the other side like an emulator
# dying on its own -- twice, on 2026-08-31, before the pattern was the
# suspect. Match the name as a whole argument, never as a prefix.
pkill -f "[e]mulator.*-avd $avd([[:space:]]|\$)" >/dev/null 2>&1 || true
log="/tmp/$avd-emulator.log"
rm -f "$log"
# `-gpu host` runs the guest's GLES on this machine's real GPU, which here
# means the *host's* GPU reached through virtio-gpu -- so a headless
# emulator nobody is watching still renders onto the desktop somebody is
# sitting at. It stays the default because it was measured (25% janky
# frames against 3%, and 3.9 GB resident against 2.9 GB) and the Compose
# scroll benchmarks depend on it. EMU_GPU=software opts out, for work
# where the emulator's own frame rate is not what is under test: it
# renders on the CPU and touches no GPU at all, which also means the
# guest gets a software Vulkan device (SwiftShader) instead of the host
# Vulkan that gfxstream cannot pair with Venus here.
if [ "${EMU_GPU:-}" != software ] && { [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; }; then
echo "emu: starting '$avd' with GPU acceleration" >&2
"$sdk/emulator/emulator" -avd "$avd" -gpu host -no-audio >"$log" 2>&1 &
elif [ "${EMU_GPU:-}" != software ] && display=$(headless_display); then
echo "emu: no display -- starting '$avd' headless on the GPU ($display)" >&2
DISPLAY="$display" "$sdk/emulator/emulator" -avd "$avd" \
-gpu host $GPU_HOST_FEATURES -no-audio -no-window >"$log" 2>&1 &
else
if [ "${EMU_GPU:-}" = software ]; then
echo "emu: EMU_GPU=software -- starting '$avd' on the CPU, off the host GPU" >&2
else
echo "emu: no display and no compositor -- starting '$avd' on software rendering" >&2
fi
"$sdk/emulator/emulator" -avd "$avd" -gpu swiftshader_indirect -no-audio -no-window \
>"$log" 2>&1 &
fi
pid=$!
for _ in $(seq 150); do
if ! kill -0 "$pid" 2>/dev/null; then
echo "emu: the emulator exited before it finished booting:" >&2
tail -20 "$log" >&2
exit 1
fi
if serial=$(avd_serial "$adb" "$avd"); then
booted=$("$adb" -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
if [ "$booted" = 1 ]; then
echo "emu: '$avd' booted ($serial)" >&2
echo "$serial"
return 0
fi
fi
sleep 2
done
echo "emu: '$avd' did not finish booting in five minutes; log at $log" >&2
exit 1
}
cmd_down() {
if ! serial=$(avd_serial "$adb" "$avd"); then
echo "emu: '$avd' is not running" >&2
return 0
fi
"$adb" -s "$serial" emu kill >/dev/null
rm -f "${XDG_RUNTIME_DIR:-/tmp}/emulator-tools/$avd.serial"
echo "emu: stopped '$avd' ($serial)" >&2
}
# Whether a command that talks to "every attached device" can safely run in
# this directory.
#
# Gradle's Android install, uninstall and connected-test tasks do exactly
# that: they ask the adb server for every device and act on all of them. With
# two sessions' emulators up, `./gradlew installDebug` replaces the app on
# both, reports success, and says nothing -- from the other session it reads
# as its own build never landing. That happened on 2026-08-31.
#
# The rule is the adb wrapper's, so that "which device does a command here
# mean" has one answer: what must not be touched is *another checkout's
# emulator*. A physical phone is nobody's checkout, and this checkout's own
# emulator is this checkout's business.
cmd_check() {
[ -n "${EMU_ANY_DEVICE:-}" ] && return 0
others=$(running_avds "$adb" | awk -F'\t' -v mine="$avd" 'NF && $2 != mine')
[ -n "$others" ] || return 0
# A caller that named a device has already narrowed the fan-out to one,
# and choosing a phone or their own emulator is a decision, not a
# mistake. The single bad case is naming somebody else's -- which is
# what a serial exported into a long-lived shell decays into, once that
# emulator restarts and another checkout's takes the port.
if [ -n "${ANDROID_SERIAL:-}" ]; then
clash=$(printf '%s\n' "$others" |
awk -F'\t' -v s="$ANDROID_SERIAL" '$1 == s {print $2; exit}')
[ -n "$clash" ] || return 0
{
echo "emu: ANDROID_SERIAL=$ANDROID_SERIAL is the '$clash' checkout's emulator."
echo " This directory means '$avd'."
echo
echo " ANDROID_SERIAL=\$(emu serial) <command> aim at '$avd' instead"
} >&2
exit 1
fi
{
echo "emu: this reaches every attached device, and these belong to other checkouts:"
printf '%s\n' "$others" | while IFS=$'\t' read -r s n; do
printf ' %-16s %s\n' "$s" "$n"
done
echo
echo " ANDROID_SERIAL=\$(emu serial) <command> aim at this checkout's '$avd'"
echo " emu up start '$avd', if it is not running"
echo " EMU_ANY_DEVICE=1 <command> really mean every device"
} >&2
exit 1
}
case "${1:-}" in
name) echo "$avd" ;;
serial) avd_serial "$adb" "$avd" || { echo "emu: '$avd' is not running" >&2; exit 1; } ;;
list) cmd_list ;;
up) cmd_up ;;
down) cmd_down ;;
check) cmd_check ;;
*) usage ;;
esac