diff --git a/internal/filedata/poller.go b/internal/filedata/poller.go new file mode 100644 index 00000000..816304b5 --- /dev/null +++ b/internal/filedata/poller.go @@ -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 +// 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 +} diff --git a/internal/filedata/poller_test.go b/internal/filedata/poller_test.go new file mode 100644 index 00000000..f46548a9 --- /dev/null +++ b/internal/filedata/poller_test.go @@ -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) +}