package sched import ( "context" "log/slog" "math/rand/v2" "strconv" "sync" "testing" "time" "internetpressure.io/probe-agent/internal/protocol" ) func TestJitterBounds(t *testing.T) { rng := rand.New(rand.NewPCG(1, 2)) d := 20 * time.Second var sum time.Duration for i := 0; i < 20000; i++ { j := Jitter(d, rng) if j < 18*time.Second || j > 22*time.Second { t.Fatalf("jitter out of ±10 %%: %v", j) } sum += j } mean := sum / 20000 if mean < 19900*time.Millisecond || mean > 20100*time.Millisecond { t.Fatalf("jitter mean drifted: %v", mean) } } func TestInitialOffsetSpread(t *testing.T) { rng := rand.New(rand.NewPCG(3, 4)) d := 60 * time.Second buckets := make([]int, 6) n := 60000 for i := 0; i < n; i++ { o := InitialOffset(d, rng) if o < 0 || o >= d { t.Fatalf("offset out of [0, d): %v", o) } buckets[int(o/(10*time.Second))]++ } for i, b := range buckets { if b < n/6*9/10 || b > n/6*11/10 { t.Fatalf("bucket %d not uniform: %d of %d", i, b, n) } } if InitialOffset(0, rng) != 0 { t.Fatal("zero interval must give zero offset") } } func cfgWith(n int, checks []string, tier int) *protocol.RemoteConfig { cfg := &protocol.RemoteConfig{ConfigVersion: "v1"} cfg.Probe.Enabled = true cfg.Schedule = protocol.Schedule{Tiers: map[string]int{"1": 20, "2": 45, "3": 180}, DNSEvery: 60, PingEvery: 30, TracerouteEvery: 900} for i := 0; i < n; i++ { id := "t" + strconv.Itoa(i) cfg.Targets = append(cfg.Targets, protocol.Target{TargetID: id, Hostname: id + ".example", URL: "https://" + id + ".example/", Port: 443, Tier: tier, Checks: checks, Traceroute: true}) } return cfg } func TestJobsForIntervalsClampAndCapabilities(t *testing.T) { cfg := cfgWith(1, []string{"http", "dns", "ping"}, 1) cfg.Schedule.Tiers["1"] = 3 // below the 10 s floor cfg.Schedule.PingEvery = 0 // default 30 s jobs := JobsFor(cfg, Options{CanPing: true, CanTraceroute: true, Logger: slog.Default()}) got := map[Family]time.Duration{} for _, j := range jobs { got[j.Family] = j.Interval } if len(jobs) != 4 { t.Fatalf("expected 4 jobs, got %d: %+v", len(jobs), jobs) } if got[FamilyHTTP] != MinInterval { t.Errorf("tier 3 s should be clamped to 10 s, got %v", got[FamilyHTTP]) } if got[FamilyDNS] != 60*time.Second || got[FamilyPing] != 30*time.Second || got[FamilyTraceroute] != 900*time.Second { t.Errorf("intervals: %v", got) } // No ICMP → ping becomes tcp; no traceroute binary → no traceroute job. jobs = JobsFor(cfg, Options{CanPing: false, CanTraceroute: false}) fams := map[Family]int{} for _, j := range jobs { fams[j.Family]++ } if fams[FamilyPing] != 0 || fams[FamilyTCP] != 1 || fams[FamilyTraceroute] != 0 || len(jobs) != 3 { t.Errorf("capability filter: %v", fams) } // Explicit "tcp" next to "ping" without ICMP must not duplicate. cfg = cfgWith(1, []string{"ping", "tcp"}, 2) jobs = JobsFor(cfg, Options{CanPing: false}) if len(jobs) != 1 || jobs[0].Family != FamilyTCP { t.Errorf("ping+tcp without icmp: %+v", jobs) } jobs = JobsFor(cfg, Options{CanPing: true}) if len(jobs) != 2 { t.Errorf("ping+tcp with icmp: %+v", jobs) } } func TestBoost(t *testing.T) { s := New(func(context.Context, Job) {}, Options{MinInterval: time.Second, Seed: 7}) job := Job{Target: protocol.Target{TargetID: "a"}, Interval: 100 * time.Second} now := time.Now() s.boost = &protocol.Boost{Targets: []string{"a"}, Factor: 0.5, Until: protocol.FormatTime(now.Add(time.Hour))} if d := s.nextDelay(job, now); d < 45*time.Second || d > 55*time.Second { t.Fatalf("boosted delay = %v", d) } s.boost.Until = protocol.FormatTime(now.Add(-time.Minute)) // expired if d := s.nextDelay(job, now); d < 90*time.Second || d > 110*time.Second { t.Fatalf("expired boost delay = %v", d) } s.boost = &protocol.Boost{Targets: []string{"b"}, Factor: 0.1, Until: protocol.FormatTime(now.Add(time.Hour))} if d := s.nextDelay(job, now); d < 90*time.Second { t.Fatalf("boost for another target applied: %v", d) } // Boost can never push below the floor. s.boost = &protocol.Boost{Targets: []string{"a"}, Factor: 0.001, Until: protocol.FormatTime(now.Add(time.Hour))} if d := s.nextDelay(job, now); d < 900*time.Millisecond { t.Fatalf("boost broke the floor: %v", d) } } // Live run with tiny intervals: every job runs repeatedly, never two checks on the same host at once, never // more than MaxConcurrency in flight, and a config change adds/removes jobs without restart. func TestRunConcurrencyAndLiveApply(t *testing.T) { var ( mu sync.Mutex perHost = map[string]int{} inflight int maxIn int runs = map[string]int{} ) runner := func(ctx context.Context, j Job) { mu.Lock() perHost[j.Target.Hostname]++ if perHost[j.Target.Hostname] > 1 { t.Errorf("two checks in flight for %s", j.Target.Hostname) } inflight++ if inflight > maxIn { maxIn = inflight } runs[j.Key]++ mu.Unlock() time.Sleep(15 * time.Millisecond) mu.Lock() perHost[j.Target.Hostname]-- inflight-- mu.Unlock() } // IntervalScale 0.001 → configured seconds become milliseconds: 40 ms floor, 60 ms intervals. s := New(runner, Options{MaxConcurrency: 3, MinInterval: 40 * time.Millisecond, CanPing: true, CanTraceroute: true, Seed: 11, IntervalScale: 0.001}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go s.Run(ctx) cfg := cfgWith(5, []string{"http", "dns", "ping"}, 1) cfg.Schedule.Tiers["1"], cfg.Schedule.DNSEvery, cfg.Schedule.PingEvery, cfg.Schedule.TracerouteEvery = 60, 60, 60, 60 s.Apply(cfg) time.Sleep(600 * time.Millisecond) if s.Targets() != 5 || s.Jobs() != 20 { t.Fatalf("targets=%d jobs=%d", s.Targets(), s.Jobs()) } mu.Lock() for _, tg := range cfg.Targets { for _, f := range []Family{FamilyHTTP, FamilyDNS, FamilyPing, FamilyTraceroute} { if runs[tg.TargetID+"/"+string(f)] < 2 { t.Errorf("job %s/%s ran %d times", tg.TargetID, f, runs[tg.TargetID+"/"+string(f)]) } } } if maxIn > 3 { t.Errorf("max in flight %d > 3", maxIn) } mu.Unlock() // Shrink to 2 targets with http only; the rest must stop running. cfg2 := cfgWith(2, []string{"http"}, 1) cfg2.Schedule.Tiers["1"] = 60 for i := range cfg2.Targets { cfg2.Targets[i].Traceroute = false } s.Apply(cfg2) time.Sleep(150 * time.Millisecond) mu.Lock() before := runs["t4/dns"] mu.Unlock() time.Sleep(200 * time.Millisecond) mu.Lock() after := runs["t4/dns"] mu.Unlock() if after != before { t.Errorf("removed job kept running: %d → %d", before, after) } if s.Targets() != 2 || s.Jobs() != 2 { t.Errorf("after shrink: targets=%d jobs=%d", s.Targets(), s.Jobs()) } // Disabled probe → idle. cfg2.Probe.Enabled = false s.Apply(cfg2) time.Sleep(100 * time.Millisecond) if s.Jobs() != 0 { t.Errorf("disabled probe should have no jobs, has %d", s.Jobs()) } if s.Panics() != 0 { t.Errorf("unexpected panics: %d", s.Panics()) } } func TestPanicRecovered(t *testing.T) { s := New(func(context.Context, Job) { panic("boom") }, Options{MinInterval: 30 * time.Millisecond, Seed: 5, Logger: slog.New(slog.DiscardHandler), IntervalScale: 0.001}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go s.Run(ctx) cfg := cfgWith(1, []string{"http"}, 1) cfg.Schedule.Tiers["1"] = 30 s.Apply(cfg) time.Sleep(200 * time.Millisecond) if s.Panics() < 2 { t.Fatalf("panics should be recovered and the job rescheduled, got %d", s.Panics()) } }