Skip to content

Exclude helper's read-modify-write is unserialized: concurrent worktree provisioning silently loses patterns #381

Description

@pbean

Found while reviewing #376. That PR made the exclude write atomic (atomic_write_text), which closes the truncation hole (#375) and the fixed-.tmp-name collision. It does not — and cannot — close the read-modify-write around it.

install._worktree_local_exclude reads the exclude, merges the new patterns, and writes the whole file back:

existing = exclude.read_text(encoding="utf-8") if exclude.is_file() else ""   # install.py:793
present = set(existing.splitlines())
new = [p for p in patterns if p not in present]
...
atomic_write_text(exclude, prefix + "\n".join(new) + "\n")                     # install.py:812

The read and the write are not serialized. Atomicity per write is not isolation across the pair, so two provisioning runs that interleave produce a clean lost update.

This is specifically reachable here rather than theoretical: every linked worktree of a repo resolves to the same --git-common-dir, so all worktrees of one repo contend on one .git/info/exclude. The comment at install.py:806-811 posits exactly this scenario ("two runs provisioning against one repo") as the motivation for the unique temp name.

Reproduction

Two linked worktrees of one repo; wt2's full cycle interleaved between wt1's read and wt1's write:

wt1 returned: None
/from-wt1 present: True
/from-wt2 present: False   <-- lost

Both calls returned None. Script:

import subprocess, sys, tempfile
from pathlib import Path
sys.path.insert(0, "src")
from bmad_loop import install

d = Path(tempfile.mkdtemp())
repo = d / "repo"; repo.mkdir()
run = lambda *a: subprocess.run(a, cwd=str(repo), check=True, capture_output=True)
run("git", "init", "-q"); run("git", "config", "user.email", "t@t"); run("git", "config", "user.name", "t")
(repo / "f").write_text("x"); run("git", "add", "-A"); run("git", "commit", "-qm", "init")
run("git", "worktree", "add", "-q", "-b", "wt1", str(d / "wt1"))
run("git", "worktree", "add", "-q", "-b", "wt2", str(d / "wt2"))

orig = install.atomic_write_text
def interleaved(path, text):
    install.atomic_write_text = orig                       # wt2 runs uninterrupted
    install._worktree_local_exclude(d / "wt2", ["/from-wt2"])
    return orig(path, text)                                # wt1 writes from its stale read
install.atomic_write_text = interleaved
print("wt1 returned:", install._worktree_local_exclude(d / "wt1", ["/from-wt1"]))
install.atomic_write_text = orig

body = (repo / ".git" / "info" / "exclude").read_text()
print("/from-wt1:", "/from-wt1" in body, "| /from-wt2:", "/from-wt2" in body)

Why the silence is the harmful part

The helper's docstring is explicit about what a missing exclude costs:

Silence here is not cosmetic: without the exclude the unit's git add -A commits the tool files this provisioning just wrote into the story's merge.

A lost update produces precisely that outcome, with a None return — the "nothing to report" signal — on both sides. The degrade-reason channel this helper exists to feed never fires.

Scope / relationship to other issues

Reachability

No in-process concurrency: engine.py / worktree_flow.py run no threads or pools, so a single bmad-loop run cannot hit this. It needs two bmad-loop run processes against one repo — the scenario the code comment itself posits.

Fix shapes

  1. Lock the common dirO_EXCL lockfile next to the exclude (git's own .lock convention), read-merge-write inside it, best-effort on failure to stay consistent with the helper's contract. Matches what git does to the same directory.
  2. Retry on changed mtime/size — re-read and re-merge if the file moved under us. Cheaper, still racy in principle, but the merge is idempotent (set union), so a bounded retry converges.
  3. Append instead of rewriteopen("a") a single anchored block. Sidesteps the read entirely for the common case, but loses dedup against existing lines and cannot be made idempotent across runs.

(1) is the honest fix; (2) is the cheap one. Either way, a lost update should surface through the existing degrade-reason channel rather than returning None.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineOrchestrator engine and run lifecyclebugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions