skip to content

A Go REPL calls signal.Notify for SIGINT and now Ctrl-C does nothing at all. Why, and how do you get an escape hatch back?

level: seniorimportance: nice to knowfreq 34%

answer

  1. registering it took something away too
  2. delivery is best effort, not queued
  3. ask who is on the other end of the channel
  4. one signal still has its default handler
  5. Stop hands the signal back

basics

~20 s

signal.Notify disabled the default termination for SIGINT, so an undrained channel means Ctrl-C is dropped and ignored. Press Ctrl-backslash: SIGQUIT still has its default handler and dumps every goroutine's stack, showing where the reader is parked.

solid answer

~50 s

Registering a signal is a trade: `signal.Notify` stops the runtime from terminating the process for SIGINT and makes delivery your problem. If the goroutine that was meant to receive has returned, is blocked elsewhere, or the one-slot buffer already holds an earlier signal, the non-blocking send is discarded -- and because the default action is gone, the program neither shuts down nor reacts. Ctrl-C becomes a no-op. To diagnose it, press Ctrl-backslash: SIGQUIT is still handled by the runtime's default, which prints a stack dump of every goroutine and exits, and that dump shows exactly what the intended reader is blocked on. The fixes are to keep the reader alive for as long as the registration lives, and to call `signal.Stop(ch)` when a component is done with the signal so the default termination comes back rather than staying disabled forever.

code

go · 15 lines
go
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
defer signal.Stop(ch) // no listener left -> SIGINT terminates again

for {
	select {
	case sig := <-ch:
		fmt.Println("\naborted by", sig)
	case line, ok := <-lines:
		if !ok {
			return
		}
		run(line)
	}
}

go deeper

for a junior

Take away the core fact: once signal.Notify registers SIGINT, Ctrl-C no longer stops the program on its own. Something in your code must receive and act.

for a middle

Explain the three ways a delivery is lost -- full buffer, returned reader, blocked reader -- and what signal.Stop restores when the last registration for a signal goes away.

for a senior

Walk the diagnosis end to end: reach for SIGQUIT's stack dump, read which goroutine is parked where, then fix ownership of the channel rather than widening the buffer.

for a principal

Make the rule explicit for the codebase: which layer owns signal registration, that it is registered once near main, and that no library quietly takes signals away from the program it is linked into.

## The trade you made `signal.Notify` does two things at once, and engineers usually only remember the first. It starts relaying the named signals to your channel, **and** it stops the runtime applying the default action for them. Before the call, SIGINT killed the process. After it, SIGINT is entirely your responsibility -- and if you drop it, nothing else picks it up. That is how a program becomes *less* interruptible by adding signal handling. Consider the shape this leaf is about: an interactive terminal program, a REPL, that reads a line from the user, runs a command, prints, and loops. Ctrl-C is supposed to abort the running command. Someone added `signal.Notify` and a goroutine to read the channel. Six months later, a new joiner reports that Ctrl-C sometimes does nothing, and eventually that it never does. ## The three ways delivery is lost 1. **Nobody is receiving and the buffer is full.** Package signal performs a non-blocking send. If the channel has capacity 1 and an earlier signal is still sitting in it unread, every later delivery is discarded. 2. **The reader goroutine returned.** A reader written as a one-shot -- receive once, run cleanup, return -- leaves the registration in place with nothing behind it. The first Ctrl-C works, and every one after it is silently dropped. 3. **The reader is blocked on something else.** A reader in a `select` that also waits on a mutex-guarded resource, or that calls into the command being aborted, can be parked exactly when the signal arrives; with the buffer already full, the delivery is gone. In all three cases there is no error, no log line, no counter. Silence is the entire symptom. ## Getting a diagnosis with SIGQUIT The escape hatch the terminal already gives you is Ctrl-backslash, which sends SIGQUIT. Unless your program registered SIGQUIT too, the Go runtime still handles it with its default behaviour: print a stack dump of every goroutine, then exit. That dump is precisely the diagnostic you want here, because it names the function each goroutine is parked in. You will see either that no goroutine is sitting on a receive from the signal channel at all (case 2), or exactly what the intended reader is blocked on (case 3). Setting `GOTRACEBACK=all` before starting the process widens the dump when goroutines are being elided. The comparison worth internalising: the handler that was *supposed* to run left no trace, while the runtime's default handler for a signal you never touched produced the complete picture. That is a good argument for not registering every signal reflexively -- `signal.Notify(ch)` with no signal arguments relays everything, which quietly takes away SIGQUIT's stack dump as well. ## Fixes **Keep the receiver alive as long as the registration.** The reader should be a loop for the process lifetime, not a one-shot, if the program intends to handle repeated interrupts. In a REPL, that usually means the signal channel is one case of the main loop's `select`, alongside the channel of input lines, so it is drained on every iteration. **Unregister when you are done.** `signal.Stop(ch)` undoes every prior `Notify` for that channel; once no channel remains registered for a signal, it reverts to its default behaviour -- for SIGINT, terminating the program again. A component that handles a signal for part of a program's life should defer `signal.Stop`, so the interval where the process is uninterruptible is bounded by the interval where something is actually listening. `signal.Reset(sig...)` is the signal-oriented counterpart: it undoes registrations for the named signals regardless of channel. **Know what signal.Ignore does, and that it is not this.** `signal.Ignore(sig...)` makes the named signals be discarded outright -- no channel, no default action. It is occasionally right for a program that must not be stopped by a stray SIGHUP, and it is exactly the wrong reach when the real problem is that nothing is draining a registered channel. The distinction interviewers listen for: `Notify` redirects, `Stop`/`Reset` restore the default, `Ignore` throws away. **Log the handling, not just the shutdown.** One line at the point the signal is received -- naming the signal -- converts "Ctrl-C does nothing" from a mystery into an observation about whether the reader ran at all. ## What is never fixable in-process Whatever you do here, SIGKILL remains undeliverable, so a genuinely wedged program can always be stopped from outside. That is the honest last line of the answer, and it is also the reason this bug survives so long in real codebases: the person hitting it just kills the terminal and moves on rather than filing it.

  • Why is Ctrl-backslash still useful when Ctrl-C has been captured?
    SIGQUIT was never registered, so the Go runtime's default handling applies: it prints a stack dump of every goroutine and exits. That dump shows whether any goroutine is parked on a receive from the signal channel, which distinguishes a dead reader from a blocked one.
  • What is the difference between signal.Stop and signal.Ignore?
    signal.Stop(ch) undoes prior Notify calls for that channel; once no channel is registered for a signal it goes back to its default behaviour, so SIGINT terminates the process again. signal.Ignore(sig...) instead discards the named signals entirely -- no channel and no default action.
  • Would a bigger buffer have prevented this?
    Only case one, where a full one-slot buffer crowds out later deliveries. A reader that has returned or is blocked forever will fill any buffer eventually, and the registration keeps default termination disabled the whole time. The real fix is a receiver that lives as long as the registration.

saying these in an interview costs you the question

  • Blames the terminal instead of the disabled default action
  • Adds a bigger buffer without checking whether anyone still reads
  • Uses signal.Ignore to 'reset' a channel registration
  • Thinks a dropped signal falls back to killing the process
  • Registers every signal with signal.Notify(ch) and loses the SIGQUIT stack dump