package main import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" ) func feed(l *limitCtl, cfg limitsConfig, n int, cpu, gpu float64) { for i := 0; i < n; i++ { l.step(cfg, cpu, gpu) } } func TestLoadUnderTheLimitsHoldsNothing(t *testing.T) { l := &limitCtl{} feed(l, defaultLimits(), 10, 60, 50) if l.Holding || l.Throttle != 0 { t.Fatalf("%+v", l) } } func TestEitherLimitHoldsNewWorkAndSlowsEngines(t *testing.T) { for _, tc := range []struct { name string cpu, gpu float64 }{{"cpu", 95, 10}, {"gpu", 10, 85}} { l := &limitCtl{} feed(l, defaultLimits(), 5, tc.cpu, tc.gpu) if !l.Holding || l.Throttle <= 0 { t.Errorf("%s: %+v", tc.name, l) } } l := &limitCtl{} feed(l, defaultLimits(), 30, 100, 100) if l.Throttle > maxThrottle+1e-9 { t.Errorf("the engines must keep running a little: %v", l.Throttle) } } func TestOneSpikeDoesNotHoldWork(t *testing.T) { l := &limitCtl{} feed(l, defaultLimits(), 4, 40, 40) l.step(defaultLimits(), 100, 100) // one second of 100%: the average of three stays under both limits if l.Holding { t.Errorf("%+v", l) } } func TestWorkIsLetInOnlyWellBelowTheLimit(t *testing.T) { cfg := defaultLimits() l := &limitCtl{} feed(l, cfg, 6, 95, 20) if !l.Holding { t.Fatal("not held") } feed(l, cfg, 6, 88, 20) // under 90 but not 3 below it: still held (no flapping) if !l.Holding { t.Error("let in too early") } feed(l, cfg, 6, 70, 20) if l.Holding { t.Error("still held with the load well under the limit") } feed(l, cfg, 12, 70, 20) if l.Throttle != 0 { t.Errorf("the engines stay slowed: %v", l.Throttle) } } func TestDisabledLimitsNeverHold(t *testing.T) { cfg := defaultLimits() l := &limitCtl{} feed(l, cfg, 6, 100, 100) cfg.Enabled = false l.step(cfg, 100, 100) if l.Holding || l.Throttle != 0 { t.Errorf("%+v", l) } } func useLimitsFile(t *testing.T) { old := limitsFile f := filepath.Join(t.TempDir(), "LIMITS.json") limitsFile = func() string { return f } limits.Lock() limits.cfg, limits.ctl = defaultLimits(), limitCtl{} limits.Unlock() t.Cleanup(func() { limitsFile = old limits.Lock() limits.cfg, limits.ctl = defaultLimits(), limitCtl{} limits.Unlock() }) } func TestLimitsAreSavedValidatedAndDefaultToNinetyAndEighty(t *testing.T) { useLimitsFile(t) if c := loadLimits(); c != (limitsConfig{true, 90, 80}) { t.Fatalf("default: %+v", c) } if err := saveLimits(limitsConfig{true, 70, 60}); err != nil { t.Fatal(err) } if c := loadLimits(); c != (limitsConfig{true, 70, 60}) { t.Errorf("saved: %+v", c) } for _, bad := range []limitsConfig{{true, 10, 80}, {true, 90, 101}, {true, 0, 0}} { if saveLimits(bad) == nil { t.Errorf("%+v was accepted", bad) } } os.WriteFile(limitsFile(), []byte("{broken"), 0644) if c := loadLimits(); c != defaultLimits() { t.Errorf("a damaged file must fall back to the defaults: %+v", c) } } func TestWaitForLoad(t *testing.T) { useLimitsFile(t) if why := waitForLoad(context.Background(), time.Second); why != "" { t.Errorf("idle machine made work wait: %q", why) } limits.Lock() limits.ctl = limitCtl{Holding: true, CPUAvg: 97, GPUAvg: 40} limits.Unlock() start := time.Now() why := waitForLoad(context.Background(), 700*time.Millisecond) if !strings.Contains(why, "temperature limit") || !strings.Contains(why, "97°C") || time.Since(start) < 600*time.Millisecond { t.Errorf("a held request must wait, then say why: %q after %v", why, time.Since(start)) } go func() { time.Sleep(300 * time.Millisecond); limits.Lock(); limits.ctl.Holding = false; limits.Unlock() }() if why := waitForLoad(context.Background(), 5*time.Second); why != "" { t.Errorf("work must start once the load falls: %q", why) } ctx, cancel := context.WithCancel(context.Background()) limits.Lock() limits.ctl.Holding = true limits.Unlock() cancel() if why := waitForLoad(ctx, time.Minute); why == "" { t.Error("a cancelled wait must not admit the work") } } func TestLimitsRPC(t *testing.T) { useLimitsFile(t) run := func(method, params string) (map[string]any, string) { var out Msg r := &rpcCtx{m: Msg{ID: "1", Method: method, Params: json.RawMessage(params)}, send: func(m Msg) { out = m }} r.limitsRPC() var v map[string]any json.Unmarshal(out.Data, &v) return v, out.Err } v, err := run("limits.get", "") if err != "" || v["cpu_temp_max"] != float64(90) || v["gpu_temp_max"] != float64(80) || v["enabled"] != true { t.Fatalf("get: %v %q", v, err) } v, err = run("limits.set", `{"enabled":true,"cpu_temp_c":75,"gpu_temp_c":65}`) if err != "" || v["cpu_temp_max"] != float64(75) || v["gpu_temp_max"] != float64(65) { t.Fatalf("set: %v %q", v, err) } if c := loadLimits(); c.CPUMax != 75 || c.GPUMax != 65 { t.Errorf("not saved: %+v", c) } if _, err = run("limits.set", `{"enabled":true,"cpu_temp_c":5,"gpu_temp_c":65}`); err == "" { t.Error("a limit of 5 °C must be refused") } if _, err = run("limits.set", `nonsense`); err == "" { t.Error("bad input must be refused") } limits.Lock() limits.ctl = limitCtl{Holding: true, Throttle: 0.4} limits.Unlock() run("limits.set", `{"enabled":false,"cpu_temp_c":75,"gpu_temp_c":65}`) if s := limitsSnapshot(); s.Holding || s.ThrottlePct != 0 || s.Enabled { t.Errorf("switching the limits off must release at once: %+v", s) } } // The first version of the limits (percent of load, keys cpu_max / gpu_max) must not be read as temperatures. func TestAnOldPercentLimitsFileFallsBackToTheDefaults(t *testing.T) { useLimitsFile(t) os.WriteFile(limitsFile(), []byte(`{"enabled":true,"cpu_max":90,"gpu_max":80}`), 0644) if c := loadLimits(); c != defaultLimits() { t.Errorf("%+v", c) } }