6

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.

4

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.

  • @Venkat-Madhav I've attempted to better explain the process group IDs and improve this answer. I hope it helps. – RobertL Nov 4 '15 at 15:43
  • you really need #!/bin/sh -m for correct behavior of nested subshells. fg, bg, and wait wont work correctly otherwise. read the standard – mikeserv Nov 5 '15 at 1:42
  • Also, this code will fail if $$ is not the process group leader, such as when the script is run under strace. Since a call to setsid(2) is probably tricky from a shell script, one approach might be to ps and obtain the process group ID from that. – thrig Nov 6 '15 at 14:47
1

In play.sh source the loop file like this:

source ./loop.sh

A subshell once exited due to a trap can't be returned to on exiting the trap.

  • I don't think this answer enables the questioner to have several child scripts, does it? – RobertL Nov 4 '15 at 2:00
  • in that case every child script should source the trap and the trap function independently. – ferdy Nov 4 '15 at 6:07
1

"I understand that when a process receives a SIGINT signal, it propagates the signal to all the child processes"

Where did you get this incorrect idea from?

$ perl -E '$SIG{INT}=sub { say "ouch $$" }; if (fork()) { say "parent $$"; sleep 3; kill 2, $$ } else { say "child $$"; sleep 99 }'
parent 25831
child 25832
ouch 25831
$

If there was propagation, as claimed, one might have expected an "ouch" from the child process.

In reality:

"Whenever we type our terminal's interrupt key (often DELETE or Control-C) or quit key (often Control-backslash) this causes either the interrupt signal or the quit signal to be sent to all processes in the foreground process group" -- W. Richard Stevens. "Advanced Programming in the UNIX® Environment". Addison-Wesley. 1993. p.246.

This can be observed via:

$ perl -E '$SIG{INT}=sub { say "ouch $$" }; fork(); sleep 99'
^Couch 25971
ouch 25972
$

Since all processes of the foreground process group will eat a SIGINT from the Control+C, you'll need to design all processes to properly handle or ignore that signal, as appropriate, or to possibly have sub-processes become the new foreground process group so the parent (e.g. the shell) does not see the signal, on account of it no longer being in the foreground process group.

  • @thrig, thanks for your insights. But I was looking for a solution is shell as I am not familiar with perl – Venkat Madhav Nov 4 '15 at 13:33
  • @RobertL what, pray tell, is false in my examples? I am curious, so please be precise. – thrig Nov 4 '15 at 16:42
  • @RobertL so again, what, precisely, is false? Do you deny that a Control+C goes to the foreground process group? What happens if instead of sleep a program that installs a custom INT handler is run? – thrig Nov 5 '15 at 0:18
  • I take back my misguided tweaky comments. Sorry. I'm just kinda bugged because we haven't a clear solution for this. The concepts you present may apply but there's a bunch of different code between the receipt of the signal and the perl or shell code that's being interpreted. I really think this question is depending on the idiosyncrasies of shell signal handling. – RobertL Nov 6 '15 at 8:36
0

The problem is sleep.

When you CTRL+C you kill a sleep - not your sh (even if the syntax in your #!/bin/sh is a little suspicious). The whole process group gets the signal, though, because you don't install your handler in loop.sh - which is a subshell - it terminates immediately afterward.

You need a trap in loop.sh. Traps are cleared for every subshell started unless they are explicitly trap ''SIG ignored by the parent.

You also need job controlled -monitoring in your parent so it keep track of its children. wait, for example, only works at all with job control. -monitor mode is how shells interact with terminals.

sh  -cm '  . /dev/fd/3' 3<<""                     # `-m`onitor is important
     sh -c ' kill -INT "$$"'&                     # get the sig#
     wait %%;INT=$?                               # keep it in $INT
     trap  " stty $(stty -g;stty -icanon)" EXIT   # lose canonical input
     loop()( trap    exit INT                     # this is a child
             while   [ "$#" -le 100 ]             # same as yours
             do      echo "$#"                    # writing the iterator
                     set "" "$@"                  # iterating
                     sleep 3                      # sleeping
             done
     )
     int(){  case    $1      in                   # handler fn
             ($INT)  printf  "\33[2K\rDo you want to stop playing? "
                     case    $(dd bs=1 count=1; echo >&3)  in
                     ([Nn])  return
             esac;   esac;   exit "$1"
     }       3>&2    >&2     2>/dev/null
     while   loop ||
             int "$?"
     do :;   done

0
1
2
3
4
Do you want to stop playing? n
0
1
2
3
Do you want to stop playing? N
0
1
Do you want to stop playing? y
[mikeserv@desktop tmp]$
  • Your script works differently than what I anticipated. When the script encounters Ctrl+C, it should prompt the user and when user wants to continue the execution, then it should resume from where ever the signal was captured. – Venkat Madhav Nov 9 '15 at 14:26
  • 1
    @VenkatMadhav - well, then you need to handle the trap in the shell process which you want to keep running. this just exits and returns to the parent where the central logic is handled - which is what I thought you were after. but the trap that matters is set in the foreground process subshell - that's where it needs to be. the foreground process is what matters. and you need to be careful about what you do in a handler - read and wait are always interruptible, but a lot of other things are not. thats where the term reentrant comes from. – mikeserv Nov 9 '15 at 14:28
  • 1
    I really like how it handles the cbreak mode and the $# loop counting is awesome. It runs if I paste the code into a bash command line. Paste it into /bin/sh cmd line, it exits after I hit control-c. Paste it into the zsh cmd line I get syntax errors. It doesn't run correctly under any shell in a command file. And often, when it doesn't work correctly, I have to reset my tty and manually kill background processes. Any ideas? This seems a little dangerous. :-) – RobertL Nov 10 '15 at 8:07
  • @RobertL - all very good points. i remember seeing stephane talking about some of this stuff the other day - and it has to do with what shell executes what trap at what time. the subshells run here are in interactive mode, which bash and other shells handle differently. i do have a preference for dash myself, but i was testing this when i wrote it in bash. i overlooked something - which i mean to fix - and it has to do with what readline does with the termios settings when its not in the foreground and what other shells do instead. what we need to do is get another process group. – mikeserv Nov 10 '15 at 8:49
  • @RobertL - this was fixed almost as soon as you mentioned it, by the way. – mikeserv Nov 10 '15 at 19:21

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.