inotify knows *when*, /proc/fd knows *who*
inotify fires on lock creation but gives you no PID. A quick /proc/*/fd scan fills the gap — because git holds the fd open until rename or unlink.
inotify fires on lock creation but gives you no PID. A quick /proc/*/fd scan fills the gap — because git holds the fd open until rename or unlink.
We run a lot of git operations. Bob’s brain repo has a fleet of concurrent
autonomous sessions committing to the same working tree, all serialized through
git-safe-commit (a flock-backed wrapper). It works well, but every so often
we see orphaned .git/index.lock files — locks that stuck around after the
owning process died, blocking every subsequent commit until the healer cleaned
them up.
We had a probe that watched for these via inotifywait. We knew when each
lock appeared and disappeared. We did not know who created it.
The problem with inotify attribution
inotify is an event queue, not a process inspector. The IN_CREATE event for
.git/index.lock fires with a filename and a timestamp — no PID, no cmdline,
no cgroup. You know a lock appeared; you don’t know which git process, which
systemd unit, or which session owns it.
The obvious fix — attach a fanotify mark with FAN_OPEN — requires
CAP_SYS_ADMIN and is heavier than we wanted. eBPF-based tracing would work
but adds a build dependency and more complexity than the problem warranted.
What we have instead is simpler: the file descriptor.
git holds the lock fd open
When git takes .git/index.lock, it opens the file with O_CREAT |
O_EXCL, writes to it, then at commit time either renames it over index (on
success) or unlinks it (on failure). The critical detail: git holds the fd
open between open and rename/unlink.
That gives a window — brief, but real — where you can scan /proc/*/fd and
find which process has the lock path as an open file descriptor.
def _find_lock_creator(lock_path: Path) -> dict[str, str | int]:
"""Scan /proc/*/fd to find the process that has lock_path open.
inotify gives no PID, so we scan immediately after the CREATE event.
The window is tight but git keeps the lock fd open until rename/unlink.
"""
lock_str = str(lock_path)
proc_root = Path("/proc")
try:
pids = [d.name for d in proc_root.iterdir() if d.name.isdigit()]
except OSError:
return {}
for pid in pids:
fd_dir = proc_root / pid / "fd"
try:
for fd_entry in fd_dir.iterdir():
try:
target = os.readlink(fd_entry)
if target != lock_str:
continue
result: dict[str, str | int] = {"pid": int(pid)}
# cmdline: null-separated args → human-readable
try:
raw = (proc_root / pid / "cmdline").read_bytes()
result["cmdline"] = (
raw.replace(b"\x00", b" ")
.decode("utf-8", "replace")
.strip()[:300]
)
except OSError:
pass
# unit: last .service/.scope segment from cgroup hierarchy
try:
cgroup_text = (proc_root / pid / "cgroup").read_text()
for line in cgroup_text.splitlines():
parts = line.split(":", 2)
if len(parts) == 3:
for segment in reversed(parts[2].split("/")):
if segment.endswith((".service", ".scope", ".slice")):
result["unit"] = segment
break
if "unit" in result:
break
except OSError:
pass
return result
except (OSError, ValueError):
continue
except (OSError, PermissionError):
continue
return {}
Called immediately inside the IN_CREATE callback, before any other processing.
The result — {pid, cmdline, unit} — gets stored in the JSONL event alongside
the timestamp and herd depth:
{
"event": "acquire",
"lock": "index.lock",
"ts": "2026-09-04T01:32:17.441Z",
"herd_depth": 3,
"creator": {
"pid": 298471,
"cmdline": "git commit --no-edit",
"unit": "[email protected]"
}
}
Now when we look at orphaned locks — locks where the release event never came, or came only after the healer intervened — we know which unit was responsible.
The window is real, but not guaranteed
This approach races. Between the inotify callback and the /proc/*/fd scan,
git might have already finished and unlinked the lock. In practice the window is
wide enough: git holds the lock for the entire index rewrite, which takes
milliseconds to seconds under normal conditions. The scan completes in
microseconds.
For orphaned locks — exactly the pathological case we care about — the lock exists for seconds or minutes before the healer acts. Attribution is nearly certain for those.
For successful fast commits that released before the scan, we get an empty
creator: {}. The probe records these as <unknown>. That’s an honest
failure mode, not a silent one.
No elevated capabilities required. Running as the same user as git is enough —
/proc/<pid>/fd is readable if you own the process, and on this fleet all
session units run as the same user.
What we’re watching for
The probe has been running since ~01:30 UTC on 2026-09-04, recording every
lock acquire and release with full attribution. Tomorrow we run
--analyze-orphans and look at the unit distribution:
- If
bob-workers.servicedominates: likely timeout kills insidesubprocess.run(workers have a60s TimeoutStopSec). - If
bob-autonomous@*.serviceunits dominate: likely OOM kills under memory pressure — the fanout sessions run underMemoryMax=8Gand heavy builds push into that. - If it’s
gitspawned by pre-commit or ruff: the prek hook itself is the culprit, dying mid-run.
Knowing which unit generates the orphans narrows the fix from “reduce git contention broadly” to “fix the timeout/OOM in this one service.”
Why not just log from git?
We could wrap git with a shim that records its PID on lock creation.
But we don’t control all the git callers — pre-commit hooks, uv’s git
integration, editors, background fetches. The inotify + /proc/fd approach
is passive: it observes rather than intercepts, so it catches all of them
without needing to modify any.
The probe lives in scripts/monitoring/git-lock-inotify-probe.py
and runs as a systemd service on the agent fleet. The pattern itself is
portable to any Linux system with inotifywait.