Skip to content

Commit c46c244

Browse files
authored
fix(journal): serialize concurrent appends via lockfile (#1002)
* fix(journal): serialize concurrent appends via lockfile appendJournalEntries used fs.appendFileSync with no cross-process coordination, so a watcher session and a manual codegraph build in a second shell could interleave lines and corrupt .codegraph/changes.journal (truncated DELETED prefixes, partial entries, newline-less tails). Wrap both appendJournalEntries and writeJournalHeader in a withJournalLock helper that: - acquires .codegraph/changes.journal.lock via fs.openSync(path, 'wx') (atomic exclusive-create, cross-platform) - stamps the holder PID for stale-lock detection - retries every 25ms up to a 5s timeout - steals the lock if the holder PID is dead or the lock file is older than 30s (covers crash-mid-write) - always releases in finally Zero new dependencies — uses only node:fs and Atomics.wait on a SharedArrayBuffer for synchronous retry sleep, preserving the existing sync API. Fixes #996 Impact: 7 functions changed, 12 affected * fix(journal): close stale-lock TOCTOU and unblock event loop (#1002) Address two Greptile review issues on the journal lockfile: - P1 TOCTOU: when two stealers observed the same stale (dead-PID) holder, one's unlink could cross the other's fresh openSync('wx') acquisition, admitting both writers into the critical section. Replace the unlink + openSync('wx') pattern with an atomic write-tmp + rename steal, then verify via a random nonce. If another stealer's rename landed after ours, we bail and retry instead of unlinking their live lockfile. Release now also nonce-verifies before unlinking. - P2 event-loop blockage: replace Atomics.wait with a short hrtime busy-spin so pending FS events and timer callbacks in the watcher keep firing during the 25ms retry window. Add a regression test for the stale-lock steal race that asserts we never unlink a lockfile whose nonce does not match our own. Impact: 7 functions changed, 10 affected * fix(journal): sweep orphaned lockfile .tmp files on withJournalLock entry (#1002) Addresses Greptile P2: crash-mid-steal in trySteal leaves .codegraph/changes.journal.lock.<nonce>.tmp files behind. Without cleanup they accumulate silently across crash cycles. Adds sweepStaleTmpFiles called at the top of withJournalLock which removes any changes.journal.lock.*.tmp older than LOCK_STALE_MS. The age filter avoids racing an in-flight steal on another process. Impact: 2 functions changed, 10 affected
1 parent 342c8dd commit c46c244

2 files changed

Lines changed: 371 additions & 51 deletions

File tree

src/domain/graph/journal.ts

Lines changed: 263 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,224 @@
1+
import crypto from 'node:crypto';
12
import fs from 'node:fs';
23
import path from 'node:path';
34
import { debug, warn } from '../../infrastructure/logger.js';
45

56
export const JOURNAL_FILENAME = 'changes.journal';
67
const 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

8223
interface 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

83297
export 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

Comments
 (0)