skip to content

questions

4

Why does a Python process running as PID 1 ignore signal.SIGTERM?

level: middleimportance: must knowfreq 45%

answer

  1. The first process is special to the kernel
  2. Default action, not the usual meaning
  3. Only installed handlers get delivered
  4. CPython wires SIGINT, never SIGTERM
  5. One signal.signal call at startup

basics

~10 s

The kernel gives PID 1 no default action: a signal left at its default disposition is discarded, not acted on. CPython installs a handler for SIGINT but not SIGTERM, so call signal.signal(signal.SIGTERM, handler) yourself.

solid answer

~50 s

For the process with PID 1 the kernel refuses to apply a signal's default action. A signal is only delivered to PID 1 if that process explicitly installed a handler for it; otherwise it is thrown away. CPython, at startup, installs `signal.default_int_handler` for `signal.SIGINT` and ignores `signal.SIGPIPE`, but leaves `signal.SIGTERM` at `signal.SIG_DFL`. On an ordinary process SIG_DFL means terminate, which is where everyone's intuition comes from; at PID 1 it means discard. So the process keeps running through the whole stop grace period until an outside supervisor escalates to `signal.SIGKILL`, which arrives from an ancestor PID namespace and cannot be handled, and every cleanup path is skipped. The fix is one line at startup in the main thread: `signal.signal(signal.SIGTERM, handler)`, where the handler only flips a `threading.Event` and the real shutdown runs in the main loop.

code

python · 17 lines
python
import os
import signal
import threading

stop = threading.Event()


def on_term(signum, frame):
    stop.set()


signal.signal(signal.SIGTERM, on_term)
print("running as pid", os.getpid())
print("handler installed:", signal.getsignal(signal.SIGTERM) is not signal.SIG_DFL)
os.kill(os.getpid(), signal.SIGTERM)
stop.wait(1)
print("stop requested:", stop.is_set())

go deeper

for a junior

Recall the shape of the problem: a script that is the very first process in an isolated environment does not stop the way it does on your laptop. Be able to say that a handler has to be registered explicitly with signal.signal.

for a middle

Explain the mechanism: the kernel skips the default action for PID 1, CPython installs a handler for interrupts but not for terminate requests, and signal.SIG_DFL therefore means discard rather than exit. Show the one-line registration and where it belongs.

for a senior

Demonstrate the production consequences — the grace period burnt, the forced kill from outside, the skipped flush, the misleading signal exit status — and show a handler that only flips a flag while the main loop owns the drain.

for a principal

Own the policy: whether every service entrypoint installs the handler unconditionally or only when it detects it is PID 1, whether an init shim owns signal handling instead, and how you keep that decision from being re-discovered service by service.

### What PID 1 is, from the kernel's point of view The process with PID 1 is init: the first process the kernel starts, and the process every orphan is eventually handed to. PID namespaces mean this is not a once-per-machine role any more. The first process inside a namespace sees itself as PID 1 and inherits init's duties whether or not it was written to be an init. An interpreter launched as the single process of an isolated environment is exactly that process. The kernel protects PID 1 with one narrow rule, and the rule is the whole answer: > For PID 1, a signal whose disposition is still the **default action** is **discarded**. The kernel does not perform the default action. Only signals for which PID 1 has explicitly installed a handler are delivered at all. The reasoning is defensive: a stray terminate signal from any process in the tree should not be able to kill init and take the whole namespace down with it. There is one deliberate escape hatch. `signal.SIGKILL` and `signal.SIGSTOP` sent from an **ancestor** namespace are always delivered, because they can never be handled and the outer world must retain a way to destroy the tree. Sent from *inside* the namespace, even SIGKILL to PID 1 is discarded. ### What CPython sets up at startup CPython does not leave the signal table untouched. At interpreter start it installs `signal.default_int_handler` for `signal.SIGINT` — the handler that raises `KeyboardInterrupt` — and sets `signal.SIGPIPE` to `signal.SIG_IGN` so that a broken pipe surfaces as `BrokenPipeError` rather than killing the process. It leaves `signal.SIGTERM` alone, at `signal.SIG_DFL`. That single asymmetry produces the classic symptom. Run the same script from a shell and a terminate request kills it, because SIG_DFL for SIGTERM means *terminate*. Run it as PID 1 and the identical disposition means *discard*. The program appears to ignore stop requests entirely; an interrupt from a keyboard still works, because CPython installed that handler. That is also why the bug survives local testing — developers stop things with an interrupt — and only appears where a supervisor sends a terminate signal. What follows is worse than a slow shutdown. The supervisor waits out its grace period, then sends SIGKILL from outside the namespace, which is delivered and unconditionally fatal. Buffers are not flushed, an in-flight unit of work is abandoned mid-write, connections are dropped rather than closed, and the recorded exit status reports a signal kill instead of a clean exit — which downstream tooling reads as a crash. ### Installing the handler The fix is `signal.signal(signal.SIGTERM, handler)`, executed at startup. Two constraints matter. First, **placement**: `signal.signal` may only be called from the main thread of the main interpreter; anywhere else it raises `ValueError`. So registration belongs in start-up code, before worker threads exist. Second, **what the handler may do**. CPython does not run handlers in the signal context the way C does. It sets a flag, and the eval loop runs your Python callable at the next bytecode boundary in the main thread — at an arbitrary point in whatever the program was doing, including inside your own cleanup. So a handler must be tiny and re-entrancy-tolerant: set a `threading.Event` or a module-level flag and return. Put the actual drain-and-exit in the main loop, where ordering is something you can reason about. A handler that performs the whole shutdown can be entered again by a second signal halfway through the first. You can verify the wiring from inside the process: `signal.getsignal(signal.SIGTERM)` returns the callable you installed, and comparing it against `signal.SIG_DFL` at startup tells you nobody wired it. That check is worth logging once at boot for a program that is meant to run as PID 1. ### Adjacent ways to get the same symptom Two variants produce identical behaviour and are worth recognising. Wrapping the program in a shell — a string command line rather than an argument list — makes the shell PID 1; the shell then runs the interpreter as a child and does not pass a terminate signal on, so the handler you carefully installed never sees anything. And running the interpreter under a small init shim moves PID 1 to the shim, which changes who must handle and forward signals; the design of such shims belongs to the container tooling rather than to Python. From Python's side there are only two checkable facts: am I PID 1, and is there a handler installed for the signal I care about.

  • Why does an interrupt from the keyboard still stop the same process when a terminate signal does not?
    Because CPython installs `signal.default_int_handler` for `signal.SIGINT` at interpreter startup, so that signal has a handler and the kernel delivers it; the handler raises `KeyboardInterrupt` and the program unwinds. `signal.SIGTERM` is left at `signal.SIG_DFL`, and at PID 1 a default disposition means the signal is discarded. The asymmetry is purely about which handler CPython installed for you.
  • Can a process inside the same PID namespace kill its PID 1 with signal.SIGKILL?
    No. SIGKILL and SIGSTOP cannot have handlers, so from inside the namespace they fall under the same rule and are discarded for PID 1. Only an ancestor namespace can send a SIGKILL that is actually delivered, which is how an outside supervisor eventually destroys a process that ignored its stop request.
  • Why should the handler set a flag rather than run the shutdown itself?
    CPython runs the handler as ordinary Python in the main thread at the next bytecode boundary, so it can fire at any point — including inside your own cleanup. A second signal can re-enter a handler that is still shutting down. Setting a `threading.Event` and draining in the main loop keeps ordering explicit and makes the shutdown path testable without signals.

The kernel treats init like the last light switch in the building: it will not let anyone flip it by reflex, only someone who explicitly wired a switch to it.

saying these in an interview costs you the question

  • Says a terminate signal always kills a process without a handler
  • Thinks the interpreter registers a SIGTERM handler for you
  • Claims the signal is queued until a handler appears
  • Believes signal.SIGKILL from inside the namespace kills PID 1
  • Puts the whole shutdown sequence inside the handler
  • Registers the handler from a worker thread

context

open as a page

How can a Python program tell at startup that it is running as PID 1?

level: juniorimportance: should knowfreq 20%

basics

~20 s

Compare os.getpid() with 1; for the first process of a PID namespace os.getppid() also returns 0. Both report namespace-local numbers, so a process can be PID 1 inside an isolated environment while carrying a completely different PID outside it.

open as a page

How should a Python PID 1 forward signal.SIGTERM to the children it started?

level: middleimportance: should knowfreq 30%

basics

~20 s

A signal sent to PID 1 reaches only that process; children see nothing. Record the request in the handler, then have the main loop forward it with subprocess.Popen.send_signal, wait on a shared deadline, and escalate to signal.SIGKILL.

open as a page

How does a Python program at PID 1 reap the orphans it inherits?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Orphans are reparented to PID 1, and a dead child stays a zombie until its parent collects it. A Python program there must loop on os.waitpid(-1, os.WNOHANG) until it reports nothing more, on signal.SIGCHLD or each main-loop pass.

open as a page