Frontend Labs
⋯

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.


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