mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-11-09 10:39:41 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user