Skip to content
92 changes: 92 additions & 0 deletions internal/filedata/poller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package filedata

import (
"os"
"sync"
"time"
)

// fileState is the observed state of one file, or its absence.
type fileState struct {
exists bool
modTime time.Time
size int64
}

// Poller detects changes to a set of files by examining them on a fixed interval, as an
// alternative or supplement to filesystem change notifications for environments where those
// are unavailable or unreliable. A change to any file's modification time or size — including
// the file appearing or disappearing — invokes the onChange callback. A file that cannot be

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unicode dashes in poller comments

Low Severity

The new Poller godoc uses Unicode em dashes () in two phrases. Project Go comment style expects ASCII -- instead of em dashes for readability across editors and terminals.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: Go comments: use ASCII dashes and arrows, not Unicode

Reviewed by Cursor Bugbot for commit a693af0. Configure here.

// examined is treated as absent, so a file becoming temporarily unreadable and recovering is
// also detected.
//
// Detection is deliberately generous: onChange may be invoked for changes that do not alter
// the effective data, and consumers are expected to feed it into a Reloader, whose debouncing
// and skip-unchanged handling absorb the excess.
type Poller struct {
paths []string
interval time.Duration
onChange func()
last []fileState
closeCh chan struct{}
doneCh chan struct{}
closeOnce sync.Once
}

// NewPoller creates a started Poller. The initial observation happens immediately, so only
// changes after creation invoke onChange. Call Close to stop it.
func NewPoller(paths []string, interval time.Duration, onChange func()) *Poller {
p := &Poller{
paths: paths,
interval: interval,
onChange: onChange,
last: observeAll(paths),
closeCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
go p.run()
return p
}

// Close stops the poller. The onChange callback will not be invoked after Close returns.
func (p *Poller) Close() {
p.closeOnce.Do(func() {
close(p.closeCh)
<-p.doneCh
})
}

func (p *Poller) run() {
defer close(p.doneCh)
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-p.closeCh:
return
case <-ticker.C:
current := observeAll(p.paths)
changed := false
for i := range current {
if current[i] != p.last[i] {
changed = true
break
}
}
p.last = current
if changed {
p.onChange()
}
}
}
}

func observeAll(paths []string) []fileState {
states := make([]fileState, len(paths))
for i, path := range paths {
if info, err := os.Stat(path); err == nil {
states[i] = fileState{exists: true, modTime: info.ModTime(), size: info.Size()}
}
}
return states
}
102 changes: 102 additions & 0 deletions internal/filedata/poller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package filedata

import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/require"
)

const pollTestInterval = 5 * time.Millisecond

func newPollerFixture(t *testing.T, paths []string) chan struct{} {
t.Helper()
changed := make(chan struct{}, 100)
p := NewPoller(paths, pollTestInterval, func() { changed <- struct{}{} })
t.Cleanup(p.Close)
return changed
}

func requireChange(t *testing.T, changed chan struct{}) {
t.Helper()
select {
case <-changed:
case <-time.After(testTimeout):
require.FailNow(t, "timed out waiting for change detection")
}
}

func requireNoChange(t *testing.T, changed chan struct{}, duration time.Duration) {
t.Helper()
select {
case <-changed:
require.FailNow(t, "unexpected change detection")
case <-time.After(duration):
}
}

// writeFileWithNewModTime rewrites a file and guarantees the observed (modTime, size) state
// differs from the previous state, so the poller must detect it regardless of filesystem
// timestamp granularity.
func writeFileWithNewModTime(t *testing.T, path string, content string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(content), 0600))
newTime := time.Now().Add(time.Duration(len(content)) * time.Second)
require.NoError(t, os.Chtimes(path, newTime, newTime))
}

func TestPollerDetectsModification(t *testing.T) {
path := filepath.Join(t.TempDir(), "data.json")
writeFileWithNewModTime(t, path, "one")
changed := newPollerFixture(t, []string{path})

requireNoChange(t, changed, 20*pollTestInterval)

writeFileWithNewModTime(t, path, "two!")
requireChange(t, changed)
}

func TestPollerDetectsFileAppearing(t *testing.T) {
path := filepath.Join(t.TempDir(), "data.json")
changed := newPollerFixture(t, []string{path})

requireNoChange(t, changed, 20*pollTestInterval)

writeFileWithNewModTime(t, path, "created")
requireChange(t, changed)
}

func TestPollerDetectsFileDisappearing(t *testing.T) {
path := filepath.Join(t.TempDir(), "data.json")
writeFileWithNewModTime(t, path, "content")
changed := newPollerFixture(t, []string{path})

require.NoError(t, os.Remove(path))
requireChange(t, changed)
}

func TestPollerWatchesAllFiles(t *testing.T) {
dir := t.TempDir()
path1 := filepath.Join(dir, "one.json")
path2 := filepath.Join(dir, "two.json")
writeFileWithNewModTime(t, path1, "one")
writeFileWithNewModTime(t, path2, "two")
changed := newPollerFixture(t, []string{path1, path2})

writeFileWithNewModTime(t, path2, "two-changed")
requireChange(t, changed)
}

func TestPollerStopsOnClose(t *testing.T) {
path := filepath.Join(t.TempDir(), "data.json")
writeFileWithNewModTime(t, path, "one")
changed := make(chan struct{}, 100)
p := NewPoller([]string{path}, pollTestInterval, func() { changed <- struct{}{} })
p.Close()
p.Close() // idempotent

writeFileWithNewModTime(t, path, "two!")
requireNoChange(t, changed, 20*pollTestInterval)
}
Loading