fix(watch): unlink .rebuild.lock on release and rewrite single PID line (#858)

The rebuild lock file accumulated concatenated PIDs across post-commit
rebuilds without a separator, and was never removed when the rebuild
finished. Two practical consequences for users:

1. Downstream tooling that polls for `.rebuild.lock` to disappear before
   doing post-rebuild work (publish scripts copying graph.html to a web
   root, etc.) blocked forever / until its own timeout.
2. The accumulated digit string could not be parsed by humans or tooling
   to find the owning PID.

The `_rebuild_lock` context manager now:

- Opens the lock file with `a+` so a non-acquiring caller does not
  truncate the existing holder's PID.
- After flock acquisition, truncates and writes a single `<pid>\n` line
  so external readers can `kill -0 $(cat .rebuild.lock)` to check
  liveness.
- Unlinks the lock file in the finally block (only when *we* held the
  lock), restoring the "signal-by-absence" convention users rely on.

Four regression tests added under `tests/test_watch.py` covering the
PID-with-newline payload, post-release unlink, no-accumulation across
sequential acquisitions, and the non-blocking-caller-does-not-clobber
invariant.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
voidborne-d
2026-05-14 08:05:55 +08:00
co-authored by Claude Opus 4.7
parent 77bb10c682
commit 2c975ee9fb
3 changed files with 90 additions and 5 deletions
+57 -1
View File
@@ -1,9 +1,11 @@
"""Tests for watch.py - file watcher helpers (no watchdog required)."""
import os
import sys
import time
from pathlib import Path
import pytest
from graphify.watch import _notify_only, _WATCHED_EXTENSIONS
from graphify.watch import _notify_only, _WATCHED_EXTENSIONS, _rebuild_lock
# --- _notify_only ---
@@ -96,6 +98,60 @@ def test_watch_raises_without_watchdog(tmp_path, monkeypatch):
watch(tmp_path)
# --- _rebuild_lock (GH-858) ---
@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)")
def test_rebuild_lock_writes_pid_with_newline(tmp_path):
out = tmp_path / "graphify-out"
lock_path = out / ".rebuild.lock"
with _rebuild_lock(out) as got:
assert got is True
assert lock_path.exists()
contents = lock_path.read_text(encoding="utf-8")
assert contents == f"{os.getpid()}\n", contents
@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)")
def test_rebuild_lock_removed_after_release(tmp_path):
"""GH-858: lock file must be unlinked once the rebuild completes so
downstream waiters that poll for its absence unblock promptly."""
out = tmp_path / "graphify-out"
lock_path = out / ".rebuild.lock"
with _rebuild_lock(out) as got:
assert got is True
assert not lock_path.exists(), "lock file should be unlinked after release"
@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)")
def test_rebuild_lock_does_not_accumulate_pids_across_runs(tmp_path):
"""GH-858: each acquisition truncates and rewrites the PID line rather
than appending, so the file never grows into a digit-concatenation."""
out = tmp_path / "graphify-out"
lock_path = out / ".rebuild.lock"
expected = f"{os.getpid()}\n"
for _ in range(5):
with _rebuild_lock(out) as got:
assert got is True
assert lock_path.read_text(encoding="utf-8") == expected
assert not lock_path.exists()
@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)")
def test_rebuild_lock_non_blocking_does_not_clobber_holder(tmp_path):
"""GH-858: a non-blocking caller that fails to acquire the lock must not
truncate the holder's PID payload."""
out = tmp_path / "graphify-out"
lock_path = out / ".rebuild.lock"
with _rebuild_lock(out) as outer:
assert outer is True
held_contents = lock_path.read_text(encoding="utf-8")
with _rebuild_lock(out, blocking=False) as inner:
assert inner is False
# Holder's PID line must still be intact.
assert lock_path.read_text(encoding="utf-8") == held_contents
def test_rebuild_code_is_idempotent_when_cluster_ids_flap(tmp_path, monkeypatch):
from graphify import cluster as cluster_mod
from graphify.watch import _rebuild_code