diff --git a/modules/mesh-delivery/cmd/mesh-delivery/timers_source_test.go b/modules/mesh-delivery/cmd/mesh-delivery/timers_source_test.go new file mode 100644 index 00000000..731c4863 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/timers_source_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +// **A timer in this module names the bounded step it retries** (novox/hq ADR 0406 "How it is checked", issue 975): the +// module acts on events, so a clock that fires on its own — a ticker, time.Tick — is a poll, and a one-shot timer +// (time.AfterFunc, time.NewTimer) is allowed only for a bounded step, said in a comment on the line above: +// `// retries: `. This reads the module's Go source, not its tests. + +var ( + pollingClock = regexp.MustCompile(`\btime\.(NewTicker|Tick)\(`) + oneShot = regexp.MustCompile(`\btime\.(AfterFunc|NewTimer)\(`) + retriesLine = regexp.MustCompile(`^\s*//\s*retries:\s*\S`) +) + +// timerFaults are the lines of a source file that make a clock of their own without naming the step it retries. +func timerFaults(name, src string) []string { + var faults []string + lines := strings.Split(src, "\n") + for i, line := range lines { + code, _, _ := strings.Cut(line, "//") + switch { + case pollingClock.MatchString(code): + faults = append(faults, name+":"+strconv.Itoa(i+1)+": a ticker polls; act on an event, or name a bounded step's one-shot timer: "+strings.TrimSpace(line)) + case oneShot.MatchString(code) && (i == 0 || !retriesLine.MatchString(lines[i-1])): + faults = append(faults, name+":"+strconv.Itoa(i+1)+": a timer with no `// retries: ` comment on the line above: "+strings.TrimSpace(line)) + } + } + return faults +} + +func TestNoTimerInTheModuleFiresWithoutNamingTheBoundedStepItRetries(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil || len(files) == 0 { + t.Fatalf("no source read: %v", err) + } + timers := 0 + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + raw, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, fault := range timerFaults(f, string(raw)) { + t.Error(fault) + } + timers += len(oneShot.FindAllString(string(raw), -1)) + } + if timers == 0 { + t.Fatal("the test found no timer at all: it is not reading the source it should") + } +} + +// The checker itself: the tick this module had before it acted on events, and a timer that names nothing, fail; a +// named one passes. +func TestTheTimerCheckFailsOnATickerAndOnAnUnnamedTimer(t *testing.T) { + was := "\tgo func() {\n\t\ttick := time.NewTicker(10 * time.Second)\n\t\tdefer tick.Stop()\n\t}()\n" + if got := timerFaults("main.go", was); len(got) != 1 || !strings.Contains(got[0], "main.go:2") { + t.Fatalf("the old tick was not found: %v", got) + } + if got := timerFaults("x.go", "for range time.Tick(time.Second) {}\n"); len(got) != 1 { + t.Fatalf("time.Tick was not found: %v", got) + } + if got := timerFaults("x.go", "\ttime.AfterFunc(d, f)\n"); len(got) != 1 { + t.Fatalf("an unnamed timer was not found: %v", got) + } + if got := timerFaults("x.go", "\t// retries:\n\tt := time.NewTimer(d)\n"); len(got) != 1 { + t.Fatalf("a comment that names no step passed: %v", got) + } + if got := timerFaults("x.go", "\t// retries: the refused merge, after a minute\n\ttime.AfterFunc(d, f)\n"); len(got) != 0 { + t.Fatalf("a named timer failed: %v", got) + } + if got := timerFaults("x.go", "\t// time.NewTicker was here\n"); len(got) != 0 { + t.Fatalf("a comment about a ticker failed: %v", got) + } +}