Atomic metadata writes: unique tmp path per writer so concurrent pty tag / state mutations cant corrupt the file (closes #22)
Reported against pty tag by schickling-assistant. Root cause: four
write sites (writeMetadata, supervisor state save, pty supervisor
reset, event-log retention) all used `target + ".tmp"` — a single
fixed path per target. Two concurrent writers truncated and
overwrote each others tmp file, and whichever rename ran during a
mid-write moment could publish half-written bytes as the target.
That explained both symptoms in #22:
- Stable: 9 of 10 concurrent writes lost ("last-write-wins" was
not the behavior; worse, the file could be left in a torn state).
- Transient: pty tag <session> returning "Session not found" even
while pty list --json showed the session alive — readMetadata
silently catches JSON.parse failure and returns null, which
propagates as "not found."
Fix: new atomicWriteFileSync / atomicWriteFile helpers in
sessions.ts that write to `<target>.tmp.<pid>.<rand>` (unique per
writer) and then rename into place. POSIX same-filesystem rename
is atomic — readers always see either the old file or the new
one, never an intermediate. All four call sites now route through
the helper. Event-log retention also uses tmp+rename so pty events
readers never see a mid-rewrite file.
Contract: last-write-wins under concurrent writers; lost updates
are possible, corruption is not. Matches the reporters explicit
"last-write-wins is fine" in the Expected section of #22.
Known limit documented in events.ts: very large (> PIPE_BUF ~4KB)
user.* / state.set event payloads can still interleave at the
POSIX O_APPEND level. Keep large blobs in state, not events.
Tests (tests/atomic-writes.test.ts): reader-during-writer for both
helpers, tmp-file cleanup check, concurrent pty tag via 10 and 20
child processes (asserts metadata parses after the race),
Promise.all setState x 50 in-process, event-log truncation vs
concurrent reader (asserts every recovered event has a valid
type).