← Back to DevBytes

Zsh Scripting: Signal Handling Complete Guide

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles