DEV Community

Minwook Je
Minwook Je

Posted on

controller-runtime/pkg/manager/signals

package signals

SetupSignalHandler

package signals

import (
    "context"
    "os"
    "os/signal"
)

var onlyOneSignalHandler = make(chan struct{})

// SetupSignalHandler registers for SIGTERM and SIGINT. A context is returned
// which is canceled on one of these signals. If a second signal is caught, the program
// is terminated with exit code 1.
func SetupSignalHandler() context.Context {
    close(onlyOneSignalHandler) // panics when called twice

    ctx, cancel := context.WithCancel(context.Background())

    c := make(chan os.Signal, 2)
    signal.Notify(c, shutdownSignals...)
    go func() {
        <-c
        cancel()
        <-c
        os.Exit(1) // second signal. Exit directly.
    }()

    return ctx
}

Enter fullscreen mode Exit fullscreen mode

Implements Graceful Shutdown Pattern to safely handle signal-based program termination.

  • First Signal: Triggers graceful shutdown via context cancellation
  • Second Signal: Forces immediate termination (user's strong intent)

Q1: What's the scope of the global variable and why prevent duplicate calls this way?

Scope: Package-level global variable
Why: Signal handlers must be one per process. Multiple registrations make signal handling unpredictable. Using close(closed channel) → immediate panic implements fail-fast principle.

Q2: Why create and return a context instead of handling shutdown directly?

Context Pattern Benefits:

  • Graceful propagation: Multiple goroutines can receive shutdown signal simultaneously
  • Standardized cancellation: Uses Go's standard cancellation mechanism
  • Composable: Can be combined with other contexts
// Example usage
ctx := SetupSignalHandler()
go worker1(ctx)  // All workers receive
go worker2(ctx)  // shutdown signal
go worker3(ctx)  // simultaneously
Enter fullscreen mode Exit fullscreen mode

Q3: How does signal.Notify work internally with OS signals? What about multi-core or fork/spawn scenarios?

OS Level Integration:

  • Go runtime registers signal handlers with the OS kernel
  • Uses platform-specific mechanisms: signalfd(Linux), kqueue(BSD/macOS), or signal(POSIX)
  • OS delivers signals to the process, Go runtime converts them to channel sends
  • Multi-core: Signals are process-level, not thread-level - any thread can receive but Go runtime handles distribution
  • Fork/Spawn: Child processes inherit signal dispositions but need separate Go signal handlers

Internal Flow:

  1. signal.Notify(c, SIGTERM) → Go runtime calls signal.signal(SIGTERM, handler)
  2. OS receives signal → calls Go's C signal handler
  3. C handler → Go runtime signal dispatcher → channel send

Q4: What about goroutine lifecycle - doesn't this create a permanent goroutine that never gets cleaned up?

Goroutine Lifecycle Concern: Yes, this creates a daemon goroutine that lives until process termination. Unlike closures that can be garbage collected, this goroutine:

  • Holds references to channel c and cancel function
  • Blocks on channel operations, preventing GC
  • Intentionally permanent - it's a system-level daemon
  • Gets cleaned up only when entire process exits

Q5: What happens to the signal handler goroutine when the main goroutine exits?

The signal handler goroutine dies with the entire process when the main goroutine terminates.

Even if signal.Notify() is registered, the signal handler may never run if the program exits first.

// Case 1: Signal handler never gets a chance
func main() {
    ctx := SetupSignalHandler()
    worker(ctx)  // blocks main
    // if worker returns → main exits → signal handler may never run
}

// Case 2: Signal handler *might* work
func main() {
    ctx := SetupSignalHandler()
    go worker(ctx)      // worker runs in background
    <-ctx.Done()  // main explicitly waits for signal-triggered cancel
    // gives signal handler time to receive and cancel
    // but the signal handler may not run if the signal isn't received before main exits
}
Enter fullscreen mode Exit fullscreen mode

Key Point: The signal handler may receive signals and cancel context, but only if the main goroutine stays alive long enough. Go programs exit when main() returns, regardless of active goroutines.

Q6: Why handle two signals?

Two-stage shutdown: First signal starts graceful shutdown, second signal forces immediate termination (common Ctrl+C twice pattern).

Additional

Buffer Size of 2

c := make(chan os.Signal, 2)
Enter fullscreen mode Exit fullscreen mode

Allows buffering two signals that may arrive simultaneously or in quick succession. If buffer was 1 and two signals arrive before the goroutine processes them, the second signal could be lost.

Goroutine Behavior

Important: If only one signal is received, the goroutine will block forever on the second <-c:

go func() {
    <-c          // ✅ First signal received
    cancel()     // ✅ Context cancelled
    <-c          // ⏳ May block forever if no second signal
    os.Exit(1)   // Never reached
}()
Enter fullscreen mode Exit fullscreen mode

This is acceptable because the program terminates gracefully via context cancellation, making the blocking goroutine irrelevant.

Use case

  • In kubernetes sigs.k8s.io/controller-runtime
func (cm *controllerManager) Start(ctx context.Context) (err error) {
    cm.Lock()
    if cm.started {
        cm.Unlock()
        return errors.New("manager already started")
    }
    cm.started = true

    var ready bool
    defer func() {
        // Only unlock the manager if we haven't reached
        // the internal readiness condition.
        if !ready {
            cm.Unlock()
        }
    }()

    // Initialize the internal context.
    cm.internalCtx, cm.internalCancel = context.WithCancel(ctx)
...

Enter fullscreen mode Exit fullscreen mode

Top comments (0)