Catching Control-C and a gotcha with shell scripts

September 10, 2019

Suppose, not entirely hypothetically, that you have some sort of spiffy program that wants to use Control-C as a key binding to get it to take some action. In Unix, there are two ways of catching Control-C for this sort of thing. First, you can put the terminal into raw mode, where Control-C becomes just another character that you read from the terminal and you can react to it in any way you like. This is very general but it has various drawbacks, like you have to manage the terminal state and you have to be actively reading from the terminal so you can notice when the key is typed. The simpler alternative way of catching Control-C is to set a signal handler for SIGINT and then react when it's invoked. With a signal handler, the kernel's standard tty input handling does all of that hard work for you and you just get the end result in the form of an asynchronous SIGINT signal. It's quite convenient and leaves you with a lot less code and complexity in your spiffy Control-C catching program.

Then some day you run your spiffy program from inside a shell script (perhaps you wanted to add some locking), hit Control-C to signal your program, and suddenly you have a mess (what sort of a mess depends on whether or not your shell does job control). The problem is that when you let the kernel handle Control-C by delivering a SIGINT signal, it doesn't just deliver it to your program; it delivers it to the shell script and in fact any other programs that the shell script is also running (such as a flock command used to add locking). The shell script and these other programs are not expecting to receive SIGINT signals and haven't set up anything special to handle it, so they will get killed.

(Specifically, the kernel will send the SIGINT to all processes in the foreground process group.)

Since your shell was running the shell script as your command and the shell script exited, many shells will decide that your command has finished. This means they'll show you the shell prompt and start interacting with you again. This can leave your spiffy program and your shell fighting over terminal output and perhaps terminal input as well. Even if your shell and your spiffy program don't fight for input and write their output and shell prompt all over each other, generally things don't go well; for example, the rest of your shell script isn't getting run, because the shell script died.

Unfortunately there isn't a good general way around this problem. If you can arrange it, the ideal is for the wrapper shell script to wind up directly exec'ing your spiffy program so there's nothing else a SIGINT will be sent to (and kill). Failing that, you might have to make the wrapper script trap and ignore SIGINT while it's running your program (and to make your program unconditionally install its SIGINT signal handler, even if SIGINT is ignored when the program starts).

Speaking from painful personal experience, this is an easy issue to overlook (and a mysterious one to diagnose). And of course everything works when you test your spiffy program by running it directly, because then the only process getting a SIGINT is the one that's prepared for it.

Comments on this page:

By Greg A. Woods at 2019-09-11 14:50:58:

Normally trap works pretty good for me, though there are some caveats for SIGCHLD.

There is one major caveat though. If the signal comes from somewhere other than the terminal driver, e.g. you send a SIGINT to just the shell script process itself from another session, then a sub-process being waited on by the shell running the script won't get the signal.

Unfortunately trap handlers cannot kill processes that the shell is waiting on because of course they cannot run until after the waited on process exits, and normally there's no way to find out the PID of a process the shell is waiting on either (except by looking externally at the process table, etc.).

Arguably this could be fixed by having some new builtin that gave a trap handler the ability to kill any waited on process, though implementing it might be a little tricky in some shells.

However as you've mentioned, normally this isn't a problem provided the process being waited on dies one way or another when it too gets the SIGINT from the terminal driver.

Written on 10 September 2019.
« A safety note about using (or having) go.mod inside $GOPATH in Go 1.13
Making your own changes to things that use Go modules »

Page tools: View Source, View Normal, Add Comment.
Login: Password:
Atom Syndication: Recent Comments.

Last modified: Tue Sep 10 20:54:47 2019
This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.