From f0e5a5a3677ae957afe6f1cbc30e8e9c11c020a5 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 5 Mar 2026 23:48:50 +0800 Subject: [PATCH] test(watcher): add unit test for server update timer cancellation and immediate reload logic - Add `TestTriggerServerUpdateCancelsPendingTimerOnImmediate` to verify proper handling of server update debounce and timer cancellation. - Fix logic in `triggerServerUpdate` to prevent duplicate timers and ensure proper cleanup of pending state. --- internal/watcher/clients.go | 22 ++++++++++++++---- internal/watcher/watcher_test.go | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/internal/watcher/clients.go b/internal/watcher/clients.go index de1b80f4..2697fa05 100644 --- a/internal/watcher/clients.go +++ b/internal/watcher/clients.go @@ -327,6 +327,11 @@ func (w *Watcher) triggerServerUpdate(cfg *config.Config) { w.serverUpdateMu.Lock() if w.serverUpdateLast.IsZero() || now.Sub(w.serverUpdateLast) >= serverUpdateDebounce { w.serverUpdateLast = now + if w.serverUpdateTimer != nil { + w.serverUpdateTimer.Stop() + w.serverUpdateTimer = nil + } + w.serverUpdatePend = false w.serverUpdateMu.Unlock() w.reloadCallback(cfg) return @@ -344,26 +349,33 @@ func (w *Watcher) triggerServerUpdate(cfg *config.Config) { w.serverUpdatePend = true if w.serverUpdateTimer != nil { w.serverUpdateTimer.Stop() + w.serverUpdateTimer = nil } - w.serverUpdateTimer = time.AfterFunc(delay, func() { + var timer *time.Timer + timer = time.AfterFunc(delay, func() { if w.stopped.Load() { return } w.clientsMutex.RLock() latestCfg := w.config w.clientsMutex.RUnlock() + + w.serverUpdateMu.Lock() + if w.serverUpdateTimer != timer || !w.serverUpdatePend { + w.serverUpdateMu.Unlock() + return + } + w.serverUpdateTimer = nil + w.serverUpdatePend = false if latestCfg == nil || w.reloadCallback == nil || w.stopped.Load() { - w.serverUpdateMu.Lock() - w.serverUpdatePend = false w.serverUpdateMu.Unlock() return } - w.serverUpdateMu.Lock() w.serverUpdateLast = time.Now() - w.serverUpdatePend = false w.serverUpdateMu.Unlock() w.reloadCallback(latestCfg) }) + w.serverUpdateTimer = timer w.serverUpdateMu.Unlock() } diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index a3be5877..0f9cd019 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -441,6 +441,46 @@ func TestRemoveClientRemovesHash(t *testing.T) { } } +func TestTriggerServerUpdateCancelsPendingTimerOnImmediate(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{AuthDir: tmpDir} + + var reloads int32 + w := &Watcher{ + reloadCallback: func(*config.Config) { + atomic.AddInt32(&reloads, 1) + }, + } + w.SetConfig(cfg) + + w.serverUpdateMu.Lock() + w.serverUpdateLast = time.Now().Add(-(serverUpdateDebounce - 100*time.Millisecond)) + w.serverUpdateMu.Unlock() + w.triggerServerUpdate(cfg) + + if got := atomic.LoadInt32(&reloads); got != 0 { + t.Fatalf("expected no immediate reload, got %d", got) + } + + w.serverUpdateMu.Lock() + if !w.serverUpdatePend || w.serverUpdateTimer == nil { + w.serverUpdateMu.Unlock() + t.Fatal("expected a pending server update timer") + } + w.serverUpdateLast = time.Now().Add(-(serverUpdateDebounce + 10*time.Millisecond)) + w.serverUpdateMu.Unlock() + + w.triggerServerUpdate(cfg) + if got := atomic.LoadInt32(&reloads); got != 1 { + t.Fatalf("expected immediate reload once, got %d", got) + } + + time.Sleep(250 * time.Millisecond) + if got := atomic.LoadInt32(&reloads); got != 1 { + t.Fatalf("expected pending timer to be cancelled, got %d reloads", got) + } +} + func TestShouldDebounceRemove(t *testing.T) { w := &Watcher{} path := filepath.Clean("test.json")