From 134a88e3a3fc3dc92e81a2d8f292ad0dc936262f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 13 Oct 2026 13:32:54 -0600 Subject: [PATCH 1/4] Stop deleting Gradle lock files before assembleDebug (MOB-469) --- CHANGELOG.md | 15 ++++++++++++++ lib/mob_dev/native_build.ex | 32 ------------------------------ test/mob_dev/native_build_test.exs | 28 -------------------------- 3 files changed, 15 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af0fcad..38108f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## [Unreleased] + +### Fixed + +- **mob_dev no longer deletes Gradle lock files before Android builds + (MOB-469).** It removed `~/.gradle/daemon/*/registry.bin.lock`, + `wrapper/dists/**/*.lck` and `caches/**/*.{lock,lck}` before every + `assembleDebug`. Gradle's locks are OS file locks released when the holder + exits, so they are never stale; deleting one only unlinks the inode, so a + concurrent build took a "new" lock on a fresh file and both builds wrote the + shared caches at once. A contended lock now behaves as Gradle intends: it + waits, then times out naming the owner PID (stop it with `./gradlew --stop` + or kill that PID). The hang this worked around was fixed separately + (`--no-daemon`, running `gradlew` through `bash`). + ## [0.7.23] - 2026-10-09 ### Fixed diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index c1de292..56402d2 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1516,10 +1516,6 @@ defmodule MobDev.NativeBuild do android_dir = Path.join(File.cwd!(), "android") gradlew = Path.join(android_dir, "gradlew") - # Stale Gradle daemon registry locks accumulate when builds are killed (Ctrl+C, - # force-stop, etc.) and cause subsequent runs to hang silently while the wrapper - # waits to acquire the lock. Clear them before every build. - clear_stale_gradle_locks() remove_stale_release_otp_zip(android_dir) if File.exists?(gradlew) do @@ -1571,34 +1567,6 @@ defmodule MobDev.NativeBuild do :ok end - # Remove stale Gradle lock files left behind when a build is interrupted - # (Ctrl+C, kill, etc.). These cause the next run to hang indefinitely while - # the wrapper waits to acquire the lock. - # - # Never `/native/**/*.lock` (MOB-468): native-platform holds - # each only while it extracts (never left stale), and its 1-byte content - # marks a finished extraction. Without one, the next Gradle JVM re-extracts - # `libnative-platform.so` (and friends) in place, over the inode every - # running Gradle JVM has mapped, and those JVMs SIGSEGV (the wrapper in - # `ld-linux` at exit, the daemon in a JNI `stat`/`getPid`). - @doc false - @spec clear_stale_gradle_locks(Path.t()) :: :ok - def clear_stale_gradle_locks( - gradle_home \\ System.get_env("GRADLE_USER_HOME") || - Path.join(System.user_home!(), ".gradle") - ) do - patterns = [ - "#{gradle_home}/daemon/*/registry.bin.lock", - "#{gradle_home}/wrapper/dists/**/*.lck", - "#{gradle_home}/caches/**/*.lock", - "#{gradle_home}/caches/**/*.lck" - ] - - Enum.each(patterns, fn pattern -> - Path.wildcard(pattern, match_dot: true) |> Enum.each(&File.rm/1) - end) - end - @doc """ Decide whether an `adb install -r` result forces a clean (uninstall + install) reinstall. diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index bae7642..f837a4b 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2400,34 +2400,6 @@ defmodule MobDev.NativeBuildTest do end end - describe "clear_stale_gradle_locks/1" do - @describetag :tmp_dir - - # MOB-468: native-platform's `.so.lock` marks a finished extraction. - # Deleting it made the next Gradle JVM rewrite the mapped .so in place and - # crashed every concurrent Gradle JVM (SIGSEGV in ld-linux / JNI). - test "clears stale wrapper, daemon and cache locks but keeps native-platform's extraction markers", - %{tmp_dir: home} do - marker = Path.join(home, "native/68d5/linux-amd64/libnative-platform.so.lock") - lib = Path.join(home, "native/68d5/linux-amd64/libnative-platform.so") - - stale = [ - Path.join(home, "daemon/8.2.1/registry.bin.lock"), - Path.join(home, "caches/8.2.1/fileHashes/fileHashes.lock") - ] - - for f <- [marker, lib | stale] do - File.mkdir_p!(Path.dirname(f)) - File.write!(f, <<1>>) - end - - assert NativeBuild.clear_stale_gradle_locks(home) == :ok - assert File.exists?(marker) - assert File.exists?(lib) - for f <- stale, do: refute(File.exists?(f), f) - end - end - # ── ios_bundle_id/1 ─────────────────────────────────────────────────────────── # # The sim bundle, the device bundle, and code signing must all stamp the From fc978a0f021948395ebb8a4db3f0f12f58dbe623 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 13 Oct 2026 13:40:01 -0600 Subject: [PATCH 2/4] CHANGELOG: don't overstate Gradle lock diagnostics --- CHANGELOG.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38108f3..92adf07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,12 @@ - **mob_dev no longer deletes Gradle lock files before Android builds (MOB-469).** It removed `~/.gradle/daemon/*/registry.bin.lock`, `wrapper/dists/**/*.lck` and `caches/**/*.{lock,lck}` before every - `assembleDebug`. Gradle's locks are OS file locks released when the holder - exits, so they are never stale; deleting one only unlinks the inode, so a - concurrent build took a "new" lock on a fresh file and both builds wrote the - shared caches at once. A contended lock now behaves as Gradle intends: it - waits, then times out naming the owner PID (stop it with `./gradlew --stop` - or kill that PID). The hang this worked around was fixed separately - (`--no-daemon`, running `gradlew` through `bash`). + `assembleDebug`. Gradle's locks are OS-backed file locks: the OS releases + them when the holding JVM exits, even though the file stays on disk, so they + are never left held. Deleting one only unlinks the inode, so a concurrent + build locked a fresh file and both builds wrote the shared caches at once. A + contended lock now behaves as Gradle intends: it waits, then reports a + timeout; find and stop the live owner rather than deleting the file. ## [0.7.23] - 2026-10-09 From 41fd28f0400dbbcfd5811737caf7102093ee000d Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 13 Oct 2026 13:47:58 -0600 Subject: [PATCH 3/4] Regression test and decision record for never deleting Gradle locks --- .../2026-10-13-never-delete-gradle-locks.md | 41 ++++++++++++++++++ lib/mob_dev/native_build.ex | 9 +++- test/mob_dev/native_build_test.exs | 42 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 decisions/2026-10-13-never-delete-gradle-locks.md diff --git a/decisions/2026-10-13-never-delete-gradle-locks.md b/decisions/2026-10-13-never-delete-gradle-locks.md new file mode 100644 index 0000000..0d24e0f --- /dev/null +++ b/decisions/2026-10-13-never-delete-gradle-locks.md @@ -0,0 +1,41 @@ +# mob_dev never deletes Gradle lock files + +- Date: 2026-10-13 +- Status: accepted +- Issue: MOB-469 (follows MOB-468) + +## Context + +Since 2026-04-23 `gradle_assemble` ran `clear_stale_gradle_locks/1` before every +`gradlew assembleDebug`, deleting `~/.gradle/daemon/*/registry.bin.lock`, +`wrapper/dists/**/*.lck` and `caches/**/*.{lock,lck}` (and, until MOB-468, +`native/**/*.lock`). The comment said interrupted builds left these behind and +the next build hung waiting for them. + +Gradle's locks are OS-backed `FileChannel` locks. The OS releases them when the +holding JVM exits, killed or not; the file staying on disk means nothing. A +contended lock waits and then fails with a timeout (the cache and daemon locks +name the owner PID when Gradle knows it). Deleting the file does not release a +live lock: it unlinks the inode, so the next process locks a new file at the +same path while the old holder keeps going, and two builds write the shared +caches at once. MOB-468 was this failure for `native/`: concurrent JVMs crashed +with SIGSEGV. The cache and wrapper locks guard the same kind of shared state. + +## Decision + +mob_dev does not delete, truncate or otherwise touch any file under +`GRADLE_USER_HOME`. A contended lock is left to Gradle: the user stops the live +owner (or waits) instead of mob_dev deleting the file. + +## Consequences + +- Concurrent Android builds on one host (two projects, the NUC's CI lanes) + keep Gradle's mutual exclusion. +- A build killed mid-Gradle (Ctrl+C, `kill -9`) does not hang the next build: + its locks went with its JVM. Verified on macOS with a blank host on + emulator-5554: wrapper and daemon killed with SIGKILL during + `:app:mergeExtDexDebug`, rerun finished (`BUILD SUCCESSFUL in 13s`). +- A lock held by a live process now surfaces as Gradle's timeout error rather + than being silently stolen. +- `gradle_assemble/1` has a test asserting lock files in `GRADLE_USER_HOME` + survive a build. diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 56402d2..a3cb1af 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1510,10 +1510,15 @@ defmodule MobDev.NativeBuild do :ok end - defp gradle_assemble do + # Gradle's locks are OS-backed file locks, released when the holding JVM + # exits; the files themselves are never "stale". Never delete them: that + # only unlinks the inode and lets concurrent builds share a cache unlocked + # (MOB-468, MOB-469, decisions/2026-10-13-never-delete-gradle-locks.md). + @doc false + @spec gradle_assemble(String.t()) :: :ok | {:error, String.t()} + def gradle_assemble(android_dir \\ Path.join(File.cwd!(), "android")) do IO.puts(" Running Gradle assembleDebug...") IO.puts(" (first build may take a few minutes while CMake compiles native code)") - android_dir = Path.join(File.cwd!(), "android") gradlew = Path.join(android_dir, "gradlew") remove_stale_release_otp_zip(android_dir) diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index f837a4b..57a9a99 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2400,6 +2400,48 @@ defmodule MobDev.NativeBuildTest do end end + describe "gradle_assemble/1" do + @describetag :tmp_dir + + # MOB-469: Gradle's locks are OS-held; unlinking a lock file lets a + # concurrent build take a fresh lock and write the shared caches at once. + test "leaves Gradle's lock files in GRADLE_USER_HOME alone", %{tmp_dir: dir} do + home = Path.join(dir, "gradle_home") + android = Path.join(dir, "android") + File.mkdir_p!(android) + File.write!(Path.join(android, "gradlew"), "exit 0\n") + + locks = + for rel <- [ + "daemon/8.2.1/registry.bin.lock", + "wrapper/dists/gradle-8.2.1-bin/abc/gradle-8.2.1-bin.zip.lck", + "caches/8.2.1/fileHashes/fileHashes.lock", + "caches/journal-1/journal-1.lock", + "caches/transforms-3/abc/results.bin.lck" + ] do + path = Path.join(home, rel) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, "") + path + end + + prev = System.get_env("GRADLE_USER_HOME") + System.put_env("GRADLE_USER_HOME", home) + + on_exit(fn -> + if prev, + do: System.put_env("GRADLE_USER_HOME", prev), + else: System.delete_env("GRADLE_USER_HOME") + end) + + ExUnit.CaptureIO.capture_io(fn -> + assert NativeBuild.gradle_assemble(android) == :ok + end) + + for lock <- locks, do: assert(File.exists?(lock), lock) + end + end + # ── ios_bundle_id/1 ─────────────────────────────────────────────────────────── # # The sim bundle, the device bundle, and code signing must all stamp the From e9beac6eba7c7f1c03f21b082494689a0e820657 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 13 Oct 2026 13:58:20 -0600 Subject: [PATCH 4/4] Test: keep MOB-468 native-platform marker in the seed --- test/mob_dev/native_build_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 57a9a99..7355f58 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2413,6 +2413,7 @@ defmodule MobDev.NativeBuildTest do locks = for rel <- [ + "native/68d5/linux-amd64/libnative-platform.so.lock", "daemon/8.2.1/registry.bin.lock", "wrapper/dists/gradle-8.2.1-bin/abc/gradle-8.2.1-bin.zip.lck", "caches/8.2.1/fileHashes/fileHashes.lock",