Signals notify processes or threads of events using kernel-defined delivery rules. A signal’s disposition selects a default action, an installed handler, or ignoring it where permitted. A thread’s mask can block delivery of eligible signals while they remain pending. Separating disposition, masking, and pending state is essential when a program appears to miss repeated notifications or fails to stop as expected.
When it breaks
Ordinary signals are not a reliable event counter. Several occurrences of one standard signal can coalesce while it is already pending. An application that treats each SIGUSR1 as one queued work item can therefore lose that counting relationship. Real-time signals have queuing semantics, but their resources are finite and still require a deliberate application protocol.
Signal names do not promise application features. SIGHUP commonly requests reload in particular daemons, but its default action is termination. SIGTERM also terminates by default; it becomes a graceful shutdown request only when the application implements the necessary behavior. Successfully sending a signal does not prove that configuration reloaded or that in-flight work drained.
SIGKILL and SIGSTOP cannot be caught, ignored, or blocked. KILL provides no opportunity for application cleanup, and even it should not be described as guaranteeing instantaneous resource release in every kernel state. A supervisor must observe actual completion. Use symbolic signal names instead of assuming all numerical assignments are identical across platforms.
Check it quickly
Save the following as signals.py and run python3 signals.py in a disposable Linux environment. The recorded run used Linux 6.12.76 and Python 3.11.2. The script signals only itself and children it creates, restores its original mask, and confines cleanup markers to a temporary directory:
from pathlib import Path
from tempfile import TemporaryDirectory
import errno,os,signal,subprocess,sys
watched={signal.SIGUSR1,signal.SIGRTMIN}
old=signal.pthread_sigmask(signal.SIG_BLOCK,watched)
try:
for _ in range(5):os.kill(os.getpid(),signal.SIGUSR1)
assert signal.SIGUSR1 in signal.sigpending()
assert signal.sigwait({signal.SIGUSR1})==signal.SIGUSR1
assert signal.sigtimedwait({signal.SIGUSR1},0) is None
for _ in range(3):os.kill(os.getpid(),signal.SIGRTMIN)
for _ in range(3):assert signal.sigtimedwait({signal.SIGRTMIN},0).si_signo==signal.SIGRTMIN
assert signal.sigtimedwait({signal.SIGRTMIN},0) is None
print('five blocked standard signals coalesce into one; three blocked real-time signals remain queued')
finally:signal.pthread_sigmask(signal.SIG_SETMASK,old)
for sig in [signal.SIGKILL,signal.SIGSTOP]:
try:signal.signal(sig,signal.SIG_IGN)
except OSError as error:assert error.errno==errno.EINVAL
else:raise AssertionError('uncatchable signal accepted a disposition change')
old=signal.pthread_sigmask(signal.SIG_BLOCK,{signal.SIGKILL,signal.SIGSTOP})
assert not {signal.SIGKILL,signal.SIGSTOP}&signal.pthread_sigmask(signal.SIG_BLOCK,set())
signal.pthread_sigmask(signal.SIG_SETMASK,old)
print('SIGKILL and SIGSTOP cannot be ignored or added to the blocked mask')
worker=r'''
from pathlib import Path
import signal,sys
if int(sys.argv[1]):signal.signal(signal.SIGTERM,lambda *_:sys.exit(0))
try:
print('ready',flush=True)
while True:signal.pause()
finally:Path(sys.argv[2]).write_text('cleanup')
'''
with TemporaryDirectory(prefix='xbcatm-signals-') as directory:
for name,sig,handled,expected,cleaned in [
('hup-default',signal.SIGHUP,False,-signal.SIGHUP,False),
('term-default',signal.SIGTERM,False,-signal.SIGTERM,False),
('term-handled',signal.SIGTERM,True,0,True),
('kill',signal.SIGKILL,True,-signal.SIGKILL,False)]:
marker=Path(directory)/name
process=subprocess.Popen([sys.executable,'-c',worker,str(int(handled)),str(marker)],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
try:
assert process.stdout.readline().strip()=='ready'
os.kill(process.pid,sig);output,error=process.communicate(timeout=5)
assert process.returncode==expected,(name,process.returncode,error)
assert marker.exists()==cleaned,name
print(name,'returncode',process.returncode,'cleanup',cleaned)
finally:
if process.poll() is None:process.kill();process.wait()
First, the script blocks SIGUSR1, sends it to itself five times, and confirms it is pending. One synchronous wait consumes it; a second zero-time wait finds nothing. Blocking before sending makes this demonstration deterministic. It establishes coalescing of a pending standard signal, not a timing-dependent guess based on how many asynchronous callbacks happened to print.
The same experiment sends three instances of SIGRTMIN while blocked. Three waits retrieve three instances, followed by an empty wait. This contrasts the queuing rules using synchronous consumption. The test does not measure queue capacity, priority across different real-time signal numbers, or delivery of an application payload.
Attempts to ignore KILL and STOP fail with EINVAL. Adding them to the blocked mask also leaves them absent from the actual mask. These checks distinguish a rejected disposition change from a mask request whose unmaskable members are ignored; neither operation makes those signals controllable by the application.
Four child-process cases then separate sending from cleanup. Default HUP and default TERM terminate their children without creating the marker in Python’s finally block. A child with an explicit TERM handler exits through Python and creates the marker. A final child has that same TERM handler, but KILL terminates it without running the cleanup.
The recorded Python subprocess return codes are negative for signal termination: minus the terminating signal number. The handled TERM case exits normally with zero. Those values are Python’s process-status representation, not a universal shell exit-code convention. The fixture checks both the status and marker so neither one alone substitutes for observing the cleanup behavior.
Put work in the appropriate execution context
In native signal handlers, only async-signal-safe operations are suitable. Ordinary logging, allocation, and many library calls can deadlock or corrupt state if interrupted midway. A common design records a minimal notification and lets normal program flow perform coordinated shutdown. The exact mechanism should fit the runtime and its supported event loop.
Python handlers run through the interpreter’s signal machinery in the main thread, rather than executing arbitrary Python inside the low-level C handler. This is why the small TERM example can raise SystemExit. It is not a model for doing arbitrary work in a C handler, nor a guarantee that a long-running native operation will promptly yield to Python cleanup.
Define the expected response, an observation mechanism, and an appropriate deadline when controlling a service. A reload should expose evidence that the new configuration took effect; a shutdown should stop admissions, finish or abandon work according to policy, and report completion. Escalation changes termination behavior, but cannot retroactively supply cleanup the application never implemented.
Sources
- signal(7), default actions, masks, standard coalescing, and real-time queues, checked 2026-09-12.
- signal-safety(7), async-signal-safe restrictions for native signal handlers, checked 2026-09-12.
- Python 3.11 signal documentation, Python handler execution, synchronous waiting, and signal masks, checked 2026-09-12.
- kill(2), signal delivery permissions and return semantics, checked 2026-09-12.