Day 89 — Goroutine Leak Audit
Learning Objectives
- Find goroutine leaks in the complete security proxy
- Verify goroutines return to baseline after load test ends
- Fix common leak patterns: context cancellation, channel drain
Exercise: Goroutine Baseline Checker
package main
import (
"fmt"
"runtime"
"time"
)
// LeakChecker verifies goroutine count returns to baseline
type LeakChecker struct {
baseline int
label string
}
func NewLeakChecker(label string) *LeakChecker {
// Wait for GC and goroutine scheduler to settle
runtime.GC()
time.Sleep(100 * time.Millisecond)
return &LeakChecker{
baseline: runtime.NumGoroutine(),
label: label,
}
}
func (lc *LeakChecker) Check(t interface{ Errorf(string, ...interface{}) }, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
current := runtime.NumGoroutine()
if current <= lc.baseline+5 { // allow 5 goroutines overhead
return // OK
}
time.Sleep(100 * time.Millisecond)
}
current := runtime.NumGoroutine()
t.Errorf("[%s] goroutine leak: baseline=%d current=%d delta=%d",
lc.label, lc.baseline, current, current-lc.baseline)
// Dump goroutine stack for debugging
buf := make([]byte, 64*1024)
n := runtime.Stack(buf, true)
fmt.Printf("Goroutine dump:\n%s\n", buf[:n])
}
// Test goroutine leak in the full gateway
func TestGateway_NoGoroutineLeak(t *testing.T) {
ts := setupTestGateway(t)
checker := NewLeakChecker("gateway load test")
// Run 1000 concurrent requests
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 10; j++ {
resp, _ := ts.Client().Get(ts.URL + "/api/data")
if resp != nil { resp.Body.Close() }
}
}()
}
wg.Wait()
// After load: goroutine count should return to baseline within 5s
checker.Check(t, 5*time.Second)
}
Common goroutine leaks to fix:
// LEAK: Ticker not stopped
ticker := time.NewTicker(time.Second)
go func() {
for range ticker.C { doWork() } // goroutine stuck if never stopped
}()
// FIX:
ticker := time.NewTicker(time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-ticker.C: doWork()
case <-ctx.Done(): return // exits when context is cancelled
}
}
}()
// LEAK: Goroutine waiting on channel with no sender
results := make(chan string)
go func() {
r := <-results // blocks forever if no one sends
fmt.Println(r)
}()
// Caller never sends to results — goroutine leaks
// FIX:
results := make(chan string, 1) // buffered, or sender always sends before returning
Real-World Context
Goroutine leaks cause memory growth without obvious cause — gradual OOM. At 1000 rps with a 1-goroutine-per-request leak: +1000 goroutines/second, ~2KB each = +2MB/second memory growth = OOM in minutes. The runtime.Stack dump shows exactly which goroutine is stuck and on what.