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) 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 } 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 } 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) }) }