A real, file-based cross-process lock used by mix ggen_igniter.sync (see
Mix.Tasks.GgenIgniter.Sync's AR-9 correction, 2026-08-27) to serialize
concurrent mutating invocations against the same target project. Per the
PRD's FR-5 (see Mix.Tasks.GgenIgniter.Plan's moduledoc, "Read-only, no
lock"), only verbs that mutate a target project's filesystem
(mix ggen_igniter.sync, mix ggen_igniter.replay) acquire this lock --
mix ggen_igniter.doctor and mix ggen_igniter.plan are both read-only and
must never call acquire/2.
Mechanism
A real lock FILE (.ggen_igniter/.sync.lock, under the caller's
lock_key directory -- typically --manifest-dir or File.cwd!/0),
created with File.open/2's :exclusive mode -- the OS itself refuses a
second concurrent :exclusive open against the same path, so two BEAM
processes (or two separate mix invocations, which is the real scenario
this exists for -- not two processes in the same VM) genuinely cannot both
win the race. This is not an in-memory/:global/GenServer-registered
lock: those only serialize callers inside the SAME BEAM node, which is not
what "two concurrent mix ggen_igniter.sync invocations" actually are.
Stale-lock recovery
Staleness is decided by TWO real signals, PID-liveness first and mtime-age
as the fallback -- this fixes a real gap where a legitimately slow same-VM
run past @stale_after_ms (5 minutes) used to get its lock stolen by a
second concurrent invocation purely because the file was "old", even
though the original holder process was still alive and still working:
- PID-liveness (primary):
holder_marker/0writes the acquiring process's realerlang_pid=(its ownself(),inspect/1-formatted) alongsidenode=into the lock file's content. When a lateracquire/2call hits:eexiston that same node,holder_pid_status/1parses that Erlang pid back out via:erlang.list_to_pid/1and checksProcess.alive?/1for real -- no periodic background heartbeat/refresher process is needed, since liveness is checked fresh, on demand, at contention time. A confirmed live holder is never preempted, however old its file's mtime is; a confirmed dead holder (the process genuinely exited) is immediately reclaimable, however fresh its file's mtime is. - mtime-age (fallback): used only when PID-liveness is
:unknown-- the recordednode=differs fromNode.self()(the real, disclosed cross-node limitation: an Erlang pid from another node's local process table cannot be resolved locally), the marker line is missing/ unparseable, or the OS pid was reused by an unrelated process after a hard crash. In that fallback case only, a lock file older than 5 minutes is treated as abandoned (its holder crashed, was killed, or the machine restarted, without ever reachingrelease/1) and is removed automatically by the nextacquire/2caller before retrying. A live holder well inside that window is never preempted.
Real functions, no mock anywhere in this chain
acquire/2-- blocks (retrying on a real interval) until the real lock file is created oropts[:timeout_ms]elapses, then raises aRuntimeErrornaming the still-held lock path. Returns{:ok, %__MODULE__{}}on success -- the refrelease/1requires.release/1-- deletes the real lock file. Idempotent: releasing a lock whose file was already removed (e.g. cleaned up out-of-band) is a no-op, never a raise, since every real call site (Mix.Tasks.GgenIgniter.Sync'stry/after) needs "leave nothing held" to succeed unconditionally.
Summary
Functions
Acquires the real cross-process lock for lock_key (a directory path --
typically --manifest-dir or File.cwd!/0). Blocks, retrying every
opts[:retry_interval_ms] (default 50ms), until either the lock file is
genuinely created by THIS process or opts[:timeout_ms] (default
30000ms) elapses -- at which point it raises a RuntimeError naming the
still-held lock path, rather than returning a value the existing
{:ok, lock_ref} = acquire(...) call sites do not pattern-match against.
Releases a previously-acquired lock by deleting its real lock file. Idempotent -- a lock file already missing (removed out-of-band, or by a stale-lock recovery elsewhere) is treated as already-released, never a raise.
Types
Functions
Acquires the real cross-process lock for lock_key (a directory path --
typically --manifest-dir or File.cwd!/0). Blocks, retrying every
opts[:retry_interval_ms] (default 50ms), until either the lock file is
genuinely created by THIS process or opts[:timeout_ms] (default
30000ms) elapses -- at which point it raises a RuntimeError naming the
still-held lock path, rather than returning a value the existing
{:ok, lock_ref} = acquire(...) call sites do not pattern-match against.
Returns {:ok, t()} on success.
@spec release(t()) :: :ok
Releases a previously-acquired lock by deleting its real lock file. Idempotent -- a lock file already missing (removed out-of-band, or by a stale-lock recovery elsewhere) is treated as already-released, never a raise.