feat: nlog, config hot-reload, and concurrency fixes

This commit is contained in:
xboard
2026-03-24 23:12:55 +08:00
parent 191e58b9ee
commit 7e3ad7cb23
22 changed files with 572 additions and 281 deletions
+18 -9
View File
@@ -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))
}
+117
View File
@@ -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) })
}