Introduction to Signal Handling in Zsh
Signal handling is one of those topics that separates casual script writers from serious shell developers. When you write a Zsh script that runs long operations, spawns background processes, or manages resources like temporary files, you need a way to respond gracefully when something unexpected happens — like the user pressing Ctrl+C or the system shutting down. That's exactly what signal handling provides.
In Zsh, signals are software interrupts delivered to a process by the operating system. They notify the process that some event has occurred, such as a request to terminate, a segmentation fault, or a child process changing state. By default, most signals cause the script to terminate immediately, often leaving behind temporary files, locked resources, or incomplete state. With proper signal handling, you can intercept these signals, perform cleanup, and exit cleanly.
What Are Signals?
A signal is a limited form of inter-process communication used in Unix-like operating systems. Each signal has a numeric identifier and a symbolic name. When a signal is sent to a process, the operating system interrupts the normal flow of execution and invokes a signal handler — either the default handler or one you define yourself.
You can list all available signals on your system using the kill command:
kill -l
This will output something like:
HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV SYS PIPE ALRM TERM URG STOP TSTP CONT CHLD TTIN TTOU IO XCPU XFSZ VTALRM PROF WINCH INFO USR1 USR2
Each of these names corresponds to a specific situation. For shell scripting, only a handful are commonly relevant.
Why Signal Handling Matters
Without signal handling, your scripts are fragile. Consider a script that creates a temporary directory, processes several files, and then removes the directory at the end. If the user interrupts the script halfway through, the cleanup never runs, and the temporary directory is left behind. Over time, this leads to cluttered filesystems, locked resources, and inconsistent state.
Proper signal handling gives you several important benefits:
- Resource cleanup: Remove temporary files, release locks, and close connections before exiting.
- Graceful degradation: Save partial progress so work is not lost.
- User-friendly behavior: Print helpful messages instead of dying silently.
- Child process management: Ensure spawned processes are terminated, not orphaned.
- State consistency: Avoid leaving databases or config files in a half-written state.
Common Signals You Should Know
Before diving into the mechanics of handling signals, it helps to understand the ones you will encounter most often in Zsh scripting.
SIGINT (Interrupt)
SIGINT is sent when the user presses Ctrl+C at the terminal. It is the most common signal in interactive scripting. The default action is to terminate the process. In Zsh, the numeric value is 2.
SIGTERM (Termination)
SIGTERM is the standard signal sent by the kill command when no explicit signal is specified. It politely asks the process to terminate. Unlike SIGKILL, it can be caught and handled, giving your script a chance to clean up. The numeric value is 15.
SIGKILL (Kill)
SIGKILL is the nuclear option. It cannot be caught, blocked, or ignored. When sent, the operating system immediately terminates the process. No cleanup is possible. The numeric value is 9. You should never attempt to handle this signal because the kernel handles it directly.
SIGHUP (Hang Up)
SIGHUP was originally sent when a terminal was closed, indicating the controlling process should terminate. In modern usage, many daemon processes interpret SIGHUP as a request to reload their configuration. The numeric value is 1.
SIGQUIT (Quit)
SIGQUIT is sent when the user presses Ctrl+\ at the terminal. By default, it terminates the process and produces a core dump. The numeric value is 3.
SIGUSR1 and SIGUSR2 (User-Defined)
These signals have no predefined meaning. They are available for application-specific purposes. For example, you might use SIGUSR1 to tell a long-running script to print progress information without stopping. The numeric values are 30 and 31 respectively on most systems.
SIGCHLD (Child Status Change)
SIGCHLD is sent to a parent process whenever a child process terminates or changes state. This is useful for managing background jobs and implementing job control. The numeric value is 20 on most systems.
The trap Built-in Command
Zsh provides the trap built-in command for installing signal handlers. The syntax is straightforward:
trap 'handler_code' SIGNAL [SIGNAL ...]
The first argument is a string of Zsh code that will be executed when the specified signal is received. The remaining arguments are the signal names or numbers to trap. Signal names can be given with or without the SIG prefix, so INT and SIGINT are equivalent.
Basic Example: Catching SIGINT
Let's start with a simple example that catches Ctrl+C and prints a message instead of terminating immediately:
#!/usr/bin/env zsh
trap 'echo "Caught interrupt signal. Use Ctrl+\ to force quit." ' INT
echo "Running... Press Ctrl+C to test signal handling."
while true; do
sleep 1
done
When you run this script and press Ctrl+C, instead of exiting, the script prints the message and continues running. The trap command installed a handler that intercepts SIGINT and runs the echo command. Because the handler does not call exit, the script keeps going.
Cleaning Up on Exit
A more practical pattern is to use signal handlers for cleanup. The special signal name EXIT (sometimes called a pseudo-signal) is triggered when the script exits for any reason — whether normally, via an interrupt, or due to an error. This makes it ideal for cleanup code:
#!/usr/bin/env zsh
TEMP_DIR=$(mktemp -d)
cleanup() {
echo "Cleaning up $TEMP_DIR..."
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT INT TERM
echo "Working in $TEMP_DIR"
# Simulate some work
for i in {1..10}; do
echo "Processing item $i"
sleep 1
done
echo "Done!"
Notice that we trap both EXIT and the interrupt signals. When the script receives SIGINT or SIGTERM, the cleanup function runs. Then, because the script is terminating, the EXIT trap also fires. To avoid running cleanup twice, you can reset the trap inside the handler:
#!/usr/bin/env zsh
TEMP_DIR=$(mktemp -d)
cleanup() {
trap - EXIT INT TERM
echo "Cleaning up $TEMP_DIR..."
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT INT TERM
echo "Working in $TEMP_DIR"
for i in {1..10}; do
echo "Processing item $i"
sleep 1
done
The line trap - EXIT INT TERM clears all existing handlers for those signals, ensuring the cleanup function runs exactly once.
Handler Functions vs. Inline Code
In the examples above, we used both inline code (a string passed directly to trap) and a named function. Both approaches work, but named functions are generally preferable for anything beyond a single command. They are easier to read, easier to test, and can be reused.
Here is an example using a dedicated handler function that logs the signal received:
#!/usr/bin/env zsh
LOG_FILE="/tmp/myscript.log"
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}
handle_signal() {
local sig=$1
log_message "Received signal: $sig"
cleanup
exit 1
}
cleanup() {
log_message "Performing cleanup"
# Add cleanup logic here
}
trap 'handle_signal INT' INT
trap 'handle_signal TERM' TERM
trap 'handle_signal HUP' HUP
trap cleanup EXIT
log_message "Script started"
echo "Script running. Check $LOG_FILE for details."
for i in {1..30}; do
log_message "Iteration $i"
sleep 1
done
log_message "Script completed normally"
This pattern gives you fine-grained control. Each signal can trigger different behavior while sharing common cleanup logic.
Handling Multiple Signals
You can install the same handler for multiple signals in a single trap call by listing them together:
trap 'echo "Signal received, exiting"; exit 1' INT TERM HUP QUIT
This is concise and useful when you want identical behavior for several termination signals. However, if you need to distinguish which signal was received, you must use separate trap calls with different handler functions or arguments.
Ignoring Signals
Sometimes you want to ignore a signal entirely rather than handle it. You do this by passing an empty string as the handler:
trap '' INT
After this, pressing Ctrl+C will have no effect on the script. This can be useful in critical sections where interruption would leave the system in an inconsistent state:
#!/usr/bin/env zsh
echo "Starting critical operation..."
# Ignore interrupts during critical section
trap '' INT
echo "Writing important data..."
sleep 5
echo "Critical operation complete."
# Restore default behavior
trap - INT
echo "Normal operation resumed. You can interrupt now."
sleep 10
Be cautious with this pattern. If something goes wrong inside the critical section and the user cannot interrupt, they may resort to SIGKILL, which bypasses all handlers.
Resetting Signals to Default
To restore the default behavior for a signal, use a hyphen as the handler:
trap - INT
This removes your custom handler and reinstalls the operating system default. For most signals, the default action is to terminate the process. This is important when your script spawns child processes — you usually want children to have default signal behavior unless you explicitly set up handlers in them too.
Signals and Background Processes
Signal handling becomes more complex when your script spawns background processes. By default, child processes inherit the signal handlers of their parent. However, signals you send with kill only go to the specified process, not its children, unless you send them to an entire process group.
Here is an example that manages a background worker and ensures it is terminated when the parent script exits:
#!/usr/bin/env zsh
WORKER_PID=""
cleanup() {
if [[ -n "$WORKER_PID" ]]; then
echo "Stopping background worker (PID $WORKER_PID)..."
kill "$WORKER_PID" 2>/dev/null
wait "$WORKER_PID" 2>/dev/null
fi
echo "Cleanup complete."
}
trap cleanup EXIT INT TERM
# Start a background worker
(
while true; do
echo "Worker tick at $(date)"
sleep 2
done
) &
WORKER_PID=$!
echo "Started worker with PID $WORKER_PID"
# Main script does its own work
for i in {1..10}; do
echo "Main loop iteration $i"
sleep 1
done
echo "Main work done, exiting."
The wait command after kill is important. It reaps the child process, preventing it from becoming a zombie. The 2>/dev/null suppresses errors in case the process has already exited.
Sending Signals from Within Scripts
Your scripts can also send signals to other processes using the kill command. The basic syntax is:
kill -SIGNAL PID
For example, to send SIGUSR1 to a process with PID 12345:
kill -USR1 12345
This is useful for inter-process communication. Here is a complete example where a parent script sends SIGUSR1 to a child to request a status report:
#!/usr/bin/env zsh
# Start a child process that handles SIGUSR1
(
trap 'echo "Child: Status report - processed $COUNTER items so far"' USR1
COUNTER=0
while true; do
COUNTER=$((COUNTER + 1))
sleep 1
done
) &
CHILD_PID=$!
# Give the child time to start
sleep 1
# Send status requests periodically
for i in {1..5}; do
sleep 3
echo "Parent: Requesting status..."
kill -USR1 "$CHILD_PID"
done
# Clean up
kill "$CHILD_PID" 2>/dev/null
wait "$CHILD_PID" 2>/dev/null
echo "Parent: Done."
Trapping ERR for Error Handling
Zsh supports a special pseudo-signal called ERR that fires whenever a command exits with a non-zero status. This is similar to Bash's ERR trap and is extremely useful for centralized error handling:
#!/usr/bin/env zsh
err_handler() {
local exit_code=$?
echo "Error: command failed with exit code $exit_code" >&2
echo "Failed at line $LINENO" >&2
# Perform cleanup
exit "$exit_code"
}
trap err_handler ERR
echo "Starting operations..."
# This succeeds
ls /tmp > /dev/null
# This fails and triggers the ERR trap
ls /nonexistent_directory > /dev/null
echo "This line will not be reached."
Combining ERR with EXIT traps gives you a robust error-handling framework. The ERR trap handles unexpected failures, while the EXIT trap ensures cleanup always runs.
Signal Handling in Functions
Signal traps are global in Zsh. If you set a trap inside a function, it affects the entire script, not just that function. This can lead to surprising behavior if you are not careful:
#!/usr/bin/env zsh
my_function() {
trap 'echo "Interrupted inside function"' INT
sleep 5
# The trap persists after the function returns!
}
echo "Before function. Try Ctrl+C here (default behavior)."
sleep 3
my_function
echo "After function. Try Ctrl+C here (custom handler still active)."
sleep 3
If you set a trap inside a function and want it to be scoped, you must manually save and restore the previous trap. Zsh does not provide automatic scoping for traps. Here is a pattern for doing this safely:
#!/usr/bin/env zsh
scoped_trap() {
# Save the current trap (not directly possible in Zsh, so we
# use a known restoration point)
local prev_handler="default"
trap 'echo "Scoped handler active"' INT
sleep 3
# Restore default behavior
trap - INT
}
echo "Testing scoped trap..."
scoped_trap
echo "Function returned. Default INT behavior restored."
sleep 3
Best Practices for Signal Handling
Now that you understand the mechanics, here are the best practices that will keep your scripts robust and maintainable.
Always Clean Up on EXIT
Make it a habit to install an EXIT trap in any script that creates temporary files, acquires locks, or modifies shared state. This is your safety net:
trap cleanup EXIT
Even if you also trap INT and TERM separately, the EXIT trap ensures cleanup runs no matter how the script ends.
Make Handlers Idempotent
Your cleanup code should be safe to run multiple times. If a signal arrives while cleanup is already in progress, the handler might fire again. Guard against this with a flag:
#!/usr/bin/env zsh
CLEANUP_DONE=0
cleanup() {
(( CLEANUP_DONE )) && return
CLEANUP_DONE=1
echo "Running cleanup..."
# Cleanup logic here
}
trap cleanup EXIT INT TERM
Keep Handlers Short and Fast
Signal handlers should do the minimum necessary work and exit quickly. Avoid long-running operations, complex logic, or calls to functions that might themselves be interrupted. If you need to do extensive cleanup, consider setting a flag in the handler and doing the actual work in the main loop:
#!/usr/bin/env zsh
SHOULD_EXIT=0
handle_interrupt() {
echo "Interrupt received, will exit after current operation..."
SHOULD_EXIT=1
}
trap handle_interrupt INT
for i in {1..100}; do
if (( SHOULD_EXIT )); then
echo "Exiting gracefully after item $((i - 1))"
break
fi
echo "Processing item $i"
sleep 1
done
Do Not Ignore SIGTERM
SIGTERM is the standard way for the system to ask your process to stop. If you ignore it, the system may escalate to SIGKILL, which you cannot handle. Always catch SIGTERM and exit cleanly, even if you just call exit 0.
Reset Traps Before Exiting
If your handler calls exit, reset the traps first to prevent re-entrancy issues. This is especially important for the EXIT trap:
cleanup() {
trap - EXIT INT TERM HUP
# Now safe to do cleanup and exit
rm -f /tmp/myapp.lock
exit 1
}
trap cleanup EXIT INT TERM HUP
Document Your Signal Behavior
If your script handles signals in a non-obvious way, document it. A comment at the top of the script explaining which signals are trapped and what happens when they are received will save future maintainers a lot of confusion.
Test Signal Handling Thoroughly
Signal handling bugs are subtle and often do not appear until production. Test your scripts by sending signals manually with kill, pressing Ctrl+C at various points, and verifying that cleanup actually happens. Check for leftover temporary files and orphaned processes after interrupted runs.
Common Pitfalls to Avoid
Even experienced developers make mistakes with signal handling. Here are the most common pitfalls and how to avoid them.
Forgetting That sleep Is Interruptible
When a signal arrives during a sleep call, the sleep is interrupted and your handler runs. This is usually what you want, but be aware that the remaining sleep time is lost. If your logic depends on timing, you may need to account for this.
Not Handling SIGHUP in Long-Running Scripts
If your script runs in a terminal session and the terminal closes, the script receives SIGHUP. Without a handler, it will die abruptly. For long-running scripts, trap SIGHUP and either ignore it (if the script should keep running) or clean up and exit.
Assuming Handlers Run in a Clean Environment
Signal handlers run in the context of whatever the script was doing when the signal arrived. Local variables from the current function are still in scope, which can lead to unexpected behavior. Keep handlers simple and avoid relying on complex state.
Trapping Signals You Cannot Handle
SIGKILL (9) and SIGSTOP (19) cannot be trapped, blocked, or ignored. Attempting to trap them is silently ignored by Zsh. Do not waste time trying to handle them, and do not rely on them for cleanup.
Putting It All Together: A Complete Example
Here is a complete, production-ready script that demonstrates all the concepts discussed in this guide:
#!/usr/bin/env zsh
#
# backup_helper.zsh - A robust backup script with proper signal handling
#
# Signals handled:
# INT (Ctrl+C) - Graceful shutdown after current file
# TERM (kill) - Immediate graceful shutdown
# HUP (terminal) - Reload configuration
# EXIT - Always run cleanup
# ERR - Log unexpected errors
set -euo pipefail
# --- Configuration ---
BACKUP_SOURCE="${1:-/home/user/documents}"
BACKUP_DEST="${2:-/tmp/backup}"
LOG_FILE="/tmp/backup_helper.log"
LOCK_FILE="/tmp/backup_helper.lock"
# --- State ---
SHOULD_EXIT=0
CLEANUP_DONE=0
LOCK_HELD=0
# --- Logging ---
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# --- Signal Handlers ---
request_shutdown() {
log "Shutdown requested. Will exit after current file."
SHOULD_EXIT=1
}
immediate_shutdown() {
log "Immediate shutdown requested."
SHOULD_EXIT=1
}
reload_config() {
log "SIGHUP received. Reloading configuration..."
# Re-read config here if needed
log "Configuration reloaded."
}
error_handler() {
local exit_code=$?
log "ERROR: Command failed with exit code $exit_code at line $LINENO"
}
cleanup() {
(( CLEANUP_DONE )) && return
CLEANUP_DONE=1
trap - EXIT INT TERM HUP ERR
log "Starting cleanup..."
if (( LOCK_HELD )); then
rm -f "$LOCK_FILE"
log "Released lock file."
fi
log "Cleanup complete."
}
# --- Install Traps ---
trap request_shutdown INT
trap immediate_shutdown TERM
trap reload_config HUP
trap error_handler ERR
trap cleanup EXIT
# --- Main Logic ---
acquire_lock() {
if [[ -e "$LOCK_FILE" ]]; then
log "Another instance is already running ($LOCK_FILE exists)."
exit 1
fi
echo $$ > "$LOCK_FILE"
LOCK_HELD=1
log "Acquired lock file."
}
perform_backup() {
local source="$1"
local dest="$2"
local file_count=0
mkdir -p "$dest"
log "Starting backup from $source to $dest"
for file in "$source"/**/*(.); do
if (( SHOULD_EXIT )); then
log "Graceful exit requested. Backed up $file_count files."
return 1
fi
local relative="${file#$source/}"
local target="$dest/$relative"
mkdir -p "${target:h}"
cp "$file" "$target"
file_count=$((file_count + 1))
log "Backed up: $relative"
done
log "Backup complete. Total files: $file_count"
return 0
}
# --- Entry Point ---
log "=== Backup Helper Started ==="
log "PID: $$"
log "Source: $BACKUP_SOURCE"
log "Destination: $BACKUP_DEST"
acquire_lock
perform_backup "$BACKUP_SOURCE" "$BACKUP_DEST"
log "=== Backup Helper Finished ==="
This script demonstrates a complete signal handling strategy: it uses a lock file to prevent concurrent execution, handles interrupts gracefully by finishing the current file before exiting, logs all activity, and cleans up the lock file on any exit path. The set -euo pipefail at the top ensures the script fails fast on errors, while the ERR trap logs those failures.
Conclusion
Signal handling is an essential skill for writing robust Zsh scripts that behave predictably in the real world. By understanding how signals work, using the trap command effectively, and following best practices like always cleaning up on exit, making handlers idempotent, and keeping them fast, you can write scripts that survive interruptions, clean up after themselves, and provide a better experience for users and administrators alike. The patterns and examples in this guide give you a solid foundation, but the real mastery comes from practicing these techniques in your own scripts and testing them under real-world conditions where signals arrive at the most inconvenient moments.