Skip to content

Stack Traces & Debugging — Tasks

Hands-on exercises. Each comes with a problem statement, hints, and a reference solution. Difficulty: easy → hard.


Task 1 (Easy) — Print a stack trace

Write a program that calls runtime/debug.PrintStack() from inside a function and observe the output.

Hints - The output goes to os.Stderr. - Compare to fmt.Println(string(debug.Stack())), which goes to stdout.

Solution

package main

import (
    "runtime/debug"
)

func inner() {
    debug.PrintStack()
}

func outer() {
    inner()
}

func main() {
    outer()
}


Task 2 (Easy) — Find your caller

Write a function whoCalledMe() that prints the file and line number of its caller.

Hints - runtime.Caller(1)1 skips whoCalledMe itself.

Solution

package main

import (
    "fmt"
    "runtime"
)

func whoCalledMe() {
    pc, file, line, ok := runtime.Caller(1)
    if !ok {
        fmt.Println("could not get caller")
        return
    }
    fn := runtime.FuncForPC(pc)
    fmt.Printf("called from %s at %s:%d\n", fn.Name(), file, line)
}

func realCaller() {
    whoCalledMe()
}

func main() {
    realCaller()
}


Task 3 (Easy) — Capture frames into a slice

Use runtime.Callers and runtime.CallersFrames to print the function name and line of every active frame in the current goroutine.

Hints - Allocate a []uintptr of, say, 32 entries. - Pass 2 as skip to omit runtime.Callers and your printer function.

Solution

package main

import (
    "fmt"
    "runtime"
)

func dumpStack() {
    pcs := make([]uintptr, 32)
    n := runtime.Callers(2, pcs)
    frames := runtime.CallersFrames(pcs[:n])
    for {
        f, more := frames.Next()
        fmt.Printf("%s\n  %s:%d\n", f.Function, f.File, f.Line)
        if !more {
            break
        }
    }
}

func a() { dumpStack() }
func main() { a() }


Task 4 (Easy) — Recover from a panic and log the trace

Wrap a function that panics in a defer recover and log the panic value plus the stack.

Solution

package main

import (
    "log"
    "runtime/debug"
)

func mayPanic() {
    panic("something broke")
}

func safe() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("recovered: %v\n%s", r, debug.Stack())
        }
    }()
    mayPanic()
}

func main() {
    safe()
    log.Println("still running")
}


Task 5 (Medium) — Stack-aware error type

Build a New(msg string) error that captures a stack at the call site and exposes it via a StackTrace() []runtime.Frame method.

Hints - Embed a []uintptr. - Resolve frames lazily.

Solution

package main

import (
    "fmt"
    "runtime"
)

type stackErr struct {
    msg string
    pcs []uintptr
}

func (e *stackErr) Error() string { return e.msg }

func (e *stackErr) StackTrace() []runtime.Frame {
    out := make([]runtime.Frame, 0, len(e.pcs))
    fs := runtime.CallersFrames(e.pcs)
    for {
        f, more := fs.Next()
        out = append(out, f)
        if !more {
            break
        }
    }
    return out
}

func New(msg string) error {
    pcs := make([]uintptr, 32)
    n := runtime.Callers(2, pcs)
    return &stackErr{msg: msg, pcs: pcs[:n]}
}

func main() {
    err := New("boom").(*stackErr)
    for _, f := range err.StackTrace() {
        fmt.Printf("%s %s:%d\n", f.Function, f.File, f.Line)
    }
}


Task 6 (Medium) — Wrap-aware error type

Extend Task 5 so that Wrap(err error, msg string) error returns a wrapped error with a stack and supports errors.Unwrap.

Solution

type stackErr struct {
    msg string
    err error
    pcs []uintptr
}

func (e *stackErr) Error() string {
    if e.err != nil {
        return e.msg + ": " + e.err.Error()
    }
    return e.msg
}

func (e *stackErr) Unwrap() error { return e.err }

func Wrap(err error, msg string) error {
    if err == nil {
        return nil
    }
    pcs := make([]uintptr, 32)
    n := runtime.Callers(2, pcs)
    return &stackErr{msg: msg, err: err, pcs: pcs[:n]}
}


Task 7 (Medium) — Goroutine dump on demand

Write a small program that, on receiving SIGUSR1, prints the stacks of all goroutines.

Hints - signal.Notify with syscall.SIGUSR1. - runtime.Stack(buf, true).

Solution

package main

import (
    "fmt"
    "os"
    "os/signal"
    "runtime"
    "syscall"
    "time"
)

func main() {
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGUSR1)

    // Some background work
    for i := 0; i < 3; i++ {
        go func(i int) {
            time.Sleep(1 * time.Hour)
            _ = i
        }(i)
    }

    for {
        select {
        case <-sigs:
            buf := make([]byte, 1<<20)
            n := runtime.Stack(buf, true)
            fmt.Println(string(buf[:n]))
        }
    }
}

Run with kill -USR1 <pid> from another terminal.


Task 8 (Medium) — Caller-aware logger

Write Logf(format string, args ...any) that prepends file:line of the caller to every log message.

Solution

package main

import (
    "fmt"
    "path/filepath"
    "runtime"
    "time"
)

func Logf(format string, args ...any) {
    _, file, line, _ := runtime.Caller(1)
    file = filepath.Base(file)
    fmt.Printf("%s %s:%d "+format+"\n",
        append([]any{time.Now().Format("15:04:05"), file, line}, args...)...)
}

func work() {
    Logf("doing work %d", 42)
}

func main() {
    work()
}


Task 9 (Medium) — pprof endpoint

Mount net/http/pprof and let the user inspect goroutines via HTTP.

Solution

package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
    "time"
)

func busy() {
    for {
        time.Sleep(50 * time.Millisecond)
    }
}

func main() {
    for i := 0; i < 5; i++ {
        go busy()
    }
    log.Println("pprof at http://localhost:6060/debug/pprof/")
    log.Fatal(http.ListenAndServe("localhost:6060", nil))
}

Run, then visit http://localhost:6060/debug/pprof/goroutine?debug=2.


Task 10 (Medium → Hard) — Minimum-allocation stack capture

Implement a Capture struct with a fixed-size [16]uintptr array. Capturing should not allocate a slice on the heap. Verify with go test -benchmem.

Solution

package main

import (
    "runtime"
    "testing"
)

type Capture struct {
    pcs [16]uintptr
    n   int
}

func (c *Capture) Snap() {
    c.n = runtime.Callers(2, c.pcs[:])
}

func BenchmarkCapture(b *testing.B) {
    var c Capture
    for i := 0; i < b.N; i++ {
        c.Snap()
    }
}

func main() {
    var c Capture
    c.Snap()
    println(c.n, "frames captured")
}

go test -bench=. -benchmem should report 0 allocs/op.


Task 11 (Hard) — Periodic goroutine count alert

Spawn 1000 worker goroutines that each sleep for an hour. Print a goroutine dump every 5 seconds only when the goroutine count exceeds 500.

Solution

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    for i := 0; i < 1000; i++ {
        go func() { time.Sleep(time.Hour) }()
    }

    t := time.NewTicker(5 * time.Second)
    defer t.Stop()
    for range t.C {
        n := runtime.NumGoroutine()
        if n > 500 {
            buf := make([]byte, 1<<16)
            written := runtime.Stack(buf, true)
            fmt.Printf("alert: %d goroutines\n", n)
            fmt.Println(string(buf[:written]))
            return
        }
    }
}


Task 12 (Hard) — Diff two goroutine dumps

Take two snapshots of runtime.Stack(_, true) 1 second apart, and print which created by callsites have more goroutines in the second snapshot.

Hints - Parse the created by lines. - Use a map[string]int keyed by callsite.

Solution sketch

package main

import (
    "fmt"
    "runtime"
    "strings"
    "time"
)

func snapshot() map[string]int {
    buf := make([]byte, 1<<20)
    n := runtime.Stack(buf, true)
    counts := map[string]int{}
    for _, line := range strings.Split(string(buf[:n]), "\n") {
        if strings.HasPrefix(line, "created by ") {
            counts[strings.TrimPrefix(line, "created by ")]++
        }
    }
    return counts
}

func main() {
    for i := 0; i < 5; i++ {
        go func() { time.Sleep(time.Hour) }()
    }
    a := snapshot()
    time.Sleep(time.Second)
    for i := 0; i < 50; i++ {
        go func() { time.Sleep(time.Hour) }()
    }
    b := snapshot()

    for k, v := range b {
        if v > a[k] {
            fmt.Printf("growing: %s (%d -> %d)\n", k, a[k], v)
        }
    }
}


Task 13 (Hard) — Recover middleware with stack-on-error

Build an HTTP middleware that: - Recovers from panics in handlers. - Logs panic value + stack. - Returns 500 to the client without leaking the stack.

Solution

package main

import (
    "log"
    "net/http"
    "runtime/debug"
)

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic in %s %s: %v\n%s",
                    r.Method, r.URL.Path, rec, debug.Stack())
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func bad(w http.ResponseWriter, r *http.Request) {
    panic("kaboom")
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", bad)
    log.Fatal(http.ListenAndServe(":8080", recoverMiddleware(mux)))
}


Task 14 (Hard) — Detect a deadlock with a watchdog

Write a watchdog goroutine that prints a goroutine dump if main's heartbeat channel goes silent for 3 seconds.

Solution

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

func main() {
    heartbeat := make(chan struct{})

    // Watchdog
    go func() {
        timer := time.NewTimer(3 * time.Second)
        for {
            select {
            case <-heartbeat:
                if !timer.Stop() {
                    select { case <-timer.C: default: }
                }
                timer.Reset(3 * time.Second)
            case <-timer.C:
                buf := make([]byte, 1<<20)
                n := runtime.Stack(buf, true)
                fmt.Printf("watchdog fired:\n%s", buf[:n])
                return
            }
        }
    }()

    var mu sync.Mutex
    mu.Lock()
    go func() {
        mu.Lock() // deadlock
    }()

    for i := 0; i < 5; i++ {
        heartbeat <- struct{}{}
        time.Sleep(500 * time.Millisecond)
    }
    // Now main blocks, heartbeat stops, watchdog fires
    mu.Lock()
}


Task 15 (Boss-level) — Build a cockroachdb/errors-style API

Build an error package with: - New(msg string) error — creates an error with stack. - Wrap(err error, msg string) error — preserves the original stack, adds a layer of context. - Unwrap, Is, As compatibility. - FormatStack(err error) string — walks the chain and prints the original stack.

Solution sketch

package errx

import (
    "errors"
    "fmt"
    "runtime"
    "strings"
)

type withStack struct {
    err error
    pcs []uintptr
}

func (e *withStack) Error() string { return e.err.Error() }
func (e *withStack) Unwrap() error { return e.err }

func capture(skip int) []uintptr {
    pcs := make([]uintptr, 32)
    n := runtime.Callers(skip+1, pcs)
    return pcs[:n]
}

func New(msg string) error {
    return &withStack{err: errors.New(msg), pcs: capture(2)}
}

func Wrap(err error, msg string) error {
    if err == nil { return nil }
    // If the wrapped error already has a stack, do not capture a new one.
    var ws *withStack
    if errors.As(err, &ws) {
        return fmt.Errorf("%s: %w", msg, err)
    }
    return &withStack{err: fmt.Errorf("%s: %w", msg, err), pcs: capture(2)}
}

func FormatStack(err error) string {
    var ws *withStack
    if !errors.As(err, &ws) {
        return ""
    }
    var b strings.Builder
    fs := runtime.CallersFrames(ws.pcs)
    for {
        f, more := fs.Next()
        fmt.Fprintf(&b, "%s\n  %s:%d\n", f.Function, f.File, f.Line)
        if !more { break }
    }
    return b.String()
}

Use:

err := errx.New("read file failed")
err = errx.Wrap(err, "load config")
err = errx.Wrap(err, "start server")
fmt.Println(err)             // start server: load config: read file failed
fmt.Print(errx.FormatStack(err)) // original stack from New

The pattern: capture once, at origin; wraps add context strings without new stacks. This is exactly how cockroachdb/errors and the original pkg/errors worked.