CONCEPT Cited by 1 source
Signal-handler fork inheritance¶
Definition¶
Signal-handler fork inheritance is the set of POSIX rules that
govern what happens to installed signal handlers when a process
calls fork(2). The rules are simple on paper:
- The child inherits the parent's signal dispositions (per-signal handler or SIG_IGN / SIG_DFL).
- The child inherits the parent's signal mask (blocked signals).
- The child starts with an empty set of pending signals.
In practice they create a confusing class of production bug: a signal handler you thought was installed for a worker process turns out to be installed for nobody who will actually receive the signal, or — worse — turns out to have been silently replaced.
Why it gets confusing¶
Three factors compound:
- Frameworks reset handlers post-fork. Gunicorn, uWSGI, Unicorn, Puma and most pre-fork servers explicitly reset some or all signals to defaults in the worker before invoking user hooks. A handler registered in the leader at import time is therefore inherited at fork, but may be overwritten microseconds later by the worker's own signal-init code. The POSIX inheritance guarantee is honoured; it just doesn't survive long enough to matter.
- Handlers close over leader-local state. Even if the handler survives into the worker, its closure may reference the leader's open files, sockets, or in-memory state — which either don't exist in the worker (distinct FDs after COW) or are shared-and-therefore-dangerous (sockets hand to both parent and child).
- The "no handler" case is terminate, not no-op. For most
signals (including
SIGUSR1/SIGUSR2), the default disposition isterminate. If your registration didn't make it into the worker, sending the signal kills the process. Failure is loud, but the cause — a registration gap — is quiet.
The footgun in Python pre-fork servers¶
gunicorn with preload_app = True imports the application in the
leader before fork. Any code that runs at import time — including
signal.signal(signal.SIGUSR2, handler) — runs in the leader:
- Without preload: the worker runs the import itself, so the handler is registered in the worker's own process table where the signal will be delivered. Works.
- With preload: the leader runs the import. The handler is
installed in the leader. The leader then forks. Whether the
worker actually ends up with the handler depends on whether
gunicorn's worker-init resets signal dispositions (it does, for
most signals, to ensure clean supervisor semantics). Net: the
worker ends up with
SIG_DFLforSIGUSR2, sokill -USR2 <worker-pid>terminates the worker.
Lyft hit this exactly:
"Since the app had preload=True, only the leader process was registering the USR2 signal to handle the tracing. The worker process did not register due to copy-on-write and that causes any
kill -USR2to actually kill the process!" (Source: sources/2025-12-15-lyft-from-python38-to-python310-memory-leak)
The mechanistic framing "did not register due to copy-on-write" is
slightly loose — POSIX does inherit handlers across fork, and COW
applies to memory pages, not to kernel-level signal dispositions.
The load-bearing actor is gunicorn's post-fork signal reset, not
COW itself. The operational lesson is identical either way (see
below). Flagged ⚠️ mechanism-framing on the source page; not
counted as a contradiction because the fix is the same.
The fix¶
Register signal handlers after fork, inside the worker, not at import time. Every major pre-fork server exposes a hook for this:
- gunicorn:
post_fork(server, worker)in the config module. - uWSGI:
@postforkdecorator. - Unicorn / Puma:
after_fork do |server, worker|block. - Any fork()-yourself server: between
os.fork()returning 0 and the worker's main loop.
Example (gunicorn config):
# gunicorn.conf.py
def post_fork(server, worker):
import signal
from mysvc.mem_profiler import MemoryProfiler
MemoryProfiler().register_handlers()
This runs in the worker, after gunicorn's own signal reset, so the handler is installed where the signal will actually be delivered.
Generalises beyond Python¶
The same pattern applies to:
- Ruby / Unicorn / Puma with large Rails applications (preload +
after_forkfor DB connection reset + signal handler installation). - Node.js
clustermode (master forks workers;process.on( 'SIGUSR2', ...)in the worker, not in a module imported pre- fork by the master). - Any C / Go daemon that prefers pre-fork to thread-per- connection, if it does pre-fork setup in the supervisor.
Anywhere the supervisor process initialises shared state before forking is a place this class of bug can live.
Related¶
- concepts/pre-fork-copy-on-write — the memory-sharing mechanism usually cited as the reason to preload in the first place; creates the temptation to do more at import time.
- systems/gunicorn — the canonical Python example.
- patterns/signal-triggered-heap-snapshot-diff — the heap-profiling pattern that exposes this footgun in production because it specifically targets workers by PID.
Seen in¶
- sources/2025-12-15-lyft-from-python38-to-python310-memory-leak
— Lyft's memory-profiler was installed at import time, hit the
gunicorn
preload=Truefootgun, and terminated the worker on the firstkill -USR2. "Several hours of debugging" to localise the cause topreload.