Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 57 additions & 9 deletions watch/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,57 @@ import (
"github.com/fsnotify/fsnotify"
)

// eventDebouncer coalesces rapid successive WRITE events for the same path.
// Non-WRITE operations are never debounced so create/remove transitions stay accurate.
type eventDebouncer struct {
window time.Duration
pruneAfter time.Duration
lastSeen map[string]time.Time
lastPruned time.Time
}

func newEventDebouncer(window time.Duration) *eventDebouncer {
pruneAfter := 10 * window
if pruneAfter < time.Second {
pruneAfter = time.Second
}
return &eventDebouncer{
window: window,
pruneAfter: pruneAfter,
lastSeen: make(map[string]time.Time),
}
}

func (d *eventDebouncer) shouldSkip(event fsnotify.Event, now time.Time) bool {
if event.Op&fsnotify.Write == 0 {
return false
}

Comment thread
JordanCoin marked this conversation as resolved.
Outdated
if last, exists := d.lastSeen[event.Name]; exists && now.Sub(last) < d.window {
return true
}
d.lastSeen[event.Name] = now

if d.lastPruned.IsZero() || now.Sub(d.lastPruned) >= d.pruneAfter {
d.prune(now)
d.lastPruned = now
}

return false
}

func (d *eventDebouncer) prune(now time.Time) {
cutoff := now.Add(-d.pruneAfter)
for path, ts := range d.lastSeen {
if ts.Before(cutoff) {
delete(d.lastSeen, path)
}
}
}

// eventLoop processes file system events
func (d *Daemon) eventLoop() {
// Debounce rapid changes (e.g., save + format)
debounce := make(map[string]time.Time)
debounceWindow := 100 * time.Millisecond
debouncer := newEventDebouncer(100 * time.Millisecond)

for {
select {
Expand Down Expand Up @@ -50,13 +96,9 @@ func (d *Daemon) eventLoop() {
}
}

// Debounce rapid events on same file
if last, exists := debounce[event.Name]; exists {
if time.Since(last) < debounceWindow {
continue
}
if debouncer.shouldSkip(event, time.Now()) {
continue
}
debounce[event.Name] = time.Now()

// Process the event
d.handleEvent(event)
Expand Down Expand Up @@ -125,6 +167,12 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) {
case "CREATE", "WRITE":
info, err := os.Stat(fsEvent.Name)
if err != nil {
// Event delivery can race file deletion (e.g., atomic saves or temp
// files); if the path disappeared, clear any stale tracked entry.
if os.IsNotExist(err) {
delete(d.graph.Files, relPath)
delete(d.graph.State, relPath)
}
d.graph.mu.Unlock()
return
}
Expand Down
62 changes: 62 additions & 0 deletions watch/events_debounce_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package watch

import (
"testing"
"time"

"github.com/fsnotify/fsnotify"
)

func TestEventDebouncerSkipsRapidWrites(t *testing.T) {
debouncer := newEventDebouncer(100 * time.Millisecond)
base := time.Unix(0, 0)
event := fsnotify.Event{Name: "src/file.go", Op: fsnotify.Write}

if debouncer.shouldSkip(event, base) {
t.Fatal("first write should not be skipped")
}
if !debouncer.shouldSkip(event, base.Add(50*time.Millisecond)) {
t.Fatal("rapid write should be skipped")
}
if debouncer.shouldSkip(event, base.Add(150*time.Millisecond)) {
t.Fatal("write outside debounce window should not be skipped")
}
}

func TestEventDebouncerDoesNotSkipNonWriteOps(t *testing.T) {
debouncer := newEventDebouncer(100 * time.Millisecond)
base := time.Unix(0, 0)
path := "src/tmp.go"

if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Create}, base) {
t.Fatal("create should not be skipped")
}
if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Remove}, base.Add(5*time.Millisecond)) {
t.Fatal("remove should not be skipped even after rapid create")
}
if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Write}, base.Add(10*time.Millisecond)) {
t.Fatal("first write should not be skipped")
}
if debouncer.shouldSkip(fsnotify.Event{Name: path, Op: fsnotify.Rename}, base.Add(15*time.Millisecond)) {
t.Fatal("rename should not be skipped even after rapid write")
}
}

func TestEventDebouncerPrunesStaleEntries(t *testing.T) {
debouncer := newEventDebouncer(100 * time.Millisecond)
base := time.Unix(0, 0)

debouncer.shouldSkip(fsnotify.Event{Name: "src/old.go", Op: fsnotify.Write}, base)
if len(debouncer.lastSeen) != 1 {
t.Fatalf("expected 1 tracked path, got %d", len(debouncer.lastSeen))
}

debouncer.shouldSkip(fsnotify.Event{Name: "src/new.go", Op: fsnotify.Write}, base.Add(2*time.Second))

if _, exists := debouncer.lastSeen["src/old.go"]; exists {
t.Fatal("expected stale path entry to be pruned")
}
if _, exists := debouncer.lastSeen["src/new.go"]; !exists {
t.Fatal("expected recent path entry to be retained")
}
}
40 changes: 40 additions & 0 deletions watch/events_handle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package watch

import (
"path/filepath"
"testing"

"codemap/scanner"

"github.com/fsnotify/fsnotify"
)

func TestHandleEventMissingWriteClearsStaleTrackedFile(t *testing.T) {
root := t.TempDir()
rel := "ghost.go"
abs := filepath.Join(root, rel)

d := &Daemon{
root: root,
graph: &Graph{
Files: map[string]*scanner.FileInfo{
rel: {Path: rel, Size: 32, Ext: ".go"},
},
State: map[string]*FileState{
rel: {Lines: 3, Size: 32},
},
},
}

d.handleEvent(fsnotify.Event{Name: abs, Op: fsnotify.Write})

d.graph.mu.RLock()
defer d.graph.mu.RUnlock()

if _, exists := d.graph.Files[rel]; exists {
t.Fatalf("expected stale file %q to be removed from graph.Files", rel)
}
if _, exists := d.graph.State[rel]; exists {
t.Fatalf("expected stale file %q to be removed from graph.State", rel)
}
}
Loading