maambmb
maambmb

Reputation: 951

Inconsistency when trying to ignore SIGINT

I am of the understanding that when you set a signal handler, all child processes inherit said handler by default.

Thus, the following code runs as expected:

import subprocess, signal
signal.signal( signal.SIGINT, signal.SIG_IGN ) # use the ignore handler
subprocess.check_call( [ "sleep", "10" ] )

I.e. regardless of how much I press Ctrl-C, the script doesn't terminate until after the 10 seconds has elapsed.

However, if I switch the call to a git clone xxxxxx, it seems I am able to interrupt the script.

I don't understand why there is a difference in behaviour... Any ideas?

Thanks!

Upvotes: 0

Views: 973

Answers (1)

torek
torek

Reputation: 488083

I am of the understanding that when you set a signal handler, all child processes inherit said handler by default.

As phrased, this is not quite right, though your example is fine.

signal.signal( signal.SIGINT, signal.SIG_IGN ) # use the ignore handler

Technically, SIG_IGN is not a handler at all, but rather a special value telling the kernel that the signal should be discarded at the time it is sent. A handler is a user-supplied function; behind the scenes, when you install a handler, the kernel becomes set up to deliver the signal to the user code.1

The key difference here is that the user code version requires that said user code continue to exist, but when one process runs another, using fork+exec or spawn or whatever (all nicely hidden by the Python subprocess.Popen interface), the new process has thrown away, or even never had, the user code from the original process. This means any user-code-based handler no longer exists in the new process (whether it is sleep or git or anything else), and therefore the signal disposition must be restored to the default.

When using SIG_IGN, however, the disposition is "discard signal immediately", which needs no user-code action. Hence fork+exec or spawn (again hidden behind subprocess.Popen) does not force the signal disposition to be reset.

As Barmar commented, however, any process can change the disposition of signals at its own will. Clearly Git is setting its own new disposition for SIGINT.

Programmers should, at least in theory, write this sort of code with a bit of boilerplate. In Python-ese it would translate as:

with signal.hold([signal.SIGINT]):
    previous = signal.signal(signal.SIGINT, handler)
    if previous == signal.SIG_IGN:
        # Parent process told us NOT to catch SIGINT,
        # so we should leave it ignored.
        signal.signal(signal.SIGINT, signal.SIG_IGN)

This uses a pair of functions Python fails to expose (probably because not all systems actually implemented it, although it is now standard POSIX), wrapped into a with context manager. If we simply set the signal's disposition first, then check what it was and restore it if needed, we open a race window during which we've changed the disposition. To fix the race, we can use the POSIX sigprocmask function to temporarily block the signal (defer it in the kernel for some period) while we fiddle around with the disposition. Once we're sure we have the correct disposition, we unblock the signal. If any were delivered during that period, they get disposed-of at the unblock point.

None of this helps much since it requires a fix to be made to the other program(s) (to check the initial disposition of signals when they install their own handlers). However, there are several ways to work around it. The simplest is to use the signal blocking technique, because the blocking mask is also inherited, along with any "ignore" disposition—and most programs don't bother fussing with the blocking mask, or if they do, use a correct bit of boilerplate (the one we've hidden here behind the non-existent Python with signal.hold(...) trick):

  • call sigprocmask to block the signal while retrieving the current mask at entry
  • call sigprocmask to restore the saved mask (not just explicitly unblock) at exit.

Unfortunately, this requires calling the POSIX sigprocmask function, which is not exposed even in Python 3.4. Python 3.4 does expose pthread_sigmask and that may (depending on your kernel) suffice. It's not clear whether it's worth coding up, though.

Another (even more complex) method of dealing with this is to make your Python program do POSIX-style job control, like most shells. It can then decide which process group should receive tty-generated signals such as SIGINT.


1Technically, the kernel to user signal delivery goes through something called a trampoline. There are several different traditional mechanisms for this, and it is a fairly big ball of hair to make sure it all works correctly.

Upvotes: 2

Related Questions