skip to content

Why does Go's os package define os.Interrupt but leave SIGTERM to syscall?

level: middleimportance: should knowfreq 46%

answer

  1. one package promises every platform, one does not
  2. only two signal values are guaranteed
  3. a human sends one, a supervisor the other
  4. os.Kill is for sending, not receiving

basics

~20 s

The os package is portable, so it defines only the two signal values guaranteed everywhere: os.Interrupt and os.Kill. Everything else, including SIGTERM, lives in the platform-specific syscall package. Registering os.Kill or syscall.SIGKILL with signal.Notify never fires.

solid answer

~40 s

`os` is Go's portable operating-system layer, so it exports only what it can promise on every supported platform. For signals that is exactly two values -- `os.Interrupt`, meaning "interrupt this process", and `os.Kill`, meaning "force it to exit". Everything else, including SIGTERM, SIGHUP and SIGUSR1, is platform vocabulary and therefore lives in `syscall`, which is compiled per GOOS. That is why the idiomatic registration reads `signal.Notify(ch, os.Interrupt, syscall.SIGTERM)` -- one portable value and one Unix value, mixed deliberately, because a real service is stopped by a human at a terminal (SIGINT) and by a supervisor (SIGTERM). The trap is `os.Kill`: it is a value you can *send* to another process, never one you can receive. Registering it, or `syscall.SIGKILL`, compiles cleanly and simply never delivers anything.

code

go · 7 lines
go
ch := make(chan os.Signal, 1)

// the human at a terminal, and the supervisor
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)

// compiles, never delivers: SIGKILL cannot reach a handler
signal.Notify(ch, os.Kill)

go deeper

for a junior

Know the idiomatic line by heart -- signal.Notify(ch, os.Interrupt, syscall.SIGTERM) -- and that os.Interrupt is Ctrl-C while SIGTERM is what stops a running service.

for a middle

Explain the split: os is the portable layer and guarantees only os.Interrupt and os.Kill, while syscall is generated per platform. Say why os.Kill can be sent but never received.

for a senior

Show that you design for the ungraceful case too: because SIGKILL cannot be observed, correctness after a forced stop has to come from idempotent work or recoverable state, not a handler.

for a principal

Own the convention across services: which signals every binary registers, what each one is allowed to take time over, and what the team's contract with its supervisor actually promises when the polite path is skipped.

## Two packages, two jobs Go splits operating-system access deliberately. `os` is the portable layer: everything it exports is meant to work the same way on Linux, macOS, Windows, and the rest. `syscall` is the platform layer: its contents are generated per GOOS and GOARCH, and what you find there on Linux is not what you find on Windows. Signals sit awkwardly across that line. The *concept* of interrupting a process exists everywhere in some form; the specific numbered signals of POSIX do not. So the `os` package's documentation is explicit that the only signal values guaranteed to be present on all systems are `os.Interrupt` (send the process an interrupt) and `os.Kill` (force the process to exit). On Unix those correspond to SIGINT and SIGKILL. There is no `os.Terminate`, no `os.Hangup`, no `os.UserSignal1` -- if you want SIGTERM you write `syscall.SIGTERM` and accept that you have written a line whose meaning is tied to the platform. ## What this means for the registration you actually write The near-universal line in a Go service is: ``` signal.Notify(ch, os.Interrupt, syscall.SIGTERM) ``` That mix is not sloppiness, it is the honest expression of two different callers. `os.Interrupt` covers the human: a developer running the binary in a terminal and pressing Ctrl-C, which makes the terminal driver send SIGINT to the foreground process group. `syscall.SIGTERM` covers the machine: an init system, a process supervisor, or anything else that stops long-running services politely. A program that registers only `os.Interrupt` behaves beautifully in local development and is killed abruptly in production, which is one of the more common ways graceful shutdown code turns out never to have run. ## The os.Kill trap `os.Kill` looks symmetrical with `os.Interrupt` and is not. It exists so you can *send* it -- `p.Signal(os.Kill)` on an `*os.Process` -- not so you can receive it. Passing it to `signal.Notify` is accepted by the compiler and by the package, and then simply never produces a delivery, because the operating system does not give a process the chance to observe SIGKILL. The same is true of `syscall.SIGKILL` and `syscall.SIGSTOP`. This is worth stating plainly in an interview because the failure mode is invisible: the code reads as though it handles the forceful case, reviewers nod, and the cleanup path it guards is dead. If your design needs work to happen before a forced kill, that work has to happen on a *different* trigger -- the polite signal, a health check flipping to unready, or an external record of intent -- because there is no in-process hook to hang it on. ## Portability in practice On Windows, Ctrl-C in a console arrives as `os.Interrupt`, so the portable half of the registration does the right thing. `syscall.SIGTERM` is a constant Go defines there for source compatibility, but the platform has no POSIX signalling model behind it, so nothing in normal operation delivers it. Code that must run on both worlds either keeps the registration list as-is -- harmless, because an undeliverable registration is a no-op -- or splits it with build-constrained files when the shutdown paths genuinely differ. A related portability wrinkle: signal *values* are `os.Signal`, an interface with `String()` and `Signal()`, and the concrete Unix type is `syscall.Signal`, an integer. Logging the value you received is therefore free and readable -- `"interrupt"` or `"terminated"` -- and is worth doing, because when someone later asks whether the process was stopped by a person or by the supervisor, the log line already answers it. ## What the interviewer is checking Three things. That you know the split exists and why (portable layer versus platform layer, not arbitrary API design). That you register both the terminal signal and the supervisor signal rather than just the one you see in development. And that you know a registration for SIGKILL is a no-op, so no design may depend on running code when it arrives. Getting the third one wrong is the answer that costs you the question, because it means a cleanup path you believe in does not exist.

  • What breaks in production if a service registers only os.Interrupt?
    Local development looks fine, because Ctrl-C sends SIGINT. A supervisor stopping the service sends SIGTERM instead, which is not registered, so the runtime's default action terminates the process outright and the shutdown path never executes. Registering syscall.SIGTERM alongside os.Interrupt is the fix.
  • If SIGKILL cannot be caught, how do you make cleanup reliable?
    You cannot, in-process. Anything that must survive a forced kill has to be externally recoverable -- idempotent work, a durable record of in-flight state, or a lease that expires -- rather than something a handler tidies up. The in-process path is best-effort on the polite signal only.
  • How do you tell in the logs which signal stopped the process?
    The received value is an os.Signal whose String method yields "interrupt" or "terminated", so logging it directly answers the question later. It costs one field and distinguishes a person pressing Ctrl-C from a supervisor stopping the service.

saying these in an interview costs you the question

  • Believes registering syscall.SIGKILL lets you clean up before a forced kill
  • Looks for os.Terminate as the counterpart to os.Interrupt
  • Registers only os.Interrupt in a service run by a supervisor
  • Thinks os.Kill is a signal you can receive
  • Assumes every syscall.SIGxxx constant behaves identically on all platforms