CrackedRuby logo

CrackedRuby

Signal Handling

Ruby signal handling for process control, interrupt management, and system communication in Unix-like systems.

Core Built-in Classes Process and System
2.10.3

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