Why does bash set $? (exit status) to non-zero on Ctrl-C or Ctrl-Z?

Because 0 is the exit code for a normal exit state.

Intercepting an Interrupt or Break signal is not a usual exit state, nor is being suspended to the background. The non-zero exit codes tell you this is what is happening so that you can react accordingly in a script if the job it fires off is killed or suspended rather than exiting conventionally with a non-error state.

The interactive shell session, when you press ^C, which throws a SIGINT signal (signal 2), aborts the current interactive command entry, which is a non-normal state for the command entry (i. e. the command prompt) to be in. This causes it to return status 130 (128+2), and give you a new prompt.

More details can be found at http://tldp.org/LDP/abs/html/exitcodes.html#EXITCODESREF.


When you press Ctrl+C on the command line, nothing exits, but the handler for SIGINT (sigint_sighandler()) sets the exit status to 130 (128 + 2, as DopeGhoti's answer explains) anyway:

if (interrupt_immediately)
  {
    interrupt_immediately = 0;
    last_command_exit_value = 128 + sig;
    throw_to_top_level ();
  }

And in throw_to_top_level():

if (interrupt_state)
  {
    if (last_command_exit_value < 128)
    last_command_exit_value = 128 + SIGINT;
    print_newline = 1;
    DELINTERRUPT;
  }

When you press Ctrl+C to kill a background process, the shell observes that the process has died and also sets the exit status $? to 128 plus the signal number.

When you press Ctrl+Z to suspend a background process, the shell observes that something has happened to the process: it hasn't died, but the information is reported through the same system call (wait and friends). Here as well, the shell sets the exit status $? to 128 plus the signal number, which is 148 (SIGTSTP = 20).