Skip to content

Commit 58a8d1b

Browse files
authored
fix: default watcher to polling on Windows to avoid ReFS BSOD (#778)
* fix: default watcher to polling on Windows to avoid ReFS BSOD fs.watch with recursive:true calls NtNotifyChangeDirectoryFileEx, which can crash the ReFS driver on Windows Dev Drives causing HYPERVISOR_ERROR (20001) blue screens. Default to stat-based polling on Windows (process.platform === 'win32'). Native fs.watch remains the default on macOS/Linux. Users can override with --poll (force polling) or --native (force OS watchers). * fix: add --poll/--native mutual exclusion and remove Commander default (#778)
1 parent 4d383bb commit 58a8d1b

2 files changed

Lines changed: 120 additions & 18 deletions

File tree

src/cli/commands/watch.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,23 @@ import type { CommandDefinition } from '../types.js';
55
export const command: CommandDefinition = {
66
name: 'watch [dir]',
77
description: 'Watch project for file changes and incrementally update the graph',
8-
async execute([dir], _opts, ctx) {
8+
options: [
9+
['--poll', 'Use stat-based polling (default on Windows to avoid ReFS/Dev Drive crashes)'],
10+
['--native', 'Force native OS file watchers instead of polling'],
11+
['--poll-interval <ms>', 'Polling interval in milliseconds (default: 2000)'],
12+
],
13+
async execute([dir], opts, ctx) {
914
const root = path.resolve(dir || '.');
1015
const engine = ctx.program.opts().engine;
11-
await watchProject(root, { engine });
16+
if (opts.poll && opts.native) {
17+
ctx.program.error('--poll and --native are mutually exclusive');
18+
}
19+
// Explicit --poll or --native wins; otherwise let watcher auto-detect by platform
20+
const poll = opts.poll ? true : opts.native ? false : undefined;
21+
await watchProject(root, {
22+
engine,
23+
poll,
24+
pollInterval: opts.pollInterval ? Number(opts.pollInterval) : undefined,
25+
});
1226
},
1327
};

src/domain/graph/watcher.ts

Lines changed: 104 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,29 @@ function logRebuildResults(updates: RebuildResult[]): void {
136136
}
137137
}
138138

139-
export async function watchProject(rootDir: string, opts: { engine?: string } = {}): Promise<void> {
139+
/** Recursively collect tracked source files for stat-based polling. */
140+
function collectTrackedFiles(dir: string, result: string[]): void {
141+
let entries: fs.Dirent[];
142+
try {
143+
entries = fs.readdirSync(dir, { withFileTypes: true });
144+
} catch {
145+
return;
146+
}
147+
for (const entry of entries) {
148+
if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
149+
const full = path.join(dir, entry.name);
150+
if (entry.isDirectory()) {
151+
collectTrackedFiles(full, result);
152+
} else if (EXTENSIONS.has(path.extname(entry.name))) {
153+
result.push(full);
154+
}
155+
}
156+
}
157+
158+
export async function watchProject(
159+
rootDir: string,
160+
opts: { engine?: string; poll?: boolean; pollInterval?: number } = {},
161+
): Promise<void> {
140162
const dbPath = path.join(rootDir, '.codegraph', 'graph.db');
141163
if (!fs.existsSync(dbPath)) {
142164
throw new DbError('No graph.db found. Run `codegraph build` first.', { file: dbPath });
@@ -165,28 +187,94 @@ export async function watchProject(rootDir: string, opts: { engine?: string } =
165187
let timer: ReturnType<typeof setTimeout> | null = null;
166188
const DEBOUNCE_MS = 300;
167189

168-
info(`Watching ${rootDir} for changes...`);
190+
const usePoll = opts.poll ?? process.platform === 'win32';
191+
const POLL_INTERVAL_MS = opts.pollInterval ?? 2000;
192+
193+
info(`Watching ${rootDir} for changes${usePoll ? ' (polling mode)' : ''}...`);
169194
info('Press Ctrl+C to stop.');
170195

171-
const watcher = fs.watch(rootDir, { recursive: true }, (_eventType, filename) => {
172-
if (!filename) return;
173-
if (shouldIgnore(filename)) return;
174-
if (!isTrackedExt(filename)) return;
196+
let cleanup: () => void;
175197

176-
const fullPath = path.join(rootDir, filename);
177-
pending.add(fullPath);
198+
if (usePoll) {
199+
// Polling mode: avoids native OS file watchers (NtNotifyChangeDirectoryFileEx)
200+
// which can crash ReFS drivers on Windows Dev Drives.
201+
const mtimeMap = new Map<string, number>();
178202

179-
if (timer) clearTimeout(timer);
180-
timer = setTimeout(async () => {
181-
const files = [...pending];
182-
pending.clear();
183-
await processPendingFiles(files, db, rootDir, stmts, engineOpts, cache);
184-
}, DEBOUNCE_MS);
185-
});
203+
// Seed initial mtimes
204+
const initial: string[] = [];
205+
collectTrackedFiles(rootDir, initial);
206+
for (const f of initial) {
207+
try {
208+
mtimeMap.set(f, fs.statSync(f).mtimeMs);
209+
} catch {
210+
/* deleted between collect and stat */
211+
}
212+
}
213+
info(`Polling ${initial.length} tracked files every ${POLL_INTERVAL_MS}ms`);
214+
215+
const pollTimer = setInterval(() => {
216+
const current: string[] = [];
217+
collectTrackedFiles(rootDir, current);
218+
const currentSet = new Set(current);
219+
220+
// Detect modified or new files
221+
for (const f of current) {
222+
try {
223+
const mtime = fs.statSync(f).mtimeMs;
224+
const prev = mtimeMap.get(f);
225+
if (prev === undefined || mtime !== prev) {
226+
mtimeMap.set(f, mtime);
227+
pending.add(f);
228+
}
229+
} catch {
230+
/* deleted between collect and stat */
231+
}
232+
}
233+
234+
// Detect deleted files
235+
for (const f of mtimeMap.keys()) {
236+
if (!currentSet.has(f)) {
237+
mtimeMap.delete(f);
238+
pending.add(f);
239+
}
240+
}
241+
242+
if (pending.size > 0) {
243+
if (timer) clearTimeout(timer);
244+
timer = setTimeout(async () => {
245+
const files = [...pending];
246+
pending.clear();
247+
await processPendingFiles(files, db, rootDir, stmts, engineOpts, cache);
248+
}, DEBOUNCE_MS);
249+
}
250+
}, POLL_INTERVAL_MS);
251+
252+
cleanup = () => clearInterval(pollTimer);
253+
} else {
254+
// Native OS watcher — efficient but can trigger ReFS crashes on Windows Dev Drives.
255+
// Use --poll if you experience BSOD/HYPERVISOR_ERROR on ReFS volumes.
256+
const watcher = fs.watch(rootDir, { recursive: true }, (_eventType, filename) => {
257+
if (!filename) return;
258+
if (shouldIgnore(filename)) return;
259+
if (!isTrackedExt(filename)) return;
260+
261+
const fullPath = path.join(rootDir, filename);
262+
pending.add(fullPath);
263+
264+
if (timer) clearTimeout(timer);
265+
timer = setTimeout(async () => {
266+
const files = [...pending];
267+
pending.clear();
268+
await processPendingFiles(files, db, rootDir, stmts, engineOpts, cache);
269+
}, DEBOUNCE_MS);
270+
});
271+
272+
cleanup = () => watcher.close();
273+
}
186274

187275
process.on('SIGINT', () => {
188276
info('Stopping watcher...');
189-
watcher.close();
277+
cleanup();
190278
// Flush any pending file paths to journal before exit
191279
if (pending.size > 0) {
192280
const entries = [...pending].map((filePath) => ({

0 commit comments

Comments
 (0)