From 2555714086507f80573ea8ac293cb04bc8ac5343 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 13 Oct 2026 17:05:23 +0000 Subject: [PATCH] watcher: a stop during the first scan sends no startup notification (closes #271) Run sent the startup notification right after the first scan without checking the context. RunOnce returns as usual when shutdown cuts the scan short, so a stop during the first scan sent "has started and completed its initial scan" to every endpoint as the process shut down. Run now sends it only when the context was not cancelled. The new test cancels the context before Run starts, so the scan never sends its lookup; a stop part way through a scan follows the same path. Model: opus-5-5 --- TODO.md | 2 ++ internal/watcher/cancelled_test.go | 24 ++++++++++++++++++++++++ internal/watcher/watcher.go | 7 ++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 96cbcee..957cefd 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a stop before the first scan completes sends no startup + notification; a scan that ran to the end still sends it (closes #271). - 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without `DNSWATCHER_METRICS_PASSWORD` stops startup (closes #268). - 2026-10-13: a save syncs the state file to disk before renaming it into place, diff --git a/internal/watcher/cancelled_test.go b/internal/watcher/cancelled_test.go index 5d83bc1..247ab7c 100644 --- a/internal/watcher/cancelled_test.go +++ b/internal/watcher/cancelled_test.go @@ -83,6 +83,30 @@ func TestCancelledCheckSavesNothing(t *testing.T) { } } +// TestStopBeforeFirstScanSendsNoStartupNotification runs a watcher with +// the startup notification on and its context already cancelled, so the +// first scan is cut short before its lookup is sent. The scan did not +// complete, so no startup notification may be sent. +func TestStopBeforeFirstScanSendsNoStartupNotification(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{testHost} + cfg.SendTestNotification = true + + w, deps := newTestWatcher(t, cfg) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + w.Run(ctx) + + notifications := deps.notifier.getNotifications() + if len(notifications) != 0 { + t.Errorf("sent %v, want no notifications", notifications) + } +} + // newLoggingWatcher returns a watcher for a domain and a hostname, with // the real resolver, that writes what it logs at warning level or above // into the returned buffer. diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 8c5b17b..9b078b6 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -133,7 +133,12 @@ func (w *Watcher) Run(ctx context.Context) { w.cleanupRemovedTargets() w.RunOnce(ctx) - w.maybeSendTestNotification(ctx) + + // RunOnce returns as usual when shutdown cuts the scan short, and + // the startup notification says the scan completed. + if ctx.Err() == nil { + w.maybeSendTestNotification(ctx) + } dnsTicker := time.NewTicker(w.config.DNSInterval) tlsTicker := time.NewTicker(w.config.TLSInterval)