#!/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 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 Environment: AVD_NAME overrides the name, EMU_FORCE=1 overrides the memory refusal, 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. rss=$(ps -eo rss=,comm=,args= | awk -v want="-avd $name" '$2 ~ /^qemu-system/ && index($0, want) {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. pkill -f "[e]mulator.*-avd $avd" >/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 } 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 ;; *) usage ;; esac