From cde4c18a3528133fde862d91b6846949837f2f62 Mon Sep 17 00:00:00 2001 From: timkjr Date: Sat, 29 Aug 2026 21:13:14 -0500 Subject: [PATCH 1/5] Detect NFS (and non-WSL SMB) mounts in slowWatchMount, not just WSL2 slowWatchMount's whole filesystem-type check was gated behind runningUnderWSL(): on any non-WSL2 Linux host, the function returned false unconditionally without ever inspecting the actual filesystem, even though its own doc comment describes detecting "a filesystem where native fsnotify is unreliable or prohibitively slow" generically. This meant a repo tracked on a plain NFS mount on a native Linux host fell through the one safety net that exists specifically for this class of problem. The fsnotify backend then reliably failed its Watcher.Start() 5-second readiness wait (confirmWatchActive/the ready channel select) -- and because that failure path returns before the adaptive poller is ever constructed, the repo ended up with neither fsnotify nor the poller fallback: no update mechanism at all until a manual untrack + track. Reproduced consistently on a Debian host with repos on an NFS4 mount -- every daemon restart in the log history hits "watcher: backend did not become ready within 5s" for every NFS-mounted repo, with the daemon's own warning ("their graphs go stale until the daemon restarts") describing a fix that doesn't actually happen, since restarting just re-triggers the same failure. Removed the WSL gate; the magic-number check now runs on any Linux host and adds NFS_SUPER_MAGIC (0x6969) to the switch. Factored the switch into isSlowMountFSType(fsType int64) so it's unit-testable without needing a live WSL2/SMB/NFS mount, and verified the fix against a real NFS4 mount: slowWatchMount() now returns true (was false), while local-disk paths are unaffected. runningUnderWSL() removed as dead code -- 9p and CIFS mounts are exactly as unreliable for fsnotify when mounted directly on native Linux as they are inside WSL2, so gating the whole check on WSL detection was never actually necessary for those two either. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NktJ7aab9oD9U1ks35g9TS --- internal/indexer/slow_mount_linux.go | 42 +++++++++++++--------------- internal/indexer/slow_mount_test.go | 32 ++++++++++++++++++++- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/internal/indexer/slow_mount_linux.go b/internal/indexer/slow_mount_linux.go index 738cccd1c..5795fc2f6 100644 --- a/internal/indexer/slow_mount_linux.go +++ b/internal/indexer/slow_mount_linux.go @@ -4,42 +4,40 @@ package indexer import ( "os" - "strings" "syscall" ) // slowWatchMount reports whether path lives on a filesystem where native -// fsnotify is unreliable or prohibitively slow — notably a Windows drive -// surfaced into WSL2 via 9p/drvfs (inotify events arrive late or never), or -// an SMB/CIFS share. On such a mount the watcher disables fsnotify and -// relies on the adaptive poller + git hooks. GORTEX_FORCE_FSNOTIFY=1 forces -// native fsnotify on regardless. +// fsnotify is unreliable or prohibitively slow — a Windows drive surfaced +// into WSL2 via 9p/drvfs (inotify events arrive late or never), an SMB/CIFS +// share (whether mounted directly or via WSL2), or an NFS mount (the kernel +// inotify backend does not reliably notice changes made by another NFS +// client, and even same-client notifications can arrive late enough to miss +// the watcher's readiness window entirely — see confirmWatchActive's 5s +// timeout). On such a mount the watcher disables fsnotify and relies on the +// adaptive poller + git hooks, which are mount-agnostic. +// GORTEX_FORCE_FSNOTIFY=1 forces native fsnotify on regardless. func slowWatchMount(path string) bool { if path == "" || os.Getenv("GORTEX_FORCE_FSNOTIFY") == "1" { return false } - if !runningUnderWSL() { - return false - } var st syscall.Statfs_t if err := syscall.Statfs(path, &st); err != nil { return false } - switch int64(st.Type) { + return isSlowMountFSType(int64(st.Type)) +} + +// isSlowMountFSType is the magic-number check slowWatchMount applies to a +// statfs result. Factored out so it can be unit-tested directly against +// known-bad magic numbers without needing a live WSL2, SMB, or NFS mount in +// the test environment. +func isSlowMountFSType(fsType int64) bool { + switch fsType { case 0x01021997, // V9FS_MAGIC — 9p, WSL2's drvfs transport for Windows drives - 0xFF534D42: // CIFS_MAGIC — SMB/CIFS share + 0xFF534D42, // CIFS_MAGIC — SMB/CIFS share + 0x6969: // NFS_SUPER_MAGIC — NFS v3/v4 return true } return false } - -// runningUnderWSL reports whether the process is inside the Windows -// Subsystem for Linux, probed from /proc/version's microsoft/WSL marker. -func runningUnderWSL() bool { - b, err := os.ReadFile("/proc/version") - if err != nil { - return false - } - v := strings.ToLower(string(b)) - return strings.Contains(v, "microsoft") || strings.Contains(v, "wsl") -} diff --git a/internal/indexer/slow_mount_test.go b/internal/indexer/slow_mount_test.go index d871a9e7b..2497dad3f 100644 --- a/internal/indexer/slow_mount_test.go +++ b/internal/indexer/slow_mount_test.go @@ -4,7 +4,7 @@ import "testing" // TestSlowWatchMount_NormalMountNotDegraded guards the safe default: a // normal local filesystem (the test temp dir) must never be flagged as a -// slow mount, so fsnotify is only disabled on a genuine WSL2 9p/SMB mount. +// slow mount, so fsnotify is only disabled on a genuine slow-mount type. func TestSlowWatchMount_NormalMountNotDegraded(t *testing.T) { if slowWatchMount(t.TempDir()) { t.Error("a normal local mount must not be flagged slow (fsnotify must stay enabled)") @@ -13,3 +13,33 @@ func TestSlowWatchMount_NormalMountNotDegraded(t *testing.T) { t.Error("an empty path must not be flagged slow") } } + +// TestIsSlowMountFSType pins the magic-number check slowWatchMount applies +// to a statfs result, independent of the host actually having a WSL2, SMB, +// or NFS mount available to probe. NFS (0x6969, NFS_SUPER_MAGIC) previously +// went undetected on any non-WSL2 host: slowWatchMount's own filesystem-type +// check was gated behind a WSL-only probe, so a native Linux host with a +// repo on an NFS mount fell through every safety net — the fsnotify backend +// then reliably failed its 5s readiness wait, and because that failure path +// returns before the adaptive poller is ever constructed, the repo ended up +// with neither fsnotify nor the poller: no update mechanism at all until a +// manual untrack+track. +func TestIsSlowMountFSType(t *testing.T) { + cases := []struct { + name string + typ int64 + want bool + }{ + {"ext4/xfs/local (typical, unlisted)", 0xEF53, false}, + {"V9FS (WSL2 9p/drvfs)", 0x01021997, true}, + {"CIFS/SMB", 0xFF534D42, true}, + {"NFS_SUPER_MAGIC", 0x6969, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := isSlowMountFSType(c.typ); got != c.want { + t.Errorf("isSlowMountFSType(%#x) = %v, want %v", c.typ, got, c.want) + } + }) + } +} From 969c26b4ca8e5bb8f76a8af891d8ec434e80800e Mon Sep 17 00:00:00 2001 From: timkjr Date: Sat, 29 Aug 2026 23:39:01 -0500 Subject: [PATCH 2/5] fix(watcher): make WatchConfig.Enabled actually disable watching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watcher.Start only gated the safe branches (slow-mount skip-to-poller, poller-as-fallback) behind config.Watch.Enabled — it never gated the actual attempt to start native fsnotify, which ran unconditionally regardless of the flag. Since config.Default() ships Enabled: false, every repo without an explicit watch.enabled: true silently attempted raw fsnotify, raced confirmWatchActive's 5s readiness timeout with no safety net, and got no poller fallback on either success or failure — exactly backwards from what "disabled" should mean. Add an early return when Enabled is false, matching what the existing comments in this function already claimed ("a repo that opted out of watching gets no fallback either"). Set degradedNoFsnotify in that path too, since Stop() skips waiting on the loop-closed w.stopped channel only in degraded mode — without it, Stop() on a never-started watcher blocks forever. Simplify the two now-redundant nested Enabled checks further down in Start, since Enabled is guaranteed true for the rest of the function after the early return. --- internal/indexer/watcher.go | 70 ++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index 6303383e9..f85197cbd 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -418,25 +418,43 @@ func (w *Watcher) Start(paths []string) (retErr error) { return errors.New("watcher: no paths to watch") } + // WatchConfig.Enabled is the repo's opt-in to being watched at all — + // by fsnotify or by the adaptive poller. Every fallback below already + // assumed this (each one is itself gated on Enabled), but nothing + // actually stopped a disabled repo from still attempting raw native + // fsnotify first: config.Default() ships Enabled: false, which was + // silently attempting fsnotify unconditionally, racing + // confirmWatchActive's 5s timeout with no safety net and no + // fallback on either success or failure. Make the flag mean what its + // name and every comment below already claim it means. + // + // degradedNoFsnotify must be set here too: Stop() skips waiting on + // w.stopped only in degraded mode, because that channel is closed by + // w.loop(), which this early return — like the slow-mount branch + // below — never launches. + if !w.config.Enabled { + w.degradedNoFsnotify = true + return nil + } + // WSL2 / slow-mount degradation: on a 9p/drvfs mount (a Windows drive - // under WSL2, an SMB share) native fsnotify delivers events late or not - // at all, and confirmWatchActive would hang ~5s per path before timing - // out. Skip the fsnotify backend entirely and rely on the adaptive - // poller + git hooks, which are mount-agnostic. The downstream code - // already tolerates a nil fsw. GORTEX_FORCE_FSNOTIFY=1 overrides. - if w.config.Enabled { - probe := paths[0] - if abs, err := filepath.Abs(probe); err == nil { - probe = abs - } - if slowWatchMount(probe) { - w.degradedNoFsnotify = true - w.logger.Warn("watcher: slow mount detected — disabling native fsnotify, using adaptive poller fallback", - zap.String("path", probe)) - w.poller = newPoller(w, w.indexer, w.logger) - w.poller.Start() - return nil - } + // under WSL2, an SMB share) or an NFS mount, native fsnotify delivers + // events late or not at all, and confirmWatchActive would hang ~5s per + // path before timing out. Skip the fsnotify backend entirely and rely + // on the adaptive poller + git hooks, which are mount-agnostic. The + // downstream code already tolerates a nil fsw. GORTEX_FORCE_FSNOTIFY=1 + // overrides. + probe := paths[0] + if abs, err := filepath.Abs(probe); err == nil { + probe = abs + } + if slowWatchMount(probe) { + w.degradedNoFsnotify = true + w.logger.Warn("watcher: slow mount detected — disabling native fsnotify, using adaptive poller fallback", + zap.String("path", probe)) + w.poller = newPoller(w, w.indexer, w.logger) + w.poller.Start() + return nil } ready := make(chan struct{}) // Own the events/dropped channels so the library never closes them on @@ -543,10 +561,8 @@ func (w *Watcher) Start(paths []string) (retErr error) { w.fsw.Close() w.fsw = nil } - if w.config.Enabled { - w.poller = newPoller(w, w.indexer, w.logger) - w.poller.Start() - } + w.poller = newPoller(w, w.indexer, w.logger) + w.poller.Start() return nil } return err @@ -610,12 +626,10 @@ func (w *Watcher) Start(paths []string) (retErr error) { // Launch the adaptive-interval poller alongside the fsnotify // backend. It is a fallback for the changes fsnotify misses, so - // it shares the watcher's lifecycle. Gated on WatchConfig.Enabled - // — a repo that opted out of watching gets no fallback either. - if w.config.Enabled { - w.poller = newPoller(w, w.indexer, w.logger) - w.poller.Start() - } + // it shares the watcher's lifecycle. Enabled is already guaranteed + // true here (see the early return at the top of Start). + w.poller = newPoller(w, w.indexer, w.logger) + w.poller.Start() return nil } From b652be03e3b1f90b68a30e199868882401f17f63 Mon Sep 17 00:00:00 2001 From: timkjr Date: Sat, 29 Aug 2026 23:53:16 -0500 Subject: [PATCH 3/5] docs(watcher): clarify the Enabled early-return comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reword to make clear the comment describes the bug this commit fixes, not pre-existing behavior — a reviewer reading the PR cold could otherwise misread "every fallback below already assumed this" as a statement about the code before this patch. --- internal/indexer/watcher.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index f85197cbd..f066a3551 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -419,14 +419,15 @@ func (w *Watcher) Start(paths []string) (retErr error) { } // WatchConfig.Enabled is the repo's opt-in to being watched at all — - // by fsnotify or by the adaptive poller. Every fallback below already - // assumed this (each one is itself gated on Enabled), but nothing - // actually stopped a disabled repo from still attempting raw native - // fsnotify first: config.Default() ships Enabled: false, which was - // silently attempting fsnotify unconditionally, racing - // confirmWatchActive's 5s timeout with no safety net and no - // fallback on either success or failure. Make the flag mean what its - // name and every comment below already claim it means. + // by fsnotify or by the adaptive poller. Every fallback below was + // already WRITTEN as if this were true (each one is itself gated on + // Enabled) — but until this early return, nothing actually enforced + // it: a disabled repo still attempted raw native fsnotify first. + // config.Default() ships Enabled: false, so that unconditional + // attempt was the common case, silently racing confirmWatchActive's + // 5s timeout with no safety net and no fallback on either success + // or failure. This return makes the flag mean what its name and + // every comment below already claimed it meant. // // degradedNoFsnotify must be set here too: Stop() skips waiting on // w.stopped only in degraded mode, because that channel is closed by From 89e360b2001ba8ea33702c686d80f86e695e2f0b Mon Sep 17 00:00:00 2001 From: timkjr Date: Sun, 30 Aug 2026 08:33:13 -0500 Subject: [PATCH 4/5] fix(indexer): gate slow-mount-magic test to Linux, unbreak macOS build TestIsSlowMountFSType exercises isSlowMountFSType, which only exists in slow_mount_linux.go (//go:build linux). The test file had no build tag, so it compiled on every platform, and on macOS/Windows (only slow_mount_other.go compiled, defining slowWatchMount but not isSlowMountFSType) the package failed to build: internal/indexer/slow_mount_test.go:40:14: undefined: isSlowMountFSType Split the file: TestIsSlowMountFSType stays behind //go:build linux alongside the function it pins; TestSlowWatchMount_NormalMountNotDegraded (exercises the cross-platform slowWatchMount) moves to a new untagged file so it keeps running on macOS/Windows too. --- internal/indexer/slow_mount_test.go | 14 ++------------ internal/indexer/slow_watch_mount_test.go | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 internal/indexer/slow_watch_mount_test.go diff --git a/internal/indexer/slow_mount_test.go b/internal/indexer/slow_mount_test.go index 2497dad3f..49dcab9b5 100644 --- a/internal/indexer/slow_mount_test.go +++ b/internal/indexer/slow_mount_test.go @@ -1,19 +1,9 @@ +//go:build linux + package indexer import "testing" -// TestSlowWatchMount_NormalMountNotDegraded guards the safe default: a -// normal local filesystem (the test temp dir) must never be flagged as a -// slow mount, so fsnotify is only disabled on a genuine slow-mount type. -func TestSlowWatchMount_NormalMountNotDegraded(t *testing.T) { - if slowWatchMount(t.TempDir()) { - t.Error("a normal local mount must not be flagged slow (fsnotify must stay enabled)") - } - if slowWatchMount("") { - t.Error("an empty path must not be flagged slow") - } -} - // TestIsSlowMountFSType pins the magic-number check slowWatchMount applies // to a statfs result, independent of the host actually having a WSL2, SMB, // or NFS mount available to probe. NFS (0x6969, NFS_SUPER_MAGIC) previously diff --git a/internal/indexer/slow_watch_mount_test.go b/internal/indexer/slow_watch_mount_test.go new file mode 100644 index 000000000..6e9c94306 --- /dev/null +++ b/internal/indexer/slow_watch_mount_test.go @@ -0,0 +1,17 @@ +package indexer + +import "testing" + +// TestSlowWatchMount_NormalMountNotDegraded guards the safe default: a +// normal local filesystem (the test temp dir) must never be flagged as a +// slow mount, so fsnotify is only disabled on a genuine slow-mount type. +// Runs on every platform, unlike TestIsSlowMountFSType (Linux-only, in +// slow_mount_test.go) which pins the statfs magic-number check itself. +func TestSlowWatchMount_NormalMountNotDegraded(t *testing.T) { + if slowWatchMount(t.TempDir()) { + t.Error("a normal local mount must not be flagged slow (fsnotify must stay enabled)") + } + if slowWatchMount("") { + t.Error("an empty path must not be flagged slow") + } +} From 30878fbed29a3584c294f099049fd84745834389 Mon Sep 17 00:00:00 2001 From: timkjr Date: Mon, 31 Aug 2026 08:01:01 -0500 Subject: [PATCH 5/5] fix(watcher): make WatchConfig.Enabled disable only the extra poller, not fsnotify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous change gated all of Start() behind `if !w.config.Enabled { return nil }`, which disabled fsnotify itself whenever a repo hadn't opted in to watching — the opposite of the intent (Enabled was meant to gate only the belt-and-braces adaptive poller that runs alongside a healthy fsnotify backend). Since config.Default() ships Enabled: false, every repo without an explicit watch.enabled: true silently got no live indexing at all. Removed the early return. The two degraded-path pollers (slow-mount fallback, inotify/FD-exhaustion fallback) start unconditionally, since fsnotify is already dead on those paths and declining the poller there means the repo goes stale with no fallback. Enabled now gates only the poller that runs alongside a live fsnotify backend. Added TestWatcher_ShippedDefaultStillWatches, which drives the real config.Default().Watch (Enabled: false) through Start() and confirms a file change still reaches the graph via fsnotify, not the poller. Every other watcher test in this package hardcodes Enabled: true via setupWatcher, so none of them exercised the daemon's actual shipped default — that gap is why this regression shipped uncaught. Also, per review on #697: - Fixed TestPoller_RespectsWatcherDisableKnob's doc comment, which claimed a disabled repo gets no fallback at all — no longer true now that degraded-path pollers run unconditionally. - Hardened TestSlowWatchMount_NormalMountNotDegraded: on Linux it was probing t.TempDir() via a live statfs, which assumes TMPDIR is local disk. Skip that probe on Linux, where TestIsSlowMountFSType already pins the same logic against known magic numbers instead of a live mount. --- internal/indexer/poller_test.go | 6 ++- internal/indexer/slow_watch_mount_test.go | 30 ++++++++++---- internal/indexer/watcher.go | 37 ++++++----------- internal/indexer/watcher_test.go | 48 +++++++++++++++++++++++ 4 files changed, 85 insertions(+), 36 deletions(-) diff --git a/internal/indexer/poller_test.go b/internal/indexer/poller_test.go index 28a555aa8..c91e1b568 100644 --- a/internal/indexer/poller_test.go +++ b/internal/indexer/poller_test.go @@ -209,8 +209,10 @@ func TestPoller_DetectsGitHeadMoveMissedByFsnotify(t *testing.T) { // TestPoller_RespectsWatcherDisableKnob verifies the poller honours // the per-repo watcher-disable knob: when WatchConfig.Enabled is -// false, Start must not create a poller — the disabled repo gets no -// fallback either. +// false, Start must not create the alongside-fsnotify poller. This +// is not the whole story on a slow mount, where the degraded-path +// pollers still start unconditionally regardless of Enabled — see +// TestWatcher_ShippedDefaultStillWatches and the slow-mount tests. func TestPoller_RespectsWatcherDisableKnob(t *testing.T) { dir := t.TempDir() writeTestFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc Main() {}\n") diff --git a/internal/indexer/slow_watch_mount_test.go b/internal/indexer/slow_watch_mount_test.go index 6e9c94306..407105b45 100644 --- a/internal/indexer/slow_watch_mount_test.go +++ b/internal/indexer/slow_watch_mount_test.go @@ -1,17 +1,31 @@ package indexer -import "testing" +import ( + "runtime" + "testing" +) // TestSlowWatchMount_NormalMountNotDegraded guards the safe default: a -// normal local filesystem (the test temp dir) must never be flagged as a -// slow mount, so fsnotify is only disabled on a genuine slow-mount type. -// Runs on every platform, unlike TestIsSlowMountFSType (Linux-only, in -// slow_mount_test.go) which pins the statfs magic-number check itself. +// normal local filesystem must never be flagged as a slow mount, so +// fsnotify is only disabled on a genuine slow-mount type. Runs on every +// platform, unlike TestIsSlowMountFSType (Linux-only, in +// slow_mount_test.go) which pins the statfs magic-number check itself +// against known constants and is the sturdier of the two checks. +// +// On Linux, slowWatchMount does a live statfs of the given path, so +// probing t.TempDir() here depends on TMPDIR actually sitting on local +// disk — not guaranteed on every CI runner. TestIsSlowMountFSType +// already covers the Linux logic against a known-local magic number +// (0xEF53), so skip the live probe there and keep it only on platforms +// where slowWatchMount is a static stub (always false, can't flake). func TestSlowWatchMount_NormalMountNotDegraded(t *testing.T) { - if slowWatchMount(t.TempDir()) { - t.Error("a normal local mount must not be flagged slow (fsnotify must stay enabled)") - } if slowWatchMount("") { t.Error("an empty path must not be flagged slow") } + if runtime.GOOS == "linux" { + t.Skip("Linux magic-number logic is pinned by TestIsSlowMountFSType against known constants, not a live TMPDIR probe") + } + if slowWatchMount(t.TempDir()) { + t.Error("a normal local mount must not be flagged slow (fsnotify must stay enabled)") + } } diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index f066a3551..182518a0d 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -418,26 +418,6 @@ func (w *Watcher) Start(paths []string) (retErr error) { return errors.New("watcher: no paths to watch") } - // WatchConfig.Enabled is the repo's opt-in to being watched at all — - // by fsnotify or by the adaptive poller. Every fallback below was - // already WRITTEN as if this were true (each one is itself gated on - // Enabled) — but until this early return, nothing actually enforced - // it: a disabled repo still attempted raw native fsnotify first. - // config.Default() ships Enabled: false, so that unconditional - // attempt was the common case, silently racing confirmWatchActive's - // 5s timeout with no safety net and no fallback on either success - // or failure. This return makes the flag mean what its name and - // every comment below already claimed it meant. - // - // degradedNoFsnotify must be set here too: Stop() skips waiting on - // w.stopped only in degraded mode, because that channel is closed by - // w.loop(), which this early return — like the slow-mount branch - // below — never launches. - if !w.config.Enabled { - w.degradedNoFsnotify = true - return nil - } - // WSL2 / slow-mount degradation: on a 9p/drvfs mount (a Windows drive // under WSL2, an SMB share) or an NFS mount, native fsnotify delivers // events late or not at all, and confirmWatchActive would hang ~5s per @@ -625,12 +605,17 @@ func (w *Watcher) Start(paths []string) (retErr error) { } } - // Launch the adaptive-interval poller alongside the fsnotify - // backend. It is a fallback for the changes fsnotify misses, so - // it shares the watcher's lifecycle. Enabled is already guaranteed - // true here (see the early return at the top of Start). - w.poller = newPoller(w, w.indexer, w.logger) - w.poller.Start() + // Launch the adaptive-interval poller alongside the fsnotify backend. + // It is a fallback for the changes fsnotify misses, so it shares the + // watcher's lifecycle. Enabled is the opt-in only here, where + // fsnotify is LIVE — there the poller is a belt-and-braces extra for + // what fsnotify misses, and a repo may decline it. The degraded + // paths above start it unconditionally: fsnotify is dead there, so + // declining it means the repo silently goes stale. + if w.config.Enabled { + w.poller = newPoller(w, w.indexer, w.logger) + w.poller.Start() + } return nil } diff --git a/internal/indexer/watcher_test.go b/internal/indexer/watcher_test.go index 2d2a4f79b..16f3bc934 100644 --- a/internal/indexer/watcher_test.go +++ b/internal/indexer/watcher_test.go @@ -58,6 +58,54 @@ func writeTestFile(t *testing.T, path, content string) { require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) } +// TestWatcher_ShippedDefaultStillWatches drives config.Default().Watch — +// Enabled: false, exactly what every repo under `gortex daemon` gets with +// no override — through Start() and asserts a file change still reaches +// the graph. Every other watcher test in this package hardcodes +// Enabled: true, so none of them exercise the daemon's actual default; +// that gap once let Enabled: false silently disable fsnotify itself +// (not just the adaptive poller) without any test catching it. +func TestWatcher_ShippedDefaultStillWatches(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, filepath.Join(dir, "main.go"), `package main + +func Original() {} +`) + + g := graph.New() + reg := parser.NewRegistry() + reg.Register(languages.NewGoExtractor()) + cfg := config.Default() + cfg.Index.Workers = 1 + + idx := New(g, reg, cfg.Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + + wcfg := cfg.Watch + require.False(t, wcfg.Enabled, "config.Default().Watch must ship Enabled: false") + wcfg.Paths = []string{dir} + wcfg.DebounceMs = 50 // short debounce for tests + + w, err := NewWatcher(idx, wcfg, zap.NewNop()) + require.NoError(t, err) + require.NoError(t, w.Start([]string{dir})) + t.Cleanup(func() { _ = w.Stop() }) + + assert.Nil(t, w.poller, + "the shipped default disables the adaptive poller only, not fsnotify") + + writeTestFile(t, filepath.Join(dir, "main.go"), `package main + +func Modified() {} +`) + + ev := waitForEvent(t, w, 2*time.Second) + assert.Equal(t, ChangeModified, ev.Kind) + assert.NotEmpty(t, idx.graph.FindNodesByName("Modified"), + "a file change under the shipped watch default must reach the graph via fsnotify") +} + func waitForEvent(t *testing.T, w *Watcher, timeout time.Duration) GraphChangeEvent { t.Helper() select {