diff --git a/CHANGELOG.md b/CHANGELOG.md index af0fcad..92adf07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [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-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 ### Fixed 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 c1de292..a3cb1af 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1510,16 +1510,17 @@ 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") - # 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 +1572,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..7355f58 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2400,31 +2400,46 @@ defmodule MobDev.NativeBuildTest do end end - describe "clear_stale_gradle_locks/1" do + describe "gradle_assemble/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") - ] + # 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 <- [ + "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", + "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 - for f <- [marker, lib | stale] do - File.mkdir_p!(Path.dirname(f)) - File.write!(f, <<1>>) - 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) - 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) + for lock <- locks, do: assert(File.exists?(lock), lock) end end