Day 111 — Memory & Goroutine Leak Hunting
| Learning objective | Audit all middleware for goroutine leaks and heap growth under load |
| Key packages | runtime, runtime/pprof, net/http/pprof, testing, sync |
| Security context | Goroutine leaks are a denial-of-service vector — an attacker can exhaust memory by triggering leak paths |
Key Concepts
Common goroutine leak patterns:
// LEAK: goroutine blocks forever on unbuffered channel with no sender
go func() {
result := <-ch // if nobody sends, this goroutine lives forever
process(result)
}()
// FIX: use context cancellation
go func() {
select {
case result := <-ch:
process(result)
case <-ctx.Done():
return
}
}()
// LEAK: goroutine started without exit condition
go func() {
for {
doWork() // if done channel is never closed, goroutine lives forever
}
}()
// FIX: always provide exit
go func() {
for {
select {
case <-done:
return
default:
doWork()
}
}
}()
Exercise — LeakChecker for Load Tests
package ops
import (
"fmt"
"runtime"
"testing"
"time"
)
// LeakChecker verifies goroutine count returns to baseline after a test
type LeakChecker struct {
baseline int
t *testing.T
}
// NewLeakChecker captures the current goroutine baseline.
// Call at the START of a test, before starting any goroutines.
func NewLeakChecker(t *testing.T) *LeakChecker {
// Allow a brief settle period for any framework goroutines
time.Sleep(10 * time.Millisecond)
return &LeakChecker{baseline: runtime.NumGoroutine(), t: t}
}
// Check verifies goroutine count is within tolerance of baseline.
// Call at the END of a test, after stopping all goroutines.
func (lc *LeakChecker) Check() {
lc.CheckWithTolerance(5, 5*time.Second)
}
func (lc *LeakChecker) CheckWithTolerance(tolerance int, waitFor time.Duration) {
deadline := time.Now().Add(waitFor)
for {
current := runtime.NumGoroutine()
if current <= lc.baseline+tolerance {
lc.t.Logf("goroutine leak check: baseline=%d current=%d (OK)", lc.baseline, current)
return
}
if time.Now().After(deadline) {
// Capture stack for diagnosis
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
lc.t.Errorf("goroutine leak: baseline=%d current=%d (leaked %d)\n%s",
lc.baseline, current, current-lc.baseline, buf[:n])
return
}
time.Sleep(100 * time.Millisecond)
}
}
// GoroutineDelta measures goroutine growth during a function
func GoroutineDelta(fn func()) (delta int, stacks string) {
before := runtime.NumGoroutine()
fn()
time.Sleep(50 * time.Millisecond) // let goroutines start
after := runtime.NumGoroutine()
delta = after - before
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
return delta, string(buf[:n])
}
// HeapDiff compares heap allocation before and after fn.
// Returns growth in bytes. Use this to detect unbounded memory growth.
func HeapDiff(fn func()) (heapGrowthBytes uint64) {
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
fn()
runtime.GC()
var after runtime.MemStats
runtime.ReadMemStats(&after)
if after.HeapAlloc > before.HeapAlloc {
return after.HeapAlloc - before.HeapAlloc
}
return 0
}
// Example usage in test:
// func TestMiddlewareNoLeak(t *testing.T) {
// leak := NewLeakChecker(t)
// // ... run 1000 requests through middleware ...
// leak.Check() // fails if goroutines leak
// }