Files

161 lines
3.1 KiB
Go

package config
import (
"context"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/cedar2025/xboard-node/internal/nlog"
"github.com/fsnotify/fsnotify"
)
// Watcher reloads the config file on change (debounced) and calls onChange.
type Watcher struct {
path string
debounce time.Duration
onChange func(*Config)
onChangeRoot func(*RootConfig)
watcher *fsnotify.Watcher
stopOnce sync.Once
stopCh chan struct{}
stopped atomic.Bool
}
// WatchConfig watches path; invalid reloads are logged and ignored.
// Stop via Stop() or cancel ctx.
func WatchConfig(ctx context.Context, path string, onChange func(*Config)) (*Watcher, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return nil, err
}
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
// Watch parent dir so rename/atomic saves are visible.
dir := filepath.Dir(absPath)
if err := fsw.Add(dir); err != nil {
fsw.Close()
return nil, err
}
w := &Watcher{
path: absPath,
debounce: 1 * time.Second,
onChange: onChange,
watcher: fsw,
stopCh: make(chan struct{}),
}
go w.loop(ctx)
nlog.Core().Info("config watcher started", "path", absPath)
return w, nil
}
// WatchConfigRoot watches path and reloads the root config model.
func WatchConfigRoot(ctx context.Context, path string, onChange func(*RootConfig)) (*Watcher, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return nil, err
}
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
dir := filepath.Dir(absPath)
if err := fsw.Add(dir); err != nil {
fsw.Close()
return nil, err
}
w := &Watcher{
path: absPath,
debounce: 1 * time.Second,
onChangeRoot: onChange,
watcher: fsw,
stopCh: make(chan struct{}),
}
go w.loop(ctx)
nlog.Core().Info("config watcher started", "path", absPath)
return w, nil
}
func (w *Watcher) loop(ctx context.Context) {
var timer *time.Timer
defer func() {
if timer != nil {
timer.Stop()
}
w.watcher.Close()
}()
for {
select {
case <-ctx.Done():
return
case <-w.stopCh:
return
case ev, ok := <-w.watcher.Events:
if !ok {
return
}
if filepath.Clean(ev.Name) != w.path {
continue
}
if !ev.Has(fsnotify.Write) && !ev.Has(fsnotify.Create) {
continue
}
if timer == nil {
timer = time.AfterFunc(w.debounce, w.reload)
} else {
timer.Reset(w.debounce)
}
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
nlog.Core().Warn("config watcher error", "error", err)
}
}
}
func (w *Watcher) reload() {
if w.stopped.Load() {
return
}
if w.onChangeRoot != nil {
root, err := LoadRoot(w.path)
if err != nil {
nlog.Core().Error("config reload failed, keeping current config", "error", err)
return
}
nlog.Core().Info("config reloaded successfully")
w.onChangeRoot(root)
return
}
cfg, err := Load(w.path)
if err != nil {
nlog.Core().Error("config reload failed, keeping current config", "error", err)
return
}
nlog.Core().Info("config reloaded successfully")
w.onChange(cfg)
}
func (w *Watcher) Stop() {
w.stopped.Store(true)
w.stopOnce.Do(func() { close(w.stopCh) })
}