Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
41 changes: 41 additions & 0 deletions decisions/2026-10-13-never-delete-gradle-locks.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 7 additions & 34 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<gradle_home>/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.
Expand Down
57 changes: 36 additions & 21 deletions test/mob_dev/native_build_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<lib>.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

Expand Down
Loading