binder build: refuse a second concurrent build in the same checkout

Two builds in one worktree overwrite each other's generated _quarto.yml, index.qmd
and freeze files and fail with unrelated-looking errors. Hold an flock on
.binder-build.lock for the serial build path; the OS drops it when the holder dies,
so a killed build never leaves a stale lock. Separate worktrees do not contend.
This commit is contained in:
Vijay Janapa Reddi
2026-10-18 15:09:01 +02:00
parent ab88f1d4d3
commit 89b828efa3
4 changed files with 152 additions and 1 deletions
+3
View File
@@ -479,3 +479,6 @@ binder/tools/audit/artifacts/chapter_pdf_audit.*
# LEGO verify pipeline run reports (regenerated on every run)
binder/tools/audit/artifacts/lego_cells_verify_report.json
binder/tools/audit/artifacts/lego_prose_coherence_report.json
# Per-checkout build lock written by `binder build`
.binder-build.lock
+57
View File
@@ -9,6 +9,7 @@ this checkout's sources ahead of any installed copy.
from __future__ import annotations
import contextlib
import errno
import os
import signal
import subprocess
@@ -135,3 +136,59 @@ def interrupts_as_keyboard_interrupt() -> Iterator[None]:
finally:
for sig, handler in previous.items():
signal.signal(sig, handler)
class BuildInProgress(RuntimeError):
"""Raised when another build already holds this checkout's build lock."""
BUILD_LOCK_NAME = ".binder-build.lock"
@contextlib.contextmanager
def build_lock(root_dir: Path) -> Iterator[None]:
"""Hold an exclusive per-checkout lock for the length of a build.
Two builds in one checkout overwrite each other's generated ``_quarto.yml``,
``index.qmd`` and ``*_files/execute-results`` freeze files, so the second
one fails with confusing errors ("project change not allowed", a missing
rename target). Failing fast with a clear message is better.
The lock is an ``flock`` on a file in *root_dir*. The operating system drops
it when the holding process exits, however it exits, so a killed build never
leaves a stale lock behind. Separate worktrees have separate roots and
never contend. On platforms without ``fcntl`` (Windows) this does nothing.
Raises:
BuildInProgress: another process holds the lock; the message names its PID.
"""
try:
import fcntl
except ImportError: # pragma: no cover - Windows
yield
return
lock_path = Path(root_dir) / BUILD_LOCK_NAME
handle = open(lock_path, "a+")
try:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
if exc.errno not in (errno.EACCES, errno.EAGAIN):
raise
handle.seek(0)
holder = handle.read().strip() or "unknown"
raise BuildInProgress(
f"another build is already running in {Path(root_dir)} (PID {holder}). "
"Wait for it to finish, or build from a separate worktree."
) from None
handle.seek(0)
handle.truncate()
handle.write(str(os.getpid()))
handle.flush()
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
finally:
handle.close()
+2 -1
View File
@@ -22,6 +22,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from cli.core.config import ConfigManager
from cli.core.discovery import ChapterDiscovery, VOLUME_DIRS
from cli.core.process import build_lock
from cli.commands.build import BuildCommand
from cli.commands.preview import PreviewCommand
from cli.commands.doctor import DoctorCommand
@@ -714,7 +715,7 @@ class MLSysBookCLI:
stdout_redirect = redirect_stdout(sys.stderr) if json_output else nullcontext()
try:
with stdout_redirect:
with build_lock(self.root_dir), stdout_redirect:
if build_all:
# Every volume in turn; --parallel runs them side by side instead.
ok = True
+90
View File
@@ -0,0 +1,90 @@
"""The per-checkout build lock stops two builds from colliding in one worktree."""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import textwrap
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from binder.cli.core.process import BUILD_LOCK_NAME, BuildInProgress, build_lock # noqa: E402
pytestmark = pytest.mark.skipif(os.name != "posix", reason="flock is POSIX only")
HOLDER = textwrap.dedent(
"""
import sys, time
sys.path.insert(0, {repo!r})
from pathlib import Path
from binder.cli.core.process import build_lock
with build_lock(Path({root!r})):
print("held", flush=True)
time.sleep(60)
"""
)
def _start_holder(root: Path) -> subprocess.Popen:
proc = subprocess.Popen(
[sys.executable, "-c", HOLDER.format(repo=str(REPO_ROOT), root=str(root))],
stdout=subprocess.PIPE,
text=True,
)
assert proc.stdout.readline().strip() == "held"
return proc
def test_lock_is_reentrant_across_sequential_builds(tmp_path: Path) -> None:
with build_lock(tmp_path):
pass
with build_lock(tmp_path):
pass
def test_second_build_in_same_checkout_fails_and_names_the_holder(tmp_path: Path) -> None:
holder = _start_holder(tmp_path)
try:
with pytest.raises(BuildInProgress) as err:
with build_lock(tmp_path):
pytest.fail("the lock should not have been granted")
assert str(holder.pid) in str(err.value)
assert "separate worktree" in str(err.value)
finally:
holder.kill()
holder.wait()
def test_killed_build_leaves_no_stale_lock(tmp_path: Path) -> None:
holder = _start_holder(tmp_path)
holder.send_signal(signal.SIGKILL)
holder.wait()
deadline = time.time() + 5
while True:
try:
with build_lock(tmp_path):
break
except BuildInProgress:
assert time.time() < deadline, "lock was not released after the holder died"
time.sleep(0.1)
assert (tmp_path / BUILD_LOCK_NAME).exists()
def test_separate_worktrees_do_not_contend(tmp_path: Path) -> None:
a, b = tmp_path / "a", tmp_path / "b"
a.mkdir()
b.mkdir()
holder = _start_holder(a)
try:
with build_lock(b):
pass
finally:
holder.kill()
holder.wait()