package ldfilewatch import ( "fmt" "path" "path/filepath" "time" "github.com/fsnotify/fsnotify" "github.com/launchdarkly/go-sdk-common/v3/ldlog" ) const retryDuration = time.Second type fileWatcher struct { watcher *fsnotify.Watcher loggers ldlog.Loggers reload func() paths []string absPaths map[string]bool } // WatchFiles sets up a mechanism for the file data source to reload its source files whenever one of them has // been modified. Use it as follows: // // config := Config{ // DataSource: ldfiledata.DataSource(). // FilePaths(filePaths). // Reloader(ldfilewatch.WatchFiles), // } func WatchFiles(paths []string, loggers ldlog.Loggers, reload func(), closeCh <-chan struct{}) error { watcher, err := fsnotify.NewWatcher() if err != nil { // COVERAGE: can't simulate this condition in unit tests return fmt.Errorf("unable to create file watcher: %s", err) } fw := &fileWatcher{ watcher: watcher, loggers: loggers, reload: reload, paths: paths, absPaths: make(map[string]bool), } go fw.run(closeCh) return nil } func (fw *fileWatcher) run(closeCh <-chan struct{}) { retryCh := make(chan struct{}, 1) scheduleRetry := func() { time.AfterFunc(retryDuration, func() { select { case retryCh <- struct{}{}: // don't need multiple retries so no need to block default: // COVERAGE: can't simulate this condition in unit tests } }) } for { if err := fw.setupWatches(); err != nil { fw.loggers.Error(err) scheduleRetry() } // We do the reload here rather than after waitForEvents, even though that means there will be a // redundant load when we first start up, because otherwise there's a potential race condition where // file changes could happen before we had set up our file watcher. fw.reload() quit := fw.waitForEvents(closeCh, retryCh) if quit { return } } } func (fw *fileWatcher) setupWatches() error { for _, p := range fw.paths { absDirPath := path.Dir(p) realDirPath, err := filepath.EvalSymlinks(absDirPath) if err != nil { return fmt.Errorf(`unable to evaluate symlinks for "%s": %s`, absDirPath, err) } realPath := path.Join(realDirPath, path.Base(p)) fw.absPaths[realPath] = true if err = fw.watcher.Add(realPath); err != nil { // COVERAGE: can't simulate this condition in unit tests return fmt.Errorf(`unable to watch path "%s": %s`, realPath, err) } if err = fw.watcher.Add(realDirPath); err != nil { // COVERAGE: can't simulate this in unit tests return fmt.Errorf(`unable to watch path "%s": %s`, realDirPath, err) } } return nil } func (fw *fileWatcher) waitForEvents(closeCh <-chan struct{}, retryCh <-chan struct{}) bool { for { select { case <-closeCh: err := fw.watcher.Close() if err != nil { // COVERAGE: can't simulate this condition in unit tests fw.loggers.Errorf("Error closing Watcher: %s", err) } return true case event := <-fw.watcher.Events: if !fw.absPaths[event.Name] { // COVERAGE: can't simulate this condition in unit tests break } fw.consumeExtraEvents() return false case err := <-fw.watcher.Errors: fw.loggers.Error(err) // COVERAGE: can't simulate this condition in unit tests case <-retryCh: consumeExtraRetries(retryCh) return false } } } func (fw *fileWatcher) consumeExtraEvents() { for { select { case <-fw.watcher.Events: // COVERAGE: can't simulate this condition in unit tests default: return } } } func consumeExtraRetries(retryCh <-chan struct{}) { for { select { case <-retryCh: // COVERAGE: can't simulate this condition in unit tests default: return } } }