Stop Gradle installing on every checkout's emulator at once
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.
This commit is contained in:
1 parent
1da445ae32
commit
69a22c54e7
4 files changed
+158
-2
No files matched your search
@@ -14,11 +14,12 @@ anything it replaces as `<name>.bak`.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `emu` | this checkout's emulator: `name`, `serial`, `list`, `up`, `down` |
|
| `emu` | this checkout's emulator: `name`, `serial`, `list`, `up`, `down`, `check` |
|
||||||
| `adb` | adb, aimed at this checkout's emulator, with screenshots scaled |
|
| `adb` | adb, aimed at this checkout's emulator, with screenshots scaled |
|
||||||
| `ui-trace` | records a screen as text at 60Hz, and drives it |
|
| `ui-trace` | records a screen as text at 60Hz, and drives it |
|
||||||
| `lib/project-avd.sh` | the rules all three share |
|
| `lib/project-avd.sh` | the rules all three share |
|
||||||
| `share/ui-trace/` | the device recorder (`UiTrace.java`) and its build |
|
| `share/ui-trace/` | the device recorder (`UiTrace.java`) and its build |
|
||||||
|
| `share/gradle-init/` | the init script that stops Gradle installing on every device |
|
||||||
|
|
||||||
## One emulator per checkout, named after it
|
## One emulator per checkout, named after it
|
||||||
|
|
||||||
@@ -40,6 +41,38 @@ about one device (`adb devices`) all turn the defaulting off, and nothing is
|
|||||||
guessed when this checkout's emulator is not running: adb's own error is
|
guessed when this checkout's emulator is not running: adb's own error is
|
||||||
better than a wrapper picking a stranger's device.
|
better than a wrapper picking a stranger's device.
|
||||||
|
|
||||||
|
## Gradle is the hole the wrapper cannot cover
|
||||||
|
|
||||||
|
`adb` 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. On 2026-08-31
|
||||||
|
one session ran `./gradlew :androidApp:installDebug` with two emulators up
|
||||||
|
and replaced the app on both. Gradle reported success; from the other
|
||||||
|
session it looked like its own build had never landed.
|
||||||
|
|
||||||
|
So `install.sh` also links `share/gradle-init/emulator-tools.gradle` into
|
||||||
|
`~/.gradle/init.d`, where Gradle applies it to **every** build on this
|
||||||
|
machine — including checkouts nobody has adopted it in, which are exactly
|
||||||
|
the ones this is protecting. Before any of those tasks runs it calls
|
||||||
|
|
||||||
|
emu check
|
||||||
|
|
||||||
|
in the project's own directory. That is the same rule the `adb` wrapper
|
||||||
|
follows, in one place: what must not be touched is *another checkout's
|
||||||
|
emulator*. A physical phone is nobody's checkout, one emulator on its own is
|
||||||
|
nobody else's business, and a caller who named a device has already narrowed
|
||||||
|
the fan-out to it — so `check` passes all of those, and refuses only when a
|
||||||
|
stranger's emulator is in range. `ANDROID_SERIAL` pointing at one is refused
|
||||||
|
too, because that is what a serial exported into a long-lived shell decays
|
||||||
|
into once an emulator restarts and another checkout's takes the port.
|
||||||
|
|
||||||
|
Say which device you mean at the moment you use it rather than exporting it:
|
||||||
|
|
||||||
|
ANDROID_SERIAL=$(emu serial) ./gradlew :androidApp:installDebug
|
||||||
|
|
||||||
|
`EMU_ANY_DEVICE=1` means every attached device anyway, for the case that is
|
||||||
|
genuinely what you want.
|
||||||
|
|
||||||
## Rendering on the GPU with no display
|
## Rendering on the GPU with no display
|
||||||
|
|
||||||
This machine has no screen, and for a long time that meant the emulator
|
This machine has no screen, and for a long time that meant the emulator
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ usage: emu <command>
|
|||||||
list every emulator attached, with its AVD and what it is costing
|
list every emulator attached, with its AVD and what it is costing
|
||||||
up start it, refusing if the machine has no room
|
up start it, refusing if the machine has no room
|
||||||
down stop it
|
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
|
Environment: AVD_NAME overrides the name, EMU_FORCE=1 overrides the memory
|
||||||
refusal, DEVICE_PROFILE and SYSTEM_IMAGE decide what `up` creates.
|
refusal, EMU_ANY_DEVICE=1 overrides `check`, DEVICE_PROFILE and SYSTEM_IMAGE
|
||||||
|
decide what `up` creates.
|
||||||
USAGE
|
USAGE
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
@@ -266,11 +269,62 @@ cmd_down() {
|
|||||||
echo "emu: stopped '$avd' ($serial)" >&2
|
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
|
case "${1:-}" in
|
||||||
name) echo "$avd" ;;
|
name) echo "$avd" ;;
|
||||||
serial) avd_serial "$adb" "$avd" || { echo "emu: '$avd' is not running" >&2; exit 1; } ;;
|
serial) avd_serial "$adb" "$avd" || { echo "emu: '$avd' is not running" >&2; exit 1; } ;;
|
||||||
list) cmd_list ;;
|
list) cmd_list ;;
|
||||||
up) cmd_up ;;
|
up) cmd_up ;;
|
||||||
down) cmd_down ;;
|
down) cmd_down ;;
|
||||||
|
check) cmd_check ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
+14
@@ -40,6 +40,20 @@ fi
|
|||||||
ln -sfn "$here/share/ui-trace" "$sharedir/ui-trace"
|
ln -sfn "$here/share/ui-trace" "$sharedir/ui-trace"
|
||||||
echo "install: $sharedir/ui-trace -> $here/share/ui-trace"
|
echo "install: $sharedir/ui-trace -> $here/share/ui-trace"
|
||||||
|
|
||||||
|
# Gradle never goes through the adb wrapper -- its device tasks reach every
|
||||||
|
# attached device by themselves -- so the one place that can hold for a
|
||||||
|
# checkout nobody has adopted this in is an init script, which Gradle applies
|
||||||
|
# to every build on this machine.
|
||||||
|
gradledir=${GRADLE_INIT_DIR:-$HOME/.gradle/init.d}
|
||||||
|
mkdir -p "$gradledir"
|
||||||
|
init="$gradledir/emulator-tools.gradle"
|
||||||
|
if [ -e "$init" ] && [ ! -L "$init" ]; then
|
||||||
|
echo "install: keeping the existing emulator-tools.gradle as emulator-tools.gradle.bak"
|
||||||
|
mv "$init" "$init.bak"
|
||||||
|
fi
|
||||||
|
ln -sfn "$here/share/gradle-init/emulator-tools.gradle" "$init"
|
||||||
|
echo "install: $init -> $here/share/gradle-init/emulator-tools.gradle"
|
||||||
|
|
||||||
case ":$PATH:" in
|
case ":$PATH:" in
|
||||||
*":$bindir:"*) ;;
|
*":$bindir:"*) ;;
|
||||||
*) echo "install: note -- $bindir is not on PATH" ;;
|
*) echo "install: note -- $bindir is not on PATH" ;;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Nothing Gradle builds here may land on another checkout's emulator.
|
||||||
|
//
|
||||||
|
// Android's install, uninstall and connected-test tasks talk to the adb
|
||||||
|
// server directly and act on *every* attached device -- the wrapper `adb` in
|
||||||
|
// this repo never sees them, so none of its aiming applies. With two
|
||||||
|
// sessions' emulators up, `./gradlew installDebug` replaces the app on both,
|
||||||
|
// reports success, and says nothing; from the other session that reads as
|
||||||
|
// its own build never landing. It happened on 2026-08-31.
|
||||||
|
//
|
||||||
|
// This is an init script, applied to every build on this machine, rather
|
||||||
|
// than something each project opts into: a rule a checkout has to adopt is
|
||||||
|
// one the next checkout will not have, and the sessions this protects are
|
||||||
|
// working in checkouts nobody has visited yet.
|
||||||
|
//
|
||||||
|
// The decision itself is `emu check` (one rule for "which device does a
|
||||||
|
// command here mean", shared with the adb wrapper), run in the project's own
|
||||||
|
// directory because that is what names the AVD. It refuses only when another
|
||||||
|
// checkout's emulator is attached; EMU_ANY_DEVICE=1 means every device
|
||||||
|
// anyway.
|
||||||
|
|
||||||
|
def emu = new File(System.getProperty("user.home"), ".local/bin/emu").absolutePath
|
||||||
|
|
||||||
|
gradle.allprojects { project ->
|
||||||
|
def workingDir = project.rootDir
|
||||||
|
project.tasks.configureEach { task ->
|
||||||
|
// Matched on where the task class comes from as well as on its name:
|
||||||
|
// a build of any kind may have an `install`, and this must not fail
|
||||||
|
// a project that has nothing to do with a device.
|
||||||
|
if (!task.getClass().name.startsWith('com.android.')) return
|
||||||
|
if (!(task.name =~ /^(install|uninstall|connected)/)) return
|
||||||
|
|
||||||
|
task.doFirst {
|
||||||
|
def said
|
||||||
|
def status
|
||||||
|
try {
|
||||||
|
def process = new ProcessBuilder(emu, 'check')
|
||||||
|
.directory(workingDir)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
said = process.inputStream.getText('UTF-8')
|
||||||
|
status = process.waitFor()
|
||||||
|
} catch (IOException e) {
|
||||||
|
// Failing closed: this script is installed by
|
||||||
|
// emulator-tools, so `emu` missing means a half-installation
|
||||||
|
// rather than a machine that never had the guard.
|
||||||
|
throw new org.gradle.api.GradleException(
|
||||||
|
"\ncannot check which emulator this build means: ${emu} is not runnable" +
|
||||||
|
"\n run ~/repos/emulator-tools/install.sh, or set EMU_ANY_DEVICE=1")
|
||||||
|
}
|
||||||
|
if (status != 0) {
|
||||||
|
throw new org.gradle.api.GradleException('\n' + said.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user