The adb wrapper can only aim a call that goes through it, and Gradle's Android tasks do not: installDebug, uninstallDebug and connectedAndroidTest ask the adb server for every attached device and act on all of them. One session ran installDebug with two emulators up and replaced the app on both, which Gradle reported as success and the other session read as its own build never landing. `emu check` answers the question the wrapper already answers for adb -- would a command that reaches every attached device reach a stranger's emulator from here -- and install.sh links a Gradle init script into ~/.gradle/init.d so every build on this machine asks it, including checkouts nobody has adopted it in. It refuses narrowly: a lone emulator, a physical phone and a caller who named a device are all fine. ANDROID_SERIAL naming another checkout's emulator is not, because that is what a serial exported into a long-lived shell decays into once an emulator restarts and another's takes the port.
331 lines
14 KiB
Bash
Executable File
331 lines
14 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: 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 moves: the failure is loud and
|
|
# immediate, so it costs one boot to find out.
|
|
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"
|
|
if [ -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 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
|
|
echo "emu: no display and no compositor -- starting '$avd' on software rendering" >&2
|
|
"$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
|