hub v0.163.0: a rebuilt box gets the household's sealed package back (R-1176); one run per daily job on clock-change days (R-1183); a scoped abandonment purge (R-1182); the new-link page says the old box is still linked (R-1203)
gates / gates (push) Successful in 13m23s

This commit is contained in:
admin committed 2026-10-21 13:36:45 +02:00
1 parent e07b077ac2
commit 9688e76efd
16 files changed
+1018 -29

No files matched your search

+22 -5
View File
@@ -1054,12 +1054,9 @@ func scheduleDaily(ctx context.Context, name, timeStr string, fn func(), logger
hour, min := parseHM(timeStr)
var last time.Time
for {
now := time.Now().In(budapest)
next := time.Date(now.Year(), now.Month(), now.Day(), hour, min, 0, 0, budapest)
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
next := nextDailyRun(time.Now(), last, hour, min, budapest)
delay := time.Until(next)
logger.Printf("[INFO] %s: next run at %s (in %s)", name, next.Format("2006-01-02 15:04 MST"), delay.Round(time.Second))
@@ -1067,11 +1064,31 @@ func scheduleDaily(ctx context.Context, name, timeStr string, fn func(), logger
case <-ctx.Done():
return
case <-time.After(delay):
last = next
fn()
}
}
}
// nextDailyRun returns the next HH:MM in loc that is after now and after the slot that ran last (the zero time
// when none has). R-1183: the day after is computed as a calendar day (time.Date with day+1), never as now+24h —
// on 2026-10-25 (25 hours) the old Add(24h) fired a 04:00 job at 03:00 CET and again at 04:00, and on 2027-03-28
// (23 hours) it fired at 05:00 CEST. The controller's scheduler made the same fix (scheduler.go, day+1).
// The `last` guard keeps a timer that wakes a moment early from running the same slot twice.
// Pinned by TestR1183_* in r1183_dst_schedule_test.go.
func nextDailyRun(now, last time.Time, hour, min int, loc *time.Location) time.Time {
if last.After(now) {
now = last
}
now = now.In(loc)
for d := 0; ; d++ {
next := time.Date(now.Year(), now.Month(), now.Day()+d, hour, min, 0, 0, loc)
if next.After(now) {
return next
}
}
}
// parseHM parses "HH:MM" into hour and minute. Returns 0, 0 on invalid input.
func parseHM(s string) (int, int) {
var h, m int
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"testing"
"time"
)
// R-1183: a daily hub job runs ONCE on a 25-hour day (2026-10-25, the clocks go back) and once on a 23-hour day
// (2027-03-28, the clocks go forward), each time at the wall-clock hour it was set for. The old loop added 24 h to
// an absolute time, so on 2026-10-25 a 04:00 job fired at 03:00 CET and again at 04:00 CET, and on 2027-03-28 it
// fired at 05:00 CEST — the replay is in audits/dataloss-hunt-2026-10-20/C3-T9-hub-dst/.
//
// The test walks the scheduler's own sequence (nextDailyRun after each run) across both days with a fake clock.
func walkRuns(t *testing.T, start time.Time, until time.Time, hour, min int, loc *time.Location) []time.Time {
t.Helper()
var runs []time.Time
now := start
var last time.Time
for i := 0; i < 20; i++ {
next := nextDailyRun(now, last, hour, min, loc)
if next.After(until) {
break
}
runs = append(runs, next)
last = next
now = next.Add(2 * time.Second) // the job takes a moment; the loop asks again
}
return runs
}
func TestR1183_DailyJobRunsOnceOnTheDayTheClocksGoBack(t *testing.T) {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
t.Skip("no tz database")
}
for _, hm := range [][2]int{{4, 0}, {5, 0}, {7, 10}, {2, 0}, {2, 30}} {
start := time.Date(2026, 10, 24, 12, 0, 0, 0, loc)
until := time.Date(2026, 10, 26, 23, 0, 0, 0, loc)
runs := walkRuns(t, start, until, hm[0], hm[1], loc)
perDay := map[string]int{}
for _, r := range runs {
l := r.In(loc)
perDay[l.Format("2006-01-02")]++
if l.Hour() != hm[0] || l.Minute() != hm[1] {
t.Errorf("%02d:%02d job ran at %s — not at its wall-clock time", hm[0], hm[1], l.Format("2006-01-02 15:04 MST"))
}
}
for _, d := range []string{"2026-10-25", "2026-10-26"} {
if perDay[d] != 1 {
t.Errorf("%02d:%02d job ran %d time(s) on %s, want 1 (runs: %v)", hm[0], hm[1], perDay[d], d, runs)
}
}
}
}
func TestR1183_DailyJobRunsOnceOnTheDayTheClocksGoForward(t *testing.T) {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
t.Skip("no tz database")
}
for _, hm := range [][2]int{{4, 0}, {5, 0}, {7, 10}} {
start := time.Date(2027, 3, 27, 12, 0, 0, 0, loc)
until := time.Date(2027, 3, 29, 23, 0, 0, 0, loc)
runs := walkRuns(t, start, until, hm[0], hm[1], loc)
perDay := map[string]int{}
for _, r := range runs {
l := r.In(loc)
perDay[l.Format("2006-01-02")]++
if l.Hour() != hm[0] || l.Minute() != hm[1] {
t.Errorf("%02d:%02d job ran at %s — not at its wall-clock time", hm[0], hm[1], l.Format("2006-01-02 15:04 MST"))
}
}
for _, d := range []string{"2027-03-28", "2027-03-29"} {
if perDay[d] != 1 {
t.Errorf("%02d:%02d job ran %d time(s) on %s, want 1 (runs: %v)", hm[0], hm[1], perDay[d], d, runs)
}
}
}
}
// A timer that fires a moment early (a wall-clock step) must not run the same slot twice.
func TestR1183_TheSameSlotNeverRunsTwice(t *testing.T) {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
t.Skip("no tz database")
}
slot := time.Date(2026, 11, 3, 4, 0, 0, 0, loc)
early := slot.Add(-300 * time.Millisecond) // the loop asks again while the wall clock still reads 03:59:59.7
if next := nextDailyRun(early, slot, 4, 0, loc); !next.After(slot) {
t.Fatalf("after running the %s slot, the next run is %s — the same slot again", slot, next)
}
}