v0.295.0: a box that was off at its backup time catches up once (R-871, decision 109); the missed-backup banner (decision 110); a late daily timer after a host suspend is skipped
gates / gates (push) Successful in 31s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 09:27:16 +02:00
parent e730a629fd
commit 635c33d381
24 changed files with 2074 additions and 18 deletions
@@ -4,6 +4,7 @@ import (
"context"
"io"
"log"
"sync"
"testing"
"time"
)
@@ -79,3 +80,60 @@ func TestUpdateDaily_GoroutineConsumesReschedule(t *testing.T) {
// drained by the goroutine → immediacy works
}
}
// R-871 (v0.295.0): a daily timer that fires long after its wall-clock time (a host suspend: the timer's clock stops)
// SKIPS the job; one that fires on time runs it.
// COMPANION RED-PROOF: drop the `late > DailyLateLimit` branch → "a 6-hour-late fire ran the job".
func TestDaily_LateFireIsSkipped(t *testing.T) {
loc := getBudapestLocation()
for _, tc := range []struct {
name string
firedAt time.Time
wantRun bool
}{
{"on time", time.Date(2026, 10, 5, 2, 30, 1, 0, loc), true},
{"after a suspend", time.Date(2026, 10, 5, 8, 31, 0, 0, loc), false},
} {
t.Run(tc.name, func(t *testing.T) {
s := discardScheduler()
var mu sync.Mutex
now := time.Date(2026, 10, 5, 1, 0, 0, 0, loc)
s.nowFn = func() time.Time { mu.Lock(); defer mu.Unlock(); return now }
fired := make(chan time.Time, 1)
armed := make(chan struct{}, 1)
calls := 0
s.afterFn = func(time.Duration) <-chan time.Time {
mu.Lock()
calls++
first := calls == 1
mu.Unlock()
if !first {
return make(chan time.Time) // never: park after the first fire
}
armed <- struct{}{} // the next run (02:30) is computed against 01:00
return fired
}
ran := make(chan struct{}, 1)
s.Daily("offbox-backup", "02:30", func(context.Context) error { ran <- struct{}{}; return nil })
ctx, cancel := context.WithCancel(context.Background())
s.Start(ctx)
<-armed
mu.Lock()
now = tc.firedAt
mu.Unlock()
fired <- tc.firedAt
select {
case <-ran:
if !tc.wantRun {
t.Fatal("a 6-hour-late fire ran the job (the app-update leg would start at noon)")
}
case <-time.After(300 * time.Millisecond):
if tc.wantRun {
t.Fatal("an on-time fire did not run the job")
}
}
cancel()
s.Stop()
})
}
}