From 121123eaaab5bbca3ab4c74604b702087054958a Mon Sep 17 00:00:00 2001 From: PluS98 <104105009+Plus98ir@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:34:22 +0300 Subject: [PATCH] fix: keep panel restarts from hanging on the CPU job and Telegram (#7029) Restarting the panel left it unreachable for up to a minute. CheckCpuJob sampled cpu.Percent over its whole one-minute period, so cron.Stop nearly always waited on it; it now diffs cpu.Times against its previous run (iowait counted as idle, as before). With Telegram unreachable, bus.Stop waited on an in-flight send and tgbot.Start ran getMe/setMyCommands inline; each bot call now returns once StopBot closes the bot's stop channel, the bot stops before the bus, and the artwork/commands warm-up runs in the background. --- internal/util/sys/cpu_times.go | 36 ++++++ internal/web/job/check_cpu_usage.go | 29 +++-- internal/web/job/check_cpu_usage_test.go | 104 ++++++++++++++++++ internal/web/service/server.go | 28 +---- .../web/service/tgbot/stoppable_caller.go | 43 ++++++++ .../service/tgbot/stoppable_caller_test.go | 68 ++++++++++++ internal/web/service/tgbot/tgbot.go | 31 +++++- internal/web/web.go | 7 +- 8 files changed, 304 insertions(+), 42 deletions(-) create mode 100644 internal/util/sys/cpu_times.go create mode 100644 internal/web/job/check_cpu_usage_test.go create mode 100644 internal/web/service/tgbot/stoppable_caller.go create mode 100644 internal/web/service/tgbot/stoppable_caller_test.go diff --git a/internal/util/sys/cpu_times.go b/internal/util/sys/cpu_times.go new file mode 100644 index 000000000..18194397a --- /dev/null +++ b/internal/util/sys/cpu_times.go @@ -0,0 +1,36 @@ +package sys + +import "github.com/shirou/gopsutil/v4/cpu" + +// CPUBusyPercent is the busy share of CPU time between two cumulative samples, +// counting iowait as busy; ok is false when no time elapsed or the counters reset. +func CPUBusyPercent(prev, cur cpu.TimesStat) (percent float64, ok bool) { + return cpuPercent(prev, cur, true) +} + +// CPUWorkPercent is CPUBusyPercent with iowait counted as idle, as gopsutil's +// cpu.Percent does, so a host waiting on disk is not reported as CPU-bound. +func CPUWorkPercent(prev, cur cpu.TimesStat) (percent float64, ok bool) { + return cpuPercent(prev, cur, false) +} + +func cpuPercent(prev, cur cpu.TimesStat, iowaitIsBusy bool) (float64, bool) { + // Guest and GuestNice are already counted inside User and Nice on Linux. + idle := cur.Idle - prev.Idle + busy := (cur.User - prev.User) + + (cur.System - prev.System) + + (cur.Nice - prev.Nice) + + (cur.Irq - prev.Irq) + + (cur.Softirq - prev.Softirq) + + (cur.Steal - prev.Steal) + if iowait := cur.Iowait - prev.Iowait; iowaitIsBusy { + busy += iowait + } else { + idle += iowait + } + total := busy + idle + if total <= 0 { + return 0, false + } + return min(max(100*busy/total, 0), 100), true +} diff --git a/internal/web/job/check_cpu_usage.go b/internal/web/job/check_cpu_usage.go index 6e11838c9..f25a70293 100644 --- a/internal/web/job/check_cpu_usage.go +++ b/internal/web/job/check_cpu_usage.go @@ -1,25 +1,38 @@ package job import ( - "time" - "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + "github.com/mhsanaei/3x-ui/v3/internal/util/sys" "github.com/shirou/gopsutil/v4/cpu" ) // CheckCpuJob monitors CPU usage and publishes events when threshold is exceeded. -type CheckCpuJob struct{} +type CheckCpuJob struct { + readTimes func(percpu bool) ([]cpu.TimesStat, error) + last cpu.TimesStat + hasLast bool +} // NewCheckCpuJob creates a new CPU monitoring job instance. func NewCheckCpuJob() *CheckCpuJob { - return new(CheckCpuJob) + return &CheckCpuJob{readTimes: cpu.Times} } -// Run checks CPU usage and publishes a cpu.high event with raw metric data. +// Run publishes a cpu.high event with the CPU usage since the previous run. It +// must not sample in place: cron.Stop, and so every panel restart, waits on it. func (j *CheckCpuJob) Run() { - percent, err := cpu.Percent(1*time.Minute, false) - if err != nil || len(percent) == 0 { + times, err := j.readTimes(false) + if err != nil || len(times) == 0 { + return + } + prev, hadPrev := j.last, j.hasLast + j.last, j.hasLast = times[0], true + if !hadPrev { + return + } + percent, ok := sys.CPUWorkPercent(prev, times[0]) + if !ok { return } @@ -27,7 +40,7 @@ func (j *CheckCpuJob) Run() { EventBus.Publish(eventbus.Event{ Type: eventbus.EventCPUHigh, Data: &eventbus.SystemMetricData{ - Percent: percent[0], + Percent: percent, }, }) } diff --git a/internal/web/job/check_cpu_usage_test.go b/internal/web/job/check_cpu_usage_test.go new file mode 100644 index 000000000..de76dc6d5 --- /dev/null +++ b/internal/web/job/check_cpu_usage_test.go @@ -0,0 +1,104 @@ +package job + +import ( + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + + "github.com/shirou/gopsutil/v4/cpu" +) + +// The alarm must report usage over the minute between runs, not since boot: +// cumulative counters alone would hide a spike behind days of idle time. +func TestCheckCpuJobPublishesUsageSinceThePreviousRun(t *testing.T) { + samples := []cpu.TimesStat{ + {User: 1000, System: 500, Idle: 98500}, + {User: 1060, System: 515, Idle: 98525}, // 60+15 busy, 25 idle: 75% + } + j := &CheckCpuJob{readTimes: func(bool) ([]cpu.TimesStat, error) { + s := samples[0] + samples = samples[1:] + return []cpu.TimesStat{s}, nil + }} + + bus := eventbus.New(0) + t.Cleanup(bus.Stop) + got := make(chan float64, 2) + bus.Subscribe("test", func(e eventbus.Event) { + got <- e.Data.(*eventbus.SystemMetricData).Percent + }) + prevBus := EventBus + EventBus = bus + t.Cleanup(func() { EventBus = prevBus }) + + j.Run() + j.Run() + + select { + case p := <-got: + if p != 75 { + t.Fatalf("published %v%%, want 75%% (busy share between the two runs)", p) + } + case <-time.After(2 * time.Second): + t.Fatal("no cpu.high event published by the second run") + } + select { + case p := <-got: + t.Fatalf("first run has no baseline yet but published %v%%", p) + case <-time.After(100 * time.Millisecond): + } +} + +// gopsutil's cpu.Percent, which the alarm used before, counts iowait as idle; counting it busy +// would page operators every minute of a backup on a slow-disk host. +func TestCheckCpuJobDoesNotCountIowaitAsBusy(t *testing.T) { + samples := []cpu.TimesStat{ + {User: 1000, Iowait: 2000, Idle: 97000}, + {User: 1030, Iowait: 2060, Idle: 97010}, // 30 user, 60 iowait, 10 idle: 30% + } + j := &CheckCpuJob{readTimes: func(bool) ([]cpu.TimesStat, error) { + s := samples[0] + samples = samples[1:] + return []cpu.TimesStat{s}, nil + }} + + bus := eventbus.New(0) + t.Cleanup(bus.Stop) + got := make(chan float64, 2) + bus.Subscribe("test", func(e eventbus.Event) { + got <- e.Data.(*eventbus.SystemMetricData).Percent + }) + prevBus := EventBus + EventBus = bus + t.Cleanup(func() { EventBus = prevBus }) + + j.Run() + j.Run() + + select { + case p := <-got: + if p != 30 { + t.Fatalf("published %v%%, want 30%% (iowait is waiting, not CPU work)", p) + } + case <-time.After(2 * time.Second): + t.Fatal("no cpu.high event published by the second run") + } +} + +// A Run that samples for its whole period keeps cron.Stop waiting, and with it +// every panel restart, for up to a minute behind a closed listener. +func TestCheckCpuJobRunDoesNotBlockForTheSamplePeriod(t *testing.T) { + j := NewCheckCpuJob() + done := make(chan struct{}) + go func() { + j.Run() + j.Run() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("CheckCpuJob.Run still blocking after 5s; it must read CPU time since its previous run") + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index ee0930578..d598c8a59 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -880,36 +880,12 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) { return s.emaCPU, nil } - // Compute busy and total deltas - // Note: Guest and GuestNice times are already included in User and Nice respectively, - // so we exclude them to avoid double-counting (Linux kernel accounting) - idleDelta := cur.Idle - s.lastCPUTimes.Idle - busyDelta := (cur.User - s.lastCPUTimes.User) + - (cur.System - s.lastCPUTimes.System) + - (cur.Nice - s.lastCPUTimes.Nice) + - (cur.Iowait - s.lastCPUTimes.Iowait) + - (cur.Irq - s.lastCPUTimes.Irq) + - (cur.Softirq - s.lastCPUTimes.Softirq) + - (cur.Steal - s.lastCPUTimes.Steal) - - totalDelta := busyDelta + idleDelta - - // Update last sample for next time + raw, ok := sys.CPUBusyPercent(s.lastCPUTimes, cur) s.lastCPUTimes = cur - - // Guard against division by zero or negative deltas (e.g., counter resets) - if totalDelta <= 0 { + if !ok { return s.emaCPU, nil } - raw := 100.0 * (busyDelta / totalDelta) - if raw < 0 { - raw = 0 - } - if raw > 100 { - raw = 100 - } - // Exponential moving average to smooth spikes const alpha = 0.3 // smoothing factor (0