Files
Vijay Janapa Reddi 6218c52866 binder: clean up build worktrees when a parallel run is killed
Parallel and debug builds removed their worktrees only on normal exit or
Ctrl-C. SIGTERM (timeouts, agent harnesses) and SIGHUP (closed terminal)
killed the process before cleanup, and nothing ever swept the leftovers,
so detached worktrees piled up in $TMPDIR/binder-workspaces (28 GB today).

- run_jobs and the debug bisection now turn SIGTERM/SIGHUP into
  KeyboardInterrupt, so their existing cleanup runs.
- run_jobs first removes runs whose binder process has exited (the pid is
  in the run directory name) and prunes their worktree registrations.
- --keep-workspaces runs are marked and skipped by the automatic sweep.
- New `binder clean workspaces` removes leftovers on demand, kept runs
  included.
2026-10-19 18:52:14 +02:00

252 lines
9.2 KiB
Python

"""Disposable git worktrees that keep concurrent binder builds apart.
A build writes at the Quarto project root (``_quarto.yml``, ``index.qmd``,
LaTeX intermediates, the ``.quarto`` cache), so two builds in one checkout
collide. A workspace is a detached ``git worktree`` of a snapshot of the
invoking checkout, created under the system temporary directory and removed
when the run ends. The snapshot carries staged and unstaged edits, and
untracked, non-ignored files are copied in, so a workspace builds what is on
disk rather than only what is committed.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
#: Parent directory of every workspace binder creates. Workspaces are deleted
#: recursively, so :func:`create_workspace` and :func:`remove_workspace`
#: refuse paths outside it.
WORKSPACE_ROOT = Path(tempfile.gettempdir()) / "binder-workspaces"
#: Empty hooks directory, shared by a run's workspaces, that ``git worktree add``
#: is pointed at so repository hooks do not run in a throwaway checkout.
_NO_HOOKS = ".no-hooks"
def workspace_path(run_id: str, name: str) -> Path:
"""Return the directory for workspace *name* of run *run_id* under :data:`WORKSPACE_ROOT`."""
return WORKSPACE_ROOT / run_id / name
def _git(repo_root: Path, *args: str, check: bool = True) -> str:
"""Run ``git -C repo_root <args>`` and return its standard output.
Raises:
RuntimeError: If *check* is set and git exits non-zero.
"""
result = subprocess.run(["git", "-C", str(repo_root), *args], capture_output=True, text=True)
if check and result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise RuntimeError(f"git {' '.join(args)} failed: {detail}")
return result.stdout
@dataclass(frozen=True)
class Snapshot:
"""Content that every workspace of one run is created from.
Attributes:
repo_root: Checkout the snapshot was taken from.
commit: Commit holding the tracked content, uncommitted edits included.
untracked: Repository-relative paths of untracked, non-ignored files.
"""
repo_root: Path
commit: str
untracked: Tuple[str, ...] = ()
def take_snapshot(repo_root: Path) -> Snapshot:
"""Capture *repo_root*'s working tree for workspace creation.
``git stash create`` records staged and unstaged edits as an unreferenced
commit without touching the shared stash list; a clean tree falls back to
``HEAD``. Untracked files are listed, not committed, and copied into each
workspace by :func:`create_workspace`.
"""
repo_root = Path(repo_root).resolve()
commit = ""
for attempt in range(5):
try:
commit = _git(repo_root, "stash", "create").strip()
break
except RuntimeError as err:
if ("could not write index" in str(err) or "index.lock" in str(err)) and attempt < 4:
import time
time.sleep(0.3)
continue
raise
if not commit:
commit = _git(repo_root, "rev-parse", "HEAD").strip()
listed = _git(repo_root, "ls-files", "--others", "--exclude-standard", "-z")
return Snapshot(repo_root, commit, tuple(path for path in listed.split("\0") if path))
@dataclass
class Workspace:
"""A worktree created by :func:`create_workspace`."""
path: Path
snapshot: Snapshot
def create_workspace(snapshot: Snapshot, path: Path) -> Workspace:
"""Check out *snapshot* as a detached worktree at *path*.
Repository hooks are disabled for the checkout; they set up developer
tooling that a throwaway build tree does not need. Untracked files from the
snapshot are copied in afterwards.
Raises:
ValueError: If *path* is not inside :data:`WORKSPACE_ROOT`.
RuntimeError: If ``git worktree add`` fails.
"""
path = _checked_path(path)
no_hooks = path.parent / _NO_HOOKS
no_hooks.mkdir(parents=True, exist_ok=True)
try:
_git(snapshot.repo_root, "-c", f"core.hooksPath={no_hooks}",
"worktree", "add", "--detach", "--force", str(path), snapshot.commit)
except RuntimeError:
_remove_empty_run_dir(path.parent)
raise
for relative in snapshot.untracked:
source = snapshot.repo_root / relative
if source.is_file():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
return Workspace(path, snapshot)
def remove_workspace(workspace: Workspace) -> None:
"""Delete a workspace directory and its worktree registration.
Anything left inside the workspace, including build output, is discarded;
callers collect what they need first. The run directory holding the
workspace goes too once its last workspace is removed.
Raises:
ValueError: If the workspace path is not inside :data:`WORKSPACE_ROOT`.
"""
path = _checked_path(workspace.path)
repo_root = workspace.snapshot.repo_root
_git(repo_root, "worktree", "remove", "--force", str(path), check=False)
if path.exists():
shutil.rmtree(path, ignore_errors=True)
_git(repo_root, "worktree", "prune", check=False)
_remove_empty_run_dir(path.parent)
#: Run directories are named ``<YYYYMMDD>-<HHMMSS>-<pid>`` by
#: ``cli.core.parallel.new_run_id``; the pid says whether the run is still alive.
_RUN_DIR_NAME = re.compile(r"^\d{8}-\d{6}-(\d+)$")
#: Marker left in a run directory whose workspaces were kept on purpose
#: (``--keep-workspaces``); the automatic sweep leaves such runs alone.
KEEP_MARKER = ".keep"
def mark_kept(workspace: Workspace) -> None:
"""Record that *workspace*'s run was kept for inspection."""
(workspace.path.parent / KEEP_MARKER).touch()
def _pid_alive(pid: int) -> bool:
"""Return True if a process with *pid* exists (or might: permission denied)."""
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def stale_runs(root: Optional[Path] = None, include_kept: bool = False) -> List[Path]:
"""Return run directories left behind by binder processes that have exited.
A run is stale when its directory name ends in a pid that no longer exists.
Runs of the current process, runs whose process is alive, and directories
that do not look like run directories are never returned. A recycled pid
makes a dead run look alive, which only delays its removal. Runs kept
with ``--keep-workspaces`` are skipped unless *include_kept* is set.
"""
root = Path(root or WORKSPACE_ROOT).resolve()
if not root.is_dir():
return []
stale = []
for entry in sorted(root.iterdir()):
match = _RUN_DIR_NAME.match(entry.name)
if not match or not entry.is_dir() or entry.is_symlink():
continue
pid = int(match.group(1))
if not include_kept and (entry / KEEP_MARKER).exists():
continue
if pid != os.getpid() and not _pid_alive(pid):
stale.append(entry)
return stale
def remove_stale_runs(repo_root: Optional[Path] = None, include_kept: bool = False) -> List[Path]:
"""Delete run directories of exited binder processes and prune their worktrees.
Builds normally remove their own workspaces; this catches the ones left by
a build that was killed outright (SIGKILL, a crash, a closed machine).
After deleting, ``git worktree prune`` drops the registrations in
*repo_root* whose directories are gone. *include_kept* also removes runs
kept with ``--keep-workspaces``.
Returns:
The run directories that were removed.
"""
removed = []
root = WORKSPACE_ROOT.resolve()
for run_dir in stale_runs(include_kept=include_kept):
if run_dir.parent != root:
continue
shutil.rmtree(run_dir, ignore_errors=True)
if not run_dir.exists():
removed.append(run_dir)
if removed and repo_root is not None:
_git(Path(repo_root), "worktree", "prune", check=False)
return removed
def _remove_empty_run_dir(run_dir: Path) -> None:
"""Delete *run_dir* once it holds nothing but the shared hooks directory.
Every workspace of a run shares the hooks directory, so it is removed with
the last one. The workspace root itself stays, since other runs may be
creating directories in it.
"""
if run_dir == WORKSPACE_ROOT.resolve() or not run_dir.is_dir():
return
if any(entry.name != _NO_HOOKS for entry in run_dir.iterdir()):
return
for directory in (run_dir / _NO_HOOKS, run_dir):
try:
directory.rmdir()
except OSError:
pass
def _checked_path(path: Path) -> Path:
"""Return *path* resolved, refusing anything outside :data:`WORKSPACE_ROOT`.
Workspaces are deleted recursively; confining them to one directory keeps
a bad argument from ever pointing that deletion at a real checkout.
"""
resolved = Path(path).resolve()
root = WORKSPACE_ROOT.resolve()
if root not in resolved.parents:
raise ValueError(f"Workspace path must be inside {root}: {resolved}")
return resolved