From 1548c567abfdbfd833bf30313dbfa13173fde950 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 4 Feb 2026 02:39:26 +0800 Subject: [PATCH] feat(pprof): add support for configurable pprof HTTP debug server - Introduced a new `pprof` server to enable/debug HTTP profiling. - Added configuration options for enabling/disabling and specifying the server address. - Integrated pprof server lifecycle management with `Service`. #1287 --- config.example.yaml | 5 + internal/config/config.go | 23 +++- internal/watcher/diff/config_diff.go | 6 + sdk/cliproxy/pprof_server.go | 163 +++++++++++++++++++++++++++ sdk/cliproxy/service.go | 13 +++ 5 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 sdk/cliproxy/pprof_server.go diff --git a/config.example.yaml b/config.example.yaml index 76c9e15e..75e0030c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -40,6 +40,11 @@ api-keys: # Enable debug logging debug: false +# Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety. +pprof: + enable: false + addr: "127.0.0.1:8316" + # When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency. commercial-mode: false diff --git a/internal/config/config.go b/internal/config/config.go index 1352ffde..dcf6b1f7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,7 +18,10 @@ import ( "gopkg.in/yaml.v3" ) -const DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" +const ( + DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" + DefaultPprofAddr = "127.0.0.1:8316" +) // Config represents the application's configuration, loaded from a YAML file. type Config struct { @@ -41,6 +44,9 @@ type Config struct { // Debug enables or disables debug-level logging and other debug features. Debug bool `yaml:"debug" json:"debug"` + // Pprof config controls the optional pprof HTTP debug server. + Pprof PprofConfig `yaml:"pprof" json:"pprof"` + // CommercialMode disables high-overhead HTTP middleware features to minimize per-request memory usage. CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"` @@ -121,6 +127,14 @@ type TLSConfig struct { Key string `yaml:"key" json:"key"` } +// PprofConfig holds pprof HTTP server settings. +type PprofConfig struct { + // Enable toggles the pprof HTTP debug server. + Enable bool `yaml:"enable" json:"enable"` + // Addr is the host:port address for the pprof HTTP server. + Addr string `yaml:"addr" json:"addr"` +} + // RemoteManagement holds management API configuration under 'remote-management'. type RemoteManagement struct { // AllowRemote toggles remote (non-localhost) access to management API. @@ -514,6 +528,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.ErrorLogsMaxFiles = 10 cfg.UsageStatisticsEnabled = false cfg.DisableCooling = false + cfg.Pprof.Enable = false + cfg.Pprof.Addr = DefaultPprofAddr cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository if err = yaml.Unmarshal(data, &cfg); err != nil { @@ -556,6 +572,11 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository } + cfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr) + if cfg.Pprof.Addr == "" { + cfg.Pprof.Addr = DefaultPprofAddr + } + if cfg.LogsMaxTotalSizeMB < 0 { cfg.LogsMaxTotalSizeMB = 0 } diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 0ba287bf..98698ead 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -27,6 +27,12 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.Debug != newCfg.Debug { changes = append(changes, fmt.Sprintf("debug: %t -> %t", oldCfg.Debug, newCfg.Debug)) } + if oldCfg.Pprof.Enable != newCfg.Pprof.Enable { + changes = append(changes, fmt.Sprintf("pprof.enable: %t -> %t", oldCfg.Pprof.Enable, newCfg.Pprof.Enable)) + } + if strings.TrimSpace(oldCfg.Pprof.Addr) != strings.TrimSpace(newCfg.Pprof.Addr) { + changes = append(changes, fmt.Sprintf("pprof.addr: %s -> %s", strings.TrimSpace(oldCfg.Pprof.Addr), strings.TrimSpace(newCfg.Pprof.Addr))) + } if oldCfg.LoggingToFile != newCfg.LoggingToFile { changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile)) } diff --git a/sdk/cliproxy/pprof_server.go b/sdk/cliproxy/pprof_server.go new file mode 100644 index 00000000..3fafef4c --- /dev/null +++ b/sdk/cliproxy/pprof_server.go @@ -0,0 +1,163 @@ +package cliproxy + +import ( + "context" + "errors" + "net/http" + "net/http/pprof" + "strings" + "sync" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + log "github.com/sirupsen/logrus" +) + +type pprofServer struct { + mu sync.Mutex + server *http.Server + addr string + enabled bool +} + +func newPprofServer() *pprofServer { + return &pprofServer{} +} + +func (s *Service) applyPprofConfig(cfg *config.Config) { + if s == nil || cfg == nil { + return + } + if s.pprofServer == nil { + s.pprofServer = newPprofServer() + } + s.pprofServer.Apply(cfg) +} + +func (s *Service) shutdownPprof(ctx context.Context) error { + if s == nil || s.pprofServer == nil { + return nil + } + return s.pprofServer.Shutdown(ctx) +} + +func (p *pprofServer) Apply(cfg *config.Config) { + if p == nil || cfg == nil { + return + } + addr := strings.TrimSpace(cfg.Pprof.Addr) + if addr == "" { + addr = config.DefaultPprofAddr + } + enabled := cfg.Pprof.Enable + + p.mu.Lock() + currentServer := p.server + currentAddr := p.addr + p.addr = addr + p.enabled = enabled + if !enabled { + p.server = nil + p.mu.Unlock() + if currentServer != nil { + p.stopServer(currentServer, currentAddr, "disabled") + } + return + } + if currentServer != nil && currentAddr == addr { + p.mu.Unlock() + return + } + p.server = nil + p.mu.Unlock() + + if currentServer != nil { + p.stopServer(currentServer, currentAddr, "restarted") + } + + p.startServer(addr) +} + +func (p *pprofServer) Shutdown(ctx context.Context) error { + if p == nil { + return nil + } + p.mu.Lock() + currentServer := p.server + currentAddr := p.addr + p.server = nil + p.enabled = false + p.mu.Unlock() + + if currentServer == nil { + return nil + } + return p.stopServerWithContext(ctx, currentServer, currentAddr, "shutdown") +} + +func (p *pprofServer) startServer(addr string) { + mux := newPprofMux() + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + p.mu.Lock() + if !p.enabled || p.addr != addr || p.server != nil { + p.mu.Unlock() + return + } + p.server = server + p.mu.Unlock() + + log.Infof("pprof server starting on %s", addr) + go func() { + if errServe := server.ListenAndServe(); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) { + log.Errorf("pprof server failed on %s: %v", addr, errServe) + p.mu.Lock() + if p.server == server { + p.server = nil + } + p.mu.Unlock() + } + }() +} + +func (p *pprofServer) stopServer(server *http.Server, addr string, reason string) { + _ = p.stopServerWithContext(context.Background(), server, addr, reason) +} + +func (p *pprofServer) stopServerWithContext(ctx context.Context, server *http.Server, addr string, reason string) error { + if server == nil { + return nil + } + stopCtx := ctx + if stopCtx == nil { + stopCtx = context.Background() + } + stopCtx, cancel := context.WithTimeout(stopCtx, 5*time.Second) + defer cancel() + if errStop := server.Shutdown(stopCtx); errStop != nil { + log.Errorf("pprof server stop failed on %s: %v", addr, errStop) + return errStop + } + log.Infof("pprof server stopped on %s (%s)", addr, reason) + return nil +} + +func newPprofMux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + mux.Handle("/debug/pprof/allocs", pprof.Handler("allocs")) + mux.Handle("/debug/pprof/block", pprof.Handler("block")) + mux.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine")) + mux.Handle("/debug/pprof/heap", pprof.Handler("heap")) + mux.Handle("/debug/pprof/mutex", pprof.Handler("mutex")) + mux.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate")) + return mux +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 63eaf9eb..d08f5027 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -57,6 +57,9 @@ type Service struct { // server is the HTTP API server instance. server *api.Server + // pprofServer manages the optional pprof HTTP debug server. + pprofServer *pprofServer + // serverErr channel for server startup/shutdown errors. serverErr chan error @@ -501,6 +504,8 @@ func (s *Service) Run(ctx context.Context) error { time.Sleep(100 * time.Millisecond) fmt.Printf("API server started successfully on: %s:%d\n", s.cfg.Host, s.cfg.Port) + s.applyPprofConfig(s.cfg) + if s.hooks.OnAfterStart != nil { s.hooks.OnAfterStart(s) } @@ -546,6 +551,7 @@ func (s *Service) Run(ctx context.Context) error { } s.applyRetryConfig(newCfg) + s.applyPprofConfig(newCfg) if s.server != nil { s.server.UpdateClients(newCfg) } @@ -639,6 +645,13 @@ func (s *Service) Shutdown(ctx context.Context) error { s.authQueueStop = nil } + if errShutdownPprof := s.shutdownPprof(ctx); errShutdownPprof != nil { + log.Errorf("failed to stop pprof server: %v", errShutdownPprof) + if shutdownErr == nil { + shutdownErr = errShutdownPprof + } + } + // no legacy clients to persist if s.server != nil {