Signals and Context in CLIs

Updated

September 8, 2026

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

  1. sleep 30 under your CLI with ctx; Ctrl-C and confirm quick exit.
  2. Download a large file; cancel mid-way; ensure partial file is removed or kept intentionally.
  3. Unit-test run with a pre-canceled context.