diff --git a/cmd/xui-agent/main.go b/cmd/xui-agent/main.go index 88359e3..13edbe6 100644 --- a/cmd/xui-agent/main.go +++ b/cmd/xui-agent/main.go @@ -3,7 +3,7 @@ // panel binary under the "agent" subcommand, shipping the whole web // panel and the embedded frontend to every node. This entry links only // the agent's own dependency closure (internal/agent plus config, -// logger, selfupdate, xray-api types and two util leaves), so the node +// logger, selfupdate, xray-api types and a few util leaves), so the node // artifact is a fraction of the panel binary while remaining a drop-in // replacement for it: // @@ -25,6 +25,8 @@ import ( "flag" "fmt" "log" + "net/http" + _ "net/http/pprof" "os" "os/signal" "path/filepath" @@ -33,6 +35,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/agent" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/sys" "github.com/op/go-logging" ) @@ -96,6 +99,23 @@ func runAgent(args []string) { logger.InitLogger(logging.INFO) log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName) + // Agent memory profile (ITEM-P3 A1/M5): the same runtime tuning + // family as the panel, with the agent's tighter budget, plus the + // periodic release the panel runs through its job scheduler (the + // agent has no scheduler), plus the panel's XUI_PPROF gate. + for _, line := range sys.ApplyAgentMemoryTuning() { + log.Println(line) + } + sys.StartPeriodicMemoryRelease() + if os.Getenv("XUI_PPROF") == "true" { + go func() { + log.Println("pprof profiling server listening on 127.0.0.1:6060") + if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil { + log.Printf("pprof server stopped: %v", err) + } + }() + } + ag := agent.New(cfg) // Handle shutdown signals diff --git a/internal/util/sys/memlimit.go b/internal/util/sys/memlimit.go index ca68363..ee7c8a4 100644 --- a/internal/util/sys/memlimit.go +++ b/internal/util/sys/memlimit.go @@ -6,12 +6,24 @@ import ( "runtime/debug" "strconv" "strings" + "time" ) const ( memLimitHeadroomPercent = 90 defaultGCPercent = 75 defaultReleaseMinutes = 10 + + // Agent tuning profile (ITEM-P3 A1). The baseline measurement put + // the agent's live heap at ~7-8 MiB of anonymous memory, yet + // production agents idled at 38-40 MB RSS: with Go defaults + // (GOGC=100, no soft limit, no periodic release) the runtime + // holds high-water arenas indefinitely. The agent profile pairs + // the panel's GOGC env value (50) with an explicit 24 MiB soft + // budget — roughly 3x the live heap, enough headroom for stats + // bursts, far below the untuned high-water mark. + agentGCPercent = 50 + agentSoftLimitMB = 24 ) // ApplyMemoryTuning configures the Go runtime for a lower, steadier footprint and @@ -32,11 +44,15 @@ func ApplyMemoryTuning() []string { // applyGCPercent lowers GOGC so the heap high-water mark, and thus RSS, stays // smaller. An explicit GOGC env (including GOGC=off) is left to the runtime. func applyGCPercent() string { + return applyGCPercentWithDefault(defaultGCPercent) +} + +func applyGCPercentWithDefault(def int) string { if _, ok := os.LookupEnv("GOGC"); ok { return "GC percent: GOGC env (handled by the Go runtime)" } - pct := defaultGCPercent + pct := def if v := strings.TrimSpace(os.Getenv("XUI_GOGC")); v != "" { if n, err := strconv.Atoi(v); err == nil { pct = n @@ -50,6 +66,65 @@ func applyGCPercent() string { return fmt.Sprintf("GC percent set to %d", pct) } +// ApplyAgentMemoryTuning is ApplyMemoryTuning with the node agent's +// budget profile (ITEM-P3 A1), applied by both agent entries (the +// standalone cmd/xui-agent and the dual-mode binary's agent branch). +// It differs from the panel profile in two ways: the default GC +// percent is 50 instead of 75, and the soft limit falls back to an +// explicit agent budget (agentSoftLimitMB) instead of "no budget". +// The cgroup fallback is deliberately not used for the agent: fleet +// agents run on whole VMs or containers whose cgroup limit describes +// the box, not an agent budget, and would defeat the tighter default. +// Explicit env budgets (GOMEMLIMIT, XUI_MEMORY_LIMIT) still win. +func ApplyAgentMemoryTuning() []string { + lines := []string{applyGCPercentWithDefault(agentGCPercent)} + if limit, source := applyAgentMemoryLimit(); limit > 0 { + lines = append(lines, fmt.Sprintf("Go memory soft limit set to %d MiB (%s)", limit>>20, source)) + } else { + lines = append(lines, "Go memory soft limit not enforced: "+source) + } + return lines +} + +func applyAgentMemoryLimit() (int64, string) { + if strings.TrimSpace(os.Getenv("GOMEMLIMIT")) != "" { + return 0, "GOMEMLIMIT env (handled by the Go runtime)" + } + + if v := strings.TrimSpace(os.Getenv("XUI_MEMORY_LIMIT")); v != "" { + if mb, err := strconv.ParseInt(v, 10, 64); err == nil && mb > 0 { + limit := mb << 20 + debug.SetMemoryLimit(limit) + return limit, "XUI_MEMORY_LIMIT=" + v + "MiB" + } + } + + limit := int64(agentSoftLimitMB) << 20 + debug.SetMemoryLimit(limit) + return limit, "agent default budget" +} + +// StartPeriodicMemoryRelease launches the goroutine that returns +// freed heap pages to the OS on the shared release cadence +// (MemoryReleaseIntervalMinutes; 0 disables, and no goroutine is +// started). The panel runs the same cadence through its job +// scheduler; agent processes have no scheduler, so their entries +// start this loop directly. It never stops — the loop's lifetime is +// the process's. +func StartPeriodicMemoryRelease() { + mins := MemoryReleaseIntervalMinutes() + if mins <= 0 { + return + } + go func() { + ticker := time.NewTicker(time.Duration(mins) * time.Minute) + defer ticker.Stop() + for range ticker.C { + debug.FreeOSMemory() + } + }() +} + // applyMemoryLimit sets the soft limit only from an explicit budget: GOMEMLIMIT // env (left to the runtime), XUI_MEMORY_LIMIT in MiB, or a real cgroup limit at // 90% to leave headroom for non-heap and the xray child. No budget -> Go default. diff --git a/internal/util/sys/memlimit_agent_test.go b/internal/util/sys/memlimit_agent_test.go new file mode 100644 index 0000000..08e59b2 --- /dev/null +++ b/internal/util/sys/memlimit_agent_test.go @@ -0,0 +1,105 @@ +package sys + +import ( + "os" + "runtime/debug" + "strings" + "testing" +) + +// lookupGOGC reports whether a GOGC env is present at all: presence +// (even empty handling aside) makes the tuning defer to the runtime, +// which the default-profile tests cannot exercise. +func lookupGOGC() (string, bool) { + return os.LookupEnv("GOGC") +} + +// The agent tuning profile (ITEM-P3 A1): GOGC default 50 and a +// 24 MiB default soft budget, with explicit env budgets still +// winning. The runtime knobs are process-global, so each test +// restores whatever GC percent / memory limit it found. + +func preserveRuntimeKnobs(t *testing.T) { + t.Helper() + prevGC := debug.SetGCPercent(-1) + prevLimit := debug.SetMemoryLimit(-1) + t.Cleanup(func() { + debug.SetGCPercent(prevGC) + debug.SetMemoryLimit(prevLimit) + }) +} + +func unsetenv(t *testing.T, keys ...string) { + t.Helper() + for _, k := range keys { + t.Setenv(k, "") + // t.Setenv cannot unset; an empty value is what the tuning + // code treats as absent (it trims before checking), except + // for GOGC where LookupEnv counts presence — see the GOGC + // case below, which is exercised only via XUI_GOGC instead. + } +} + +func TestApplyAgentMemoryTuningDefaults(t *testing.T) { + preserveRuntimeKnobs(t) + unsetenv(t, "GOMEMLIMIT", "XUI_MEMORY_LIMIT", "XUI_GOGC") + if _, present := lookupGOGC(); present { + t.Skip("GOGC is set in the test environment") + } + + lines := ApplyAgentMemoryTuning() + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "GC percent set to 50") { + t.Fatalf("lines = %v, want GC percent 50", lines) + } + if !strings.Contains(joined, "Go memory soft limit set to 24 MiB (agent default budget)") { + t.Fatalf("lines = %v, want 24 MiB agent default budget", lines) + } + if got := debug.SetMemoryLimit(-1); got != int64(24)<<20 { + t.Fatalf("memory limit = %d, want %d", got, int64(24)<<20) + } + if got := debug.SetGCPercent(-1); got != 50 { + t.Fatalf("GC percent = %d, want 50", got) + } +} + +func TestApplyAgentMemoryTuningExplicitLimitWins(t *testing.T) { + preserveRuntimeKnobs(t) + unsetenv(t, "GOMEMLIMIT") + t.Setenv("XUI_MEMORY_LIMIT", "40") + + lines := ApplyAgentMemoryTuning() + if !strings.Contains(strings.Join(lines, "\n"), "Go memory soft limit set to 40 MiB (XUI_MEMORY_LIMIT=40MiB)") { + t.Fatalf("lines = %v, want XUI_MEMORY_LIMIT budget", lines) + } + if got := debug.SetMemoryLimit(-1); got != int64(40)<<20 { + t.Fatalf("memory limit = %d, want %d", got, int64(40)<<20) + } +} + +func TestApplyAgentMemoryTuningGOMEMLIMITEnvWins(t *testing.T) { + preserveRuntimeKnobs(t) + before := debug.SetMemoryLimit(-1) + t.Setenv("GOMEMLIMIT", "32MiB") + + lines := ApplyAgentMemoryTuning() + if !strings.Contains(strings.Join(lines, "\n"), "Go memory soft limit not enforced: GOMEMLIMIT env") { + t.Fatalf("lines = %v, want GOMEMLIMIT left to the runtime", lines) + } + if got := debug.SetMemoryLimit(-1); got != before { + t.Fatalf("memory limit changed to %d, want untouched %d", got, before) + } +} + +func TestApplyAgentMemoryTuningXUIGOGCOverride(t *testing.T) { + preserveRuntimeKnobs(t) + if _, present := lookupGOGC(); present { + t.Skip("GOGC is set in the test environment") + } + t.Setenv("XUI_GOGC", "80") + + lines := ApplyAgentMemoryTuning() + if !strings.Contains(strings.Join(lines, "\n"), "GC percent set to 80") { + t.Fatalf("lines = %v, want XUI_GOGC override", lines) + } +} diff --git a/main.go b/main.go index d0d8fc6..6a36f09 100644 --- a/main.go +++ b/main.go @@ -95,6 +95,23 @@ func runAgent(args []string) { logger.InitLogger(logging.INFO) log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName) + // Agent memory profile (ITEM-P3 A1/M5): the same runtime tuning + // family as the panel, with the agent's tighter budget, plus the + // periodic release the panel runs through its job scheduler (the + // agent has no scheduler), plus the panel's XUI_PPROF gate. + for _, line := range sys.ApplyAgentMemoryTuning() { + log.Println(line) + } + sys.StartPeriodicMemoryRelease() + if os.Getenv("XUI_PPROF") == "true" { + go func() { + log.Println("pprof profiling server listening on 127.0.0.1:6060") + if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil { + log.Printf("pprof server stopped: %v", err) + } + }() + } + ag := agent.New(cfg) // Handle shutdown signals