From 6531eb27d55e8b1b3bbe75fad60577bd2e2d8cd1 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:08:26 +0200 Subject: [PATCH] fix(watch): preserve final rapid-write state Co-Authored-By: GPT-5.6 Sol --- watch/events.go | 22 ++++++++++++++++++++- watch/events_debounce_test.go | 36 +++++++++++++++++++++++++++++++++++ watch/more_test.go | 2 +- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/watch/events.go b/watch/events.go index 41c8206..5ba6cf9 100644 --- a/watch/events.go +++ b/watch/events.go @@ -114,7 +114,7 @@ func (d *Daemon) eventLoop() { continue } - if debouncer.shouldSkip(event, time.Now()) { + if d.shouldSkipEvent(debouncer, event, time.Now()) { continue } @@ -132,6 +132,26 @@ func (d *Daemon) eventLoop() { } } +func (d *Daemon) shouldSkipEvent(debouncer *eventDebouncer, event fsnotify.Event, now time.Time) bool { + if !debouncer.shouldSkip(event, now) { + return false + } + info, err := os.Stat(event.Name) + if err != nil { + return false + } + relPath, err := filepath.Rel(d.root, event.Name) + if err != nil { + return false + } + + d.graph.mu.RLock() + cached := d.graph.State[relPath] + matches := cached != nil && cached.Size == info.Size() + d.graph.mu.RUnlock() + return matches +} + // isSourceFile checks if a file should be tracked. // Derives from the canonical extension registry in scanner. func (d *Daemon) isSourceFile(path string) bool { diff --git a/watch/events_debounce_test.go b/watch/events_debounce_test.go index f4934b6..a1af383 100644 --- a/watch/events_debounce_test.go +++ b/watch/events_debounce_test.go @@ -1,12 +1,48 @@ package watch import ( + "os" + "path/filepath" "testing" "time" "github.com/fsnotify/fsnotify" ) +func TestDaemonDoesNotDebounceWriteWhenCachedSizeIsStale(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "main.go") + if err := os.WriteFile(path, []byte("package main\n\nfunc changed() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + + d := &Daemon{ + root: root, + graph: &Graph{State: map[string]*FileState{ + "main.go": {Size: 0}, + }}, + } + debouncer := newEventDebouncer(100 * time.Millisecond) + event := fsnotify.Event{Name: path, Op: fsnotify.Write} + base := time.Unix(0, 0) + + if d.shouldSkipEvent(debouncer, event, base) { + t.Fatal("first write should not be skipped") + } + if d.shouldSkipEvent(debouncer, event, base.Add(10*time.Millisecond)) { + t.Fatal("final write should refresh stale cached state") + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + d.graph.State["main.go"].Size = info.Size() + if !d.shouldSkipEvent(debouncer, event, base.Add(20*time.Millisecond)) { + t.Fatal("duplicate write should be skipped once cached state is current") + } +} + func TestEventDebouncerSkipsRapidWrites(t *testing.T) { debouncer := newEventDebouncer(100 * time.Millisecond) base := time.Unix(0, 0) diff --git a/watch/more_test.go b/watch/more_test.go index 59fb20c..b8faf7a 100644 --- a/watch/more_test.go +++ b/watch/more_test.go @@ -156,7 +156,7 @@ func TestDaemonStartTracksWriteEventsAndState(t *testing.T) { waitForWatchCondition(t, 2*time.Second, func() bool { events := d.GetEvents(0) - return len(events) > 0 + return len(events) > 0 && events[len(events)-1].Path == "main.go" && events[len(events)-1].Delta > 0 }) events := d.GetEvents(0)