Frontend Labs
⋯

Day 108 — Signal Handling & Operational Controls

Learning objective Handle SIGHUP (config reload), SIGUSR1 (log rotate), SIGUSR2 (goroutine dump)
Key packages os/signal, syscall, runtime, log/slog
Security context Ops teams must be able to reload security rules and rotate logs without restarts

Key Concepts

Signal Convention Action
SIGTERM Kubernetes, systemd Graceful shutdown
SIGINT Ctrl-C Graceful shutdown
SIGHUP Log rotation (historically) Reload config
SIGUSR1 User-defined 1 Log file rotation
SIGUSR2 User-defined 2 Goroutine stack dump

Exercise — Signal Handler Dispatcher

package ops

import (
    "bytes"
    "fmt"
    "log/slog"
    "os"
    "os/signal"
    "runtime"
    "syscall"
    "time"
)

// SignalDispatcher routes OS signals to registered handlers
type SignalDispatcher struct {
    configReloader func() error
    logRotator     func() error
    logger         *slog.Logger
    done           chan struct{}
}

func NewSignalDispatcher(logger *slog.Logger) *SignalDispatcher {
    return &SignalDispatcher{logger: logger, done: make(chan struct{})}
}

func (sd *SignalDispatcher) OnConfigReload(fn func() error) { sd.configReloader = fn }
func (sd *SignalDispatcher) OnLogRotate(fn func() error)    { sd.logRotator = fn }

// Start begins listening for signals. Call in a goroutine.
func (sd *SignalDispatcher) Start() {
    sigCh := make(chan os.Signal, 8)
    signal.Notify(sigCh,
        syscall.SIGHUP,
        syscall.SIGUSR1,
        syscall.SIGUSR2,
    )
    defer signal.Stop(sigCh)

    for {
        select {
        case <-sd.done:
            return
        case sig := <-sigCh:
            sd.dispatch(sig)
        }
    }
}

func (sd *SignalDispatcher) dispatch(sig os.Signal) {
    sd.logger.Info("signal received", "signal", sig.String())
    switch sig {
    case syscall.SIGHUP:
        sd.handleConfigReload()
    case syscall.SIGUSR1:
        sd.handleLogRotate()
    case syscall.SIGUSR2:
        sd.handleGoroutineDump()
    }
}

func (sd *SignalDispatcher) handleConfigReload() {
    if sd.configReloader == nil {
        sd.logger.Warn("SIGHUP received but no config reloader registered")
        return
    }
    sd.logger.Info("reloading config")
    if err := sd.configReloader(); err != nil {
        // CRITICAL: keep serving with old config — never apply invalid config
        sd.logger.Error("config reload failed — keeping old config", "error", err)
        return
    }
    sd.logger.Info("config reloaded successfully")
}

func (sd *SignalDispatcher) handleLogRotate() {
    if sd.logRotator == nil {
        sd.logger.Warn("SIGUSR1 received but no log rotator registered")
        return
    }
    sd.logger.Info("rotating logs")
    if err := sd.logRotator(); err != nil {
        sd.logger.Error("log rotation failed", "error", err)
        return
    }
    sd.logger.Info("log rotation complete")
}

func (sd *SignalDispatcher) handleGoroutineDump() {
    buf := make([]byte, 1<<20) // 1MB buffer
    n := runtime.Stack(buf, true)
    dump := string(buf[:n])

    // Count goroutines
    lines := bytes.Split(buf[:n], []byte("\n"))
    goroutineCount := 0
    for _, line := range lines {
        if bytes.HasPrefix(line, []byte("goroutine ")) {
            goroutineCount++
        }
    }

    sd.logger.Info("goroutine dump",
        "goroutine_count", goroutineCount,
        "dump_bytes", n,
    )

    // Write full dump to a timestamped file
    filename := fmt.Sprintf("/tmp/goroutine-dump-%s.txt", time.Now().Format("20060102-150405"))
    if err := os.WriteFile(filename, []byte(dump), 0600); err != nil {
        sd.logger.Error("failed to write goroutine dump", "error", err)
        return
    }
    sd.logger.Info("goroutine dump written", "file", filename)
}

func (sd *SignalDispatcher) Stop() { close(sd.done) }

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