// Package watcher reloads the domains CDB when the file changes on disk. // // It is the table mode of rcptd's watcher, trimmed: it watches the files // themselves (kqueue on FreeBSD/macOS only reports in-place writes through a // file watch) and their directories (an atomic `mv` over the file shows up as // a Create in the directory), debounces bursts and calls one reload function. package watcher import ( "fmt" "log/slog" "os" "path/filepath" "sync" "time" "github.com/fsnotify/fsnotify" ) // ReloadFunc is called, debounced, after a watched file changed. Its error is // logged; the watcher keeps running either way. type ReloadFunc func() error // Watcher watches a set of files and calls reload on change. type Watcher struct { reload ReloadFunc fsw *fsnotify.Watcher files map[string]bool // absolute paths we care about debounce time.Duration done chan struct{} stopOnce sync.Once wg sync.WaitGroup timerMu sync.Mutex timer *time.Timer runMu sync.Mutex // serialises reload calls } // New watches paths. Their directories must exist; the files themselves may // appear later (the first deploy of the CDB triggers a reload). func New(paths []string, reload ReloadFunc) (*Watcher, error) { fsw, err := fsnotify.NewWatcher() if err != nil { return nil, err } w := &Watcher{ reload: reload, fsw: fsw, files: make(map[string]bool, len(paths)), debounce: 500 * time.Millisecond, done: make(chan struct{}), } dirs := map[string]bool{} for _, p := range paths { abs, err := filepath.Abs(p) if err != nil { fsw.Close() return nil, fmt.Errorf("resolve path %s: %w", p, err) } w.files[abs] = true if dir := filepath.Dir(abs); !dirs[dir] { if err := fsw.Add(dir); err != nil { fsw.Close() return nil, fmt.Errorf("watch directory %s: %w", dir, err) } dirs[dir] = true } } for abs := range w.files { if _, err := os.Stat(abs); err == nil { _ = fsw.Add(abs) } } return w, nil } // Start begins watching in a background goroutine. func (w *Watcher) Start() { w.wg.Add(1) go func() { defer w.wg.Done() for { select { case event, ok := <-w.fsw.Events: if !ok { return } if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { continue } abs, err := filepath.Abs(event.Name) if err != nil || !w.files[abs] { continue } // After a rename-over the old file watch is stale: re-add it so // in-place writes to the new inode are seen too. if event.Op&fsnotify.Create != 0 { _ = w.fsw.Add(abs) } w.schedule() case err, ok := <-w.fsw.Errors: if !ok { return } slog.Error("file watcher error", "error", err) case <-w.done: return } } }() } // schedule (re)arms the debounce timer. func (w *Watcher) schedule() { w.timerMu.Lock() defer w.timerMu.Unlock() if w.timer != nil { w.timer.Stop() } w.timer = time.AfterFunc(w.debounce, w.run) } func (w *Watcher) run() { select { case <-w.done: return default: } w.runMu.Lock() defer w.runMu.Unlock() if err := w.reload(); err != nil { slog.Error("reload after file change failed", "error", err) return } slog.Info("reloaded after file change") } // Stop ends watching and waits for the goroutine; safe to call twice. func (w *Watcher) Stop() { w.stopOnce.Do(func() { w.timerMu.Lock() if w.timer != nil { w.timer.Stop() } w.timerMu.Unlock() close(w.done) w.fsw.Close() w.wg.Wait() }) }