Appendix A - 10. Build-serializer (M=8 semaphore)
The Structure of Build-serializer (M=8 semaphore) — its shape at a glance:
Each of the routed tools acquires one of M slots before running and releases after; the semaphore lets concurrency rise to the host's capacity and caps it there.
flowchart LR
T1[Compiler] --> Sem{{Semaphore, M=8}}
T2[Type-checker] --> Sem
T3[Query tool] --> Sem
Sem -->|slot free| Run([Tool runs])
Sem -->|8 in flight| Wait([Queue for a slot])
Accessible description: several heavy build tools each acquire one of eight semaphore slots before running; a tool runs when a slot is free and queues when all eight are in flight, so concurrency rises to the host's capacity and is capped there.
Projected from the catalogue entry mediators-and-resource-locks / Build-serializer (M=8 semaphore).
On this page: Intent · Motivation · Applicability · Structure · Sample Code · Consequences · Example use within DocAble · Related Patterns
Intent
Intent — A host-level counting semaphore (M=8) over the adjacent heavy-compute tools (dotnet build, tsc, csharp-query, jedi lints, pyright), so concurrent worktrees get parallelism up to the machine's capacity without oversubscribing it.
Motivation
The heavy build tools are the same shared-host hazard as dotnet test: N worktrees compiling, type-checking, and running Roslyn queries at once saturate CPU and memory and slow everyone. But unlike dotnet test, these tools are numerous and mostly parallel-safe, so the failure to avoid is both oversubscription (too many at once melts the host) and over-serialization (N=1 would waste cores and stall the fleet).
Applicability
- A counting-semaphore primitive (here byte-range flock over M offsets).
- An identified set of adjacent heavy tools to route; anything left out leaks past the cap.
- A per-tool enforcer for each, so the mediated path is the only path.
- A tuned M appropriate to the host's core/memory budget.
Structure
The Structure diagram appears at the top of this page.
Sample Code
The lock's cardinality follows the resource's contention profile: a bounded semaphore for parallel-safe but heavy work. Each routed tool acquires a slot before running; an enforcer refuses the un-mediated call so the cap can't be skipped.
import fcntl
M = 8 # tuned to the host's core / memory budget — parallel-safe but heavy work
def with_build_slot(lock_path: str, run_tool):
with open(lock_path, "w") as lock:
for slot in range(M): # byte-range semaphore: M independent 1-byte locks
try:
fcntl.lockf(lock, fcntl.LOCK_EX | fcntl.LOCK_NB, 1, slot)
return run_tool() # hold the slot for the tool's duration
except BlockingIOError:
continue
raise SystemExit("all build slots busy — queue and retry")
def enforce_mediated(argv0: str, cwd: str) -> None:
if is_agent_worktree(cwd): # the raw call is banned from an agent worktree
raise SystemExit(f"run {argv0} through the build mediator, not directly")
Consequences
- M is a fixed guess. Eight is a static ceiling, not adaptive to host size or current load: too high oversubscribes a small host, too low wastes a big one.
- Coverage is only as complete as the routed set. A new heavy tool not wired through the semaphore silently escapes the cap.
- Five enforcers to maintain. Each tool's enforcer is a surface that can drift.
- Log proliferation. Per-run timestamped logs accumulate (by design, but they add up).
Example use within DocAble
- The build serializer: the M=8 byte-range semaphore over the five tools.
- The five active per-tool enforcers (
dotnet build/tsc/csharp-query/ jedi /pyright).
Related Patterns
- See also (sibling) — test-serializer: the same pattern at N=1. Together they are the worked example of choosing lock cardinality by contention profile (destructive ⇒ N=1; parallel-safe-but-heavy ⇒ M=8).
- Layer — with test-serializer and aggregate-compute-protection, the host-compute rationing tier.