fix(config,server): merge defaults on Load and fix monitoring registration

Two related fixes that must land together:

1. config.Load() now starts from DefaultConfig() before unmarshaling
   YAML. This ensures missing top-level fields (Performance,
   Monitoring, Resolver) use their documented defaults instead of
   zero values. Most importantly, file_cache is no longer silently
   disabled when users omit the performance: section.

2. startSingleMode() now checks Monitoring.Status.Enabled instead of
   Path/Allow to decide whether to register the status endpoint.
   Without this change, fix #1 would have caused a regression where
   the status handler is registered even when monitoring is disabled,
   because DefaultConfig() sets Path and Allow defaults.

Also replace remaining log.Printf in status.go and lua/api_timer.go
with zerolog to follow project logging conventions.

Added tests:
- config/load_test.go: verifies defaults are applied, explicit values
  override defaults, and monitoring stays disabled by default.
- server/monitoring_registration_test.go: verifies /_status is only
  registered when enabled and remains reachable with static handler
  on path: /.
This commit is contained in:
xfy 2026-06-11 15:08:57 +08:00
parent 7cc76f0d5b
commit e8fbbf368c
6 changed files with 253 additions and 16 deletions

View File

@ -134,8 +134,12 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("读取配置文件失败: %w", err) return nil, fmt.Errorf("读取配置文件失败: %w", err)
} }
var cfg Config // 从默认值开始YAML 只覆盖显式配置的字段。
if err := yaml.Unmarshal(data, &cfg); err != nil { // 注意yaml.v3 对 slice 会整体替换,因此用户显式配置的 Servers[]
// 元素不会继承 server-level 默认值;但顶层 struct 字段Performance、
// Monitoring、Resolver的默认值会被保留。
cfg := DefaultConfig()
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err) return nil, fmt.Errorf("解析配置文件失败: %w", err)
} }
@ -145,16 +149,16 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("获取配置文件绝对路径失败: %w", err) return nil, fmt.Errorf("获取配置文件绝对路径失败: %w", err)
} }
visited := map[string]bool{absPath: true} visited := map[string]bool{absPath: true}
if err := processIncludes(&cfg, filepath.Dir(path), 0, visited); err != nil { if err := processIncludes(cfg, filepath.Dir(path), 0, visited); err != nil {
return nil, fmt.Errorf("处理配置引入失败: %w", err) return nil, fmt.Errorf("处理配置引入失败: %w", err)
} }
} }
if err := Validate(&cfg); err != nil { if err := Validate(cfg); err != nil {
return nil, fmt.Errorf("配置验证失败: %w", err) return nil, fmt.Errorf("配置验证失败: %w", err)
} }
return &cfg, nil return cfg, nil
} }
const maxIncludeDepth = 10 const maxIncludeDepth = 10

View File

@ -0,0 +1,104 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoad_MergesDefaults(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "minimal.yaml")
content := `
servers:
- listen: ":8080"
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Performance.FileCache.MaxEntries == 0 {
t.Error("Performance.FileCache.MaxEntries should have default value")
}
if cfg.Performance.FileCache.MaxSize == 0 {
t.Error("Performance.FileCache.MaxSize should have default value")
}
if cfg.Performance.FileCache.Inactive == 0 {
t.Error("Performance.FileCache.Inactive should have default value")
}
if cfg.Monitoring.Status.Path != "/_status" {
t.Errorf("Monitoring.Status.Path = %q, want %q", cfg.Monitoring.Status.Path, "/_status")
}
if cfg.Monitoring.Pprof.Path != "/debug/pprof" {
t.Errorf("Monitoring.Pprof.Path = %q, want %q", cfg.Monitoring.Pprof.Path, "/debug/pprof")
}
if cfg.Resolver.Valid == 0 {
t.Error("Resolver.Valid should have default value")
}
}
func TestLoad_ExplicitOverridesDefault(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "explicit.yaml")
content := `
performance:
file_cache:
max_entries: 500
max_size: 52428800
inactive: 120s
servers:
- listen: ":8080"
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Performance.FileCache.MaxEntries != 500 {
t.Errorf("MaxEntries = %d, want 500", cfg.Performance.FileCache.MaxEntries)
}
if cfg.Performance.FileCache.MaxSize != 52428800 {
t.Errorf("MaxSize = %d, want 52428800", cfg.Performance.FileCache.MaxSize)
}
if cfg.Performance.FileCache.Inactive != 120*time.Second {
t.Errorf("Inactive = %v, want 120s", cfg.Performance.FileCache.Inactive)
}
}
func TestLoad_MonitoringDisabledByDefault(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "minimal.yaml")
content := `
servers:
- listen: ":8080"
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// 默认值 Path 存在,但 Enabled 应为 false
if cfg.Monitoring.Status.Enabled {
t.Error("Monitoring.Status.Enabled should be false by default")
}
if cfg.Monitoring.Pprof.Enabled {
t.Error("Monitoring.Pprof.Enabled should be false by default")
}
if cfg.Monitoring.Status.Path != "/_status" {
t.Errorf("Monitoring.Status.Path = %q, want %q", cfg.Monitoring.Status.Path, "/_status")
}
}

View File

@ -21,12 +21,12 @@ package lua
import ( import (
"fmt" "fmt"
"log"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
glua "github.com/yuin/gopher-lua" glua "github.com/yuin/gopher-lua"
"rua.plus/lolly/internal/logging"
) )
// CallbackEntry 回调队列条目,封装定时器触发的 Lua 回调。 // CallbackEntry 回调队列条目,封装定时器触发的 Lua 回调。
@ -261,7 +261,7 @@ func (m *TimerManager) executeTimer(entry *TimerEntry) {
m.queueMu.Unlock() m.queueMu.Unlock()
default: default:
m.queueMu.Unlock() m.queueMu.Unlock()
log.Printf("[lua] timer callback dropped: queue full") logging.Warn().Msg("[lua] timer callback dropped: queue full")
} }
} }
} }
@ -277,7 +277,7 @@ func (m *TimerManager) schedulerLoop() {
// 从字节码重建函数并执行 // 从字节码重建函数并执行
fn := m.schedulerL.NewFunctionFromProto(entry.proto) fn := m.schedulerL.NewFunctionFromProto(entry.proto)
if fn == nil { if fn == nil {
log.Printf("[lua] timer callback: failed to create function from proto") logging.Error().Msg("[lua] timer callback: failed to create function from proto")
continue continue
} }
@ -286,7 +286,7 @@ func (m *TimerManager) schedulerLoop() {
Fn: fn, Fn: fn,
NRet: 0, NRet: 0,
}, entry.args...); err != nil { }, entry.args...); err != nil {
log.Printf("[lua] timer callback error: %v", err) logging.Error().Err(err).Msg("[lua] timer callback error")
} }
} }
} }
@ -405,7 +405,7 @@ func (m *TimerManager) gracefulShutdown(timeout time.Duration) {
case <-time.After(timeout): case <-time.After(timeout):
abandoned := len(m.callbackQueue) abandoned := len(m.callbackQueue)
if abandoned > 0 { if abandoned > 0 {
log.Printf("[lua] shutdown timeout: %d callbacks abandoned", abandoned) logging.Warn().Int("abandoned", abandoned).Msg("[lua] shutdown timeout: callbacks abandoned")
} }
} }
} }

View File

@ -0,0 +1,129 @@
package server
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/valyala/fasthttp"
"rua.plus/lolly/internal/config"
"rua.plus/lolly/internal/matcher"
)
func TestMonitoringEndpoints_OnlyRegisteredWhenEnabled(t *testing.T) {
// Case 1: monitoring 未启用时,/_status 不应注册
cfg := &config.Config{
Servers: []config.ServerConfig{{
Listen: "127.0.0.1:0",
Static: []config.StaticConfig{{
Path: "/",
Root: t.TempDir(),
}},
}},
}
srv := New(cfg)
go srv.Start()
defer srv.StopWithTimeout(5 * time.Second)
var addr string
for start := time.Now(); time.Since(start) < 2*time.Second; time.Sleep(10 * time.Millisecond) {
listeners := srv.GetListeners()
if len(listeners) > 0 {
addr = listeners[0].Addr().String()
break
}
}
if addr == "" {
t.Fatal("server has no listeners")
}
client := &fasthttp.Client{}
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI("http://" + addr + "/_status")
req.Header.SetMethod("GET")
if err := client.Do(req, resp); err != nil {
t.Fatalf("request failed: %v", err)
}
// 未启用时应返回 404被 static handler 处理,找不到文件)
assert.Equal(t, fasthttp.StatusNotFound, resp.StatusCode(),
"status endpoint should NOT be registered when monitoring is disabled")
}
func TestMonitoringEndpoints_ReachableWhenEnabled(t *testing.T) {
cfg := &config.Config{
Monitoring: config.MonitoringConfig{
Status: config.StatusConfig{
Enabled: true,
Path: "/_status",
Allow: []string{"127.0.0.1"},
},
},
Servers: []config.ServerConfig{{
Listen: "127.0.0.1:0",
Static: []config.StaticConfig{{
Path: "/",
Root: t.TempDir(),
}},
}},
}
srv := New(cfg)
go srv.Start()
defer srv.StopWithTimeout(5 * time.Second)
var addr string
for start := time.Now(); time.Since(start) < 2*time.Second; time.Sleep(10 * time.Millisecond) {
listeners := srv.GetListeners()
if len(listeners) > 0 {
addr = listeners[0].Addr().String()
break
}
}
if addr == "" {
t.Fatal("server has no listeners")
}
client := &fasthttp.Client{}
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI("http://" + addr + "/_status")
req.Header.SetMethod("GET")
if err := client.Do(req, resp); err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, fasthttp.StatusOK, resp.StatusCode(),
"status endpoint should be reachable when enabled, even with static handler on /")
}
func TestLocationEngine_StatusExactBeatsStaticPrefix(t *testing.T) {
// 独立验证 location engine 的优先级exact match 应该 beat prefix /
engine := matcher.NewLocationEngine()
engine.AddExact("/_status", func(ctx *fasthttp.RequestCtx) {
ctx.SetStatusCode(fasthttp.StatusOK)
}, false)
engine.AddPrefix("/", func(ctx *fasthttp.RequestCtx) {
ctx.SetStatusCode(fasthttp.StatusNotFound)
}, false)
engine.MarkInitialized()
result := engine.Match([]byte("/_status"))
if result == nil {
t.Fatal("expected match")
}
if result.LocationType != matcher.LocationTypeExact {
t.Errorf("expected exact match, got %s", result.LocationType)
}
}

View File

@ -476,7 +476,7 @@ func (s *Server) startSingleMode() error {
s.locationEngine = matcher.NewLocationEngine() s.locationEngine = matcher.NewLocationEngine()
// 注册状态监控端点(如果配置) // 注册状态监控端点(如果配置)
if s.config.Monitoring.Status.Path != "" || len(s.config.Monitoring.Status.Allow) > 0 { if s.config.Monitoring.Status.Enabled {
statusHandler, err := NewStatusHandler(s, &s.config.Monitoring.Status) statusHandler, err := NewStatusHandler(s, &s.config.Monitoring.Status)
if err != nil { if err != nil {
logging.Error().Msg("Failed to create status handler: " + err.Error()) logging.Error().Msg("Failed to create status handler: " + err.Error())

View File

@ -14,13 +14,13 @@ package server
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net" "net"
"strings" "strings"
"time" "time"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"rua.plus/lolly/internal/config" "rua.plus/lolly/internal/config"
"rua.plus/lolly/internal/logging"
"rua.plus/lolly/internal/utils" "rua.plus/lolly/internal/utils"
"rua.plus/lolly/internal/version" "rua.plus/lolly/internal/version"
) )
@ -299,7 +299,7 @@ func (h *StatusHandler) servePrometheus(ctx *fasthttp.RequestCtx, status *Status
} }
if _, err := ctx.WriteString(buf.String()); err != nil { if _, err := ctx.WriteString(buf.String()); err != nil {
log.Printf("failed to write metrics response: %v", err) logging.Error().Err(err).Msg("failed to write metrics response")
} }
} }
@ -315,7 +315,7 @@ func (h *StatusHandler) serveJSON(ctx *fasthttp.RequestCtx, status *Status) {
} }
if _, err := ctx.Write(data); err != nil { if _, err := ctx.Write(data); err != nil {
log.Printf("failed to write status response: %v", err) logging.Error().Err(err).Msg("failed to write status response")
} }
} }
@ -372,7 +372,7 @@ func (h *StatusHandler) serveText(ctx *fasthttp.RequestCtx, status *Status) {
} }
if _, err := ctx.WriteString(buf.String()); err != nil { if _, err := ctx.WriteString(buf.String()); err != nil {
log.Printf("failed to write text response: %v", err) logging.Error().Err(err).Msg("failed to write text response")
} }
} }
@ -477,7 +477,7 @@ func (h *StatusHandler) serveHTML(ctx *fasthttp.RequestCtx, status *Status) {
buf.WriteString("</html>\n") buf.WriteString("</html>\n")
if _, err := ctx.WriteString(buf.String()); err != nil { if _, err := ctx.WriteString(buf.String()); err != nil {
log.Printf("failed to write html response: %v", err) logging.Error().Err(err).Msg("failed to write html response")
} }
} }