Skip to content

Handle interrupts quietly

A user pressing Ctrl+C has not encountered a failure — they have made a decision. Reporting that as ERROR interrupted by signal is noise: it looks like your tool broke when it did exactly what it was told.

But the run must still exit non-zero, because that is how the shell, CI, and any supervisor learn the run did not complete.

LevelFatalQuiet resolves the tension: exit exactly like LevelFatal, but log at debug.

Report an interrupt

err := errorhandling.WithExitCode(
    errors.Newf("interrupted by signal: %v", sig),
    128+int(sig.(syscall.Signal)),
)

handler.Check(err, "", errorhandling.LevelFatalQuiet)

The process exits 130 for SIGINT (128+2) or 143 for SIGTERM (128+15) — the convention shells and supervisors understand — while the notice stays out of the user's face. It is still there under debug, so anyone diagnosing a run sees it.

The general rule: reach for LevelFatalQuiet whenever a termination is expected and user-initiated, and the exit code is itself the signal — an error line would add nothing.

Wiring a signal watcher

The module handles reporting; catching signals is your program's job. A pattern worth copying, learned the hard way:

sigs := make(chan os.Signal, 2) // buffered for BOTH the graceful and force signals
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigs)

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

go func() {
    sig := <-sigs
    slog.Default().Warn("interrupt received — press again to force quit")
    cancel()           // first signal: graceful shutdown

    <-sigs
    os.Exit(130)       // second signal: force quit; a hung cleanup can't trap the user
}()

Three details that matter:

  • Buffer the channel to 2. An unbuffered channel can drop the second signal — the one the user sends because the first didn't work.
  • A second signal must force-exit. Otherwise a wedged cleanup traps them, and the only escape is kill -9.
  • Register both os.Interrupt and syscall.SIGTERM with no build tags. On Windows only os.Interrupt ever fires; the SIGTERM registration is harmless.

signal.NotifyContext looks like the obvious tool here and isn't quite enough: it cannot tell you which signal fired (you need that for 128+signum) and cannot observe a second signal (you need that for force-quit). signal.Notify plus context.WithCancel gives both.

The signal wins over the returned error

When a run is interrupted, the command tree usually returns context.Canceled — an artefact of the cancellation, not the reason the run ended. Prefer the signal:

err := runCommandTree(ctx)

if sig := receivedSignal(); sig != nil {
    flush()                                    // see below — before any fatal call
    handler.Check(interruptErr(sig), "", errorhandling.LevelFatalQuiet)

    return
}

if err != nil {
    handler.Fatal(err)
}

Flush before you exit, not in a defer

Both the quiet and loud fatal paths exit the process, so deferred cleanup in the calling frames never runs. This is the classic way a telemetry flush silently stops happening the moment a real error occurs.

Invoke pre-exit work explicitly before the fatal call, guarded by a sync.Once so the normal paths can still defer it — and give it a fresh, bounded context, never the one the interrupt just cancelled, or it aborts instantly.

Interactive prompts and Ctrl-C

If your tool renders a TUI prompt, the terminal is usually in raw mode — there, Ctrl+C arrives as a keystroke, not a signal. The prompt aborts; your outer watcher never fires. That is the behaviour users expect: a stray Ctrl-C cancels the question, not the whole run.

An external kill -INT is different: Go delivers it to every subscriber, so the prompt aborts and the run cancels with 130. That is the right semantic for supervisors — an external terminate should end the run, prompt or not.