Skip to content

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:

  1. 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.
  2. 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).
  3. The "no handler" case is terminate, not no-op. For most signals (including SIGUSR1 / SIGUSR2), the default disposition is terminate. 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_DFL for SIGUSR2, so kill -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 -USR2 to 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: @postfork decorator.
  • 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_fork for DB connection reset + signal handler installation).
  • Node.js cluster mode (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.

Seen in

Last updated · 319 distilled / 1,201 read