Skip to content

fix: default watcher to polling on Windows to avoid ReFS BSOD#778

Merged
carlos-alm merged 3 commits intomainfrom
fix/watcher-poll-default-windows
Apr 3, 2026
Merged

fix: default watcher to polling on Windows to avoid ReFS BSOD#778
carlos-alm merged 3 commits intomainfrom
fix/watcher-poll-default-windows

Conversation

@carlos-alm
Copy link
Copy Markdown
Contributor

Summary

  • fs.watch with recursive: true calls NtNotifyChangeDirectoryFileEx on Windows, which can crash the ReFS driver on Dev Drives causing HYPERVISOR_ERROR (20001) blue screens
  • Default to stat-based mtime polling on Windows (process.platform === 'win32'), native fs.watch remains default on macOS/Linux
  • Added --poll flag to force polling on any platform, --native to force OS watchers, and --poll-interval <ms> to tune polling frequency

Test plan

  • Existing watcher tests pass (watcher-incremental, watcher-rebuild)
  • TypeScript compiles cleanly
  • Biome lint passes
  • Manual: codegraph watch on Windows uses polling by default
  • Manual: codegraph watch --native on Windows uses fs.watch
  • Manual: codegraph watch --poll on macOS/Linux uses polling

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).
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 3, 2026

Greptile Summary

This PR defaults codegraph watch to mtime-based polling on Windows to avoid NtNotifyChangeDirectoryFileEx-triggered ReFS kernel crashes (HYPERVISOR_ERROR BSOD), while preserving the native fs.watch path on macOS/Linux. Three new CLI flags (--poll, --native, --poll-interval) are wired through to watchProject, and previously flagged issues (Commander default injection, conflicting-flag silencing) have been cleanly resolved.

Confidence Score: 5/5

Safe to merge; the core fix is correct and previous P1 concerns are resolved — remaining findings are P2 style issues.

Both open findings are P2: one is an edge case for invalid CLI input (NaN poll interval), the other is a minor hidden-directory filtering inconsistency between modes. Neither affects correctness or data integrity for typical usage. Prior P1 concerns (Commander default injection, silent flag conflict) are fully addressed.

src/domain/graph/watcher.ts — collectTrackedFiles hidden-directory filtering differs from native mode

Important Files Changed

Filename Overview
src/cli/commands/watch.ts Adds --poll, --native, and --poll-interval CLI flags; mutual exclusion guard for conflicting flags; delegates resolved options to watchProject.
src/domain/graph/watcher.ts Adds polling mode (mtime-based setInterval) as Windows default to avoid ReFS BSOD; introduces collectTrackedFiles for recursive file discovery; minor hidden-directory filtering inconsistency vs. native mode and no validation on user-supplied poll interval.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[codegraph watch] --> B{--poll AND --native?}
    B -- yes --> ERR[program.error: mutually exclusive]
    B -- no --> C{resolve poll mode}
    C -- "--poll" --> POLL[poll = true]
    C -- "--native" --> NATIVE[poll = false]
    C -- neither --> AUTO{process.platform === 'win32'?}
    AUTO -- yes --> POLL
    AUTO -- no --> NATIVE
    POLL --> D[Polling mode\nsetInterval every POLL_INTERVAL_MS]
    D --> E[collectTrackedFiles: sync recursive readdirSync]
    E --> F[Diff mtimes vs mtimeMap]
    F --> G[Add changed/new/deleted files to pending]
    G --> H{pending.size > 0?}
    H -- yes --> I[debounce 300ms → processPendingFiles]
    H -- no --> D
    NATIVE --> J[fs.watch recursive\nnative OS watcher]
    J --> K[shouldIgnore + isTrackedExt filter]
    K --> L[Add to pending]
    L --> M[debounce 300ms → processPendingFiles]
    I --> N[rebuildFile → journal → changeEvents]
    M --> N
    N --> O[SIGINT → cleanup + closeDb + exit]
Loading

Reviews (2): Last reviewed commit: "fix: add --poll/--native mutual exclusio..." | Re-trigger Greptile

options: [
['--poll', 'Use stat-based polling (default on Windows to avoid ReFS/Dev Drive crashes)'],
['--native', 'Force native OS file watchers instead of polling'],
['--poll-interval <ms>', 'Polling interval in milliseconds (default: 2000)', '2000'],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 --poll-interval default is always truthy, undefined is never passed

Because Commander.js injects the third argument ('2000') as the option's default, opts.pollInterval is always the string '2000' — it is never undefined or empty. The opts.pollInterval ? ... : undefined guard on line 21 is therefore always true, and pollInterval: 2000 is always forwarded to watchProject. This is harmless today (both defaults match), but it means the watcher's internal default can never take precedence via the CLI if it changes later. The cleanest fix is to remove the default from the Commander option definition.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed the Commander.js default value from the --poll-interval option definition so opts.pollInterval is undefined when not explicitly passed by the user, allowing the watcher's internal default (2000ms) to take precedence.

const engine = ctx.program.opts().engine;
await watchProject(root, { engine });
// Explicit --poll or --native wins; otherwise let watcher auto-detect by platform
const poll = opts.poll ? true : opts.native ? false : undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Conflicting --poll and --native flags are silently resolved

When a user passes both --poll and --native, --poll wins without any warning. A simple guard or Commander's .conflicts() API would make this explicit and prevent user confusion:

if (opts.poll && opts.native) {
  ctx.program.error('--poll and --native are mutually exclusive');
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added a mutual exclusion guard that calls ctx.program.error() when both --poll and --native are passed, giving users a clear error message instead of silently picking one.

@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit 58a8d1b into main Apr 3, 2026
21 checks passed
@carlos-alm carlos-alm deleted the fix/watcher-poll-default-windows branch April 3, 2026 04:07
@github-actions github-actions bot locked and limited conversation to collaborators Apr 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant