Files
zzet--gortex/internal/indexer/realtime_reliability_test.go
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

568 lines
20 KiB
Go

package indexer
import (
"errors"
"os"
"os/exec"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/sgtdi/fswatcher"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/search"
)
// toggleExtractor is a parser.Extractor whose Extract result is flipped
// at runtime. In good mode it emits a file node plus one function node;
// in fail mode it returns (nil, err) — the exact shape that drives
// indexFile's `result == nil` branch (a transient parse failure /
// quarantine), which tree-sitter's error-tolerant grammars never
// produce for real Go source. The custom ".fk" extension keeps it off
// every other extractor's turf.
type toggleExtractor struct {
mu sync.Mutex
fail bool
funcs []string
}
func (e *toggleExtractor) Language() string { return "faketoggle" }
func (e *toggleExtractor) Extensions() []string { return []string{".fk"} }
func (e *toggleExtractor) setFail(f bool) {
e.mu.Lock()
e.fail = f
e.mu.Unlock()
}
func (e *toggleExtractor) setFuncs(names ...string) {
e.mu.Lock()
e.funcs = names
e.mu.Unlock()
}
func (e *toggleExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
e.mu.Lock()
fail := e.fail
funcs := append([]string(nil), e.funcs...)
e.mu.Unlock()
if fail {
return nil, errors.New("toggleExtractor: forced parse failure")
}
nodes := []*graph.Node{{
ID: filePath,
Kind: graph.KindFile,
Name: filepath.Base(filePath),
FilePath: filePath,
Language: "faketoggle",
}}
for _, fn := range funcs {
nodes = append(nodes, &graph.Node{
ID: filePath + "::" + fn,
Kind: graph.KindFunction,
Name: fn,
FilePath: filePath,
Language: "faketoggle",
})
}
return &parser.ExtractionResult{Nodes: nodes}, nil
}
func newToggleIndexer(t *testing.T) (*Indexer, *toggleExtractor) {
t.Helper()
ext := &toggleExtractor{}
reg := parser.NewRegistry()
reg.Register(ext)
g := graph.New()
idx := New(g, reg, config.IndexConfig{Workers: 1}, zap.NewNop())
idx.search = search.NewBM25()
return idx, ext
}
func searchHasID(idx *Indexer, query, id string) bool {
for _, r := range idx.search.Search(query, 50) {
if r.ID == id {
return true
}
}
return false
}
// TestIndexFile_ParseFailureKeepsPriorNodes is the central proof of the
// parse-then-swap fix: re-indexing a file whose new bytes are
// unparseable must NOT zero the file's prior nodes / edges / search
// entries. Stale-but-present beats empty. A clean re-index then swaps
// them as normal.
//
// Against the pre-fix evict-first code this test FAILS: indexFile
// evicted the graph + search entries before parsing and returned early
// on result == nil, leaving the file at zero nodes.
func TestIndexFile_ParseFailureKeepsPriorNodes(t *testing.T) {
idx, ext := newToggleIndexer(t)
dir := t.TempDir()
path := filepath.Join(dir, "main.fk")
idx.SetRootPath(dir)
// First index, good mode — one function lands in graph + search.
ext.setFail(false)
ext.setFuncs("Alpha")
writeFile(t, path, "alpha body")
require.NoError(t, idx.IndexFile(path))
funcID := "main.fk::Alpha"
require.NotNil(t, idx.graph.GetNode(funcID), "Alpha must be indexed before the bad edit")
require.True(t, searchHasID(idx, "Alpha", funcID), "Alpha must be in the search index before the bad edit")
nodesBefore := len(idx.graph.GetFileNodes("main.fk"))
require.Equal(t, 2, nodesBefore, "file node + Alpha")
// Save a transiently unparseable edit. extractFile returns
// (nil, err); indexFile must NOT evict.
ext.setFail(true)
writeFile(t, path, "this no longer parses")
require.Error(t, idx.IndexFile(path),
"a failed parse should surface the extractor error")
// The prior state survives, untouched.
assert.Equal(t, nodesBefore, len(idx.graph.GetFileNodes("main.fk")),
"a failed re-index must leave the file's prior nodes intact, not zero them")
assert.NotNil(t, idx.graph.GetNode(funcID),
"Alpha must still exist after the failed re-index")
assert.True(t, searchHasID(idx, "Alpha", funcID),
"Alpha must still be in the search index after the failed re-index")
// A subsequent valid re-index swaps cleanly: Alpha gone, Beta in.
ext.setFail(false)
ext.setFuncs("Beta")
writeFile(t, path, "beta body")
require.NoError(t, idx.IndexFile(path))
assert.Nil(t, idx.graph.GetNode(funcID),
"a successful re-index must evict the old Alpha node")
assert.False(t, searchHasID(idx, "Alpha", funcID),
"a successful re-index must remove Alpha's stale search entry")
betaID := "main.fk::Beta"
assert.NotNil(t, idx.graph.GetNode(betaID), "Beta must be indexed by the clean swap")
assert.True(t, searchHasID(idx, "Beta", betaID), "Beta must be in the search index after the clean swap")
}
// TestPatchGraphModify_ParseFailureKeepsPriorNodes proves the LIVE
// watcher path is parse-safe, not just indexFile in isolation. The
// editor-save path goes Watcher event -> patchGraph(ChangeModified),
// which used to call EvictFile BEFORE IndexFile — so a transiently
// unparseable save dropped the file's nodes even with indexFile itself
// fixed. With the pre-evict removed, a failed modify through patchGraph
// must leave the file's prior nodes / search entries intact, and a clean
// modify must still swap. Against the pre-fix patchGraph this FAILS.
func TestPatchGraphModify_ParseFailureKeepsPriorNodes(t *testing.T) {
idx, ext := newToggleIndexer(t)
dir := t.TempDir()
idx.SetRootPath(dir)
path := filepath.Join(dir, "main.fk")
w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
// Initial index through the live create patch.
ext.setFail(false)
ext.setFuncs("Alpha")
writeFile(t, path, "alpha body")
w.patchGraph(path, ChangeCreated)
funcID := "main.fk::Alpha"
require.NotNil(t, idx.graph.GetNode(funcID), "Alpha must be indexed via the create patch")
require.True(t, searchHasID(idx, "Alpha", funcID))
nodesBefore := len(idx.graph.GetFileNodes("main.fk"))
require.Equal(t, 2, nodesBefore, "file node + Alpha")
// A transiently-unparseable save arrives as a Modify on the live path.
ext.setFail(true)
writeFile(t, path, "this no longer parses")
w.patchGraph(path, ChangeModified)
assert.Equal(t, nodesBefore, len(idx.graph.GetFileNodes("main.fk")),
"a failed modify through the live watcher path must not zero the file's nodes")
assert.NotNil(t, idx.graph.GetNode(funcID), "Alpha must survive the failed live modify")
assert.True(t, searchHasID(idx, "Alpha", funcID), "Alpha's search entry must survive the failed live modify")
// A clean modify swaps cleanly.
ext.setFail(false)
ext.setFuncs("Beta")
writeFile(t, path, "beta body")
w.patchGraph(path, ChangeModified)
assert.Nil(t, idx.graph.GetNode(funcID), "a clean live modify evicts Alpha")
assert.NotNil(t, idx.graph.GetNode("main.fk::Beta"), "a clean live modify indexes Beta")
}
// TestPollGitHead_DiffFailureRetriesRange proves the lastSHA-advance
// fix: when the git diff for the moved range errors, pollGitHead must
// leave lastSHA at the old SHA so the next cycle retries the same
// (un-reconciled) range. Advancing it on failure would permanently skip
// that span.
//
// We force the diff failure by seeding lastSHA with a bogus SHA — `git
// diff <bogus>..HEAD` errors with "unknown revision". The fix then
// requires lastSHA to stay bogus across the failing poll, and the range
// to reconcile once lastSHA is a real prior commit.
func TestPollGitHead_DiffFailureRetriesRange(t *testing.T) {
if !haveGit(t) {
t.Skip("git binary not available in PATH")
}
repoDir := t.TempDir()
runGit(t, repoDir, "init", "-q", "-b", "main")
runGit(t, repoDir, "config", "user.email", "test@example.com")
runGit(t, repoDir, "config", "user.name", "Test")
runGit(t, repoDir, "config", "commit.gpgsign", "false")
writeFile(t, filepath.Join(repoDir, "a.go"), "package main\nfunc Alpha() {}\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-q", "-m", "main: Alpha")
firstSHA, err := pollerHeadSHA(repoDir)
require.NoError(t, err)
writeFile(t, filepath.Join(repoDir, "b.go"), "package main\nfunc Beta() {}\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-q", "-m", "main: Beta")
g := graph.New()
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
idx.search = search.NewBM25()
idx.SetRootPath(repoDir)
_, err = idx.IndexCtx(testCtx(), repoDir)
require.NoError(t, err)
require.NotEmpty(t, g.GetFileNodes("a.go"))
require.NotEmpty(t, g.GetFileNodes("b.go"))
w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
p := newPoller(w, idx, zap.NewNop())
// Seed lastSHA with a SHA git can't resolve — the diff for this
// cycle's range will error.
const bogus = "0000000000000000000000000000000000000000"
p.mu.Lock()
p.lastSHA = bogus
p.mu.Unlock()
// Failing cycle: diff errors, so lastSHA must NOT advance.
require.False(t, p.pollGitHead(), "a failed diff reports no reconcile")
p.mu.Lock()
stuck := p.lastSHA
p.mu.Unlock()
require.Equal(t, bogus, stuck,
"a failed git diff must leave lastSHA at the old SHA so the range is retried, not skipped")
// Now point lastSHA at a real prior commit; the retry reconciles
// the same HEAD range that the failure left un-reconciled.
p.mu.Lock()
p.lastSHA = firstSHA
p.mu.Unlock()
require.True(t, p.pollGitHead(), "the retry must reconcile the previously-failed range")
head, err := pollerHeadSHA(repoDir)
require.NoError(t, err)
p.mu.Lock()
settled := p.lastSHA
p.mu.Unlock()
assert.Equal(t, head, settled, "a successful diff advances lastSHA to HEAD")
}
func haveGit(t *testing.T) bool {
t.Helper()
_, err := exec.LookPath("git")
return err == nil
}
// TestWatcher_OverflowEventTriggersReconcile proves the kernel-overflow
// gap is closed: a pathless EventOverflow on the Events channel triggers
// a coalesced full-tree reconcile (the signal the Linux inotify backend
// raises when its queue overflows and events are lost). The reconcileFn
// seam stands in for IncrementalReindex so the assertion is
// deterministic and platform-independent.
func TestWatcher_OverflowEventTriggersReconcile(t *testing.T) {
idx, _ := newToggleIndexer(t)
idx.SetRootPath(t.TempDir())
w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
var calls int32
done := make(chan struct{}, 1)
w.reconcileMu.Lock()
w.reconcileFn = func() {
atomic.AddInt32(&calls, 1)
select {
case done <- struct{}{}:
default:
}
}
w.reconcileMu.Unlock()
w.handleEvent(fswatcher.WatchEvent{Types: []fswatcher.EventType{fswatcher.EventOverflow}})
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("overflow event did not trigger a reconcile")
}
assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "exactly one reconcile from one overflow")
}
// TestWatcher_OverflowReconcileCoalesces proves a burst of overflow
// signals collapses into at most one reconcile in flight — the loop is
// never blocked and the tree isn't re-walked per dropped event.
func TestWatcher_OverflowReconcileCoalesces(t *testing.T) {
idx, _ := newToggleIndexer(t)
idx.SetRootPath(t.TempDir())
w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
var calls int32
release := make(chan struct{})
started := make(chan struct{}, 1)
w.reconcileMu.Lock()
w.reconcileFn = func() {
atomic.AddInt32(&calls, 1)
select {
case started <- struct{}{}:
default:
}
<-release // hold the reconcile "in flight"
}
w.reconcileMu.Unlock()
// First signal starts the (blocked) reconcile.
w.triggerOverflowReconcile("queue-overflow")
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("first reconcile never started")
}
// A burst while one is in flight must be coalesced away.
for i := 0; i < 25; i++ {
w.triggerOverflowReconcile("queue-overflow")
}
assert.Equal(t, int32(1), atomic.LoadInt32(&calls),
"overflow signals during an in-flight reconcile must coalesce to one")
close(release)
// Once the in-flight reconcile drains, a fresh signal runs again.
require.Eventually(t, func() bool {
w.reconcileMu.Lock()
pending := w.reconcilePending
w.reconcileMu.Unlock()
return !pending
}, 2*time.Second, 5*time.Millisecond, "reconcilePending must clear after the reconcile finishes")
}
// TestWatcher_OverflowReconcileIndexesMissedFile is the end-to-end proof
// that the real reconcile path (IncrementalReindex) recovers a file
// whose create/modify event was lost. We index a tree, drop a brand-new
// file on disk (simulating a missed inotify create), then drive an
// overflow through the real reconcile and assert the new file is now in
// the graph.
func TestWatcher_OverflowReconcileIndexesMissedFile(t *testing.T) {
dir := t.TempDir()
ext := &toggleExtractor{}
reg := parser.NewRegistry()
reg.Register(ext)
g := graph.New()
idx := New(g, reg, config.IndexConfig{Workers: 1}, zap.NewNop())
idx.search = search.NewBM25()
idx.SetRootPath(dir)
ext.setFail(false)
ext.setFuncs("Seed")
writeFile(t, filepath.Join(dir, "seed.fk"), "seed body")
_, err := idx.IndexCtx(testCtx(), dir)
require.NoError(t, err)
require.NotEmpty(t, g.GetFileNodes("seed.fk"))
w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
// A new file appears on disk but its create event was "lost".
ext.setFuncs("Recovered")
writeFile(t, filepath.Join(dir, "missed.fk"), "recovered body")
require.Empty(t, g.GetFileNodes("missed.fk"), "missed file must be absent before the reconcile")
// Drive the real IncrementalReindex through the overflow path, with
// a thin wrapper only to know when it finishes.
done := make(chan struct{}, 1)
w.reconcileMu.Lock()
w.reconcileFn = func() {
_, rerr := idx.IncrementalReindex(dir)
require.NoError(t, rerr)
done <- struct{}{}
}
w.reconcileMu.Unlock()
w.handleEvent(fswatcher.WatchEvent{Types: []fswatcher.EventType{fswatcher.EventOverflow}})
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("overflow reconcile never ran")
}
assert.NotEmpty(t, g.GetFileNodes("missed.fk"),
"the overflow-driven reconcile must index the previously-missed file")
}
// TestWatcher_NewSubdirScanIndexesPreWatchFile proves the new-subdir
// race is closed: a file written into a freshly-created directory before
// its watch attaches (so its own create event is never delivered) is
// still indexed, because the directory's create event triggers a scoped
// subtree scan. We drive the real path — handleEvent -> enqueueDirScan
// -> runDirScan -> IncrementalReindexPaths, no seam — and assert the
// pre-watch file lands in the graph.
func TestWatcher_NewSubdirScanIndexesPreWatchFile(t *testing.T) {
dir := t.TempDir()
ext := &toggleExtractor{}
reg := parser.NewRegistry()
reg.Register(ext)
g := graph.New()
idx := New(g, reg, config.IndexConfig{Workers: 1}, zap.NewNop())
idx.search = search.NewBM25()
idx.SetRootPath(dir)
ext.setFail(false)
ext.setFuncs("Seed")
writeFile(t, filepath.Join(dir, "seed.fk"), "seed body")
_, err := idx.IndexCtx(testCtx(), dir)
require.NoError(t, err)
require.NotEmpty(t, g.GetFileNodes("seed.fk"))
w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
// A new subdirectory appears with a file already inside it; the
// file's own create event was lost (it landed before the watch on
// the new directory attached), so only the directory create arrives.
subdir := filepath.Join(dir, "pkg")
require.NoError(t, os.MkdirAll(subdir, 0o755))
ext.setFuncs("Buried")
writeFile(t, filepath.Join(subdir, "buried.fk"), "buried body")
require.Empty(t, g.GetFileNodes("pkg/buried.fk"),
"the pre-watch file must be absent before the directory scan")
w.handleEvent(fswatcher.WatchEvent{
Path: subdir,
Types: []fswatcher.EventType{fswatcher.EventCreate},
})
require.Eventually(t, func() bool {
return len(g.GetFileNodes("pkg/buried.fk")) > 0
}, 5*time.Second, 10*time.Millisecond,
"the new-directory create must trigger a scoped scan that indexes the pre-watch file")
}
// TestWatcher_DirEventScanGating proves the scan trigger is gated on a
// Create: a directory create enqueues a scoped scan, while a bare
// directory modify (an mtime bump with no Create) does not — entry
// changes inside an existing directory fire their own file events. Uses
// the scanFn seam.
func TestWatcher_DirEventScanGating(t *testing.T) {
idx, _ := newToggleIndexer(t)
dir := t.TempDir()
idx.SetRootPath(dir)
subdir := filepath.Join(dir, "sub")
require.NoError(t, os.MkdirAll(subdir, 0o755))
w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
scanned := make(chan map[string]struct{}, 4)
w.reconcileMu.Lock()
w.scanFn = func(dirs map[string]struct{}) { scanned <- dirs }
w.reconcileMu.Unlock()
// A bare modify on the directory must NOT enqueue a scan.
w.handleEvent(fswatcher.WatchEvent{
Path: subdir,
Types: []fswatcher.EventType{fswatcher.EventMod},
})
select {
case <-scanned:
t.Fatal("a directory modify without a Create must not trigger a scan")
case <-time.After(150 * time.Millisecond):
}
// A create on the directory must enqueue a scoped scan of it.
w.handleEvent(fswatcher.WatchEvent{
Path: subdir,
Types: []fswatcher.EventType{fswatcher.EventCreate},
})
select {
case dirs := <-scanned:
_, ok := dirs[subdir]
assert.True(t, ok, "the scan set must contain the newly-created directory")
case <-time.After(2 * time.Second):
t.Fatal("a directory create must trigger a scoped scan")
}
}
// panicOnReadStore wraps a real Store but panics on GetFileNodes once
// armed — the shape store_sqlite's panicOnFatal produces when the DB is
// closed/locked (e.g. mid daemon-restart) or its schema is missing.
type panicOnReadStore struct {
graph.Store
armed atomic.Bool
}
func (s *panicOnReadStore) GetFileNodes(p string) []*graph.Node {
if s.armed.Load() {
panic("simulated fatal store error")
}
return s.Store.GetFileNodes(p)
}
// TestWatcher_PatchPanicRecoveredNotCrash proves the watcher panic
// firewall: a fatal store error during a debounced patch is recovered
// and logged, not propagated out of the timer goroutine to crash the
// whole daemon. The fsnotify-driven goroutines don't route through the
// MCP wrapToolHandler firewall, so a closed/locked DB during a restart
// (panicOnFatal) used to take the process down — the exact shape of the
// observed crash. Against the pre-firewall code the panic escapes the
// AfterFunc goroutine and aborts the test binary.
func TestWatcher_PatchPanicRecoveredNotCrash(t *testing.T) {
ext := &toggleExtractor{}
reg := parser.NewRegistry()
reg.Register(ext)
store := &panicOnReadStore{Store: graph.New()}
idx := New(store, reg, config.IndexConfig{Workers: 1}, zap.NewNop())
idx.search = search.NewBM25()
dir := t.TempDir()
idx.SetRootPath(dir)
path := filepath.Join(dir, "main.fk")
ext.setFail(false)
ext.setFuncs("Alpha")
writeFile(t, path, "alpha body")
require.NoError(t, idx.IndexFile(path))
core, logs := observer.New(zapcore.ErrorLevel)
w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 5}, zap.New(core))
require.NoError(t, err)
// Arm the store so the next read panics, then drive the debounced
// patch path. The panic fires in the AfterFunc goroutine.
store.armed.Store(true)
w.handleEvent(fswatcher.WatchEvent{
Path: path,
Types: []fswatcher.EventType{fswatcher.EventMod},
})
require.Eventually(t, func() bool {
return logs.FilterMessageSnippet("recovered from panic").Len() > 0
}, 2*time.Second, 10*time.Millisecond,
"a panic in the debounced patch must be recovered and logged, not crash the daemon")
}