From e8fbbf368c7cef27631f28f9cc4df3916857c1aa Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 11 Jun 2026 15:08:57 +0800 Subject: [PATCH] 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: /. --- internal/config/config.go | 14 +- internal/config/load_test.go | 104 ++++++++++++++ internal/lua/api_timer.go | 10 +- .../server/monitoring_registration_test.go | 129 ++++++++++++++++++ internal/server/server.go | 2 +- internal/server/status.go | 10 +- 6 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 internal/config/load_test.go create mode 100644 internal/server/monitoring_registration_test.go diff --git a/internal/config/config.go b/internal/config/config.go index edd932e..2381b5f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -134,8 +134,12 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("读取配置文件失败: %w", err) } - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { + // 从默认值开始,YAML 只覆盖显式配置的字段。 + // 注意: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) } @@ -145,16 +149,16 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("获取配置文件绝对路径失败: %w", err) } 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) } } - if err := Validate(&cfg); err != nil { + if err := Validate(cfg); err != nil { return nil, fmt.Errorf("配置验证失败: %w", err) } - return &cfg, nil + return cfg, nil } const maxIncludeDepth = 10 diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..d07e24a --- /dev/null +++ b/internal/config/load_test.go @@ -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") + } +} diff --git a/internal/lua/api_timer.go b/internal/lua/api_timer.go index 85cdfdf..9f9e923 100644 --- a/internal/lua/api_timer.go +++ b/internal/lua/api_timer.go @@ -21,12 +21,12 @@ package lua import ( "fmt" - "log" "sync" "sync/atomic" "time" glua "github.com/yuin/gopher-lua" + "rua.plus/lolly/internal/logging" ) // CallbackEntry 回调队列条目,封装定时器触发的 Lua 回调。 @@ -261,7 +261,7 @@ func (m *TimerManager) executeTimer(entry *TimerEntry) { m.queueMu.Unlock() default: 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) 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 } @@ -286,7 +286,7 @@ func (m *TimerManager) schedulerLoop() { Fn: fn, NRet: 0, }, 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): abandoned := len(m.callbackQueue) if abandoned > 0 { - log.Printf("[lua] shutdown timeout: %d callbacks abandoned", abandoned) + logging.Warn().Int("abandoned", abandoned).Msg("[lua] shutdown timeout: callbacks abandoned") } } } diff --git a/internal/server/monitoring_registration_test.go b/internal/server/monitoring_registration_test.go new file mode 100644 index 0000000..9c0075f --- /dev/null +++ b/internal/server/monitoring_registration_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 46d1c13..34ebd9f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -476,7 +476,7 @@ func (s *Server) startSingleMode() error { 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) if err != nil { logging.Error().Msg("Failed to create status handler: " + err.Error()) diff --git a/internal/server/status.go b/internal/server/status.go index 8e3aa37..69a37ea 100644 --- a/internal/server/status.go +++ b/internal/server/status.go @@ -14,13 +14,13 @@ package server import ( "encoding/json" "fmt" - "log" "net" "strings" "time" "github.com/valyala/fasthttp" "rua.plus/lolly/internal/config" + "rua.plus/lolly/internal/logging" "rua.plus/lolly/internal/utils" "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 { - 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 { - 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 { - 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("\n") 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") } }