shell – Handle SIGINT Trap with User Prompt in Shell Script

interruptlinuxshellsignalstrap:

I am trying to handle SIGINT/CTRL+C interrupt in such a way that if a user accidentally presses ctrl-c, he is prompted with a message, "Do you wish to quit?(y/n)". If he enters yes, then exit the script. If no, then continue from where ever the interrupt occurred. Basically, I need Ctrl-C to work similar to Ctrl-Z/SINTSTP but in a slightly different way. I have tried various ways to acheieve this but I didn't get the expected results. Below are few scenario which I tried.

Case:1

Script : play.sh

#!/bin/sh
function stop()
{
while true; do 
    read -rep $'\nDo you wish to stop playing?(y/n)' yn
    case $yn in
        [Yy]* ) echo "Thanks for playing !!!"; exit 1;;
        [Nn]* ) break;;
        * ) echo "Please answer (y/n)";;
    esac
done
} 
trap 'stop' SIGINT 
echo "going to sleep"
for i in {1..100}
do
  echo "$i"
  sleep 3   
done
echo "end of sleep"

When I run the above script, I get the expected results.

Output:

$ play.sh 
going to sleep
1
^C
Do you wish to stop playing?(y/n)y
Thanks for playing !!!

$ play.sh 
going to sleep
1
2
^C
Do you wish to stop playing?(y/n)n
3
4
^C
Do you wish to stop playing?(y/n)y
Thanks for playing !!! 
$  

Case:2
I moved the for loop to a new script loop.sh, thus play.sh becomes the parent process and loop.sh the child process.

Script : play.sh

#!/bin/sh
function stop()
{
while true; do 
    read -rep $'\nDo you wish to stop playing?(y/n)' yn
    case $yn in
        [Yy]* ) echo "Thanks for playing !!!"; exit 1;;
        [Nn]* ) break;;
        * ) echo "Please answer (y/n)";;
    esac
done
}
trap 'stop' SIGINT 
loop.sh

Script : loop.sh

#!/bin/sh
echo "going to sleep"
for i in {1..100}
do
  echo "$i"
  sleep 3   
done
echo "end of sleep"

Output in this case is not as expected.

Output:

$ play.sh 
going to sleep
1
2
^C
Do you wish to stop playing?(y/n)y
Thanks for playing !!!

$ play.sh 
going to sleep
1
2
3
4
^C
Do you wish to stop playing?(y/n)n
$

I understand that when a process receives a SIGINT signal, it propagates the signal to all the child processes, thus my 2nd case is failing. Is there any way that I can avoid SIGINT being propagated to child processes and thus make the loop.sh work exactly the way it worked in the 1st case?

Note: This is just an example of my actual application. The application I am working on has several child scripts in play.sh and loop.sh. I should make sure that the application on receiving SIGINT, should not terminate but it should prompt the user with a message.

Best Answer

Great classic question about managing jobs and signals with good examples! I've developed a stripped down test script to focus on the mechanics of the signal handling.

To accomplish this, after starting the children (loop.sh) in the background, call wait, and upon receipt of the INT signal, kill the process group whose PGID equals your PID.

  1. For the script in question, play.sh, this can be accomplished by the following:

  2. In the stop() function replace exit 1 with

    kill -TERM -$$  # note the dash, negative PID, kills the process group
    
  3. Start loop.sh as a background process (multiple background processes can be started here and managed by play.sh)

    loop.sh &
    
  4. Add wait at the end of the script to wait for all children.

    wait
    

When your script starts a process, that child becomes a member of a process group with PGID equal to the PID of the parent process which is $$ in the parent shell.

For example, the script trap.sh started three sleep processes in the background and is now waiting on them, notice the process group ID column (PGID) is the same as the PID of the parent process:

  PID  PGID STAT COMMAND
17121 17121 T    sh trap.sh
17122 17121 T    sleep 600
17123 17121 T    sleep 600
17124 17121 T    sleep 600

In Unix and Linux you can send a signal to every process in that process group by calling kill with the negative value of the PGID. If you give kill a negative number, it will be used as -PGID. Since the script's PID ($$) is the same as it's PGID, you can kill your process group in the shell with

kill -TERM -$$    # note the dash before $$

you have to give a signal number or name, otherwise some implementations of kill will tell you "Illegal option" or "invalid signal specification."

The simple code below illustrates all of this. It sets a trap signal handler, spawns 3 children, then goes into an endless wait loop, waiting to kill itself by the kill process group command in the signal handler.

$ cat trap.sh
#!/bin/sh

signal_handler() {
        echo
        read -p 'Interrupt: ignore? (y/n) [Y] >' answer
        case $answer in
                [nN]) 
                        kill -TERM -$$  # negative PID, kill process group
                        ;;
        esac
}

trap signal_handler INT 

for i in 1 2 3
do
    sleep 600 &
done

wait  # don't exit until process group is killed or all children die

Here's a sample run:

$ ps -o pid,pgid,stat,args
  PID  PGID STAT COMMAND
 8073  8073 Ss   /bin/bash
17111 17111 R+   ps -o pid,pgid,stat,args
$ 

OK no extra processes running. Start the test script, interrupt it (^C), choose to ignore the interrupt, and then suspend it (^Z):

$ sh trap.sh 
^C
Interrupt: ignore? (y/n) [Y] >y
^Z
[1]+  Stopped                 sh trap.sh
$

Check the running processes, note the process group numbers (PGID):

$ ps -o pid,pgid,stat,args
  PID  PGID STAT COMMAND
 8073  8073 Ss   /bin/bash
17121 17121 T    sh trap.sh
17122 17121 T    sleep 600
17123 17121 T    sleep 600
17124 17121 T    sleep 600
17143 17143 R+   ps -o pid,pgid,stat,args
$

Bring our test script to the foreground (fg) and interrupt (^C) again, this time choose not to ignore:

$ fg
sh trap.sh
^C
Interrupt: ignore? (y/n) [Y] >n
Terminated
$

Check running processes, no more sleeping:

$ ps -o pid,pgid,stat,args
  PID  PGID STAT COMMAND
 8073  8073 Ss   /bin/bash
17159 17159 R+   ps -o pid,pgid,stat,args
$ 

Note about your shell:

I had to modify your code to get it to run on my system. You have #!/bin/sh as the first line in your scripts, yet the scripts use extensions (from bash or zsh) which are not available in /bin/sh.

Related Question