feat: nlog, config hot-reload, and concurrency fixes
This commit is contained in:
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"golang.org/x/term"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -331,40 +333,47 @@ func (c *Config) ExpandNodes() []*Config {
|
||||
}
|
||||
|
||||
func InitLogger(cfg LogConfig) {
|
||||
var level slog.Level
|
||||
var minLevel slog.Level
|
||||
switch cfg.Level {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
minLevel = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
minLevel = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
minLevel = slog.LevelError
|
||||
default:
|
||||
level = slog.LevelInfo
|
||||
minLevel = slog.LevelInfo
|
||||
}
|
||||
|
||||
var w io.Writer
|
||||
useColor := false
|
||||
switch cfg.Output {
|
||||
case "stdout", "":
|
||||
w = os.Stdout
|
||||
useColor = term.IsTerminal(int(os.Stdout.Fd()))
|
||||
case "stderr":
|
||||
w = os.Stderr
|
||||
useColor = term.IsTerminal(int(os.Stderr.Fd()))
|
||||
default:
|
||||
dir := filepath.Dir(cfg.Output)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
slog.Error("failed to create log dir, falling back to stdout", "error", err)
|
||||
fmt.Fprintf(os.Stderr, "failed to create log dir, falling back to stdout: %v\n", err)
|
||||
w = os.Stdout
|
||||
useColor = term.IsTerminal(int(os.Stdout.Fd()))
|
||||
} else {
|
||||
f, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
slog.Error("failed to open log file, falling back to stdout", "error", err)
|
||||
fmt.Fprintf(os.Stderr, "failed to open log file, falling back to stdout: %v\n", err)
|
||||
w = os.Stdout
|
||||
useColor = term.IsTerminal(int(os.Stdout.Fd()))
|
||||
} else {
|
||||
w = f
|
||||
useColor = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handler := slog.NewTextHandler(w, &slog.HandlerOptions{Level: level})
|
||||
slog.SetDefault(slog.New(handler))
|
||||
nlog.Init(w, minLevel, useColor)
|
||||
// Application logging goes through nlog; silence slog.Default for stray library use.
|
||||
slog.SetDefault(slog.New(slog.DiscardHandler))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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) })
|
||||
}
|
||||
Reference in New Issue
Block a user