Proper SIGINT (Ctrl+C) Handling in Bash Scripts: Terminating Both Parent and Child Processes


1 views

When writing bash scripts that execute long-running commands, developers often encounter an unexpected behavior: pressing Ctrl+C terminates the child process but allows the parent script to continue execution. This happens because:


#!/bin/bash
# Example of problematic behavior
long_running_command  # Ctrl+C kills only this
echo "Script continues!"  # This still executes

The root cause lies in how Unix handles process groups. By default, Ctrl+C sends SIGINT only to the foreground process (the child), not the entire process group. We need to ensure signals propagate properly.

1. Using trap with EXIT


#!/bin/bash
trap "exit" INT TERM
trap "kill 0" EXIT

long_running_command &
wait  # Important for background processes

2. Process Group Termination


#!/bin/bash
set -m  # Enable job control
long_running_command &
pid=$!
trap "kill -- -$pid" INT TERM EXIT
wait $pid

3. Using exec for Direct Replacement


#!/bin/bash
exec long_running_command  # Replaces shell process entirely

Sometimes you need to perform cleanup before exiting:


#!/bin/bash
cleanup() {
    echo "Cleaning up..."
    rm -f /tmp/tempfile
    kill $(jobs -p)
    exit
}
trap cleanup INT TERM EXIT

operation1 &
operation2 &
wait
  • Forgetting wait for background processes
  • Nested traps that override each other
  • Not handling both SIGINT and SIGTERM
  • Assuming immediate process termination

#!/bin/bash
# Properly handles Ctrl+C during build process
set -euo pipefail

abort() {
    echo "Aborting build..."
    kill $(jobs -p) 2>/dev/null || true
    exit 1
}

trap abort INT TERM

compile_step1 &
compile_step2 &
link_step &

wait
echo "Build completed successfully"



When writing bash scripts that launch long-running commands, you might notice that pressing Ctrl+C (which sends SIGINT) only terminates the foreground command while allowing the script to continue execution. This behavior occurs because:

  • The shell script acts as the parent process
  • Only the foreground child process receives the SIGINT by default
  • The script's process continues unless explicitly configured otherwise

Bash provides the trap command for signal handling. The key signals involved are:

SIGINT  (2) - Interrupt from keyboard (Ctrl+C)
SIGTERM (15) - Termination signal
EXIT    (0) - Shell exit pseudo-signal

Here's a simple approach to make the script exit when Ctrl+C is pressed:

#!/bin/bash

# Set up trap
trap "echo 'Script terminated by user'; exit 1" SIGINT

# Long-running command
sleep 60
echo "This line won't be reached if interrupted"

To ensure child processes are also terminated, we need:

#!/bin/bash

# Store child process PID
child_pid=0

# Cleanup function
cleanup() {
    if [ $child_pid -ne 0 ]; then
        kill -9 $child_pid 2>/dev/null
    fi
    exit 1
}

trap cleanup SIGINT

# Launch command in background
some_long_command &
child_pid=$!

# Wait but clean up if interrupted
wait $child_pid

For more complex scenarios with multiple child processes:

#!/bin/bash

# Store process group ID
pgid=0

cleanup() {
    if [ $pgid -ne 0 ]; then
        kill -- -$pgid 2>/dev/null
    fi
    exit 1
}

trap cleanup SIGINT

# Launch command in new process group
setsid some_long_command &
pgid=$!

wait $pgid

Here's how this technique applies to a practical scenario:

#!/bin/bash

# Initialize variables
backup_pid=0
cleanup_done=false

cleanup() {
    if [ "$cleanup_done" = false ]; then
        cleanup_done=true
        if [ $backup_pid -ne 0 ]; then
            echo "Terminating backup process..."
            kill -TERM $backup_pid
            wait $backup_pid
        fi
        rm -f /tmp/backup_in_progress.lock
        exit 1
    fi
}

trap cleanup SIGINT SIGTERM

# Create lock file
touch /tmp/backup_in_progress.lock

# Start backup in background
pg_dump -U user database > backup.sql &
backup_pid=$!

# Wait for completion
wait $backup_pid

# Clean up on success
rm -f /tmp/backup_in_progress.lock
  • Forgetting to handle cases where child processes spawn their own children
  • Not properly cleaning up temporary files or resources
  • Assuming SIGINT is the only signal that needs handling
  • Creating race conditions between signal receipt and cleanup