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