fix(channels): make email Stop() wait for checkLoop (lifecycle compliance)

- 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
This commit is contained in:
zhouliang 2026-02-24 10:43:49 +08:00
parent 8e9c369312
commit abe0375ce9
2 changed files with 62 additions and 0 deletions

View file

@ -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

View file

@ -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)")
})
}