# `GgenIgniter.Lock`
[🔗](https://github.com/seanchatmangpt/ggen_igniter/blob/v26.9.8/lib/ggen_igniter/lock.ex#L1)

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/0` writes the acquiring
    process's real `erlang_pid=` (its own `self()`, `inspect/1`-formatted)
    alongside `node=` into the lock file's content. When a later
    `acquire/2` call hits `:eexist` on that same node,
    `holder_pid_status/1` parses that Erlang pid back out via
    `:erlang.list_to_pid/1` and checks `Process.alive?/1` for 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 recorded `node=` differs from `Node.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 reaching `release/1`) and is
    removed automatically by the next `acquire/2` caller 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 or `opts[:timeout_ms]` elapses, then raises a
    `RuntimeError` naming the still-held lock path. Returns
    `{:ok, %__MODULE__{}}` on success -- the ref `release/1` requires.
  * `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`'s
    `try/after`) needs "leave nothing held" to succeed unconditionally.

# `t`

```elixir
@type t() :: %GgenIgniter.Lock{key: String.t(), path: String.t()}
```

# `acquire`

```elixir
@spec acquire(
  String.t(),
  keyword()
) :: {:ok, t()}
```

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.

# `release`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
