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>
353 lines
16 KiB
Bash
Executable File
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
|