Signals and Context in CLIs
Signals and Context in CLIs
Overview
Users hit Ctrl-C. Deployments send SIGTERM. Good CLIs cancel work, close files, and exit promptly. Wire os/signal to context and pass that context through I/O and exec.
Minimal graceful cancel
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Args[1:]); err != nil {
if errors.Is(err, context.Canceled) {
fmt.Fprintln(os.Stderr, "interrupted")
os.Exit(130)
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}signal.NotifyContext (Go 1.16+) is the preferred pattern.
Pass ctx everywhere
func run(ctx context.Context, args []string) error {
// HTTP
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
// ...
// Exec
cmd := exec.CommandContext(ctx, "sleep", "60")
// Files: poll ctx in loops
return workLoop(ctx)
}
func workLoop(ctx context.Context) error {
for i := 0; i < 1000; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// unit of work
}
return nil
}Long-running workers
func runWorkers(ctx context.Context, jobs <-chan Job) error {
var wg sync.WaitGroup
defer wg.Wait()
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
process(ctx, j)
}
}
}()
}
// producer closes jobs when done; main waits via defer wg.Wait
// cancel via signal cancels ctx so workers return
return nil
}Ignore SIGPIPE? (advanced)
Writing to a closed pipe (downstream head exited) can raise SIGPIPE. Go’s runtime usually converts write errors to EPIPE—check write errors on stdout:
if _, err := fmt.Fprintln(os.Stdout, line); err != nil {
return err // broken pipe — exit quietly or 1
}Example: cancellable download CLI
func download(ctx context.Context, url, dest string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body) // Copy stops when ctx cancels the body
return err
}Use a client with timeout as a backstop even when signals work.
Double Ctrl-C
Some tools escalate: first signal cancels gracefully; second forces os.Exit(1). Keep it simple for most CLIs—one cancel is enough if work respects ctx.
go func() {
<-ctx.Done()
// optional: force exit after grace period
t := time.NewTimer(5 * time.Second)
defer t.Stop()
select {
case <-t.C:
fmt.Fprintln(os.Stderr, "forcing exit")
os.Exit(1)
}
}()Rules of thumb
| Do | Don’t |
|---|---|
NotifyContext at main |
Catch signals deep in libraries |
| Propagate ctx to HTTP/exec | Ignore cancel in tight loops |
| Map cancel to clear message | Hang forever after Ctrl-C |
| Check write errors | Assume stdout always works |
Try next
sleep 30under your CLI with ctx; Ctrl-C and confirm quick exit.- Download a large file; cancel mid-way; ensure partial file is removed or kept intentionally.
- Unit-test
runwith a pre-canceled context.