From abe0375ce9d249015f5eb61be02317299a326879 Mon Sep 17 00:00:00 2001 From: zhouliang Date: Tue, 24 Feb 2026 10:43:49 +0800 Subject: [PATCH] fix(channels): make email Stop() wait for checkLoop (lifecycle compliance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add loopWg sync.WaitGroup: Start() calls Add(1) before starting checkLoop; checkLoop defers Done(); Stop() calls Wait() after cancel so the goroutine fully exits before returning. - Harden TestEmailChannel_lifecycleCheck to assert Stop() takes at least 1s when CheckNewEmails is mocked to sleep 1s, [Refactor]: Channel System Refactoring / [Refactor]: Channel系统重构 #621 --- pkg/channels/email.go | 7 +++++ pkg/channels/email_test.go | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/pkg/channels/email.go b/pkg/channels/email.go index 8d677a5cd..380ba04b0 100644 --- a/pkg/channels/email.go +++ b/pkg/channels/email.go @@ -51,6 +51,9 @@ type EmailChannel struct { cancel context.CancelFunc checkTicker *time.Ticker + // loopWg waits for checkLoop goroutine to exit in Stop(). + loopWg sync.WaitGroup + // reconnect control reconnectClientVersion int reconnectMutex sync.Mutex @@ -88,6 +91,7 @@ func (c *EmailChannel) Start(ctx context.Context) error { c.setRunning(true) logger.InfoC("email", "Email channel started") + c.loopWg.Add(1) go c.checkLoop(runCtx) return nil @@ -111,6 +115,8 @@ func (c *EmailChannel) Stop(ctx context.Context) error { } c.mu.Unlock() + c.loopWg.Wait() // wait for checkLoop goroutine to exit + c.setRunning(false) logger.InfoC("email", "Email channel stopped") return nil @@ -412,6 +418,7 @@ func (c *EmailChannel) reconnectWithBackoff(ctx context.Context) error { } func (c *EmailChannel) checkLoop(ctx context.Context) { + defer c.loopWg.Done() interval := time.Duration(c.config.CheckInterval) * time.Second if interval <= 0 { interval = 30 * time.Second diff --git a/pkg/channels/email_test.go b/pkg/channels/email_test.go index 3e601e196..56dac6a38 100644 --- a/pkg/channels/email_test.go +++ b/pkg/channels/email_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "testing" "time" @@ -360,3 +361,57 @@ func TestEmailChannel_runIdleLoop(t *testing.T) { }) } + +func TestEmailChannel_lifecycleCheck(t *testing.T) { + // check if the current runtime is go1.25.xx + if !strings.HasPrefix(runtime.Version(), "go1.25") { + // github.com/bytedance/mockey v1.4.4 is supported in go1.25.xx + t.Skip("skipping test in non-go1.25.xx environment") + return + } + + mockey.PatchConvey("lifecycle test", t, func() { + // --------------- mock start --------------- + c := &EmailChannel{ + BaseChannel: &BaseChannel{ + bus: bus.NewMessageBus(), + }, + config: config.EmailConfig{ + Enabled: true, + CheckInterval: 1, + ForcedPolling: true, + IMAPServer: "imap.example.com", + Username: "testuser", + Password: "testpassword", + }, + } + // mock login and select to return mockClient + mockey.Mock(mockey.GetMethod(c, "connect")).To(func(*EmailChannel) error { + return nil + }).Build() + mockey.Mock(mockey.GetMethod(c, "CheckNewEmails")).To(func(*EmailChannel, context.Context) { + time.Sleep(1 * time.Second) + }).Build() + // --------------- mock end --------------- + ctx := context.Background() + err := c.Start(ctx) + assert.NoError(t, err) + wg := sync.WaitGroup{} + wg.Add(1) + var stopDone time.Time + stopStart := time.Now() + go func() { + defer wg.Done() + c.Stop(ctx) + stopDone = time.Now() + }() + // wait for checkNewEmails to finish + assert.True(t, c.IsRunning()) + wg.Wait() + elapsed := stopDone.Sub(stopStart) + // stop exit normally + assert.False(t, c.IsRunning()) + // If Stop() did not wait for checkLoop, it would return in milliseconds. + assert.GreaterOrEqual(t, elapsed, 1*time.Second, "Stop() must wait for checkLoop (lifecycle compliance)") + }) +}