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.Interruptandsyscall.SIGTERMwith no build tags. On Windows onlyos.Interruptever 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.