Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

1577 lines
57 KiB
Go

package indexer
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/sgtdi/fswatcher"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/excludes"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/pathkey"
"github.com/zzet/gortex/internal/reach"
)
// ChangeKind describes the type of filesystem change.
type ChangeKind string
const (
ChangeCreated ChangeKind = "created"
ChangeModified ChangeKind = "modified"
ChangeDeleted ChangeKind = "deleted"
ChangeRenamed ChangeKind = "renamed"
)
// GraphChangeEvent is emitted after a successful graph patch.
type GraphChangeEvent struct {
FilePath string `json:"file_path"`
Kind ChangeKind `json:"kind"`
NodesAdded int `json:"nodes_added"`
NodesRemoved int `json:"nodes_removed"`
EdgesAdded int `json:"edges_added"`
EdgesRemoved int `json:"edges_removed"`
Timestamp time.Time `json:"timestamp"`
DurationMs int64 `json:"duration_ms"`
}
// SymbolChangeCallback is called when symbols change during file re-indexing.
// It receives the file path, old symbols (before eviction), and new symbols (after re-index).
type SymbolChangeCallback func(filePath string, oldSymbols, newSymbols []*graph.Node)
// Watcher keeps the knowledge graph in live sync with the filesystem.
type Watcher struct {
indexer *Indexer
fsw fswatcher.Watcher
fsCancel context.CancelFunc
config config.WatchConfig
// degradedNoFsnotify is set when Start detected a slow mount (a WSL2
// 9p/drvfs Windows drive, an SMB share) and skipped the native fsnotify
// backend, relying on the adaptive poller + git hooks instead.
degradedNoFsnotify bool
excludes *excludes.Matcher
events chan GraphChangeEvent
history []GraphChangeEvent
historyMu sync.Mutex
pending map[string]*time.Timer
mu sync.Mutex
// patchMu serialises per-path patchGraph invocations so the
// post-patch reach rebuild (which scans every Node.Meta) cannot
// race with another debounced patch's IndexFile / EvictFile /
// detectClonesAndEmitEdges, all of which mutate the same Meta
// maps unprotected. Storm-mode uses patchGraphNoResolve (driven
// from a single goroutine in drainStorm) and bypasses this lock.
patchMu sync.Mutex
logger *zap.Logger
done chan struct{}
stopped chan struct{}
symbolChangeCb SymbolChangeCallback
symbolChangeCbMu sync.RWMutex
// Degraded-watch state: set when the OS can't fully cover the tree —
// inotify watch exhaustion (ENOSPC) or FD exhaustion (EMFILE/ENFILE).
// degradedReason is the operator-facing explanation (surfaced as a
// whole-index "frozen" banner on read tools); degradedLogged makes the
// operator log warning fire exactly once; degradedCb (optional) pushes the
// notice onto the daemon's health channel. Guarded by degradedMu.
degradedMu sync.RWMutex
degradedReason string
degradedLogged bool
degradedCb func(reason string)
// probeWaiters maps a probe-file path (created during Start to confirm
// the inotify watch is active) to a chan that handleEvent closes when
// the probe's event arrives. Empty after Start returns.
probeWaiters sync.Map
// Storm-mode state. Guarded by stormMu so the hot per-file
// debounce path (mu) doesn't contend with rate-tracking.
stormMu sync.Mutex
eventTimes []time.Time // sliding window of recent event timestamps
stormBatch map[string]ChangeKind // dirty set during an event storm
stormTimer *time.Timer // fires after the quiet period
stormActive bool // true while waiting to drain
stormDrained func(int) // test hook: batch drained; batch size arg
// poller is the adaptive-interval fallback that re-checks git
// HEAD movement and tracked-file mtimes on a timer, catching the
// changes the fsnotify backend misses (inotify watch exhaustion,
// network filesystems, dropped events). Created in Start and torn
// down in Stop alongside the fsnotify backend. nil when the
// per-repo watcher is disabled via WatchConfig.Enabled.
poller *Poller
// reconcileMu guards the overflow-driven full-tree reconcile.
// reconcilePending coalesces a burst of overflow / dropped-event
// signals into at most one reconcile in flight: the kernel inotify
// queue can overflow (EventOverflow) or the backend can drop events
// under backpressure (the Dropped() channel), and either means we
// may have lost a create/modify with no path to re-index. macOS
// FSEvents self-heals (it re-scans on UserDropped/KernelDropped),
// but Linux inotify does not — without this the lost event waits on
// the up-to-1h janitor. reconcileFn is a test seam: nil in
// production (the real IncrementalReindex runs).
reconcileMu sync.Mutex
reconcilePending bool
reconcileFn func()
// pendingScanDirs coalesces newly-created directories awaiting a
// scoped subtree re-index — the new-subdir race (see enqueueDirScan).
// dirScanActive guards a single in-flight drainer goroutine; scanFn
// is a test seam, nil in production (the real IncrementalReindexPaths
// runs). All three are guarded by reconcileMu.
pendingScanDirs map[string]struct{}
dirScanActive bool
scanFn func(map[string]struct{})
// pendingReresolve coalesces files the shape-degradation guard flagged
// for a forced scoped re-resolve (see enqueueReresolve). reresolveActive
// guards a single in-flight drainer; reresolveFn is a test seam, nil in
// production (the real ReresolveFileScoped runs). All three are guarded
// by reconcileMu.
pendingReresolve map[string]struct{}
reresolveActive bool
reresolveFn func(map[string]struct{})
}
const maxHistory = 1000
// probeMarker is the substring embedded in handshake-probe filenames
// (see confirmWatchActive) and used by handleEvent to absorb their
// create/remove events without touching the indexer.
const probeMarker = ".gortex-watcher-handshake-"
// NewWatcher creates a Watcher for the given indexer.
//
// cfg.Exclude is expected to carry the full effective pattern list (from
// ConfigManager.EffectiveExclude). If it is empty — e.g. a direct caller
// that bypasses ConfigManager — the watcher falls back to the builtin
// baseline so the obvious non-source dirs stay ignored.
func NewWatcher(idx *Indexer, cfg config.WatchConfig, logger *zap.Logger) (*Watcher, error) {
debounce := cfg.DebounceMs
if debounce <= 0 {
debounce = 150
}
cfg.DebounceMs = debounce
// Storm-mode defaults — kept conservative so a repo producing
// normal save traffic stays on the per-file path. Threshold of
// zero means the user explicitly disabled storm mode; negative is
// coerced to zero for safety.
if cfg.StormThreshold < 0 {
cfg.StormThreshold = 0
}
if cfg.StormWindowMs <= 0 {
cfg.StormWindowMs = 500
}
if cfg.StormQuietPeriodMs <= 0 {
cfg.StormQuietPeriodMs = 500
}
patterns := cfg.Exclude
if len(patterns) == 0 {
patterns = excludes.Builtin
}
return &Watcher{
indexer: idx,
config: cfg,
excludes: excludes.New(patterns),
events: make(chan GraphChangeEvent, 64),
pending: make(map[string]*time.Timer),
stormBatch: make(map[string]ChangeKind),
logger: logger,
done: make(chan struct{}),
stopped: make(chan struct{}),
}, nil
}
// Start begins watching the given paths recursively. The backend is
// fswatcher, which uses FSEvents on macOS (one stream per root,
// constant FD cost) and inotify on Linux (one watch per directory in
// the tree). On the inotify path the per-user `max_user_watches` cap
// applies; bump that sysctl if a multi-repo install grows beyond it.
func (w *Watcher) Start(paths []string) error {
if len(paths) == 0 {
return errors.New("watcher: no paths to watch")
}
// 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
}
}
ready := make(chan struct{})
// Own the events/dropped channels so the library never closes them on
// teardown. fswatcher's shutdown closes its events channel while its
// EventAggregator goroutine may still be flushing a final event into
// it — a "send on closed channel" panic under -race on the Linux
// inotify path (the aggregator's close() does not join its run loop).
// When we supply the channels, ownsEventsChannel is false and the
// library skips the close; the aggregator's send is already
// non-blocking, so a late flush lands harmlessly in our buffer (or
// the dropped channel) and our loop still exits on its own stop
// signal, not on the channel closing. Buffer sizes match the
// library's defaults so coalescing behaviour is unchanged.
droppedSize := max(fswatcher.DefaultBufferSize/fswatcher.MaxDroppedBufferRatio, fswatcher.MinDroppedBuffer)
fswEvents := make(chan fswatcher.WatchEvent, fswatcher.DefaultBufferSize)
fswDropped := make(chan fswatcher.WatchEvent, droppedSize)
opts := []fswatcher.WatcherOpt{
// Disable fswatcher's internal debouncer. Its mergeEvents path
// mutates the Types backing array of an already-delivered event
// when a follow-up event for the same path arrives, racing with
// our consumer's read. Our per-file debounce + storm-mode logic
// is the authoritative coalescer anyway.
fswatcher.WithCooldown(0),
// Drop the library's own logging chatter; we surface what we
// care about through our own logger.
fswatcher.WithSeverity(fswatcher.SeverityError),
// Block Start until the OS-level streams are actually live.
// Without this the first events after Start race against
// stream registration and silently disappear.
fswatcher.WithReadyChannel(ready),
// We own the channels (see above) — eliminates the teardown
// send-on-closed-channel race.
fswatcher.WithCustomChannels(fswEvents, fswDropped),
}
absPaths := make([]string, 0, len(paths))
for _, p := range paths {
absPath, err := filepath.Abs(p)
if err != nil {
return err
}
absPaths = append(absPaths, absPath)
opts = append(opts, fswatcher.WithPath(absPath))
}
fsw, err := fswatcher.New(opts...)
if err != nil {
return err
}
w.fsw = fsw
ctx, cancel := context.WithCancel(context.Background())
w.fsCancel = cancel
watchErr := make(chan error, 1)
go func() {
err := fsw.Watch(ctx)
watchErr <- err
if err != nil && !errors.Is(err, context.Canceled) && w.logger != nil {
w.logger.Warn("watcher: backend stopped", zap.Error(err))
}
}()
// Wait for the backend to become ready or fail fast on early
// initialisation errors (e.g. an inotify add returning ENOSPC).
select {
case <-ready:
case err := <-watchErr:
cancel()
// Watch / FD exhaustion is not a hard failure: keep Start succeeding,
// log a one-time operator warning, and fall back to the adaptive
// poller so the graph still catches git-HEAD + mtime changes. Failing
// here would leave a busy machine with no daemon at all.
if isInotifyExhausted(err) || isFDExhausted(err) {
w.noteWatchDegraded(err)
w.degradedNoFsnotify = true
if w.fsw != nil {
w.fsw.Close()
w.fsw = nil
}
if w.config.Enabled {
w.poller = newPoller(w, w.indexer, w.logger)
w.poller.Start()
}
return nil
}
return err
case <-time.After(5 * time.Second):
cancel()
return errors.New("watcher: backend did not become ready within 5s")
}
// FSEvents reports its stream as "started" the instant the C call
// returns, but immediately fires synthetic "this file exists"
// events for every pre-existing file under the watched root. The
// flags on those events are indistinguishable from real changes
// (Create + Modified are set), so we'd re-index every file on
// every daemon start. Drain everything that lands in the events
// buffer within a short grace window before starting the real
// loop — anything genuinely happening to a file during that
// window will fire again as new events.
if runtime.GOOS == "darwin" {
w.drainInitialReplay(150 * time.Millisecond)
}
go w.loop()
// On Linux, fswatcher closes its ready channel as soon as the
// inotify FD is allocated, but it registers initial paths in
// background goroutines that may not have called inotify_add_watch
// yet. Events fired before those goroutines run are lost forever.
// Probe each path with a sentinel file and wait for the resulting
// event before declaring the watcher ready.
if runtime.GOOS != "darwin" {
for _, p := range absPaths {
if err := w.confirmWatchActive(p, 5*time.Second); err != nil {
cancel()
if w.fsw != nil {
w.fsw.Close()
}
close(w.done)
<-w.stopped
return err
}
}
}
// 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()
}
return nil
}
// confirmWatchActive writes sentinel files under root in a polling loop
// until the corresponding fswatcher event arrives — proving the
// OS-level watch is registered — or the overall timeout fires. The
// retry loop is needed because the first probe may be written before
// fswatcher's async addWatch goroutine has called inotify_add_watch,
// in which case its create event is invisible to inotify entirely.
//
// The sentinel name avoids fswatcher's built-in isSystemFile filter
// (which drops *.tmp / *.bak / *.swp / etc. before they reach our
// handleEvent) and our own excludes matcher.
func (w *Watcher) confirmWatchActive(root string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
const probeStep = 100 * time.Millisecond
for time.Now().Before(deadline) {
probe := filepath.Join(root, fmt.Sprintf("%s%d-%d", probeMarker, os.Getpid(), time.Now().UnixNano()))
ch := make(chan struct{})
w.probeWaiters.Store(probe, ch)
if err := os.WriteFile(probe, nil, 0o600); err != nil {
w.probeWaiters.Delete(probe)
if w.logger != nil {
w.logger.Warn("watcher: could not write probe; continuing without confirmation",
zap.String("root", root), zap.Error(err))
}
return nil
}
select {
case <-ch:
_ = os.Remove(probe)
return nil
case <-time.After(probeStep):
w.probeWaiters.Delete(probe)
_ = os.Remove(probe)
}
}
return fmt.Errorf("watcher: inotify watch on %s did not activate within %s", root, timeout)
}
// drainInitialReplay reads from the backend's events channel until
// `window` of quiet has elapsed with no further events. macOS FSEvents
// streams emit a burst of synthetic "exists" events at startup; this
// burst is bounded by the per-stream latency (~50 ms). The first call
// blocks at least one window so early events have a chance to arrive.
func (w *Watcher) drainInitialReplay(window time.Duration) {
if w.fsw == nil {
return
}
eventsCh := w.fsw.Events()
t := time.NewTimer(window)
defer t.Stop()
for {
select {
case <-eventsCh:
t.Reset(window)
case <-t.C:
return
}
}
}
// Stop halts the watcher and cleans up resources.
func (w *Watcher) Stop() error {
// Stop the adaptive poller first so a poll cycle in flight can't
// dispatch a patch into a half-torn-down watcher.
if w.poller != nil {
w.poller.Stop()
}
close(w.done)
if w.fsCancel != nil {
w.fsCancel()
}
if w.fsw != nil {
w.fsw.Close()
}
// In slow-mount degraded mode the fsnotify loop never ran, so its
// stopped channel is never closed — don't block on it.
if !w.degradedNoFsnotify {
<-w.stopped
}
return nil
}
// Events returns a read-only channel of graph change events.
func (w *Watcher) Events() <-chan GraphChangeEvent {
return w.events
}
// History returns recent change events (up to maxHistory).
func (w *Watcher) History() []GraphChangeEvent {
w.historyMu.Lock()
defer w.historyMu.Unlock()
out := make([]GraphChangeEvent, len(w.history))
copy(out, w.history)
return out
}
// HistorySince returns change events after the given timestamp.
func (w *Watcher) HistorySince(since time.Time) []GraphChangeEvent {
w.historyMu.Lock()
defer w.historyMu.Unlock()
var out []GraphChangeEvent
for _, ev := range w.history {
if ev.Timestamp.After(since) {
out = append(out, ev)
}
}
return out
}
// OnSymbolChange registers a callback that is invoked when symbols change
// during file re-indexing. The callback receives old symbols (before eviction)
// and new symbols (after re-index).
func (w *Watcher) OnSymbolChange(cb SymbolChangeCallback) {
w.symbolChangeCbMu.Lock()
defer w.symbolChangeCbMu.Unlock()
w.symbolChangeCb = cb
}
// OnDegraded registers a callback invoked once when the file watcher first
// enters a degraded state (inotify / FD exhaustion). The daemon wires it to its
// health push-notification channel so a subscribed agent learns the index may
// be frozen without polling.
func (w *Watcher) OnDegraded(cb func(reason string)) {
w.degradedMu.Lock()
w.degradedCb = cb
w.degradedMu.Unlock()
}
// DegradedReason returns a human-readable explanation when the native file
// watcher is running degraded — inotify watch exhaustion or FD exhaustion — so
// live edits may not reach the graph until a reindex. Empty when watching is
// healthy. Read tools surface this as a whole-index "frozen" banner, distinct
// from a per-file stale flag.
func (w *Watcher) DegradedReason() string {
w.degradedMu.RLock()
defer w.degradedMu.RUnlock()
return w.degradedReason
}
// isInotifyExhausted reports whether err is the inotify watch-limit error
// (ENOSPC) — the kernel ran out of `fs.inotify.max_user_watches`.
func isInotifyExhausted(err error) bool { return errors.Is(err, syscall.ENOSPC) }
// isFDExhausted reports whether err is a file-descriptor-exhaustion error
// (EMFILE per-process, ENFILE system-wide).
func isFDExhausted(err error) bool {
return errors.Is(err, syscall.EMFILE) || errors.Is(err, syscall.ENFILE)
}
// noteWatchDegraded records a watcher-degradation cause and logs a one-time
// operator warning (subsequent calls are silent — "warns once"). ENOSPC names
// the inotify watch-limit sysctl; FD exhaustion advises raising the open-file
// limit. The first occurrence also fires the optional degraded callback so the
// daemon can push the notice. Returns true on the first (logged) call.
func (w *Watcher) noteWatchDegraded(err error) bool {
var reason, logMsg string
switch {
case isInotifyExhausted(err):
reason = "inotify watch limit reached — the graph may miss live edits until you raise fs.inotify.max_user_watches and reindex (the adaptive poller covers some changes)"
logMsg = "watcher: inotify watch limit (ENOSPC) — watches partially installed; raise fs.inotify.max_user_watches. Falling back to the adaptive poller for missed changes"
case isFDExhausted(err):
reason = "open-file limit reached — the watcher is degraded until you raise the process file-descriptor limit (ulimit -n) and reindex (the adaptive poller covers some changes)"
logMsg = "watcher: file-descriptor limit (EMFILE/ENFILE) — watcher degraded; raise ulimit -n. Falling back to the adaptive poller"
default:
return false
}
w.degradedMu.Lock()
first := !w.degradedLogged
w.degradedReason = reason
w.degradedLogged = true
cb := w.degradedCb
w.degradedMu.Unlock()
if first {
if w.logger != nil {
w.logger.Warn(logMsg)
}
if cb != nil {
cb(reason)
}
}
return first
}
func (w *Watcher) loop() {
defer close(w.stopped)
if w.fsw == nil {
// Test path: handleEvent is being driven directly without
// having called Start. Block until Stop closes w.done.
<-w.done
return
}
eventsCh := w.fsw.Events()
droppedCh := w.fsw.Dropped()
for {
select {
case <-w.done:
return
case event, ok := <-eventsCh:
if !ok {
return
}
w.handleEvent(event)
case _, ok := <-droppedCh:
if !ok {
// Backend tore down its dropped channel; keep
// draining Events only.
droppedCh = nil
continue
}
// The backend dropped an event under backpressure (the
// main Events channel was full). We don't know which path
// was lost, so reconcile the whole tree.
w.triggerOverflowReconcile("dropped-event")
}
}
}
// guardWatcherPanic recovers a panic in a watcher background goroutine —
// a debounced patch, a storm drain, an overflow reconcile, or a
// new-directory scan. Those goroutines call into the graph store, and
// store_sqlite turns a fatal storage error (a closed DB during a daemon
// restart, a busy/locked DB, disk-full) into a panic via panicOnFatal.
// The MCP tool path has its own firewall (wrapToolHandler); these
// fsnotify-driven goroutines don't route through it, so without this a
// single transient store error during a restart or rebuild takes the
// whole daemon down. Recovering aborts just that unit of work — the file
// stays stale until the next event or the reconcile janitor — instead of
// crashing the process.
func (w *Watcher) guardWatcherPanic(op string) {
if r := recover(); r != nil && w.logger != nil {
w.logger.Error("watcher: recovered from panic in background re-index",
zap.String("op", op),
zap.Any("panic", r),
zap.Stack("stack"))
}
}
// triggerOverflowReconcile schedules a single coalesced full-tree
// reconcile in response to a lost-event signal (a kernel inotify queue
// overflow or a backpressure-dropped event). A burst of signals
// collapses into at most one reconcile in flight: the first caller sets
// reconcilePending and runs the reconcile off the event loop; concurrent
// callers observe the flag and return immediately. Best-effort and
// logged — the event loop is never blocked.
func (w *Watcher) triggerOverflowReconcile(reason string) {
w.reconcileMu.Lock()
if w.reconcilePending {
w.reconcileMu.Unlock()
return
}
w.reconcilePending = true
fn := w.reconcileFn
w.reconcileMu.Unlock()
if w.logger != nil {
w.logger.Warn("watcher: event signal lost — scheduling full-tree reconcile",
zap.String("reason", reason),
zap.String("root", w.indexer.rootPath))
}
go func() {
defer func() {
w.reconcileMu.Lock()
w.reconcilePending = false
w.reconcileMu.Unlock()
}()
defer w.guardWatcherPanic("overflow-reconcile")
if fn != nil {
fn()
return
}
if _, err := w.indexer.IncrementalReindex(w.indexer.rootPath); err != nil {
if w.logger != nil {
w.logger.Warn("watcher: overflow reconcile failed",
zap.String("reason", reason),
zap.Error(err))
}
}
}()
}
// dirScanEscalateCap bounds the scoped new-directory scan: a burst that
// creates more than this many directories (a large checkout or unpack)
// escalates to a single full-tree reconcile instead of fanning out into
// that many scoped subtree walks.
const dirScanEscalateCap = 64
// enqueueDirScan schedules a scoped re-index of a newly-created
// directory's subtree, closing the new-subdir race: on Linux inotify a
// file written into a directory before its watch attaches fires no
// event. A burst of directory creates coalesces into a single in-flight
// drainer (mirrors triggerOverflowReconcile) — the first caller starts
// the goroutine, concurrent callers add their directory to
// pendingScanDirs and return. The drainer loops until the set is empty,
// so a directory enqueued while a scan is in flight is still picked up;
// nothing is lost and there is no debounce-timing race.
func (w *Watcher) enqueueDirScan(dir string) {
w.reconcileMu.Lock()
if w.pendingScanDirs == nil {
w.pendingScanDirs = make(map[string]struct{})
}
w.pendingScanDirs[dir] = struct{}{}
if w.dirScanActive {
w.reconcileMu.Unlock()
return
}
w.dirScanActive = true
w.reconcileMu.Unlock()
go func() {
for {
w.reconcileMu.Lock()
dirs := w.pendingScanDirs
w.pendingScanDirs = nil
if len(dirs) == 0 {
w.dirScanActive = false
w.reconcileMu.Unlock()
return
}
fn := w.scanFn
w.reconcileMu.Unlock()
func() {
defer w.guardWatcherPanic("dir-scan")
w.runDirScan(dirs, fn)
}()
}
}()
}
// runDirScan re-indexes the accumulated new directories. A large burst
// escalates to one full-tree reconcile (dirScanEscalateCap); otherwise
// the scoped subtrees are walked in a single IncrementalReindexPaths
// call, which IsStale-gates each file so already-current files cost only
// a stat. fn is the test seam.
func (w *Watcher) runDirScan(dirs map[string]struct{}, fn func(map[string]struct{})) {
if fn != nil {
fn(dirs)
return
}
if len(dirs) > dirScanEscalateCap {
if w.logger != nil {
w.logger.Info("watcher: large new-directory burst — full-tree reconcile",
zap.Int("dirs", len(dirs)), zap.String("root", w.indexer.rootPath))
}
if _, err := w.indexer.IncrementalReindex(w.indexer.rootPath); err != nil && w.logger != nil {
w.logger.Warn("watcher: new-directory reconcile failed", zap.Error(err))
}
return
}
paths := make([]string, 0, len(dirs))
for d := range dirs {
paths = append(paths, d)
}
if _, err := w.indexer.IncrementalReindexPaths(w.indexer.rootPath, paths); err != nil && w.logger != nil {
w.logger.Warn("watcher: new-directory scan failed",
zap.Strings("dirs", paths), zap.Error(err))
}
}
// hasEventType reports whether the aggregated event-type set contains want.
func hasEventType(types []fswatcher.EventType, want fswatcher.EventType) bool {
for _, t := range types {
if t == want {
return true
}
}
return false
}
func (w *Watcher) handleEvent(event fswatcher.WatchEvent) {
// Kernel queue overflow arrives as a pathless EventOverflow on the
// Events channel: the Linux inotify and Windows backends emit it when
// the kernel drops events and cannot tell us which paths were lost.
// macOS FSEvents never emits it — the darwin backend absorbs
// UserDropped/KernelDropped by re-scanning the affected subtree
// internally — so this branch is effectively Linux/Windows-only. With
// no path to re-index, trigger a coalesced full-tree reconcile and
// stop; every path-based step below would misfire on the empty path.
for _, t := range event.Types {
if t == fswatcher.EventOverflow {
w.triggerOverflowReconcile("queue-overflow")
return
}
}
path := normalizeEventPath(event.Path, w.indexer.rootPath)
// Probe artifacts: sentinel files Start writes to confirm the
// OS-level watch is actually active. Their create event signals
// the registered waiter; their remove event (after Start removes
// the file) is silently absorbed so it never reaches user-visible
// event consumers.
if strings.Contains(filepath.Base(path), probeMarker) {
if v, loaded := w.probeWaiters.LoadAndDelete(path); loaded {
if ch, ok := v.(chan struct{}); ok {
close(ch)
}
}
return
}
// Skip events from excluded paths. A single matcher call covers
// what the old code split across inExcludedDir + isExcluded.
if w.isExcluded(path) {
return
}
kind := pickKind(event.Types)
if kind == "" {
return
}
// Directory events. fswatcher with WatchNested attaches the watch
// for a new directory itself, so we never re-attach. But on Linux
// inotify that watch lands only AFTER the directory's create event is
// read, so a file written into the directory in that gap fires no
// event and would stay invisible until the hourly janitor. When the
// event carries a Create, scan the new directory's subtree on disk so
// those pre-watch files are picked up regardless of whether an event
// ever fired ("watch first, then scan": files created after the watch
// fire normal events, files created before are caught by the scan,
// and the overlap is at worst a redundant idempotent re-index). A dir
// event without a Create — a bare mtime bump on an existing dir —
// needs no scan: entry changes inside it fire their own file events.
// Either way the directory event itself reaches no indexer logic.
if kind == ChangeCreated || kind == ChangeModified {
if info, err := os.Stat(path); err == nil && info.IsDir() {
if hasEventType(event.Types, fswatcher.EventCreate) {
w.enqueueDirScan(path)
}
return
}
}
// Only process files with a detectable language — an extension
// the registry knows, or an unknown-extension script the shebang
// fallback can place.
if _, ok := w.indexer.effectiveLanguage(path, nil); !ok {
// Still handle remove for previously indexed files.
if kind != ChangeDeleted && kind != ChangeRenamed {
return
}
}
// Storm mode — if more than StormThreshold events arrived within
// StormWindowMs, skip the per-file debounced path and accumulate
// into a batch. The batch drains once StormQuietPeriodMs has
// passed with no further events.
if w.shouldEnterStorm() {
w.recordInStorm(path, kind)
return
}
// Debounce: reset or start timer for this file.
w.mu.Lock()
if timer, exists := w.pending[path]; exists {
timer.Stop()
}
debounce := time.Duration(w.config.DebounceMs) * time.Millisecond
w.pending[path] = time.AfterFunc(debounce, func() {
// Clean up the pending entry even if the patch panics, then
// recover so a fatal store error can't crash the daemon.
defer func() {
w.mu.Lock()
delete(w.pending, path)
w.mu.Unlock()
}()
defer w.guardWatcherPanic("patch " + path)
w.patchGraph(path, kind)
})
w.mu.Unlock()
}
// shouldEnterStorm records the current event in the rate window and
// reports whether the watcher is over threshold. Returns false when
// storm mode is disabled (threshold <= 0). The returned-true path
// guarantees the caller will enqueue to the batch, so any single
// event that crosses the threshold is captured correctly.
func (w *Watcher) shouldEnterStorm() bool {
if w.config.StormThreshold <= 0 {
return false
}
now := time.Now()
window := time.Duration(w.config.StormWindowMs) * time.Millisecond
cutoff := now.Add(-window)
w.stormMu.Lock()
defer w.stormMu.Unlock()
// Already batching — stay in storm until the drain completes.
if w.stormActive {
return true
}
// Drop timestamps older than the window. The slice is append-only
// so a linear scan from the front is the minimal thing that
// works; the window is O(threshold) bounded in steady state.
trimFrom := 0
for i, t := range w.eventTimes {
if t.After(cutoff) {
trimFrom = i
break
}
trimFrom = i + 1
}
if trimFrom > 0 {
w.eventTimes = w.eventTimes[trimFrom:]
}
w.eventTimes = append(w.eventTimes, now)
return len(w.eventTimes) > w.config.StormThreshold
}
// recordInStorm adds the event to the pending batch and resets the
// drain timer. Repeated create/modify collapse to a single patch; a
// later delete of the same path overwrites an earlier create so the
// drain does the right final thing (treats the path as deleted).
func (w *Watcher) recordInStorm(path string, kind ChangeKind) {
w.stormMu.Lock()
defer w.stormMu.Unlock()
w.stormActive = true
// Cancel any pending per-file timers for this path — storm mode
// takes over.
w.mu.Lock()
if timer, exists := w.pending[path]; exists {
timer.Stop()
delete(w.pending, path)
}
w.mu.Unlock()
w.stormBatch[path] = kind
quiet := time.Duration(w.config.StormQuietPeriodMs) * time.Millisecond
if w.stormTimer != nil {
w.stormTimer.Stop()
}
w.stormTimer = time.AfterFunc(quiet, w.drainStorm)
}
// drainStorm processes every path accumulated during the storm as a
// single batch: per-path evict/index with the resolver stage skipped,
// then one global ResolveAll at the end. Cuts a 500-file checkout
// from "resolver runs 500 times" to "resolver runs once."
func (w *Watcher) drainStorm() {
defer w.guardWatcherPanic("storm-drain")
w.stormMu.Lock()
batch := w.stormBatch
w.stormBatch = make(map[string]ChangeKind)
w.eventTimes = nil
w.stormActive = false
drained := w.stormDrained
w.stormMu.Unlock()
if len(batch) == 0 {
return
}
start := time.Now()
w.logger.Info("watcher: storm drain starting", zap.Int("paths", len(batch)))
for path, kind := range batch {
w.patchGraphNoResolve(path, kind)
}
w.indexer.ResolveAll()
w.logger.Info("watcher: storm drain complete",
zap.Int("paths", len(batch)),
zap.Duration("elapsed", time.Since(start)))
if drained != nil {
drained(len(batch))
}
}
// patchGraphNoResolve is patchGraph for the batched path: same evict
// / index dispatch, but without per-file resolver work. The caller is
// responsible for running indexer.ResolveAll() after the batch.
func (w *Watcher) patchGraphNoResolve(path string, kind ChangeKind) {
// Same disk-truth reconciliation as patchGraph: a storm-batched
// replace must not be evicted as a delete when the file is present.
kind = w.reconcileKindWithDisk(path, kind)
switch kind {
case ChangeCreated, ChangeModified:
if err := w.indexer.IndexFileNoResolve(path); err != nil {
w.logger.Warn("storm: index file failed",
zap.String("path", path), zap.Error(err))
}
case ChangeDeleted, ChangeRenamed:
w.indexer.EvictFile(path)
// Keep the persisted mtime in step with the eviction (see
// forgetDeletedFileMtime) so a warm restart after a storm-drain
// delete does not treat the vanished path as a phantom deletion.
w.forgetDeletedFileMtime(w.indexer.RelKey(path))
}
}
// reconcileKindWithDisk corrects an event kind against the file's actual
// on-disk state at patch time. FSEvents accumulates flags per path, so an
// atomic replace — git checkout writing a temp file and renaming it over the
// target, or an unlink + recreate — surfaces with ItemRemoved / ItemRenamed
// set even though a file is right back at the same path with a new inode.
// pickKind ranks Remove and Rename above Modify, so it classifies that replace
// as a deletion; the delete branch then EvictFiles the definition and stubs
// its cross-file callers with nothing to rebind them, and find_usages goes
// silently to zero. By the time the debounced patch runs the filesystem has
// settled, so the path's existence is authoritative: a delete/rename whose
// path is still a regular file is really a modify (re-parse + rebind incoming
// refs), and a create/modify whose path has vanished is really a delete. An
// in-place write (same inode) is already a bare Modify, so it is unaffected.
func (w *Watcher) reconcileKindWithDisk(path string, kind ChangeKind) ChangeKind {
info, err := os.Stat(path)
exists := err == nil && info.Mode().IsRegular()
switch kind {
case ChangeDeleted, ChangeRenamed:
if exists {
return ChangeModified
}
case ChangeCreated, ChangeModified:
if !exists {
return ChangeDeleted
}
}
return kind
}
// forgetDeletedFileMtime drops a just-evicted file's recorded mtime from
// both the in-memory map and the store's FileMtime sidecar. EvictFile
// removes the file's nodes but leaves its mtime behind, so without this the
// persisted mtime row outlives the file: the next warm restart reads it
// back, finds the path gone from disk, and treats it as a phantom deletion
// — re-running a scoped reconcile for a file that is already correct on
// every boot. Mirrors IncrementalReindex's deletion handling: prune the
// in-memory map first (pruneDeletedFileMtimes documents that its caller has
// already done so, and a later snapshot persist would otherwise resurrect
// the row from the stale in-memory entry), then the store, which self-skips
// on a backend without the FileMtimeDeleter capability. relPath must be the
// canonical relKey the mtime map and store are keyed on — the same key
// EvictFile evicted the file's nodes under.
func (w *Watcher) forgetDeletedFileMtime(relPath string) {
w.indexer.mtimeMu.Lock()
delete(w.indexer.fileMtimes, relPath)
w.indexer.mtimeMu.Unlock()
w.indexer.pruneDeletedFileMtimes([]string{relPath})
}
func (w *Watcher) patchGraph(path string, kind ChangeKind) {
w.patchMu.Lock()
defer w.patchMu.Unlock()
// A replace/revert (rename-over or unlink+recreate) reaches us as a
// delete/rename even though a file is right back at the same path;
// reconcile against disk so it takes the parse-then-swap + incoming-
// rebind modify path instead of a hard evict that would silently zero
// the definition's callers. A vanished create/modify becomes a delete.
kind = w.reconcileKindWithDisk(path, kind)
start := time.Now()
var nodesAdded, nodesRemoved, edgesAdded, edgesRemoved int
// Compute the relative path for snapshotting old symbols. RelKey
// folds it to the canonical key (slash form, Unicode NFC) so the
// GetFileNodes / snapshotSymbols lookups below hit the same graph
// key the indexer stored — a watcher event for a non-ASCII-named
// file arrives in the filesystem's Unicode form (NFD on macOS),
// which would otherwise miss an NFC-keyed node.
relPath := path
if w.indexer.rootPath != "" {
relPath = w.indexer.RelKey(path)
}
switch kind {
case ChangeCreated:
if err := w.indexer.IndexFile(path); err != nil {
w.logger.Warn("index file failed", zap.String("path", path), zap.Error(err))
return
}
newSymbols := w.indexer.graph.GetFileNodes(relPath)
nodesAdded = len(newSymbols)
edgesAdded = w.countFileEdges(newSymbols)
// Notify callback: no old symbols, only new symbols.
w.symbolChangeCbMu.RLock()
cb := w.symbolChangeCb
w.symbolChangeCbMu.RUnlock()
if cb != nil {
cb(relPath, nil, newSymbols)
}
case ChangeModified:
// Snapshot old symbols before eviction.
oldSymbols := w.snapshotSymbols(relPath)
// Content-aware skip: if the saved file's structural symbols
// are byte-for-byte identical to the ones already in the
// graph, the change touched no Function / Type / Method /
// etc. — a comment-only edit, a whitespace reflow, a doc
// change, or a config / JSON value save. Re-indexing it would
// evict and rebuild every node for no graph-level effect, so
// skip the structural reindex entirely. The probe is
// read-only; only on a proven match do we take the cheap
// path. A probe that can't run (unknown language, over-cap
// file, parser quarantine) returns ok == false and falls
// through to the normal reindex.
if newSymbols, ok := w.indexer.StructuralSymbols(path); ok &&
structuralFingerprint(newSymbols) == structuralFingerprint(oldSymbols) {
w.recordInertModify(path, relPath, oldSymbols, start)
return
}
// Do NOT pre-evict. IndexFile parse-then-swaps internally: it
// evicts the file's prior nodes and re-adds the new ones only on a
// successful parse, and leaves the prior nodes intact on a parse
// failure. Pre-evicting here was the node-loss bug — a transiently
// unparseable save (mid-edit) dropped the file's symbols from the
// graph until the next clean save. Capture the file's prior node
// count first (still present pre-swap) so removed/added telemetry
// stays gross: a rename removes one node and adds one even though
// the net node delta is zero.
priorNodes := w.indexer.graph.GetFileNodes(relPath)
fileEdgesBefore := w.countFileEdges(priorNodes)
resolvedBefore := w.countResolvedFileEdges(priorNodes)
incomingBeforeByID := w.resolvedIncomingByNode(priorNodes)
if err := w.indexer.IndexFile(path); err != nil {
w.logger.Warn("reindex file failed", zap.String("path", path), zap.Error(err))
return
}
nodesRemoved = len(priorNodes)
newSymbols := w.indexer.graph.GetFileNodes(relPath)
nodesAdded = len(newSymbols)
// Edge churn scoped to this file's nodes. A graph-wide
// EdgeCount delta would also pick up edges landed by whatever
// else mutates the graph during this patch (concurrent
// reconciles, deferred passes), which made the edges+ figure
// meaningless noise on a busy daemon.
if fileEdgesAfter := w.countFileEdges(newSymbols); fileEdgesAfter >= fileEdgesBefore {
edgesAdded = fileEdgesAfter - fileEdgesBefore
} else {
edgesRemoved = fileEdgesBefore - fileEdgesAfter
}
// Shape-degradation guard: a modify that kept its symbols but lost
// most of its resolved edges is a transient resolution failure, not a
// real deletion — flag it and enqueue a forced scoped re-resolve so it
// self-heals instead of persisting the degraded shape.
incomingBefore, incomingAfter := w.incomingRegressionForSurvivors(incomingBeforeByID, newSymbols)
w.guardResolvedEdgeRegression(path, len(priorNodes), len(newSymbols), resolvedBefore, w.countResolvedFileEdges(newSymbols), incomingBefore, incomingAfter)
// Notify callback with old and new symbols.
w.symbolChangeCbMu.RLock()
cb := w.symbolChangeCb
w.symbolChangeCbMu.RUnlock()
if cb != nil {
cb(relPath, oldSymbols, newSymbols)
}
case ChangeDeleted, ChangeRenamed:
// Snapshot old symbols before eviction.
oldSymbols := w.snapshotSymbols(relPath)
nr, er := w.indexer.EvictFile(path)
nodesRemoved = nr
edgesRemoved = er
// The file is genuinely gone from disk here — reconcileKindWithDisk
// already downgraded a replace/revert (path still present) to
// ChangeModified. Drop its now-orphaned mtime so a warm restart does
// not re-discover the vanished path as a phantom deletion. relPath is
// the canonical relKey EvictFile evicted under.
w.forgetDeletedFileMtime(relPath)
// Notify callback: old symbols removed, no new symbols.
w.symbolChangeCbMu.RLock()
cb := w.symbolChangeCb
w.symbolChangeCbMu.RUnlock()
if cb != nil {
cb(relPath, oldSymbols, nil)
}
}
ev := GraphChangeEvent{
FilePath: path,
Kind: kind,
NodesAdded: nodesAdded,
NodesRemoved: nodesRemoved,
EdgesAdded: edgesAdded,
EdgesRemoved: edgesRemoved,
Timestamp: time.Now(),
DurationMs: time.Since(start).Milliseconds(),
}
// Rebuild the reachability index so AnalyzeImpact /
// explain_change_impact stay correct against the patched topology.
// Lazy reach: instead of eagerly recomputing every seed's reach
// after a watcher-driven patch (the old reach.BuildIndex call
// here paid the full O(seeds) cost on every file edit), we just
// invalidate the build counter so subsequent AnalyzeImpact calls
// recompute on demand against the fresh graph. No-op patches
// (nodesAdded == 0 && nodesRemoved == 0 && edgesAdded == 0 &&
// edgesRemoved == 0) leave the counter alone so existing caches
// stay valid.
if nodesAdded+nodesRemoved+edgesAdded+edgesRemoved > 0 {
reach.InvalidateIndex()
}
w.historyMu.Lock()
w.history = append(w.history, ev)
if len(w.history) > maxHistory {
w.history = w.history[len(w.history)-maxHistory:]
}
w.historyMu.Unlock()
// Non-blocking send.
select {
case w.events <- ev:
default:
}
w.logger.Info("graph patch",
zap.String("kind", string(kind)),
zap.String("file", path),
zap.Int("nodes+", nodesAdded),
zap.Int("nodes-", nodesRemoved),
zap.Int("edges+", edgesAdded),
zap.Int("edges-", edgesRemoved),
zap.Int64("ms", ev.DurationMs),
)
}
// countFileEdges counts the edges incident to the given file nodes:
// every out-edge plus the in-edges that originate outside the file
// (an intra-file edge is already counted on its From side). Batched
// so a disk backend pays two bulk lookups instead of 2N point queries.
func (w *Watcher) countFileEdges(nodes []*graph.Node) int {
if len(nodes) == 0 {
return 0
}
ids := make([]string, 0, len(nodes))
inFile := make(map[string]struct{}, len(nodes))
for _, n := range nodes {
ids = append(ids, n.ID)
inFile[n.ID] = struct{}{}
}
total := 0
for _, edges := range w.indexer.graph.GetOutEdgesByNodeIDs(ids) {
total += len(edges)
}
for _, edges := range w.indexer.graph.GetInEdgesByNodeIDs(ids) {
for _, e := range edges {
if _, ok := inFile[e.From]; !ok {
total++
}
}
}
return total
}
// resolvedEdgeRegressionFloor is the minimum pre-patch resolved-edge count
// below which the shape-degradation guard stays quiet — a 1→0 or 3→1 file is
// noise, not a resolution collapse worth self-healing.
const resolvedEdgeRegressionFloor = 4
// countResolvedFileEdges counts this file's OUTGOING edges whose target is a
// concrete (resolved) node — an edge pointing at an `unresolved::` stub does
// not count. Restricted to out-edges: an incoming edge's resolution state is
// owned by the OTHER file, not this one. This is the signal countFileEdges
// cannot give: an edge demoted from a resolved target to a stub keeps the total
// incident-edge count identical while losing a resolution.
func (w *Watcher) countResolvedFileEdges(nodes []*graph.Node) int {
if len(nodes) == 0 {
return 0
}
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
ids = append(ids, n.ID)
}
total := 0
for _, edges := range w.indexer.graph.GetOutEdgesByNodeIDs(ids) {
for _, e := range edges {
if e != nil && !graph.IsUnresolvedTarget(e.To) {
total++
}
}
}
return total
}
// guardResolvedEdgeRegression fires when a modify patch lost most of its
// resolved edges on either side of the graph: the file's own out-edges dropped
// while it kept its symbols, OR a surviving definition lost most of the callers
// bound into it. Both are transient resolution failures (a dependency mid-write,
// an LSP provider not yet warm, an external revert re-parsing a definition
// file), not real deletions. It logs loudly, bumps the process-global
// regression counter, and enqueues the file for a forced scoped re-resolve so
// the graph self-heals instead of persisting the degraded shape.
func (w *Watcher) guardResolvedEdgeRegression(path string, nodesBefore, nodesAfter, resolvedBefore, resolvedAfter, incomingBefore, incomingAfter int) {
// Out-edge regression: this file's own references lost their resolutions
// while it kept (or grew) its symbols. Gated on nodesAfter >= nodesBefore —
// a symbol removal legitimately drops out-edges.
outRegressed := resolvedBefore >= resolvedEdgeRegressionFloor &&
nodesAfter >= nodesBefore &&
resolvedAfter*2 < resolvedBefore
// Incoming-edge regression: a definition that SURVIVED the re-parse lost
// most of the callers bound INTO it. The caller restricts the counts to
// surviving definitions, so a genuinely deleted symbol's lost callers do
// not read as a loss — which is why this arm is deliberately NOT gated on
// nodesAfter >= nodesBefore. An external revert removes the appended probe
// symbol (node count drops) yet the definition it leaves behind must keep
// its incoming resolved edges; their loss is a resolution failure, not a
// deletion, and without this arm the revert never self-heals the way the
// symmetric add does.
inRegressed := incomingBefore >= resolvedEdgeRegressionFloor &&
incomingAfter*2 < incomingBefore
if !outRegressed && !inRegressed {
return
}
RecordResolutionRegression()
if w.logger != nil {
w.logger.Warn("watcher: resolved-edge regression — file kept its symbols but lost most resolved edges; enqueuing forced scoped re-resolve",
zap.String("file", path),
zap.Int("nodes_before", nodesBefore),
zap.Int("nodes_after", nodesAfter),
zap.Int("resolved_edges_before", resolvedBefore),
zap.Int("resolved_edges_after", resolvedAfter),
zap.Int("incoming_resolved_before", incomingBefore),
zap.Int("incoming_resolved_after", incomingAfter))
}
w.enqueueReresolve(path)
}
// resolvedIncomingByNode returns, per referenceable definition node in `nodes`,
// the count of resolvable reference edges currently bound INTO it (callers,
// type/field references, …). countResolvedFileEdges is out-edge-only and so is
// structurally blind to a definition losing its incoming callers on a re-parse
// — this is the signal that catches an external revert zeroing a surviving
// symbol's usages.
func (w *Watcher) resolvedIncomingByNode(nodes []*graph.Node) map[string]int {
if len(nodes) == 0 {
return nil
}
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
if n != nil && graph.IsReferenceableSymbol(n.Kind) {
ids = append(ids, n.ID)
}
}
if len(ids) == 0 {
return nil
}
byNode := make(map[string]int, len(ids))
for id, edges := range w.indexer.graph.GetInEdgesByNodeIDs(ids) {
c := 0
for _, e := range edges {
if e != nil && graph.IsResolvableRefEdge(e.Kind) {
c++
}
}
if c > 0 {
byNode[id] = c
}
}
return byNode
}
// incomingRegressionForSurvivors sums the incoming resolved-edge counts, before
// and after the re-parse, over only the definitions that SURVIVED it (present
// in both snapshots by node ID). Restricting to survivors keeps a genuinely
// deleted symbol's lost callers from reading as a regression, so the guard's
// incoming arm fires only when a symbol that is still defined lost the callers
// bound into it. `before` is the resolvedIncomingByNode snapshot captured on
// the pre-re-parse nodes; `after` is the file's fresh node set.
func (w *Watcher) incomingRegressionForSurvivors(before map[string]int, after []*graph.Node) (sumBefore, sumAfter int) {
if len(before) == 0 || len(after) == 0 {
return 0, 0
}
afterByID := w.resolvedIncomingByNode(after)
for _, n := range after {
if n == nil {
continue
}
b, ok := before[n.ID]
if !ok {
continue
}
sumBefore += b
sumAfter += afterByID[n.ID]
}
return sumBefore, sumAfter
}
// enqueueReresolve batches shape-degraded files for a forced scoped re-resolve.
// Copies enqueueDirScan's coalescing drainer so a save-storm of degraded files
// runs at most one drainer goroutine and only ever O(file) scoped re-resolves
// (never a whole-graph pass). reresolveFn is a test seam.
func (w *Watcher) enqueueReresolve(path string) {
w.reconcileMu.Lock()
if w.pendingReresolve == nil {
w.pendingReresolve = make(map[string]struct{})
}
w.pendingReresolve[path] = struct{}{}
if w.reresolveActive {
w.reconcileMu.Unlock()
return
}
w.reresolveActive = true
w.reconcileMu.Unlock()
go func() {
for {
w.reconcileMu.Lock()
files := w.pendingReresolve
w.pendingReresolve = nil
if len(files) == 0 {
w.reresolveActive = false
w.reconcileMu.Unlock()
return
}
fn := w.reresolveFn
w.reconcileMu.Unlock()
func() {
defer w.guardWatcherPanic("reresolve")
if fn != nil {
fn(files)
return
}
for p := range files {
if err := w.indexer.ReresolveFileScoped(p); err != nil && w.logger != nil {
w.logger.Warn("watcher: forced scoped re-resolve failed",
zap.String("file", p), zap.Error(err))
}
}
}()
}
}()
}
// recordInertModify finishes a ChangeModified patch that the
// content-aware skip proved structurally inert. The graph already
// holds the correct symbols, so the destructive evict + reindex is
// skipped; this records the bookkeeping the skipped path would
// otherwise have produced:
//
// - the indexer's recorded mtime is restamped so the adaptive
// poller's mtime sweep does not keep re-flagging the file;
// - a zero-delta GraphChangeEvent is appended to history and
// published, so get_recent_changes still shows the save (with
// all node/edge counts zero — nothing structural moved);
// - the symbol-change callback fires with the unchanged symbol set
// on both sides, mirroring the no-op so consumers see a
// consistent before == after.
//
// The reachability index is intentionally not rebuilt — the topology
// did not change, so the existing reach stamps stay valid.
func (w *Watcher) recordInertModify(path, relPath string, symbols []*graph.Node, start time.Time) {
// Advance the recorded mtime past this save so the poller does
// not treat the (untouched) file as perpetually stale.
w.indexer.RefreshFileMtime(path)
ev := GraphChangeEvent{
FilePath: path,
Kind: ChangeModified,
Timestamp: time.Now(),
DurationMs: time.Since(start).Milliseconds(),
}
w.historyMu.Lock()
w.history = append(w.history, ev)
if len(w.history) > maxHistory {
w.history = w.history[len(w.history)-maxHistory:]
}
w.historyMu.Unlock()
select {
case w.events <- ev:
default:
}
w.symbolChangeCbMu.RLock()
cb := w.symbolChangeCb
w.symbolChangeCbMu.RUnlock()
if cb != nil {
cb(relPath, symbols, symbols)
}
w.logger.Info("graph patch skipped: structurally inert change",
zap.String("file", path),
zap.Int64("ms", ev.DurationMs),
)
}
// snapshotSymbols returns a deep copy of the symbols for a file, preserving
// their signatures in Meta so they can be compared after re-indexing.
func (w *Watcher) snapshotSymbols(relPath string) []*graph.Node {
nodes := w.indexer.graph.GetFileNodes(relPath)
snapshot := make([]*graph.Node, 0, len(nodes))
for _, n := range nodes {
// Skip file and import nodes — we only track code symbols.
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
cp := &graph.Node{
ID: n.ID,
Kind: n.Kind,
Name: n.Name,
QualName: n.QualName,
FilePath: n.FilePath,
}
if sig, ok := n.Meta["signature"]; ok {
cp.Meta = map[string]any{"signature": sig}
}
snapshot = append(snapshot, cp)
}
return snapshot
}
// structuralFingerprint reduces a set of symbols to an order-independent
// string identity built only from each structural symbol's kind, name,
// qualified name, and signature — never its line range. Two snapshots
// of the same file taken before and after an edit produce an equal
// fingerprint exactly when the edit changed no structural symbol: a
// comment-only change, a whitespace reflow, or a doc / config-value
// edit shifts line numbers but leaves every (kind, name, sig) tuple
// intact, while renaming a function, changing a signature, or
// adding / removing a declaration changes the fingerprint.
//
// Non-structural nodes (file, import, params, closures, coverage-domain
// kinds) are skipped so a change confined to them is still treated as
// inert — they carry no structural graph shape.
func structuralFingerprint(symbols []*graph.Node) string {
lines := make([]string, 0, len(symbols))
for _, n := range symbols {
if !isStructuralKind(n.Kind) {
continue
}
sig, _ := n.Meta["signature"].(string)
// NUL separates fields so a value containing the field
// delimiter can't forge a collision across two symbols.
lines = append(lines, string(n.Kind)+"\x00"+n.Name+"\x00"+n.QualName+"\x00"+sig)
}
sort.Strings(lines)
return strings.Join(lines, "\n")
}
// normalizeEventPath aligns an event path emitted by the OS-level
// backend with the form the indexer stored when it walked the tree.
//
// Two macOS-specific corrections are applied:
//
// - /private/ symlink resolution: FSEvents reports paths under
// /private/var/... and /private/tmp/... even when the watcher was
// registered with /var/... or /tmp/... — those are real
// /private/-rooted symlinks. The indexer keyed its symbols by the
// user-facing form, so without this we'd fail to find any symbols
// to evict on modify or delete.
//
// - Unicode NFC folding: APFS / HFS+ hand back filenames in
// decomposed NFD form, so a watcher event for a non-ASCII-named
// file carries different bytes than the same file does in `git
// diff` output or on a Linux checkout. Folding the path to NFC
// here means every consumer downstream — the exclude matcher, the
// storm batch, the per-file debounce map — sees one stable form.
// IndexFile / EvictFile fold again at their own boundary, so this
// is belt-and-braces, but it also keeps the debounce/batch maps
// (keyed on this path directly) free of accidental NFD/NFC
// duplicates for the same file.
func normalizeEventPath(path, rootPath string) string {
path = pathkey.Normalize(path)
if runtime.GOOS != "darwin" {
return path
}
if !strings.HasPrefix(path, "/private/") {
return path
}
// Without a rootPath we have no way to know which form (the
// /private/-prefixed canonical or the symlink form) the rest of
// the daemon expects, so leave it alone.
if rootPath == "" || strings.HasPrefix(rootPath, "/private/") {
return path
}
stripped := path[len("/private"):]
if !strings.HasPrefix(stripped, rootPath) {
// Different prefix entirely — leave the canonical form alone.
return path
}
return stripped
}
// pickKind reduces the aggregated event-type set from fswatcher to a
// single ChangeKind. Priority: Remove > Rename > Modify > Create.
// Modify outranks Create because FSEvents flags are cumulative — a
// write to an existing file fires with both Create and Modify set,
// and treating that as "created" loses the old-symbols snapshot the
// modify path produces. An event with only types we don't act on
// (e.g. chmod alone) returns "".
func pickKind(types []fswatcher.EventType) ChangeKind {
var hasCreate, hasModify, hasRemove, hasRename bool
for _, t := range types {
switch t {
case fswatcher.EventCreate:
hasCreate = true
case fswatcher.EventMod:
hasModify = true
case fswatcher.EventRemove:
hasRemove = true
case fswatcher.EventRename:
hasRename = true
}
}
switch {
case hasRemove:
return ChangeDeleted
case hasRename:
return ChangeRenamed
case hasModify:
return ChangeModified
case hasCreate:
return ChangeCreated
}
return ""
}
// isExcluded reports whether path is excluded by the effective pattern list.
func (w *Watcher) isExcluded(path string) bool {
root := w.indexer.rootPath
if root == "" {
return w.excludes.MatchRel(filepath.Base(path))
}
return w.excludes.MatchAbs(path, root)
}