Skip to content

Chapter 23: Goroutine Leak Detection

Description

Use go.uber.org/goleak to detect goroutine leaks in tests. A goroutine leak occurs when a goroutine is started but never exits — it blocks forever on a channel, waits on a timer, or holds an abandoned mutex. goleak.VerifyNone(t) checks that no goroutines are running after a test completes.

Code

type LeakyProcessor struct {
    started bool
}

func (lp *LeakyProcessor) Start() {
    lp.started = true
    go func() { for { time.Sleep(time.Second) } }()
    // goroutine never exits — leak!
}

type SafeProcessor struct {
    cancel context.CancelFunc
    wg     sync.WaitGroup
}

func (sp *SafeProcessor) Start() {
    ctx, cancel := context.WithCancel(context.Background())
    sp.cancel = cancel
    sp.wg.Add(1)
    go func() {
        defer sp.wg.Done()
        for {
            select {
            case <-ctx.Done(): return
            default: time.Sleep(100 * time.Millisecond)
            }
        }
    }()
}

func (sp *SafeProcessor) Stop() {
    sp.cancel()
    sp.wg.Wait()
}

Test

func verifyNoLeaks(t *testing.T) {
    t.Helper()
    goleak.VerifyNone(t)
}

func TestWorkerPool(t *testing.T) {
    defer verifyNoLeaks(t)
    jobs := make(chan int, 10)
    results := make(chan string, 100)
    wp := NewWorkerPool(3, jobs, results)
    for i := 0; i < 10; i++ { jobs <- i }
    close(jobs)
    wp.Stop()
    close(results)
    count := 0
    for range results { count++ }
    assert.Equal(t, 10, count)
}

func TestSafeProcessor(t *testing.T) {
    defer verifyNoLeaks(t)
    sp := NewSafeProcessor()
    sp.Start()
    sp.Stop()
}

func TestCachedService(t *testing.T) {
    defer verifyNoLeaks(t)
    cs := NewCachedService()
    cs.Set("k", "v")
    cs.Close()
}

func TestLeakyProcessor(t *testing.T) {
    t.Skip("demonstrates leak - skipped by goleak")
    lp := &LeakyProcessor{}
    lp.Start()
}

Testing Approach

Goroutine leak detection:

  1. verifyNoLeaks helper — wraps goleak.VerifyNone(t) with t.Helper(). Added via defer at the top of each clean test. If any goroutine is still running, the test fails with a dump of the leaked goroutine's stack.
  2. Skip leaky tests — tests that intentionally demonstrate leaks (LeakyProcessor, LeakyCachedService) are skipped during goleak verification with t.Skip. The code remains as documentation but doesn't pollute the test suite.
  3. wg.Wait() for synchronization — SafeProcessor.Stop() calls wg.Wait() to guarantee the goroutine has exited before the test continues. Without this, goleak might catch the goroutine in its final cleanup millisecond.
  4. close(ch) and <-ch coordination — closing a channel as a broadcast signal (close(done)) lets goroutines exit cleanly. The test closes the done channel and waits (via WaitGroup or separate done channel).

View source code on GitHub