1+ import crypto from 'node:crypto' ;
12import fs from 'node:fs' ;
23import path from 'node:path' ;
34import { debug , warn } from '../../infrastructure/logger.js' ;
45
56export const JOURNAL_FILENAME = 'changes.journal' ;
67const HEADER_PREFIX = '# codegraph-journal v1 ' ;
8+ const LOCK_SUFFIX = '.lock' ;
9+ const LOCK_TIMEOUT_MS = 5_000 ;
10+ const LOCK_STALE_MS = 30_000 ;
11+ const LOCK_RETRY_MS = 25 ;
12+
13+ // Busy-spin sleep avoids blocking the Node.js event loop (unlike Atomics.wait,
14+ // which freezes all I/O and timer callbacks). The retry interval is short
15+ // (25ms), so the CPU cost is negligible while keeping unrelated callbacks
16+ // responsive in watcher processes.
17+ function sleepSync ( ms : number ) : void {
18+ const end = process . hrtime . bigint ( ) + BigInt ( ms ) * 1_000_000n ;
19+ while ( process . hrtime . bigint ( ) < end ) {
20+ /* spin */
21+ }
22+ }
23+
24+ function isPidAlive ( pid : number ) : boolean {
25+ if ( ! Number . isFinite ( pid ) || pid <= 0 ) return false ;
26+ try {
27+ process . kill ( pid , 0 ) ;
28+ return true ;
29+ } catch ( e ) {
30+ // EPERM means the process exists but we lack permission — still alive.
31+ return ( e as NodeJS . ErrnoException ) . code === 'EPERM' ;
32+ }
33+ }
34+
35+ interface AcquiredLock {
36+ fd : number ;
37+ nonce : string ;
38+ }
39+
40+ /**
41+ * Steal a stale lockfile atomically via write-tmp + rename.
42+ *
43+ * Using rename (which is atomic on POSIX and Windows) avoids the TOCTOU race
44+ * inherent to the unlink + openSync('wx') pattern: if two stealers both
45+ * observed the same stale holder, one's unlink could cross the other's fresh
46+ * acquisition, admitting two writers into the critical section.
47+ *
48+ * After rename, we re-read the lockfile to confirm our nonce — if another
49+ * stealer's rename landed after ours, they own the lock and we retry.
50+ */
51+ function trySteal ( lockPath : string ) : AcquiredLock | null {
52+ const nonce = `${ process . pid } -${ crypto . randomBytes ( 8 ) . toString ( 'hex' ) } ` ;
53+ const tmpPath = `${ lockPath } .${ nonce } .tmp` ;
54+ try {
55+ fs . writeFileSync ( tmpPath , `${ process . pid } \n${ nonce } \n` , { flag : 'w' } ) ;
56+ } catch {
57+ return null ;
58+ }
59+
60+ try {
61+ // Atomic replace: overwrites the stale lockfile.
62+ fs . renameSync ( tmpPath , lockPath ) ;
63+ } catch {
64+ try {
65+ fs . unlinkSync ( tmpPath ) ;
66+ } catch {
67+ /* ignore */
68+ }
69+ return null ;
70+ }
71+
72+ // Verify the nonce — another stealer's rename may have landed after ours.
73+ let content : string ;
74+ try {
75+ content = fs . readFileSync ( lockPath , 'utf-8' ) ;
76+ } catch {
77+ return null ;
78+ }
79+ if ( ! content . includes ( nonce ) ) {
80+ // Lost the race to another stealer; do NOT unlink their live lockfile.
81+ return null ;
82+ }
83+
84+ let fd : number ;
85+ try {
86+ // Re-open r+ so we have a persistent fd the caller can close on release.
87+ fd = fs . openSync ( lockPath , 'r+' ) ;
88+ } catch {
89+ return null ;
90+ }
91+ return { fd, nonce } ;
92+ }
93+
94+ function acquireJournalLock ( lockPath : string ) : AcquiredLock {
95+ const start = Date . now ( ) ;
96+ for ( ; ; ) {
97+ const nonce = `${ process . pid } -${ crypto . randomBytes ( 8 ) . toString ( 'hex' ) } ` ;
98+ try {
99+ const fd = fs . openSync ( lockPath , 'wx' ) ;
100+ try {
101+ fs . writeSync ( fd , `${ process . pid } \n${ nonce } \n` ) ;
102+ } catch {
103+ // Stamp write failed (ENOSPC, I/O error). An empty lockfile would
104+ // look stale to concurrent waiters (Number('') === 0, isPidAlive(0)
105+ // returns false), so they'd steal our live lock. Release and retry.
106+ try {
107+ fs . closeSync ( fd ) ;
108+ } catch {
109+ /* ignore */
110+ }
111+ try {
112+ fs . unlinkSync ( lockPath ) ;
113+ } catch {
114+ /* ignore */
115+ }
116+ if ( Date . now ( ) - start > LOCK_TIMEOUT_MS ) {
117+ throw new Error (
118+ `Failed to acquire journal lock at ${ lockPath } within ${ LOCK_TIMEOUT_MS } ms` ,
119+ ) ;
120+ }
121+ sleepSync ( LOCK_RETRY_MS ) ;
122+ continue ;
123+ }
124+ return { fd, nonce } ;
125+ } catch ( e ) {
126+ if ( ( e as NodeJS . ErrnoException ) . code !== 'EEXIST' ) throw e ;
127+ }
128+
129+ let holderAlive = true ;
130+ try {
131+ const pidContent = fs . readFileSync ( lockPath , 'utf-8' ) . split ( '\n' ) [ 0 ] ! . trim ( ) ;
132+ holderAlive = isPidAlive ( Number ( pidContent ) ) ;
133+ } catch {
134+ /* unreadable — fall through to age check */
135+ }
136+
137+ let shouldSteal = ! holderAlive ;
138+ if ( holderAlive ) {
139+ try {
140+ const stat = fs . statSync ( lockPath ) ;
141+ if ( Date . now ( ) - stat . mtimeMs > LOCK_STALE_MS ) {
142+ shouldSteal = true ;
143+ }
144+ } catch {
145+ /* stat failed — keep retrying */
146+ }
147+ }
148+
149+ if ( shouldSteal ) {
150+ const stolen = trySteal ( lockPath ) ;
151+ if ( stolen ) return stolen ;
152+ // Steal failed or lost the race — fall through to timeout check & retry.
153+ }
154+
155+ if ( Date . now ( ) - start > LOCK_TIMEOUT_MS ) {
156+ throw new Error ( `Failed to acquire journal lock at ${ lockPath } within ${ LOCK_TIMEOUT_MS } ms` ) ;
157+ }
158+ sleepSync ( LOCK_RETRY_MS ) ;
159+ }
160+ }
161+
162+ function releaseJournalLock ( lockPath : string , lock : AcquiredLock ) : void {
163+ try {
164+ fs . closeSync ( lock . fd ) ;
165+ } catch {
166+ /* ignore */
167+ }
168+ // Only unlink if the lockfile still carries our nonce — if another stealer
169+ // decided we were stale and replaced it, we must not unlink their live lock.
170+ try {
171+ const content = fs . readFileSync ( lockPath , 'utf-8' ) ;
172+ if ( content . includes ( lock . nonce ) ) {
173+ fs . unlinkSync ( lockPath ) ;
174+ }
175+ } catch {
176+ /* lockfile gone or unreadable — nothing to unlink */
177+ }
178+ }
179+
180+ function sweepStaleTmpFiles ( dir : string ) : void {
181+ // Clean up orphaned .tmp files left behind when a process is killed after
182+ // writeFileSync(tmpPath, ...) succeeds but before renameSync(tmpPath, lockPath)
183+ // completes (trySteal path). Without this, tmp files accumulate silently in
184+ // .codegraph/ across crash cycles. Only sweep ones older than LOCK_STALE_MS
185+ // so we don't race an in-flight steal on another process.
186+ let entries : fs . Dirent [ ] ;
187+ try {
188+ entries = fs . readdirSync ( dir , { withFileTypes : true } ) ;
189+ } catch {
190+ return ;
191+ }
192+ const now = Date . now ( ) ;
193+ const prefix = `${ JOURNAL_FILENAME } ${ LOCK_SUFFIX } .` ;
194+ for ( const entry of entries ) {
195+ if ( ! entry . isFile ( ) || ! entry . name . startsWith ( prefix ) || ! entry . name . endsWith ( '.tmp' ) ) {
196+ continue ;
197+ }
198+ const tmpPath = path . join ( dir , entry . name ) ;
199+ try {
200+ const stat = fs . statSync ( tmpPath ) ;
201+ if ( now - stat . mtimeMs > LOCK_STALE_MS ) {
202+ fs . unlinkSync ( tmpPath ) ;
203+ }
204+ } catch {
205+ /* stat/unlink raced another cleaner or was already removed — ignore */
206+ }
207+ }
208+ }
209+
210+ function withJournalLock < T > ( rootDir : string , fn : ( ) => T ) : T {
211+ const dir = path . join ( rootDir , '.codegraph' ) ;
212+ fs . mkdirSync ( dir , { recursive : true } ) ;
213+ sweepStaleTmpFiles ( dir ) ;
214+ const lockPath = path . join ( dir , `${ JOURNAL_FILENAME } ${ LOCK_SUFFIX } ` ) ;
215+ const lock = acquireJournalLock ( lockPath ) ;
216+ try {
217+ return fn ( ) ;
218+ } finally {
219+ releaseJournalLock ( lockPath , lock ) ;
220+ }
221+ }
7222
8223interface JournalResult {
9224 valid : boolean ;
@@ -63,41 +278,39 @@ export function appendJournalEntries(
63278 rootDir : string ,
64279 entries : Array < { file : string ; deleted ?: boolean } > ,
65280) : void {
66- const dir = path . join ( rootDir , '.codegraph' ) ;
67- const journalPath = path . join ( dir , JOURNAL_FILENAME ) ;
281+ withJournalLock ( rootDir , ( ) => {
282+ const journalPath = path . join ( rootDir , '.codegraph' , JOURNAL_FILENAME ) ;
68283
69- fs . mkdirSync ( dir , { recursive : true } ) ;
284+ if ( ! fs . existsSync ( journalPath ) ) {
285+ fs . writeFileSync ( journalPath , `${ HEADER_PREFIX } 0\n` ) ;
286+ }
70287
71- if ( ! fs . existsSync ( journalPath ) ) {
72- fs . writeFileSync ( journalPath , `${ HEADER_PREFIX } 0\n` ) ;
73- }
288+ const lines = entries . map ( ( e ) => {
289+ if ( e . deleted ) return `DELETED ${ e . file } ` ;
290+ return e . file ;
291+ } ) ;
74292
75- const lines = entries . map ( ( e ) => {
76- if ( e . deleted ) return `DELETED ${ e . file } ` ;
77- return e . file ;
293+ fs . appendFileSync ( journalPath , `${ lines . join ( '\n' ) } \n` ) ;
78294 } ) ;
79-
80- fs . appendFileSync ( journalPath , `${ lines . join ( '\n' ) } \n` ) ;
81295}
82296
83297export function writeJournalHeader ( rootDir : string , timestamp : number ) : void {
84- const dir = path . join ( rootDir , '.codegraph' ) ;
85- const journalPath = path . join ( dir , JOURNAL_FILENAME ) ;
86- const tmpPath = `${ journalPath } .tmp` ;
87-
88- fs . mkdirSync ( dir , { recursive : true } ) ;
298+ withJournalLock ( rootDir , ( ) => {
299+ const journalPath = path . join ( rootDir , '.codegraph' , JOURNAL_FILENAME ) ;
300+ const tmpPath = `${ journalPath } .tmp` ;
89301
90- try {
91- fs . writeFileSync ( tmpPath , `${ HEADER_PREFIX } ${ timestamp } \n` ) ;
92- fs . renameSync ( tmpPath , journalPath ) ;
93- } catch ( err ) {
94- warn ( `Failed to write journal header: ${ ( err as Error ) . message } ` ) ;
95302 try {
96- fs . unlinkSync ( tmpPath ) ;
97- } catch {
98- /* ignore */
303+ fs . writeFileSync ( tmpPath , `${ HEADER_PREFIX } ${ timestamp } \n` ) ;
304+ fs . renameSync ( tmpPath , journalPath ) ;
305+ } catch ( err ) {
306+ warn ( `Failed to write journal header: ${ ( err as Error ) . message } ` ) ;
307+ try {
308+ fs . unlinkSync ( tmpPath ) ;
309+ } catch {
310+ /* ignore */
311+ }
99312 }
100- }
313+ } ) ;
101314}
102315
103316/**
@@ -116,35 +329,34 @@ export function appendJournalEntriesAndStampHeader(
116329 entries : Array < { file : string ; deleted ?: boolean } > ,
117330 timestamp : number ,
118331) : void {
119- const dir = path . join ( rootDir , '.codegraph' ) ;
120- const journalPath = path . join ( dir , JOURNAL_FILENAME ) ;
121- const tmpPath = `${ journalPath } .tmp` ;
122-
123- fs . mkdirSync ( dir , { recursive : true } ) ;
332+ withJournalLock ( rootDir , ( ) => {
333+ const journalPath = path . join ( rootDir , '.codegraph' , JOURNAL_FILENAME ) ;
334+ const tmpPath = `${ journalPath } .tmp` ;
124335
125- let existingBody = '' ;
126- try {
127- const content = fs . readFileSync ( journalPath , 'utf-8' ) ;
128- const newlineIdx = content . indexOf ( '\n' ) ;
129- if ( newlineIdx >= 0 ) existingBody = content . slice ( newlineIdx + 1 ) ;
130- } catch {
131- /* no existing journal — fall through to write header + new entries */
132- }
133- if ( existingBody && ! existingBody . endsWith ( '\n' ) ) existingBody = `${ existingBody } \n` ;
336+ let existingBody = '' ;
337+ try {
338+ const content = fs . readFileSync ( journalPath , 'utf-8' ) ;
339+ const newlineIdx = content . indexOf ( '\n' ) ;
340+ if ( newlineIdx >= 0 ) existingBody = content . slice ( newlineIdx + 1 ) ;
341+ } catch {
342+ /* no existing journal — fall through to write header + new entries */
343+ }
344+ if ( existingBody && ! existingBody . endsWith ( '\n' ) ) existingBody = `${ existingBody } \n` ;
134345
135- const newLines = entries . map ( ( e ) => ( e . deleted ? `DELETED ${ e . file } ` : e . file ) ) ;
136- const appended = newLines . length > 0 ? `${ newLines . join ( '\n' ) } \n` : '' ;
137- const content = `${ HEADER_PREFIX } ${ timestamp } \n${ existingBody } ${ appended } ` ;
346+ const newLines = entries . map ( ( e ) => ( e . deleted ? `DELETED ${ e . file } ` : e . file ) ) ;
347+ const appended = newLines . length > 0 ? `${ newLines . join ( '\n' ) } \n` : '' ;
348+ const content = `${ HEADER_PREFIX } ${ timestamp } \n${ existingBody } ${ appended } ` ;
138349
139- try {
140- fs . writeFileSync ( tmpPath , content ) ;
141- fs . renameSync ( tmpPath , journalPath ) ;
142- } catch ( err ) {
143- warn ( `Failed to update journal: ${ ( err as Error ) . message } ` ) ;
144350 try {
145- fs . unlinkSync ( tmpPath ) ;
146- } catch {
147- /* ignore */
351+ fs . writeFileSync ( tmpPath , content ) ;
352+ fs . renameSync ( tmpPath , journalPath ) ;
353+ } catch ( err ) {
354+ warn ( `Failed to update journal: ${ ( err as Error ) . message } ` ) ;
355+ try {
356+ fs . unlinkSync ( tmpPath ) ;
357+ } catch {
358+ /* ignore */
359+ }
148360 }
149- }
361+ } ) ;
150362}
0 commit comments