feat(phase 2.2): atomic write — temp+rename in setActive #24

Merged
deco merged 1 commit from feat/phase-2.2-atomic-write into main 2026-05-21 22:04:19 +03:00
Owner

Summary

Phase 2.2 of the Stream-a-Thon plan. Switches setActive from fs.writeFileSync to a write-tmp-then-renameSync pattern so a reader (the obs-source-switcher plugin polling at 1000 ms) can never observe a torn / partial / 0-byte state on the ${screen}.txt files.

Evidence

Two 30-minute Mac soaks (1 Hz writer, ~60 Hz reader, 96k+ reads each):

Strategy Reads Torn reads
write (old fs.writeFileSync) 96 281 0
rename (new) 96 261 0

Both strategies pass on macOS / APFS. The decision picks rename for Windows safety: on NTFS, MoveFileEx(MOVEFILE_REPLACE_EXISTING) (what Node's fs.renameSync calls) is atomic; fs.writeFileSync briefly exposes a truncate-then-write window. Reversibility is a one-commit revert.

Full evidence + rationale in docs/plugin-contract.md § "Phase 2.2 — Atomic-Write Decision".

Files

  • lib/atomicWrite.ts — shared atomicWriteFileSync helper (tmp + rename + cleanup-on-error)
  • app/api/setActive/route.ts:50 — call site
  • lib/__tests__/atomicWrite.test.ts — 5 tests (happy path, no-leftover-tmp, overwrite, unique tmp paths, error cleanup)
  • scripts/atomicWriteSoak.ts + scripts/atomicWriteSoak/ — the harness used to produce the evidence, kept in-repo for the Windows F1 follow-up
  • docs/plugin-contract.md — decision section
  • docs/atomic-write-soak-mac.{write,rename}.json — soak evidence artifacts
  • package.jsonnpm run soak:atomic-write
  • .gitignore — exclude docs/atomic-write-soak-tmp/

Test plan

  • 210/210 jest tests pass
  • tsc --noEmit clean
  • next build clean
  • npm run audit:sqlite-opens clean
  • F1 (non-blocking): re-run the soak on the Windows OBS host via Tailscale shell; drop result as docs/atomic-write-soak-win.rename.json
## Summary Phase 2.2 of the Stream-a-Thon plan. Switches `setActive` from `fs.writeFileSync` to a write-tmp-then-`renameSync` pattern so a reader (the `obs-source-switcher` plugin polling at 1000 ms) can never observe a torn / partial / 0-byte state on the `${screen}.txt` files. ## Evidence Two 30-minute Mac soaks (1 Hz writer, ~60 Hz reader, 96k+ reads each): | Strategy | Reads | Torn reads | |---|---|---| | `write` (old `fs.writeFileSync`) | 96 281 | 0 | | `rename` (new) | 96 261 | 0 | Both strategies pass on macOS / APFS. The decision picks `rename` for Windows safety: on NTFS, `MoveFileEx(MOVEFILE_REPLACE_EXISTING)` (what Node's `fs.renameSync` calls) is atomic; `fs.writeFileSync` briefly exposes a truncate-then-write window. Reversibility is a one-commit revert. Full evidence + rationale in `docs/plugin-contract.md` § \"Phase 2.2 — Atomic-Write Decision\". ## Files - `lib/atomicWrite.ts` — shared `atomicWriteFileSync` helper (tmp + rename + cleanup-on-error) - `app/api/setActive/route.ts:50` — call site - `lib/__tests__/atomicWrite.test.ts` — 5 tests (happy path, no-leftover-tmp, overwrite, unique tmp paths, error cleanup) - `scripts/atomicWriteSoak.ts` + `scripts/atomicWriteSoak/` — the harness used to produce the evidence, kept in-repo for the Windows F1 follow-up - `docs/plugin-contract.md` — decision section - `docs/atomic-write-soak-mac.{write,rename}.json` — soak evidence artifacts - `package.json` — `npm run soak:atomic-write` - `.gitignore` — exclude `docs/atomic-write-soak-tmp/` ## Test plan - [x] 210/210 jest tests pass - [x] `tsc --noEmit` clean - [x] `next build` clean - [x] `npm run audit:sqlite-opens` clean - [ ] F1 (non-blocking): re-run the soak on the Windows OBS host via Tailscale shell; drop result as `docs/atomic-write-soak-win.rename.json`
feat(phase 2.2): atomic write — temp+rename in setActive, soak harness, decision doc
All checks were successful
Lint and Build / build (pull_request) Successful in 4m28s
9dc2d5d41b
Two 30-min Mac soaks (1 Hz write, ~60 Hz read, 96k+ reads each) showed
zero torn reads for both fs.writeFileSync and the temp+renameSync
strategy. Strategy A (rename) wins on the Windows safety argument:
MoveFileEx(REPLACE_EXISTING) is atomic on NTFS, removing the
truncate-then-write window that fs.writeFileSync exposes. Cost on Mac
is zero-measurable; reversibility is a single-commit revert.

- lib/atomicWrite.ts: shared atomicWriteFileSync helper (write tmp,
  rename, unlink tmp on rename failure).
- app/api/setActive/route.ts: switch the \${screen}.txt write site to
  atomicWriteFileSync; drop the unused fs import.
- scripts/atomicWriteSoak.ts + scripts/atomicWriteSoak/: harness used
  to produce the evidence, kept in-repo for follow-up Windows soak (F1)
  and future regressions.
- docs/plugin-contract.md: append Phase 2.2 — Atomic-Write Decision
  section with the soak data, decision rationale, and follow-ups.
- docs/atomic-write-soak-mac.{write,rename}.json: evidence artifacts.
- package.json: add 'soak:atomic-write' npm script.
- .gitignore: exclude docs/atomic-write-soak-tmp/ runtime files.

210/210 jest tests pass; type-check, lint, audit:sqlite-opens, and
next build all clean.
deco merged commit 9af71e9bae into main 2026-05-21 22:04:19 +03:00
deco deleted branch feat/phase-2.2-atomic-write 2026-05-21 22:04:19 +03:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
deco/cuesheet!24
No description provided.