10 KiB
Add .state.loop File Lock
Close the tick/approve TOCTOU races flagged in add-status-brakes/ADVERSARIAL_BUG_REPORT.md (A6) and add-loop-runner/ADVERSARIAL_BUG_REPORT.md (A2, A7). Both reports name the same v1.1 fix: a file lock on .state.loop that serializes read-modify-write cycles across processes.
This is a v1.1 hardening task. No new features; no user-visible CLI change. Pure robustness.
Goal
Add a cross-platform file-lock helper that wraps every _read_state_loop → mutate → _write_state_loop cycle in status.py and loop-runner.py. Concurrent ticks (two schedulers firing the same loop) and concurrent approve-vs-tick writes will serialize instead of overwriting each other.
Background — the race
_write_state_loop already does atomic tmp-then-replace (status.py:1662). The write itself is atomic. The race is read-modify-write:
- Tick A reads
.state.loop(count=9). - Tick B reads
.state.loop(count=9). - Tick A passes
--check-gate(count=9 < max=10). - Tick B passes
--check-gate(count=9 < max=10). - Tick A runs harness, writes count=10.
- Tick B runs harness, writes count=10. (Still bounded, but two ticks ran for one increment.)
The --approve --loop write vs a concurrent tick's iteration increment is the same shape (status.py A6): approve wins, tick's increment is lost.
The lock closes both by serializing the full read-modify-write critical section.
Requirements
R1. New helper: _loop_lock(loop_path, exclusive=True)
A context manager (contextlib.contextmanager or __enter__/__exit__ class) that:
- Opens
<loop_path>/.state.lock(creating it if absent) and holds an OS-level exclusive lock for the duration of thewithblock. - On exit: releases the lock. The
.state.lockfile may be left on disk (it's tiny and idempotent across runs); not garbage-collected. - Blocking acquire: a second acquirer waits until the first releases. No timeout in v1.1 (loop ticks are short; if a tick wedges, the operator notices via
--loop-listshowing stalelast_tick_atand intervenes manually). - Cross-platform:
- POSIX (
sys.platform != "win32"):fcntl.flock(fd, LOCK_EX)for acquire,fcntl.flock(fd, LOCK_UN)for release. - Windows (
sys.platform == "win32"):msvcrt.locking(fd, LK_LOCK, 1)blocking acquire on a 1-byte region; release viamsvcrt.locking(fd, LK_UNLCK, 1).msvcrtis stdlib on Windows.
- POSIX (
- On
BrokenPipeError/IOErrorfrom a vanished loop dir mid-hold: surface a clear error"loop dir vanished mid-lock"and exit nonzero. Don't mask it. - File handle is kept open for the life of the
with; closed infinally.
R2. Wrap every read-modify-write cycle in status.py
Locate each _write_state_loop(...) callsite in scripts/status.py (lines 1721, 1948, 1971, 1993) and confirm each is preceded by a _read_state_loop(...) that seeds it. Wrap the read+mutate+write block in with _loop_lock(loop_path):. Do NOT wrap the --create-loop path (status.py:1819) — there is no prior state to race against; duplicate create is already refused by name (R-of-create-task).
Affected commands in status.py:
cmd_check_gate(halt write) — status.py:1721cmd_pause_loop(paused) — status.py:1948cmd_resume_loop(running) — status.py:1971cmd_approve_loop(halt clear + resumed_count++) — status.py:1993
The lock must cover the read that precedes each of these writes, not just the write. (Wrapping only the write wouldn't close the race — that just makes writes atomic, which they already are.)
R3. Wrap every read-modify-write cycle in loop-runner.py
In scripts/loop-runner.py, wrap the read+mutate+write in cmd_tick's step 10 ("atomic state write" per design/loops/technical.md §7) and any other _write_state_loop callsite (lines 125, 244, 684). Mirror the same with _loop_lock(loop_path): pattern.
The lock must be held across:
- The
--check-gatesubprocess call's effective decision (i.e. the read ofiteration_count/statusit makes), AND - The subsequent state mutation write.
Since --check-gate runs as a subprocess and reads .state.loop itself, the lock acquired by the runner blocks the runner's own subsequent subprocess read from racing a concurrent approve write, but it cannot lock the subprocess itself. This is acceptable: the lock scope we control is the parent runner's read-modify-write; a concurrent tick would block on .state.lock at the parent-runner level and the gate call inside it would still see consistent state.
R4. Idempotence and no-op fast path
If a command reads .state.loop, discovers no mutation is needed (e.g. --pause-loop on an already-paused loop), it still releases the lock cleanly. The lock MUST always be released, even on early-return code paths inside the with block. Use try/finally inside the context manager, not inside callers.
R5. Lock file location
.state.lock lives in the loop's own dir (<loop_path>/.state.lock), NOT the framework root. Rationale: per-loop granularity; a lock on loop A's tick must not block loop B's approve. Untracked loops (no .state.loop) still get a .state.lock file on first acquire — that's fine; the file is empty.
R6. No new pip deps
Use stdlib only: fcntl (POSIX), msvcrt (Windows), contextlib, sys, os. Both are already conditionally imported elsewhere in the framework (platform.system() dispatch in task 5).
R7. Compatibility with existing atomic write
The existing _write_state_loop tmp-then-replace stays. The lock adds a coarse mutex around the read-modify-write cycle; the atomic write provides last-write-wins safety even if some future code path forgets the lock. Defense-in-depth; no regression to the existing atomic semantics.
Non-goals
- No
--claim-loop-task(that's task 6). - No timeout / deadlock detection — out of scope; ticks are short. If a future tick grows long, address then.
- No advisory locking visible to harnesses — internal only; no CLI surface.
- No
outputs.retentionGC (task 3). - No
blast_radius.base_branchparameterization (task 4).
Test plan (tests/test_state_loop_lock.py)
New tests, all stdlib, all using tmp_path:
test_lock_serializes_concurrent_writes: two threads kicked off simultaneously, each does read→sleep(0.05)→write under the lock. Assert timestamps don't interleave (one finishes before the other starts its write). Use a shared "interleave detector" (a list append of enter/exit times compared after).test_lock_releases_on_clean_exit: acquire+release; the next acquire on the same loop succeeds immediately.test_lock_releases_on_exception:with _loop_lock(p): raise ValueError; next acquire succeeds.test_lock_is_per_loop: two lock acquisitions on two different loop dirs run concurrently without blocking each other (assert both complete within a tightly bounded wall-clock window).test_no_lock_on_create_loop:--create-loopof a new loop does NOT create a.state.lockfile (create-path is unwrapped per R2). Then--check-gateon it acquires/releases the lock, leaving.state.lockbehind.test_pause_loop_serialized_with_concurrent_read: spawn a thread that holds_loop_lockfor 0.1s; main thread calls--pause-loopand assert it completes after 0.1s (not before). Confirms commands actually acquire the lock.test_runner_tick_holds_lock_across_state_write: integration-style — invokeloop-runner.py --mode tickagainst a loop whose tick is artificially delayed, while a parallel--approve --loopis held; assert approve completes after the tick. (Skip if it grows flaky — turns into a smoke test asserting the lock file appears.)
Reuse the _make_loop helper pattern from tests/test_status_brakes.py for loop dir scaffolding.
Concrete code shape
@contextlib.contextmanager
def _loop_lock(loop_path: Path, exclusive: bool = True):
lock_file = loop_path / ".state.lock"
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
acquired = False
try:
if sys.platform == "win32":
import msvcrt
msvcrt.locking(fd, msvcrt.LK_LOCK if exclusive else msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
acquired = True
yield
finally:
if acquired:
if sys.platform == "win32":
import msvcrt
try:
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
except OSError:
pass
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
Callers:
with _loop_lock(loop_path):
state = _read_state_loop(loop_path) or _initial_state_loop(name)
state["status"] = "paused"
_write_state_loop(loop_path, state)
D-items (decisions locked for this task)
- D-L1: blocking acquire, no timeout in v1.1. Ticks short; operator notices via
--loop-liststalelast_tick_at. - D-L2:
.state.lockis per-loop, lives in the loop dir, not garbage-collected. - D-L3:
--create-looppath is unwrapped (no prior state to race against; create is name-unique-refused). - D-L4: stdlib only (
fcntlPOSIX,msvcrtWindows). Nofilelockpackage. - D-L5: existing atomic write semantics retained (defense-in-depth).
Risks
- Deadlock if a path holds the lock and re-enters a function that tries to re-acquire. Mitigation:
_loop_lockis not re-entrant — audit every callsite to ensure no nested_loop_lockwithin the samewithblock. POSIXflockis re-entrant on the same fd; Windowsmsvcrt.lockingis not. Safer to forbid nesting and document it. - Linux
flockon NFS has known caveats. Out of scope: the loop dir is always local (project root or~/.automaton). Document in the helper's docstring.
Verification
python3 -m py_compile scripts/status.py scripts/loop-runner.pypython3 -m pytest tests/test_state_loop_lock.py -vpython3 -m pytest tests/ -q(full suite must remain green; 433 baseline + new)- Manual:
python3 scripts/status.py --create-loop t --from-template ci-triage && python3 scripts/status.py --check-gate t— should succeed and leave.state.lockbehind on first acquire.