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-dandClaude Opus 4.7 committed 2026-05-14 08:05:55 +08:00
1 parent 77bb10c682
commit 2c975ee9fb
3 files changed
+90 -5

No files matched your search

+29 -4
View File
@@ -19,6 +19,11 @@ def _rebuild_lock(out_dir: Path, *, blocking: bool = False):
``blocking`` is False. Uses fcntl.flock so the lock is released
automatically if the process is killed (no stale-lock cleanup needed).
While the lock is held, ``.rebuild.lock`` contains the owning PID followed
by a newline so external pollers (publish scripts, etc.) can read it.
On successful release the file is unlinked so downstream tooling that
waits for the lock to clear by polling for its absence unblocks promptly.
Falls back to a no-op yield(True) on platforms without fcntl (Windows).
"""
try:
@@ -29,7 +34,11 @@ def _rebuild_lock(out_dir: Path, *, blocking: bool = False):
out_dir.mkdir(parents=True, exist_ok=True)
lock_path = out_dir / ".rebuild.lock"
fh = open(lock_path, "a", encoding="utf-8")
# "a+" creates the file if missing without truncating an existing holder's
# PID payload — important because another process may have already written
# its PID before we attempt the flock.
fh = open(lock_path, "a+", encoding="utf-8")
acquired = False
try:
flags = fcntl.LOCK_EX if blocking else (fcntl.LOCK_EX | fcntl.LOCK_NB)
try:
@@ -37,13 +46,29 @@ def _rebuild_lock(out_dir: Path, *, blocking: bool = False):
except BlockingIOError:
yield False
return
yield True
finally:
acquired = True
# Replace any prior owner's PID with ours so external readers see a
# single parseable line, not a digit-concatenation across rebuilds.
try:
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
fh.seek(0)
fh.truncate()
fh.write(f"{os.getpid()}\n")
fh.flush()
except OSError:
pass
yield True
finally:
if acquired:
try:
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
except OSError:
pass
fh.close()
# Signal "rebuild done" by removing the lock file. Only the holder
# unlinks; a non-acquiring caller leaves the existing lock in place.
if acquired:
with contextlib.suppress(OSError):
lock_path.unlink()
def _apply_resource_limits() -> None: