152 lines
3.4 KiB
Go
152 lines
3.4 KiB
Go
// 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()
|
|
})
|
|
}
|