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
}
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
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:
-
signal.Notify(c, SIGTERM)
→ Go runtime callssignal.signal(SIGTERM, handler)
- OS receives signal → calls Go's C signal handler
- 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
andcancel
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
}
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)
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
}()
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)
...
Top comments (0)