Frontend Labs
⋯

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
// }

Generated from week15.md · 8 Oct 2026 16:36