1- import { existsSync , mkdirSync , writeFileSync } from 'node:fs' ;
2- import { mkdir , readFile , writeFile } from 'node:fs/promises' ;
1+ import {
2+ closeSync ,
3+ existsSync ,
4+ fsyncSync ,
5+ mkdirSync ,
6+ openSync ,
7+ renameSync ,
8+ rmSync ,
9+ writeSync ,
10+ } from 'node:fs' ;
11+ import { mkdir , open , readFile , rename , rm } from 'node:fs/promises' ;
312import { dirname , join } from 'node:path' ;
413import { emptyPersistedState , fromPersistedJson , type PersistedState } from '@/core' ;
514
@@ -21,19 +30,97 @@ export async function loadState(path: string): Promise<PersistedState> {
2130 }
2231}
2332
24- /** Write persisted state, creating `.codiva/` if needed. */
25- export async function saveState ( state : PersistedState , path : string ) : Promise < void > {
33+ function serialize ( state : PersistedState ) : string {
34+ return `${ JSON . stringify ( state , null , 2 ) } \n` ;
35+ }
36+
37+ /**
38+ * Temp file for the atomic write, in the same directory so `rename` stays within
39+ * one filesystem. The name is fixed per (path, process, writer) instead of unique
40+ * per call so a process killed mid-write leaves at most two strays, not one per
41+ * write: async writes are serialized (see `saveState`) and the sync writer gets its
42+ * own name, so no two live writes ever share a temp file. The pid keeps a second
43+ * codiva running on the same repo from clobbering our half-written temp.
44+ */
45+ function tempPath ( path : string , writer : 'async' | 'sync' ) : string {
46+ return `${ path } .${ process . pid } .${ writer } .tmp` ;
47+ }
48+
49+ /** Serializes writes per state path — see `saveState`. */
50+ const writeQueues = new Map < string , Promise < void > > ( ) ;
51+
52+ async function writeAtomic ( state : PersistedState , path : string ) : Promise < void > {
2653 await mkdir ( dirname ( path ) , { recursive : true } ) ;
27- await writeFile ( path , `${ JSON . stringify ( state , null , 2 ) } \n` , 'utf8' ) ;
54+ const tmp = tempPath ( path , 'async' ) ;
55+ try {
56+ const handle = await open ( tmp , 'w' ) ;
57+ try {
58+ await handle . writeFile ( serialize ( state ) , 'utf8' ) ;
59+ // fsync before the rename: without it a crash can publish a rename whose
60+ // bytes never reached disk, and `loadState` would fall back to empty state.
61+ await handle . sync ( ) ;
62+ } finally {
63+ await handle . close ( ) ;
64+ }
65+ await rename ( tmp , path ) ;
66+ } catch ( error ) {
67+ await rm ( tmp , { force : true } ) . catch ( ( ) => undefined ) ;
68+ throw error ;
69+ }
70+ }
71+
72+ /**
73+ * Write persisted state, creating `.codiva/` if needed.
74+ *
75+ * Two hazards this has to avoid, both of which lose every restorable session:
76+ * 1. **Torn file** — writing `path` in place leaves truncated JSON if the process
77+ * dies mid-write, and `loadState` reads that as "no sessions". So we write a
78+ * temp file, fsync it, and `rename` it over the target (atomic on POSIX).
79+ * 2. **Out-of-order writes** — a debounced save still in flight must not land
80+ * after a newer one. Writes to the same path are chained, so the renames
81+ * happen in call order and the last caller wins.
82+ */
83+ export async function saveState ( state : PersistedState , path : string ) : Promise < void > {
84+ const tail = writeQueues . get ( path ) ?? Promise . resolve ( ) ;
85+ const run = tail . then ( ( ) => writeAtomic ( state , path ) ) ;
86+ // The queue itself must never reject: one failed write must not poison later saves.
87+ writeQueues . set (
88+ path ,
89+ run . then (
90+ ( ) => undefined ,
91+ ( ) => undefined ,
92+ ) ,
93+ ) ;
94+ await run ;
2895}
2996
3097/**
3198 * Synchronous save for exit/signal handlers (SIGTERM/SIGHUP), where the event
32- * loop won't run pending async writes before the process dies.
99+ * loop won't run pending async writes before the process dies. Same temp+rename
100+ * dance as `saveState`, on its own temp file so it can't collide with an async
101+ * write that is still in flight (that one's rename never happens — the process
102+ * exits first — so it cannot roll this snapshot back either).
33103 */
34104export function saveStateSync ( state : PersistedState , path : string ) : void {
35105 mkdirSync ( dirname ( path ) , { recursive : true } ) ;
36- writeFileSync ( path , `${ JSON . stringify ( state , null , 2 ) } \n` , 'utf8' ) ;
106+ const tmp = tempPath ( path , 'sync' ) ;
107+ try {
108+ const fd = openSync ( tmp , 'w' ) ;
109+ try {
110+ writeSync ( fd , serialize ( state ) ) ;
111+ fsyncSync ( fd ) ;
112+ } finally {
113+ closeSync ( fd ) ;
114+ }
115+ renameSync ( tmp , path ) ;
116+ } catch ( error ) {
117+ try {
118+ rmSync ( tmp , { force : true } ) ;
119+ } catch {
120+ // leaving a stray temp file behind is better than masking the real error
121+ }
122+ throw error ;
123+ }
37124}
38125
39126/**
0 commit comments