Well, it's two-fold.
On the one hand, as @Flimzy pointed out, it's shell intervening.
On the other hand, what is missing from his remark is why this happens.
The explanation is, again, two-fold:
- A process has certain default signal handling disposition which is how the process reacts to certain signals. A signal can be ignored, handled or left as is which, in most if not all cases means killing the process.
You can read more about this here.
Note that in non-trivial processes such as programs written in Go which have intricate runtime system, the default signal disposition may be different from that of "more plain" processes.
By default the SIGINT
signal is not handled meaning it kills the process.
- GNU Bash shell adds 128 to the exit code of a process if it was killed with a fatal signal. More about this — in the bash manual.
Update on the behaviour in Windows.
Let's first put up a quick fact sheet:
- Windows does not support the concept of Unix signals, at all.
- The way terminal-aware programs work on Unix-like systems is very different from the way console-aware programs work on Windows.
Say, the way Vim looks and behaves in a "Git bash" windows on Windows may look very similar to how it looks in a GNOME Terminal window on a Linux-based OS but the underlying differences are profound.
Let's now dig a bit deeper.
Unix was born without any notion of GUI and the users would interact with a Unix system using hardware terminals.
In order to support them, kernels of Unix-like OSes implement special standardized way to make terminal-aware programs interact with the system; if you're "into" deep-diving into technical details, I highly recommend reading the "TTY demystified" piece.
The two more important highlights of this approach are:
- The terminal subsystem is used even by programs running in what the contemporary generation of freshmen calls "terminals"—in windows which typically start out running a shell, in which you call various command-line programs, including those using "full screen"—such as text editors.
This basically means if you take, say Vim or GNU Nano, it will run just fine in any graphical terminal emulator, or directly on Linux's "virtual terminal" (those textual screen you can get on a PC by hitting Ctrl-Alt-F1 or booting with GUI turned off) or on a hardware terminal attached to the computer.
- The terminal subsystem allocates certain codes a keyboard may send to it to perform certain actions—as opposed to sending those coder directly to the program attached to that terminal, and Ctrl-C is one of them: in a common default setup pressing that combination of keys makes the terminal subystem send the foreground process the
SIGINT
Unix signal.
The latter is of particular interest. You can run stty -a
in a terminal window on your Linux system; amoung the copious output you'd see something like intr = ^C; quit = ^\;
which means Ctrl-C sends interactive attention (SIGINT
) signal and Ctrl-\ sends SIGQUIT
(yes, "INT" in "SIGINT" does not stand for "interrupt"—contrary to a popular belief).
You could reassign these key combos almost at will (though it's not a wise thing to do as many pieces of software expect ^C
and ^\
to be mapped the way they usually do and do not assign their own actions to these gestures—rightfully expecting to not be able to actually ever receive them.
Now back to Windows.
On Windows, there is no terminal subsystem, and no signals.
Console window on Windows was an artefact required to provide compatibility with the older MS-DOS system, and there the situation was like this: Ctrl-Break would trigger a hardware interrupt usually handled by the OS, and Ctrl-C could be explicitly enabled to do the same. The implementation of the console emulation on Windows carefully emulated this behaviour, but since Windows does not have Unix-like signals, the handling of these keyboard combos is done differently—though with much the same effect:
Each console process has its own list of application-defined HandlerRoutine
functions that handle CTRL+C
and CTRL+BREAK
signals. The handler functions also handle signals generated by the system when the user closes the console, logs off, or shuts down the system. Initially, the handler list for each process contains only a default handler function that calls the ExitProcess
function.
What this all means to Go?
Let's first see the docs:
~$ GOOS=windows go doc os.Interrupt
package os // import "os"
var (
Interrupt Signal = syscall.SIGINT
Kill Signal = syscall.SIGKILL
)
The only signal values guaranteed to be present in the os package on all systems are os.Interrupt
(send the process an interrupt) and os.Kill
(force the process to exit). On Windows, sending os.Interrupt
to a process with os.Process.Signal
is not implemented; it will return an error instead of sending a signal.
So, in a Go program running on Windows you can handle these two "signals"—even though they were not really be implemented as signals.
Let's now move to explaning the difference in the exit codes.
As you know by now, pressing Ctrl-C
when a program is running in a terminal emulator windows on a Unix-like system will make the terminal subsystem send the process the actual SIGINT
signal.
If this signal is not explicitly handled, the process gets killed by the OS (as that's what the default signal disposition says).
The shell notices that a process it spawned suddenly died, collects its exit code and adds 128 to it (because it wasn't expecting it to die that way).
On Windows, hitting Ctrl-C
makes the process perform the ExitProcess
system call, which, form the point of view of the shell process looks like normal process exit: it cannot tell this exit apart from the one occured if the process were to call os.Exit(0)
explicitly.
ksh
you would get258
, andcsh
would only show1
. – Fryd