← Back to DevBytes

Fish Scripting: Signal Handling Complete Guide

Signal Handling in Fish Shell: An Introduction

Signal handling is a fundamental aspect of shell scripting that allows your scripts to respond gracefully to external events. In the Fish shell, signal handling is both intuitive and powerful, providing a clean syntax for trapping signals and executing custom handlers. Whether you're writing a daemon-like script, a long-running process manager, or simply need to clean up temporary files on exit, understanding signal handling is essential.

What Are Signals?

Signals are standardized messages sent to processes by the operating system, other processes, or the terminal. They notify a process about events such as interrupts (Ctrl+C), termination requests, segmentation faults, or user-defined conditions. The most common signals you'll encounter in scripting include:

Why Signal Handling Matters in Fish Scripts

Without signal handling, a Fish script that receives SIGINT or SIGTERM will simply terminate immediatelyβ€”potentially leaving temporary files, lock files, or incomplete operations behind. By trapping signals, you can:

The Fish Trap Command: Core Syntax

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Fish provides the trap command to register signal handlers. The syntax differs slightly from POSIX shells like Bash, making it cleaner and more readable:

trap [OPTIONS] [COMMAND] [SIGNALS...]

Here, COMMAND is the fish code to execute when any of the specified SIGNALS is received. You can specify signals by name (e.g., INT, TERM, HUP) or by number (e.g., 2, 15, 1). The SIG prefix is optional in Fish.

Basic Trap Example

This simple script traps SIGINT and prints a message instead of terminating:

#!/usr/bin/env fish

function on_interrupt --on-signal INT
    echo "Interrupted! Press Ctrl+D to exit or wait..."
end

while true
    echo "Running... (pid: "(fish_pid)")"
    sleep 2
end

Alternatively, using the trap command inline:

#!/usr/bin/env fish

trap 'echo "Caught SIGINT! Cleaning up..."; exit 0' INT

while true
    echo "Working..."
    sleep 1
end

Trapping Multiple Signals

You can bind a single handler to multiple signals by listing them all:

#!/usr/bin/env fish

function cleanup
    echo "Cleaning up temporary files..."
    rm -rf /tmp/my_script_*
    echo "Done. Exiting."
    exit 0
end

trap cleanup INT TERM HUP

# Simulate long-running work
for i in (seq 1 100)
    echo "Step $i of 100"
    sleep 0.5
end

Using Signal Names vs Numbers

Fish accepts both signal names and numbers. Using names is recommended for readability:

# Preferred: use signal names
trap 'echo "Interrupted"' INT TERM

# Also valid: use signal numbers
trap 'echo "Interrupted"' 2 15

# The SIG prefix is optional
trap 'echo "Interrupted"' SIGINT SIGTERM

To list all available signals on your system, use:

kill -l

Advanced Signal Handling Techniques

The --on-signal Event Handler

Fish offers a unique event-driven approach through the --on-signal function attribute. This creates a named function that fires automatically when the specified signal arrives:

#!/usr/bin/env fish

function handle_hup --on-signal HUP
    echo "Received SIGHUP β€” reloading configuration..."
    source ~/.config/fish/config.fish
    echo "Configuration reloaded at "(date)"
end

function handle_usr1 --on-signal USR1
    echo "Received SIGUSR1 β€” toggling debug mode..."
    set --global DEBUG_MODE (not (set --query DEBUG_MODE && test "$DEBUG_MODE" = "true"))
    if test "$DEBUG_MODE" = "true"
        echo "Debug mode: ON"
    else
        echo "Debug mode: OFF"
    end
end

echo "Main script running with PID "(fish_pid)
echo "Send: kill -HUP "(fish_pid)" to reload config"
echo "Send: kill -USR1 "(fish_pid)" to toggle debug"

while true
    echo -n "."
    sleep 2
end

Ignoring Signals

To ignore a signal completely, pass an empty string as the command:

# Ignore SIGINT β€” Ctrl+C will have no effect
trap '' INT

echo "I'm invincible! Try Ctrl+C..."
sleep 10
echo "See? Still running."

Resetting Signal Handlers to Default

To restore the default behavior for a signal, use trap without a command argument, or pass -:

# Temporarily ignore SIGTERM
trap '' TERM
echo "SIGTERM is now ignored for 5 seconds..."
sleep 5

# Restore default SIGTERM behavior
trap - TERM
echo "SIGTERM is back to default."

Dynamic Signal Handler Registration

You can change signal handlers at runtime based on script state:

#!/usr/bin/env fish

set -g CRITICAL_SECTION false

function enter_critical
    set -g CRITICAL_SECTION true
    trap 'echo "Cannot interrupt during critical operation!"' INT
    echo "Entered critical section β€” SIGINT blocked"
end

function exit_critical
    set -g CRITICAL_SECTION false
    trap 'echo "Normal interrupt β€” exiting..."; exit 0' INT
    echo "Exited critical section β€” SIGINT allowed"
end

enter_critical
echo "Performing critical work..."
sleep 3
exit_critical
echo "Normal work..."
sleep 3

Practical Real-World Examples

Example 1: Temporary File Cleanup on Exit

A classic pattern: create temporary files and ensure they are deleted regardless of how the script terminates:

#!/usr/bin/env fish

set -g TEMP_DIR (mktemp -d /tmp/fish_script_XXXXXX)
set -g LOCK_FILE "$TEMP_DIR/lock"

function cleanup_exit
    echo "Cleaning up $TEMP_DIR..."
    rm -rf "$TEMP_DIR"
    echo "Cleanup complete."
    # Always exit to avoid hanging
    exit 0
end

trap cleanup_exit INT TERM EXIT HUP

# Create some working files
echo "Working directory: $TEMP_DIR"
echo "data" > "$TEMP_DIR/result.txt"

# Simulate work
for i in (seq 1 5)
    echo "Processing batch $i..."
    sleep 1
end

echo "Work complete β€” cleaning up normally"

Note: Fish supports trapping the pseudo-signal EXIT, which fires when the script exits normally via exit or reaching the end of the script. This is extremely useful for cleanup logic.

Example 2: Graceful Daemon Shutdown

For long-running background scripts, implement a proper shutdown sequence:

#!/usr/bin/env fish

set -g SHUTDOWN_REQUESTED false
set -g CHILD_PIDS

function handle_shutdown --on-signal TERM --on-signal INT
    echo "Shutdown signal received β€” initiating graceful stop..."
    set -g SHUTDOWN_REQUESTED true
end

function spawn_worker
    set -l worker_id $argv[1]
    fish -c "
        echo 'Worker $worker_id starting...'
        while true
            echo 'Worker $worker_id heartbeat'
            sleep 5
        end
    " &
    set -a CHILD_PIDS $last_pid
    echo "Spawned worker $worker_id (PID: $last_pid)"
end

# Start worker processes
spawn_worker 1
spawn_worker 2
spawn_worker 3

echo "Daemon running. PID: "(fish_pid)
echo "Workers: $CHILD_PIDS"

# Main loop β€” check shutdown flag
while not $SHUTDOWN_REQUESTED
    echo "Daemon alive at "(date)
    sleep 2
end

# Graceful shutdown
echo "Stopping all workers..."
for pid in $CHILD_PIDS
    echo "Terminating worker PID $pid..."
    kill -TERM $pid 2>/dev/null
end

# Wait for children to exit
for pid in $CHILD_PIDS
    wait $pid 2>/dev/null
end

echo "All workers stopped. Daemon exiting."
exit 0

Example 3: Signal-Based IPC Between Scripts

Use SIGUSR1 and SIGUSR2 to communicate between cooperating scripts:

#!/usr/bin/env fish
# receiver.fish β€” listens for custom signals

function on_data_ready --on-signal USR1
    echo "Producer says: data is ready!"
    if test -f /tmp/shared_data.txt
        echo "Reading shared data:"
        cat /tmp/shared_data.txt
    end
end

function on_producer_done --on-signal USR2
    echo "Producer has finished. Shutting down receiver..."
    set -g PRODUCER_DONE true
end

echo "Receiver PID: "(fish_pid)" β€” waiting for signals..."
set -g PRODUCER_DONE false

while not $PRODUCER_DONE
    sleep 1
end
echo "Receiver exiting."
exit 0

And the producer script:

#!/usr/bin/env fish
# producer.fish β€” sends signals to receiver

set -l RECEIVER_PID $argv[1]

if test -z "$RECEIVER_PID"
    echo "Usage: producer.fish "
    exit 1
end

echo "Producer: generating data..."
echo "Important data: "(date)" - system status OK" > /tmp/shared_data.txt
sleep 1

echo "Producer: signaling receiver (SIGUSR1)..."
kill -USR1 $RECEIVER_PID
sleep 2

echo "Producer: finishing up..."
rm -f /tmp/shared_data.txt

echo "Producer: sending final signal (SIGUSR2)..."
kill -USR2 $RECEIVER_PID

echo "Producer done."

Example 4: Preventing Double-Ctrl+C

Implement a safe exit that requires confirmation on repeated interrupt attempts:

#!/usr/bin/env fish

set -g INTERRUPT_COUNT 0

function handle_interrupt --on-signal INT
    set -g INTERRUPT_COUNT (math $INTERRUPT_COUNT + 1)
    
    if test $INTERRUPT_COUNT -eq 1
        echo ""
        echo "Caught SIGINT once β€” press Ctrl+C again within 2 seconds to confirm exit"
        
        # Reset count after a timeout
        fish -c "sleep 2; set -g INTERRUPT_COUNT 0" &
    else if test $INTERRUPT_COUNT -ge 2
        echo ""
        echo "Double interrupt confirmed β€” exiting!"
        exit 0
    end
end

echo "Running sensitive operation (PID: "(fish_pid)")"
echo "Press Ctrl+C once to see warning, twice to exit."

while true
    echo -n "."
    sleep 1
end

Best Practices for Signal Handling in Fish

1. Always Clean Up Resources

Make cleanup a habit. Use the EXIT pseudo-signal alongside INT and TERM to ensure cleanup runs on both abnormal and normal exits:

function cleanup
    rm -f /tmp/my_script.lock
    rm -rf /tmp/my_script_work
end

trap cleanup EXIT INT TERM

2. Keep Signal Handlers Short and Simple

Signal handlers should perform minimal work. Avoid complex logic, long loops, or blocking operations inside handlers. If you need to do substantial work, set a flag and handle it in the main loop:

# Good: flag-based approach
function on_term --on-signal TERM
    set -g SHOULD_EXIT true
end

while not $SHOULD_EXIT
    # Main work loop
end
# Cleanup after loop

3. Avoid Trapping SIGKILL and SIGSTOP

These signals cannot be caught, blocked, or ignored by any process. Attempting to trap them in Fish will silently fail:

# This will NOT work β€” SIGKILL (9) cannot be trapped
trap 'echo "Cannot happen"' KILL

4. Use Named Functions for Complex Handlers

For handlers that span multiple lines, define a named function rather than embedding a long inline string:

# Prefer this:
function graceful_shutdown
    echo "Shutting down..."
    jobs --list
    for job_id in (jobs -p)
        kill -TERM $job_id
    end
    wait
    echo "All jobs terminated."
    exit 0
end
trap graceful_shutdown TERM INT

# Over this:
trap 'echo "Shutting down..."; for job_id in (jobs -p); kill -TERM $job_id; end; wait; echo "All jobs terminated."; exit 0' TERM INT

5. Test Signal Handling Thoroughly

Signal handling bugs often manifest only under specific timing conditions. Test your scripts by sending signals at different points in execution:

# In one terminal:
./my_script.fish

# In another terminal, send signals at various times:
kill -INT 
kill -TERM 
kill -HUP 

6. Document Which Signals Your Script Handles

At the top of your script, clearly document expected signal behavior:

#!/usr/bin/env fish
# Signal handling:
#   SIGINT/SIGTERM β€” graceful shutdown with cleanup
#   SIGHUP         β€” reload configuration
#   SIGUSR1        β€” toggle verbose logging
#   EXIT           β€” always remove lock file

7. Be Aware of Subshell Signal Propagation

In Fish, signal handlers defined with trap only affect the current shell. Background jobs and subshells inherit the parent's signal mask but not the custom handlers:

trap 'echo "Parent caught INT"' INT

# This background job will terminate on SIGINT by default
fish -c "while true; echo 'child running'; sleep 1; end" &

# To protect it, you must trap inside the child as well
fish -c "trap 'echo child caught INT' INT; while true; echo 'child running'; sleep 1; end" &

8. Use `jobs` and `wait` for Child Process Management

When shutting down, always wait for child processes to finish to avoid zombie processes:

function shutdown
    echo "Terminating all background jobs..."
    jobs -p | while read -l pid
        kill -TERM $pid 2>/dev/null
    end
    # Wait up to 5 seconds for children to exit
    set -l timeout (math (date +%s) + 5)
    while jobs -q
        if test (date +%s) -gt $timeout
            echo "Force-killing remaining jobs..."
            jobs -p | while read -l pid
                kill -KILL $pid 2>/dev/null
            end
            break
        end
        sleep 0.1
    end
    echo "Shutdown complete."
    exit 0
end
trap shutdown TERM INT

Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting to Exit After Trapping

If your signal handler does not call exit, the script may continue running after the signal:

# Wrong: script continues after SIGINT
trap 'echo "Interrupted"' INT
while true; sleep 1; end

# Correct: handler exits
trap 'echo "Interrupted"; exit 0' INT
while true; sleep 1; end

Pitfall 2: Recursive Signal Storms

If a signal handler triggers code that might receive the same signal again, you can create infinite recursion. Disable the handler temporarily if needed:

function on_int --on-signal INT
    echo "Handling interrupt..."
    # Temporarily disable this handler
    trap - INT
    # Do potentially slow cleanup
    sleep 2
    echo "Cleanup done."
    exit 0
end

Pitfall 3: Assuming Traps Work in Non-Interactive Shells

Signal handling works in both interactive and non-interactive Fish shells, but job control signals (like SIGTSTP from Ctrl+Z) behave differently. Test in the target environment.

Pitfall 4: Trapping EXIT Incorrectly

The EXIT trap fires on normal exit but also when the shell exits due to receiving an untrapped signal. If you trap both INT and EXIT, your handler may run twice on Ctrl+C:

# This causes the cleanup function to run TWICE on Ctrl+C
trap cleanup EXIT INT

# Better: only trap EXIT, and let INT trigger exit (which fires EXIT)
trap cleanup EXIT
trap 'exit 0' INT

Signal Handling in Fish Functions vs Scripts

When writing Fish functions (for use in interactive shells or sourced files), signal handlers behave slightly differently than in standalone scripts:

# In a sourced file (~/.config/fish/functions/my_handler.fish):
function my_long_running_command
    function __cleanup_on_int --on-signal INT
        echo "Command interrupted!"
        # Don't exit the shell, just stop the command
        return 1
    end
    
    echo "Running command..."
    sleep 10
    echo "Command completed."
end

Functions should generally avoid calling exit in signal handlers unless you explicitly want to close the entire shell session. Use return instead to bail out of the function gracefully.

Complete Reference: All Trap Options

The Fish trap command supports several options for managing signal handlers:

# List current signal handlers
trap

# Set a handler for a signal
trap 'echo "Caught INT"' INT

# Remove a handler (reset to default)
trap - INT

# Remove all handlers
trap -p | while read -l _ sig
    trap - $sig
end

# The --on-signal function attribute (alternative to trap)
function handler --on-signal TERM
    echo "TERM received"
end

Conclusion

Mastering signal handling in Fish shell scripting transforms your scripts from brittle, fire-and-forget snippets into robust, production-ready programs. The Fish trap command and --on-signal function attribute provide a clean, expressive interface for responding to process signals. By trapping INT, TERM, HUP, and the EXIT pseudo-signal, you can implement reliable cleanup routines, graceful shutdown sequences, configuration reloading, and inter-process communication. Remember to keep handlers short, always clean up resources, test thoroughly under different signal timing conditions, and document your signal handling strategy clearly. With these practices in place, your Fish scripts will handle unexpected terminations with grace and reliability, providing a polished experience for both users and system administrators alike.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles