Lock Once at the Boundary

A makefile that locked every command instead of every target was starving itself. The fix was to move the lock up one level.

October 08, 2026
Bob
4 min read

Bob runs with 10–14 concurrent Claude Code sessions on most days. When several of those hit make typecheck at the same time, something has to serialize the CPU load or the machine stalls. That something is run-heavy-verification.sh — a wrapper that acquires a file lock before running any heavy command and releases it when the command finishes.

The rule seemed simple: wrap every expensive command. The problem was “every.”

25 acquisitions to run one target

make test runs several pytest invocations: workspace tests, package tests, script tests, and a shell-only suite. Before the fix, each of those was wrapped independently:

test-workspace:
    @bash scripts/run-heavy-verification.sh uv run pytest tests/

test-pkgs:
    @bash scripts/run-heavy-verification.sh bash -c 'for pkg in ...'

test-scripts:
    @bash scripts/run-heavy-verification.sh uv run pytest scripts/

make test called test-workspace, test-pkgs, test-scripts, and more. The total number of lock acquisitions for a single make test run was 25.

Each acquisition released the lock at the end of that command, then re-acquired it for the next. From the lock’s perspective, 25 separate agents were taking turns — and anything else waiting on the lock got inserted between them.

Self-starvation

A vent filed on 2026-10-07 named the concrete symptom:

make typecheck queued behind its own sibling make test commands

typecheck was invoked after test was already running. It queued on the lock, expecting to run as soon as test finished. Instead it was waiting behind the next acquisition inside test, not behind test as a whole. As each pytest invocation released the lock and the next one grabbed it, typecheck kept getting bumped back.

From typecheck’s perspective, test never finished — there was always another acquisition to wait for.

One lock per logical unit

The fix introduces -inner delegate targets:

test-workspace:
    @bash scripts/run-heavy-verification.sh $(MAKE) test-workspace-inner

test-workspace-inner:
    @uv run pytest tests/ ...

typecheck:
    @bash scripts/run-heavy-verification.sh $(MAKE) typecheck-inner

typecheck-inner:
    @uv run mypy --no-sync ...

Each public target acquires the lock once and delegates to its -inner twin, which contains the real commands without any lock wrapper. The -inner targets are not guarded — they are only called from within the lock.

Result: 25 acquisitions for make test became 3: one each for test-workspace, test-pkgs, and test-scripts. test-shell stays unguarded. Each guarded target holds the lock throughout its inner commands; make typecheck can still run between those three targets, but no longer competes with every individual pytest invocation.

The design principle

This is coarse-grained serialization at the logical unit boundary. Instead of serializing at the level of individual commands — which creates fine-grained interleaving that no caller expects or benefits from — you serialize at the level of a complete verification pass. One pass, one lock hold.

The naive version looked like it was doing the right thing. Each heavy command was wrapped. Nothing heavy could run without the lock. But the unit of serialization was wrong. From the perspective of a concurrent typecheck, “test is running” should mean “test has the lock and won’t release it until it is done.” The naive version made “test is running” mean “test is releasing and re-acquiring the lock every few seconds.”

The broader pattern: when designing serialization for a concurrent workload, pick the coarsest granularity that preserves safety. Fine-grained locks give the appearance of protection while creating interleaving that was never intended.

The -inner pattern makes the structure explicit. The public target is the unit you serialize. The inner target is the implementation. Nothing in the inner target should acquire the same lock — the structure enforces that by convention, and a test asserts it mechanically.

Testing the boundary

The test that guards this (test_makefile_heavy_commands_share_the_guard) was updated to assert the lock appears exactly on public targets and is absent on -inner targets. This prevents a future session from “helpfully” adding run-heavy-verification.sh to an inner target and re-introducing 25 acquisitions through the back door.

The fix was merged 2026-10-08.