mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
Rule text survives a suspend unchanged, so the watchdog reports the pf anchor intact while nothing translates, and the probe monitor is only armed by interface changes with a schedule frozen through the sleep. Detect the resume from the wall-clock gap and probe from there, with a bounded repair: at most two forced reloads across the retry schedule. Refresh the OS resolver before every probe - the probe aims at the first OS nameserver and passes when it has none, so a pre-sleep list makes it meaningless. Stand down for stabilization, which verifies on completion.
209 lines
6.9 KiB
Go
209 lines
6.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeWallClock returns the queued offsets from a fixed base, then repeats the last one.
|
|
type fakeWallClock struct {
|
|
nanos []int64
|
|
calls int
|
|
}
|
|
|
|
func (c *fakeWallClock) now() int64 {
|
|
if c.calls < len(c.nanos) {
|
|
n := c.nanos[c.calls]
|
|
c.calls++
|
|
return n
|
|
}
|
|
return c.nanos[len(c.nanos)-1]
|
|
}
|
|
|
|
// wallClockAt builds a clock whose readings are the given offsets from an arbitrary
|
|
// epoch. The first reading is consumed by newSuspendDetector; the rest are ticks.
|
|
func wallClockAt(offsets ...time.Duration) *fakeWallClock {
|
|
const base = int64(1787081656_000000000) // 2026-08-18T14:34:16-05:00
|
|
nanos := make([]int64, 0, len(offsets))
|
|
for _, off := range offsets {
|
|
nanos = append(nanos, base+int64(off))
|
|
}
|
|
return &fakeWallClock{nanos: nanos}
|
|
}
|
|
|
|
func TestSuspendDetectorIgnoresOrdinaryTicks(t *testing.T) {
|
|
// Construction, then three ticks two seconds apart, one stretched to 3s by a
|
|
// loaded host. None of these is a suspend.
|
|
clock := wallClockAt(0, 2*time.Second, 4*time.Second, 7*time.Second)
|
|
d := newSuspendDetector(suspendGapThreshold, clock.now)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
if gap, resumed := d.observe(); resumed {
|
|
t.Errorf("tick %d reported a suspend of %s; ordinary jitter must not count", i, gap)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSuspendDetectorReportsTheGapOnce replays the captured incident: the last pre-sleep
|
|
// sample at 14:34:47 and the first post-resume one at 14:35:05.
|
|
func TestSuspendDetectorReportsTheGapOnce(t *testing.T) {
|
|
clock := wallClockAt(
|
|
31*time.Second, // construction, 14:34:47
|
|
33*time.Second, // ordinary tick
|
|
49*time.Second, // 14:35:05 - a 16s gap over a 2s interval
|
|
51*time.Second, // ordinary again
|
|
)
|
|
d := newSuspendDetector(suspendGapThreshold, clock.now)
|
|
|
|
if _, resumed := d.observe(); resumed {
|
|
t.Fatal("the tick before the suspend reported a resume")
|
|
}
|
|
gap, resumed := d.observe()
|
|
if !resumed {
|
|
t.Fatal("a 16s gap over a 2s interval was not reported as a suspend")
|
|
}
|
|
if gap != 16*time.Second {
|
|
t.Errorf("gap = %s, want 16s: the reported gap must be the measured wall clock", gap)
|
|
}
|
|
if _, resumed := d.observe(); resumed {
|
|
t.Error("the resume was reported twice; the baseline must advance to the late tick")
|
|
}
|
|
}
|
|
|
|
// TestSuspendDetectorThresholdCoversShortNaps guards the lower bound. A nap only a few
|
|
// seconds longer than the interval still breaks pf translation, so the threshold must not
|
|
// drift up to a value that only catches long sleeps.
|
|
func TestSuspendDetectorThresholdCoversShortNaps(t *testing.T) {
|
|
if suspendGapThreshold > 10*time.Second {
|
|
t.Errorf("suspendGapThreshold = %s; a short nap would go unnoticed", suspendGapThreshold)
|
|
}
|
|
if suspendGapThreshold <= 2*suspendProbeInterval {
|
|
t.Errorf("suspendGapThreshold = %s is too close to the %s interval; ordinary jitter would fire it",
|
|
suspendGapThreshold, suspendProbeInterval)
|
|
}
|
|
|
|
clock := wallClockAt(0, suspendGapThreshold)
|
|
d := newSuspendDetector(suspendGapThreshold, clock.now)
|
|
if _, resumed := d.observe(); !resumed {
|
|
t.Error("a gap exactly at the threshold was not reported")
|
|
}
|
|
}
|
|
|
|
func TestSuspendDetectorIgnoresBackwardClockSteps(t *testing.T) {
|
|
// An NTP step backwards must not read as a resume, and must not leave the baseline
|
|
// in the future either: the next ordinary tick still has to look ordinary.
|
|
clock := wallClockAt(0, -30*time.Second, -28*time.Second)
|
|
d := newSuspendDetector(suspendGapThreshold, clock.now)
|
|
|
|
if gap, resumed := d.observe(); resumed {
|
|
t.Errorf("a backward clock step reported a suspend of %s", gap)
|
|
}
|
|
if gap, resumed := d.observe(); resumed {
|
|
t.Errorf("the tick after a backward step reported a suspend of %s", gap)
|
|
}
|
|
}
|
|
|
|
func TestWatchForResumeCallsBackPerGap(t *testing.T) {
|
|
clock := wallClockAt(
|
|
0,
|
|
2*time.Second, // ordinary
|
|
40*time.Second, // resume: 38s
|
|
42*time.Second, // ordinary
|
|
99*time.Second, // second resume: 57s
|
|
)
|
|
d := newSuspendDetector(suspendGapThreshold, clock.now)
|
|
|
|
tick := make(chan time.Time, 4)
|
|
for i := 0; i < 4; i++ {
|
|
tick <- time.Time{}
|
|
}
|
|
close(tick)
|
|
|
|
var gaps []time.Duration
|
|
watchForResume(nil, tick, d, func(gap time.Duration) { gaps = append(gaps, gap) })
|
|
|
|
if len(gaps) != 2 {
|
|
t.Fatalf("onResume called %d times (%v), want 2", len(gaps), gaps)
|
|
}
|
|
if gaps[0] != 38*time.Second || gaps[1] != 57*time.Second {
|
|
t.Errorf("gaps = %v, want [38s 57s]", gaps)
|
|
}
|
|
}
|
|
|
|
// advancingClock is a wall clock the test moves by hand, so a callback's runtime can be
|
|
// simulated without sleeping for it.
|
|
type advancingClock struct{ nano int64 }
|
|
|
|
func (c *advancingClock) now() int64 { return c.nano }
|
|
func (c *advancingClock) advance(d time.Duration) { c.nano += int64(d) }
|
|
|
|
// TestWatchForResumeDoesNotRetriggerOnItsOwnCallback pins the loop this closes. onResume
|
|
// runs on the watcher goroutine, and the real callback - verifyInterceptAfterWake - takes
|
|
// longer than the gap threshold by design: its configured waits alone total more than the
|
|
// threshold, before any probe timeout or forced reload. The ticker buffers a tick while it
|
|
// works, so a detector that keeps its pre-callback baseline measures the callback itself as
|
|
// the next gap and starts another recovery, with another repair budget, indefinitely under
|
|
// a persistent pf failure.
|
|
func TestWatchForResumeDoesNotRetriggerOnItsOwnCallback(t *testing.T) {
|
|
clock := &advancingClock{}
|
|
d := newSuspendDetector(suspendGapThreshold, clock.now)
|
|
|
|
// Capacity 1, like time.Ticker's channel: exactly one tick can queue up behind a
|
|
// callback in production.
|
|
tick := make(chan time.Time, 1)
|
|
calls := 0
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
watchForResume(nil, tick, d, func(time.Duration) {
|
|
calls++
|
|
if calls > 1 {
|
|
// A retrigger - the failure this test reports. There is nothing left to
|
|
// simulate, and tick is already closed.
|
|
return
|
|
}
|
|
// The recovery takes longer than the threshold, and a tick lands while it
|
|
// runs. Closing here ends the watcher after it drains that queued tick,
|
|
// which is the observation under test.
|
|
clock.advance(2 * suspendGapThreshold)
|
|
tick <- time.Time{}
|
|
close(tick)
|
|
})
|
|
}()
|
|
|
|
clock.advance(2 * suspendGapThreshold) // the genuine suspend
|
|
tick <- time.Time{}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("watchForResume did not finish; the queued tick was never consumed")
|
|
}
|
|
|
|
if calls != 1 {
|
|
t.Errorf("onResume called %d times, want 1: the callback's own runtime was measured as a second suspend", calls)
|
|
}
|
|
}
|
|
|
|
func TestWatchForResumeStopsOnStopCh(t *testing.T) {
|
|
stopCh := make(chan struct{})
|
|
tick := make(chan time.Time) // never fires
|
|
clock := wallClockAt(0, time.Hour)
|
|
d := newSuspendDetector(suspendGapThreshold, clock.now)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
watchForResume(stopCh, tick, d, func(time.Duration) {
|
|
t.Error("onResume ran without a tick")
|
|
})
|
|
close(done)
|
|
}()
|
|
|
|
close(stopCh)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("watchForResume did not return once stopCh was closed")
|
|
}
|
|
}
|