2026-10-29 task 2 lost its host-state entry (version 390 -> single
digits) while running under Docker/gVisor. Root cause, proven live:
- save_state tmp name contained only pid, and every containerized
host.py runs as pid 1, so concurrent writers shared one tmp file
and interleaved writes produced corrupt JSON;
- fcntl.flock does not exclude across runsc containers sharing a
bind-mounted lock file (both entered at 0.0s in a live probe);
- _read_state_file silently treated corrupt JSON as empty state, so
the next save legitimately rewrote the file with only the
surviving writer's tasks.
Fix:
- tmp name now hostname + pid + thread + uuid4 (backup tmp same);
- mkdir atomic lock replaces flock (atomic on the host fs, holds
across bind mounts/gofer), with stale-lock recovery after 120s
and a loud timeout instead of unlocked writes; reads need no lock
because publication is tmp + os.replace;
- save path uses a strict disk read: corrupt JSON, non-object top
level, or wrong-shaped tasks/automations now raise instead of
overwriting (recovery stays with restore_state + .bak).
Tests: tests/test_host_state_lock.py, 7 items (tmp uniqueness in
process and across processes, 8-process concurrent saves all
survive with version 8, stale lock recovery, fresh lock timeout
raises, corrupt/wrong-shaped disk refuses to overwrite).
Full suites: cloud 1168 (skipped 4), host 104, deploy 39, all green.