Overview
Signal handling in Ruby provides access to Unix signal mechanisms through the Signal
module and Kernel#trap
method. Ruby processes can register signal handlers, send signals to other processes, and manage signal delivery to control program execution flow.
The Ruby signal system wraps Unix signal functionality with Ruby-specific behavior. When a signal arrives, Ruby interrupts normal execution and invokes the registered handler. Signal handlers execute asynchronously, creating concurrency concerns within single-threaded Ruby programs.
# Register signal handler
Signal.trap("INT") { puts "Caught interrupt" }
# Send signal to current process
Process.kill("USR1", Process.pid)
# List available signals
Signal.list
# => {"EXIT"=>0, "HUP"=>1, "INT"=>2, "QUIT"=>3, ...}
Ruby handles signals differently than C programs. Signal handlers run in the main Ruby thread, not as interrupts. Ruby queues signals and delivers them at safe points during execution to maintain interpreter consistency.
The Signal
module provides class methods for signal management, while Kernel#trap
offers instance-level signal registration. Both mechanisms register handlers that persist until explicitly changed or the process terminates.
Signal handling affects Ruby's thread scheduler and garbage collector. Ruby delays signal delivery during critical interpreter operations, which can delay handler execution. This design prevents interpreter corruption but introduces timing considerations for signal-dependent code.
Basic Usage
The trap
method registers signal handlers using signal names or numbers. Handlers can be blocks, method names, or predefined actions like "IGNORE" or "DEFAULT".
# Block handler
trap("INT") do
puts "Interrupt received at #{Time.now}"
exit(1)
end
# Method name handler
def handle_usr1
puts "USR1 signal received"
end
trap("USR1", method(:handle_usr1))
Signal names accept both full names and abbreviated forms. Ruby converts string names to appropriate signal constants for the current platform.
# Equivalent signal specifications
trap("SIGINT") { puts "Full name" }
trap("INT") { puts "Short name" }
trap(2) { puts "Signal number" }
The Signal.trap
class method functions identically to Kernel#trap
but makes signal handling more explicit in code structure.
Signal.trap("TERM") do
puts "Termination requested"
# Cleanup code here
exit(0)
end
Signal handlers return the previous handler, allowing handler chaining and restoration. Store previous handlers to restore original behavior after temporary signal handling.
original_handler = trap("USR1") do
puts "Temporary handler"
end
# Later restore original handler
trap("USR1", original_handler)
The Process.kill
method sends signals to processes by PID. Use string signal names or integer signal numbers. Ruby validates signal names and raises exceptions for invalid signals.
# Send signals to other processes
Process.kill("TERM", 1234)
Process.kill("USR1", [1234, 5678]) # Multiple processes
# Send to current process
Process.kill("USR2", Process.pid)
Signal handling affects child processes created with fork
. Child processes inherit signal handlers from the parent, but handler objects become independent after forking.
trap("USR1") { puts "Parent handler" }
fork do
# Child inherits handler but can override
trap("USR1") { puts "Child handler" }
sleep(10)
end
Thread Safety & Concurrency
Signal handlers execute in the main thread regardless of which thread triggers the signal condition. Ruby serializes signal delivery through the main thread to avoid interpreter corruption, but this creates race conditions with threaded code.
require 'thread'
counter = 0
mutex = Mutex.new
trap("USR1") do
mutex.synchronize do
counter += 1
puts "Signal count: #{counter}"
end
end
# Create threads that modify counter
threads = 5.times.map do
Thread.new do
100.times do
mutex.synchronize { counter += 1 }
sleep(0.01)
end
end
end
# Send signals while threads run
Thread.new do
10.times do
Process.kill("USR1", Process.pid)
sleep(0.1)
end
end
threads.each(&:join)
Thread-local variables and thread-specific state become inaccessible from signal handlers since handlers always execute in the main thread context. Design signal handlers to avoid thread-local dependencies.
Thread.current[:data] = "thread local"
trap("USR1") do
# This may not access the expected thread-local data
puts Thread.current[:data] # Likely nil in main thread
end
# Better approach: use instance or global variables
@shared_data = "accessible from signals"
trap("USR1") do
puts @shared_data # Reliable access
end
Signal masking prevents signal delivery during critical sections but Ruby provides limited masking control. Use Thread.handle_interrupt
to defer signal processing during sensitive operations.
trap("INT") { puts "Interrupt during critical section" }
# Defer interrupt handling
Thread.handle_interrupt(Interrupt => :never) do
# Critical section protected from signals
10.times do |i|
puts "Critical operation #{i}"
sleep(0.1)
end
end
Concurrent signal handlers can create deadlocks when accessing shared resources. Signal handlers should minimize lock acquisition and avoid blocking operations that could deadlock with application threads.
require 'monitor'
class SignalSafeCounter
def initialize
@count = 0
@mon = Monitor.new
end
def increment
@mon.synchronize { @count += 1 }
end
def signal_increment
# Avoid synchronize in signal handler - potential deadlock
# Use atomic operations when possible
if @mon.try_enter
@count += 1
@mon.exit
else
puts "Skipped increment - lock contention"
end
end
end
counter = SignalSafeCounter.new
trap("USR1") { counter.signal_increment }
Signal delivery timing creates race conditions with condition variables and thread synchronization primitives. Signals can interrupt threads waiting on conditions, potentially causing spurious wakeups or missed notifications.
require 'thread'
cv = ConditionVariable.new
mutex = Mutex.new
ready = false
trap("USR1") do
mutex.synchronize do
ready = true
cv.signal # Wake waiting thread
end
end
# Waiting thread might miss signal if timing is wrong
Thread.new do
mutex.synchronize do
cv.wait(mutex) until ready
puts "Condition met"
end
end
Error Handling & Debugging
Signal handlers that raise exceptions can terminate the process unexpectedly. Ruby propagates unhandled exceptions from signal handlers to the main program flow, potentially crashing the application.
trap("USR1") do
raise "Signal handler error"
end
begin
sleep(10)
rescue => e
puts "Caught signal handler exception: #{e.message}"
# Process continues after handling exception
end
# Send signal to trigger handler
Process.kill("USR1", Process.pid)
Invalid signal names raise ArgumentError
exceptions during handler registration. Platform-specific signals may not exist on all systems, requiring defensive programming for portable code.
begin
trap("NONEXISTENT") { puts "Handler" }
rescue ArgumentError => e
puts "Signal not supported: #{e.message}"
end
# Platform-safe signal handling
def safe_trap(signal, &block)
trap(signal, &block)
rescue ArgumentError
puts "Signal #{signal} not available on this platform"
end
safe_trap("WINCH") { puts "Window size changed" }
Signal handler execution can be delayed indefinitely during long-running operations that don't yield control. Ruby delivers signals at safe points, which may not occur during tight loops or blocking system calls.
trap("INT") { puts "This might be delayed" }
# Tight loop delays signal delivery
1_000_000.times { |i| Math.sqrt(i) }
# Better: periodic yielding
1_000_000.times do |i|
Math.sqrt(i)
Thread.pass if i % 1000 == 0 # Allow signal delivery
end
Debugging signal handlers requires different techniques since traditional debugging interrupts normal execution flow. Use logging and state inspection rather than breakpoint debugging.
$signal_log = []
trap("USR1") do
$signal_log << {
time: Time.now,
thread: Thread.current,
backtrace: caller[0..5]
}
puts "USR1 received, logged entry #{$signal_log.size}"
end
# Later inspect signal history
def dump_signal_log
$signal_log.each_with_index do |entry, i|
puts "Signal #{i}: #{entry[:time]} in #{entry[:thread]}"
entry[:backtrace].each { |line| puts " #{line}" }
end
end
Signal handlers that access undefined variables or call missing methods can cause difficult-to-debug crashes. Signal handlers execute with the current binding but may reference variables that don't exist in the signal context.
def setup_handler
local_var = "accessible only in method scope"
trap("USR1") do
# This will raise NameError - local_var not in scope
puts local_var
end
end
setup_handler
# Better: use instance variables or constants
class SignalManager
def initialize
@accessible_var = "accessible from signals"
setup_handler
end
private
def setup_handler
trap("USR1") do
puts @accessible_var # Works correctly
end
end
end
Race conditions between signal delivery and variable access can cause inconsistent state. Signal handlers may observe variables in intermediate states during modification by other threads.
@data = { status: "initial", count: 0 }
trap("USR1") do
# May observe inconsistent state during updates
puts "Status: #{@data[:status]}, Count: #{@data[:count]}"
end
Thread.new do
loop do
@data[:status] = "updating"
@data[:count] += 1
sleep(0.001) # Signal could arrive here
@data[:status] = "complete"
sleep(0.1)
end
end
Production Patterns
Web servers use signal handling for graceful shutdown, configuration reloading, and process management. Signal handlers coordinate server lifecycle events and manage worker processes.
class WebServer
def initialize
@running = true
@connections = []
setup_signal_handlers
end
def start
puts "Server starting on port 8080"
while @running
# Simulate connection handling
connection = accept_connection
@connections << connection if connection
sleep(0.1)
end
shutdown_gracefully
end
private
def setup_signal_handlers
trap("TERM") do
puts "Received TERM signal, shutting down gracefully..."
@running = false
end
trap("USR1") do
puts "Received USR1 signal, reloading configuration..."
reload_configuration
end
trap("USR2") do
puts "Received USR2 signal, reopening log files..."
reopen_logs
end
end
def shutdown_gracefully
puts "Closing #{@connections.size} active connections..."
@connections.each(&:close)
puts "Server shutdown complete"
end
def reload_configuration
# Reload configuration without restart
puts "Configuration reloaded"
end
def reopen_logs
# Reopen log files for log rotation
puts "Log files reopened"
end
def accept_connection
# Simulate connection acceptance
rand < 0.3 ? Object.new : nil
end
end
server = WebServer.new
server.start
Process monitoring systems use signals to manage daemon processes and collect runtime statistics. Monitoring handlers provide operational visibility without stopping service.
class DaemonProcess
def initialize
@start_time = Time.now
@request_count = 0
@error_count = 0
setup_monitoring_handlers
end
def run
puts "Daemon started at #{@start_time}"
loop do
process_request
sleep(0.5)
end
end
private
def setup_monitoring_handlers
trap("USR1") do
dump_statistics
end
trap("USR2") do
dump_detailed_status
end
trap("WINCH") do
toggle_debug_mode
end
end
def process_request
@request_count += 1
# Simulate occasional errors
if rand < 0.1
@error_count += 1
puts "Error processing request #{@request_count}"
end
end
def dump_statistics
uptime = Time.now - @start_time
error_rate = (@error_count.to_f / @request_count) * 100
puts "=== Daemon Statistics ==="
puts "Uptime: #{uptime.round(2)} seconds"
puts "Requests processed: #{@request_count}"
puts "Errors: #{@error_count} (#{error_rate.round(2)}%)"
puts "Requests per second: #{(@request_count / uptime).round(2)}"
end
def dump_detailed_status
puts "=== Detailed Status ==="
puts "Process ID: #{Process.pid}"
puts "Parent ID: #{Process.ppid}"
puts "Memory usage: #{memory_usage} MB"
puts "Thread count: #{Thread.list.size}"
ObjectSpace.garbage_collect
puts "Objects in memory: #{ObjectSpace.count_objects[:TOTAL]}"
end
def memory_usage
# Simplified memory reporting
`ps -o rss= -p #{Process.pid}`.to_i / 1024
rescue
"unknown"
end
def toggle_debug_mode
@debug_mode = !@debug_mode
puts "Debug mode: #{@debug_mode ? 'enabled' : 'disabled'}"
end
end
Container orchestration systems rely on signal handling for health checks and scaling operations. Signal handlers report process state to orchestration systems and handle scaling events.
class ContainerizedService
def initialize
@healthy = true
@load_factor = 0.0
@processing_queue = []
setup_container_handlers
end
def start
health_check_thread = start_health_monitoring
loop do
process_work_queue
update_load_metrics
sleep(0.1)
end
health_check_thread.join
end
private
def setup_container_handlers
# Kubernetes sends TERM for graceful shutdown
trap("TERM") do
puts "Received shutdown signal from orchestrator"
graceful_shutdown
end
# Custom signals for operational control
trap("USR1") do
puts "Health check requested"
report_health_status
end
trap("USR2") do
puts "Load metrics requested"
report_load_metrics
end
end
def start_health_monitoring
Thread.new do
loop do
check_health
sleep(10)
end
end
end
def check_health
# Simulate health checks
@healthy = @processing_queue.size < 100 && @load_factor < 0.8
unless @healthy
puts "Service unhealthy - queue: #{@processing_queue.size}, load: #{@load_factor}"
end
end
def process_work_queue
# Simulate work processing
if rand < 0.7 # Add work
@processing_queue << "task_#{Time.now.to_f}"
end
if @processing_queue.any? # Process work
@processing_queue.shift
end
end
def update_load_metrics
queue_load = [@processing_queue.size / 50.0, 1.0].min
cpu_load = rand * 0.3 # Simulated CPU load
@load_factor = (queue_load + cpu_load) / 2.0
end
def report_health_status
status = @healthy ? "healthy" : "unhealthy"
puts "Health status: #{status}"
puts "Queue size: #{@processing_queue.size}"
puts "Load factor: #{@load_factor.round(3)}"
end
def report_load_metrics
puts "Current load metrics:"
puts " Queue utilization: #{(@processing_queue.size / 50.0 * 100).round(1)}%"
puts " Load factor: #{(@load_factor * 100).round(1)}%"
puts " Requests per minute: #{estimate_throughput}"
end
def estimate_throughput
# Simplified throughput estimation
(60 / (1 + @load_factor)).round(1)
end
def graceful_shutdown
puts "Starting graceful shutdown..."
puts "Processing remaining #{@processing_queue.size} items"
# Process remaining work with timeout
shutdown_start = Time.now
while @processing_queue.any? && (Time.now - shutdown_start) < 30
@processing_queue.shift
sleep(0.1)
end
puts "Graceful shutdown complete"
exit(0)
end
end
Common Pitfalls
Signal handlers that call non-reentrant methods can cause deadlocks or corruption. Many Ruby methods acquire internal locks that can deadlock when called from signal handlers interrupting the same methods.
# Dangerous: calling puts from signal handler while main code also uses puts
trap("USR1") do
puts "Signal received" # May deadlock if main thread is in puts
end
loop do
puts "Main thread output"
sleep(1)
end
# Safer: use a flag and check it in main loop
signal_received = false
trap("USR1") do
signal_received = true # Atomic assignment
end
loop do
if signal_received
puts "Signal was received"
signal_received = false
end
puts "Main thread output"
sleep(1)
end
Signal handlers that access complex data structures can observe inconsistent state during modifications. Ruby's GIL doesn't prevent signal interruption during object manipulation.
@complex_data = []
trap("USR1") do
# May observe @complex_data during modification
puts "Data size: #{@complex_data.size}"
@complex_data.each { |item| puts item } # Could see partial updates
end
Thread.new do
loop do
# Multi-step modification can be interrupted by signal
@complex_data.clear
100.times { |i| @complex_data << "item_#{i}" }
sleep(1)
end
end
Nested signal handlers create confusing execution flows and can mask important signals. Signal handlers should complete quickly and avoid triggering additional signals.
# Problematic nested signal handling
trap("USR1") do
puts "USR1 handler start"
Process.kill("USR2", Process.pid) # Triggers another handler
puts "USR1 handler end" # May not execute as expected
end
trap("USR2") do
puts "USR2 handler"
sleep(2) # Blocks USR1 handler completion
end
# Better: decouple signal handling
@pending_signals = []
trap("USR1") do
@pending_signals << :usr1
end
trap("USR2") do
@pending_signals << :usr2
end
# Process signals in main loop
loop do
while signal = @pending_signals.shift
case signal
when :usr1
handle_usr1
when :usr2
handle_usr2
end
end
sleep(0.1)
end
Signal masking behavior varies between Ruby versions and platforms. Code that depends on specific signal timing may behave differently across environments.
# Platform-dependent behavior
trap("PIPE") { puts "Broken pipe" }
begin
# This might behave differently on different systems
IO.popen("nonexistent_command", "w") do |pipe|
pipe.write("data")
end
rescue Errno::EPIPE
puts "Pipe error caught by exception"
rescue => e
puts "Other error: #{e.class}"
end
Memory allocation in signal handlers can cause unpredictable behavior. Signal handlers that create objects may trigger garbage collection or fail during memory pressure.
# Risky: memory allocation in signal handler
trap("USR1") do
# String creation allocates memory
message = "Signal received at #{Time.now}"
log_entries = Array.new(100) { |i| "Entry #{i}: #{message}" }
File.write("signal.log", log_entries.join("\n"))
end
# Safer: pre-allocate or use simple operations
@log_file = File.open("signal.log", "a")
trap("USR1") do
# Minimal allocation, reuse open file
@log_file.puts("#{Time.now.to_i}")
@log_file.flush
end
Signal handler registration timing creates race conditions in multi-process applications. Child processes may inherit signal handlers before they're ready to handle signals.
# Race condition: handler inherited before child setup complete
trap("USR1") do
puts "Handler called on PID #{Process.pid}"
process_signal # May fail if child not initialized
end
if fork
# Parent process
sleep(1)
# Child might not be ready for signals yet
Process.kill("USR1", child_pid)
else
# Child process - handler already inherited
initialize_child_resources # This takes time
# Child ready but signals might have been sent already
end
# Better: child signals readiness to parent
read_pipe, write_pipe = IO.pipe
if child_pid = fork
# Parent waits for child readiness
write_pipe.close
ready_signal = read_pipe.gets
read_pipe.close
if ready_signal
Process.kill("USR1", child_pid)
end
else
# Child initializes then signals ready
read_pipe.close
trap("USR1") do
puts "Child #{Process.pid} handling signal"
end
initialize_child_resources
write_pipe.puts("ready")
write_pipe.close
# Now ready for signals
sleep(10)
end
Reference
Signal Module Methods
Method | Parameters | Returns | Description |
---|---|---|---|
Signal.trap(signal, command=nil, &block) |
signal (String/Integer), command (String/Proc/nil), block |
Previous handler | Registers signal handler |
Signal.list |
None | Hash | Returns signal name to number mapping |
Signal.signame(signum) |
signum (Integer) |
String/nil | Returns signal name for number |
Kernel Methods
Method | Parameters | Returns | Description |
---|---|---|---|
trap(signal, command=nil, &block) |
signal (String/Integer), command (String/Proc/nil), block |
Previous handler | Instance method for signal registration |
Process Methods
Method | Parameters | Returns | Description |
---|---|---|---|
Process.kill(signal, *pids) |
signal (String/Integer), pids (Integer array) |
Integer | Sends signal to processes |
Process.pid |
None | Integer | Returns current process ID |
Process.ppid |
None | Integer | Returns parent process ID |
Common Signals
Signal | Number | Description | Default Action |
---|---|---|---|
HUP |
1 | Hangup | Terminate |
INT |
2 | Interrupt (Ctrl+C) | Terminate |
QUIT |
3 | Quit (Ctrl+) | Terminate with core |
TERM |
15 | Termination | Terminate |
KILL |
9 | Kill (uncatchable) | Terminate |
USR1 |
10 | User-defined 1 | Terminate |
USR2 |
12 | User-defined 2 | Terminate |
WINCH |
28 | Window size change | Ignore |
PIPE |
13 | Broken pipe | Terminate |
Handler Command Types
Command | Type | Behavior |
---|---|---|
Block | Proc | Executes block when signal received |
"IGNORE" |
String | Ignores signal completely |
"SIG_IGN" |
String | Alias for "IGNORE" |
"DEFAULT" |
String | Restores default signal behavior |
"SIG_DFL" |
String | Alias for "DEFAULT" |
"EXIT" |
String | Exits process when signal received |
"SYSTEM_DEFAULT" |
String | Uses system default action |
Platform Signal Availability
Platform | Available Signals | Notes |
---|---|---|
Unix/Linux | Full POSIX set | All standard signals supported |
macOS | Full POSIX set | Additional BSD signals available |
Windows | Limited set | Only INT, TERM, KILL supported |
JRuby | Platform dependent | Follows underlying JVM platform |
Exception Hierarchy
StandardError
└── SignalException
├── Interrupt (Signal 2 - INT)
└── SystemExit (Signal 0 - EXIT)
Signal Handler Return Values
Previous Handler | Type | Usage |
---|---|---|
nil |
NilClass | No previous handler registered |
"DEFAULT" |
String | Previous handler was default action |
"IGNORE" |
String | Previous handler was ignore action |
Proc object | Proc | Previous handler was Ruby block/method |
Thread Safety Considerations
Operation | Thread Safe | Notes |
---|---|---|
trap registration |
Yes | Atomic handler replacement |
Handler execution | No | Always runs in main thread |
Signal delivery | No | Queued and delayed during critical sections |
Handler variable access | No | Race conditions with other threads |
Memory and Performance
Aspect | Impact | Mitigation |
---|---|---|
Handler allocation | Medium | Pre-allocate objects outside handlers |
Signal queuing | Low | Ruby queues signals efficiently |
Handler delay | Medium | Avoid long-running operations in handlers |
GC interaction | Medium | Signal delivery paused during GC |