Signal handling in Ruby gives you control over how your application responds to operating system signals. These signals notify your program about events like termination requests, user interrupts, or custom inter-process messages. Managing signals properly ensures your application shuts down cleanly, responds to system events appropriately, and maintains data integrity.
Operating systems use signals as a lightweight communication mechanism between processes and the kernel. When you press Ctrl+C in a terminal, the OS sends a SIGINT signal to your running program. Without signal handling, Ruby uses default behaviors that might not suit your application's needs. With proper handlers, you decide exactly what happens when signals arrive.
Signal handling becomes critical for long-running processes like web servers, background workers, and daemon applications. These programs need to finish current work, close database connections, and clean up resources before exiting. Understanding signals helps you build production-ready applications that behave reliably under real-world conditions.
In this article, we'll explore common Unix signals and their purposes, how to trap and handle signals in Ruby, and patterns for graceful shutdowns and signal coordination.
Let's dive in!
Prerequisites
You'll need Ruby 2.7 or later installed. Signal handling works across platforms, though some signals are Unix-specific:
ruby --version
ruby 3.4.5 (2025-07-16 revision 20cda200d3) +PRISM [arm64-darwin24]
Basic understanding of processes and how programs interact with the operating system helps, though we'll explain concepts as needed:
ruby -e "puts 'Ready to handle signals!'"
Ready to handle signals!
Setting up the environment
To demonstrate signal handling effectively, you'll create programs that respond to various signals. These examples show how Ruby intercepts signals and lets you define custom behavior instead of using default actions.
Signals operate asynchronously, arriving at unpredictable times during program execution. Ruby's signal handling API lets you register callbacks that execute when specific signals arrive. This mechanism works reliably across different signal types and supports both simple and complex handling scenarios.
Create your project directory:
mkdir ruby-signals-demo && cd ruby-signals-demo
Start with a basic signal handler to see how interception works:
puts "Process ID: #{Process.pid}"
puts "Press Ctrl+C to trigger SIGINT"
trap("INT") do
puts "\nReceived SIGINT signal"
exit
end
loop do
puts "Working..."
sleep 2
end
This program runs indefinitely until interrupted. Without the trap handler, Ctrl+C would terminate immediately with Ruby's default behavior. The trap block intercepts SIGINT and executes custom code before exiting.
Run this and press Ctrl+C after a few seconds:
ruby signal_basics.rb
Process ID: 54199
Press Ctrl+C to trigger SIGINT
Working...
Working...
Working...
^C
Received SIGINT signal
The trap handler caught the signal, printed a message, then exited cleanly. This demonstrates the basic pattern for all signal handling in Ruby.
Understanding common signals
Different signals serve distinct purposes in Unix-like systems. Some request program termination, others pause execution, and some carry custom application-specific meaning. Knowing which signals exist and what they typically mean helps you handle them appropriately.
SIGTERM requests graceful termination and is the standard way to ask a process to shut down. SIGINT comes from keyboard interrupts like Ctrl+C. SIGKILL forces immediate termination and cannot be caught. SIGHUP traditionally meant a terminal hangup but now often triggers configuration reloads. SIGUSR1 and SIGUSR2 are available for custom application logic.
Ruby provides access to most signals through string names or signal numbers. The Signal module includes methods for trapping signals and querying which signals the current platform supports. Not all signals exist on every platform, so defensive programming checks signal availability before trapping.
Create a signal reference program:
# Display available signals
puts "Available signals:"
Signal.list.sort.each do |name, number|
puts " #{name.ljust(10)} (#{number})"
end
# Trap multiple signals
%w[INT TERM HUP].each do |sig|
trap(sig) do
puts "\nReceived SIG#{sig}"
exit if sig == "TERM"
end
end
puts "\nProcess ID: #{Process.pid}"
puts "Send signals with: kill -SIGNAL #{Process.pid}"
puts "Waiting for signals..."
loop { sleep 1 }
This shows all available signals and demonstrates handling multiple types. Different signals can share handlers or have distinct behaviors.
Run this in one terminal:
ruby signal_types.rb
Available signals:
ABRT (6)
ALRM (14)
BUS (10)
CHLD (20)
...
USR1 (30)
USR2 (31)
VTALRM (26)
WINCH (28)
XCPU (24)
XFSZ (25)
Process ID: 54662
Send signals with: kill -SIGNAL 54662
Waiting for signals...
From another terminal, send signals to test different handlers:
kill -HUP <your_process_id>
kill -TERM <your_process_id>
The program responds to HUP but keeps running, then exits when TERM arrives. This shows how different signals trigger different behaviors.
Implementing graceful shutdowns
Production applications need to shut down cleanly when termination signals arrive. This means finishing active work, closing database connections, flushing logs, and releasing resources properly. Without graceful shutdown, you risk data loss, corrupted state, or resource leaks.
The pattern involves setting a shutdown flag when signals arrive, completing current operations, then exiting. Long-running tasks check the flag periodically and stop at safe points. This approach balances responsiveness with data integrity, ensuring work completes correctly before termination.
Ruby's trap handlers execute in a separate context with restrictions on what operations are safe. Complex shutdown logic should set flags that the main program checks rather than doing extensive work directly in the trap handler. This avoids threading issues and keeps signal handling predictable.
Create a worker with graceful shutdown:
@shutdown = false
trap("INT") { @shutdown = true }
trap("TERM") { @shutdown = true }
def process_job(id)
puts "Processing job #{id}..."
sleep 1
puts "Job #{id} completed"
end
puts "Worker started (PID: #{Process.pid})"
puts "Press Ctrl+C for graceful shutdown"
job_id = 1
until @shutdown
process_job(job_id)
job_id += 1
end
puts "\nShutdown signal received"
puts "Finishing current work..."
puts "Cleanup completed. Exiting."
The shutdown flag lets the current job finish before exiting. Signal handlers set the flag without interrupting work, ensuring clean completion.
Run this and interrupt after a few jobs:
ruby graceful_worker.rb
Worker started (PID: 12347)
Press Ctrl+C for graceful shutdown
Processing job 1...
Job 1 completed
Processing job 2...
Job 2 completed
^C
Shutdown signal received
Finishing current work...
Cleanup completed. Exiting.
The worker finished job 2 before exiting, demonstrating graceful shutdown in action.
Handling signals with timeouts
Sometimes shutdown needs a timeout to prevent hanging indefinitely. If work doesn't complete within a reasonable time, the program should force exit. This pattern combines graceful shutdown attempts with a fallback forced termination after a deadline.
Ruby's Timeout module provides deadline enforcement, though signals offer another approach. You can trap TERM for graceful shutdown and KILL as an emergency exit, or implement a two-stage shutdown where the first signal starts cleanup and a second forces termination.
The key is balancing patience for normal shutdown with responsiveness to repeated signals. Users expect the first interrupt to trigger cleanup and a second to force exit immediately. This matches common Unix tool behavior and user expectations.
Create a worker with timeout handling:
@shutdown = false
@force_exit = false
trap("INT") do
if @shutdown
puts "\nForce exit!"
@force_exit = true
else
puts "\nGraceful shutdown initiated..."
@shutdown = true
end
end
def long_task(id)
puts "Starting task #{id}..."
10.times do |i|
return if @force_exit
sleep 0.5
print "."
end
puts "\nTask #{id} completed"
end
puts "Worker started (PID: #{Process.pid})"
puts "Press Ctrl+C once for graceful shutdown"
puts "Press Ctrl+C twice to force exit"
task_id = 1
until @shutdown || @force_exit
long_task(task_id)
task_id += 1
end
exit(1) if @force_exit
puts "Clean shutdown completed"
The first Ctrl+C triggers graceful shutdown, while a second forces immediate exit. This gives users control over shutdown urgency.
Run this and experiment with single and double interrupts:
ruby timeout_worker.rb
Worker started (PID: 56892)
Press Ctrl+C once for graceful shutdown
Press Ctrl+C twice to force exit
Starting task 1...
.^C
Graceful shutdown initiated...
.........
Task 1 completed
Clean shutdown completed
Using signals for application control
Beyond shutdown, signals enable runtime control of applications. You can reload configuration, toggle debug modes, or trigger status reports without restarting programs. This makes signals valuable for production systems that need dynamic behavior changes.
SIGUSR1 and SIGUSR2 are designated for custom application logic since they have no standard meaning. Web servers often use SIGHUP to reload configuration and USR1 to reopen log files. Background workers might use signals to pause processing or dump internal state for debugging.
Signal-based control works well because it requires no special ports, files, or protocols. Any process with appropriate permissions can send signals using standard tools. This simplicity makes signals attractive for simple control interfaces on long-running processes.
Create an application with signal-based controls:
@debug = false
@paused = false
trap("USR1") do
@debug = !@debug
puts "\nDebug mode: #{@debug ? 'ON' : 'OFF'}"
end
trap("USR2") do
@paused = !@paused
puts "\n#{@paused ? 'Paused' : 'Resumed'}"
end
def process_item(num)
return if @paused
print @debug ? " [DEBUG: item #{num}] " : "."
sleep 0.5
end
puts "Worker started (PID: #{Process.pid})"
puts "Send signals:"
puts " kill -USR1 #{Process.pid} # Toggle debug"
puts " kill -USR2 #{Process.pid} # Toggle pause"
counter = 1
loop do
process_item(counter)
counter += 1
end
Different signals control debug output and pause state without stopping the program. This demonstrates practical signal-based runtime control.
Run the program:
ruby signal_controls.rb
Worker started (PID: 12349)
Send signals:
kill -USR1 12349 # Toggle debug
kill -USR2 12349 # Toggle pause
........
Send signals to change behavior:
kill -USR1 12349 # Enable debug mode
kill -USR2 12349 # Pause processing
Worker started (PID: 57269)
Send signals:
kill -USR1 57269 # Toggle debug
kill -USR2 57269 # Toggle pause
..............................................
Debug mode: ON
[DEBUG: item 47] [DEBUG: item 48] [DEBUG: item 49] [DEBUG: item 50] [DEBUG: item 51] [DEBUG: item 52] [DEBUG: item 53] [DEBUG: item 54] [DEBUG: item 55] [DEBUG: item 56] [DEBUG: item 57] [DEBUG: item 58] [DEBUG: item 59]...
Paused
The program responds to signals by toggling modes, showing how signals enable runtime control.
Final thoughts
Signal handling lets Ruby programs respond safely to system events and user actions. The main idea is to know what each signal does, keep your handlers simple by using flags, and make sure your shutdown process finishes work safely before the program exits.
In production, graceful shutdowns help prevent data loss and resource leaks. Using signals gives you an easy way to control your app at runtime without adding complex systems. By following best practices for signal safety, your handlers will stay reliable in all situations. Check the Ruby Signal documentation and try out different signals to build stable, production-ready apps.