Handling and confirming (interrupt) signals in Python

20 minute read

Let’s say you have a long-running Python script that should run uninterrupted or perform a graceful shutdown should a user decide to terminate it ahead of completion.

By default, sending an interrupt (usually by pressing <Control-C>) to a running Python program will raise a KeyboardInterrupt exception.

One way of (gracefully or not) handling interrupts is to catch this specific exception. For example like this:

while True:
    try:
        do_your_thing()
    except KeyboardInterrupt:
        clean_up()
        sys.exit(0)

This generally works fine. However, you can still trigger another KeyboardInterrupt while the clean_up is running and thus interrupt the clean-up process. Also, as interrupts might be sent accidentally (ever cancelled the wrong script because you thought you were in a different pane?), it would be nice to let the user confirm that the script should indeed be interrupted.

Let’s see how we can make a script that can deal with both of these situations.

Handling signals instead of KeyboardInterrupt

Instead of handling the KeyboardInterrupt exception that Python gives you by default for an interrupt, you can also define your own own signal handlers. This will not only allow you to customize the behavior of an interrupt signal, but the behavior for any signal that can be caught (another good example is when your program is suspended, for example with <Control-z>).

Python comes with a built-in signal module that makes it easy to register your own handlers.

Let’s see an example which you can acutally execute:

import sys
import time
import signal


def interrupt_handler(signum, frame):
    print(f'Handling signal {signum} ({signal.Signals(signum).name}).')

    # do whatever...
    time.sleep(1)
    sys.exit(0)


def main():
    while True:
        print('.', end='', flush=True)
        time.sleep(0.3)


if __name__ == '__main__':
    signal.signal(signal.SIGINT, interrupt_handler)

    main()

With signal.signal we can set a new handler for a given signal. Here, we define that whenever a SIGINT (interrupt, signal number 2) request comes in, we want to execute interrupt_handler. Once set, we overwrite the default behavior and won’t get any KeyboardInterrupts anymore (although you could easily recreate the default behavior by setting the previous handler again, which is returned by signal()).

Running the program and then sending an interrupt (<Control-c>) will now look something like this:

$ python3 demo.py
.....^CHandling signal 2 (SIGINT).
$

Since we introduced a little delay in the handler, you can see what happens when we press <Control-c> again before the program exits.

$ python3 demo.py
.......^CHandling signal 2 (SIGINT).
^CHandling signal 2 (SIGINT).
^CHandling signal 2 (SIGINT).
^CHandling signal 2 (SIGINT).
$

The handler is called multiple times. Depending on what your function does, this may or may not be a problem. We’ll look at one way of trying to control this later on.

Let the user confirm the interrupt

Let’s say we want to implement the initially described behavior where a user must confirm an interrupt by sending another one.

One way of accomplishing this is to set a different handler once the signal is handled the first time. Going back to the original code, we can add a parameter ask to our handler and then call it first with True, then with False:

import sys
import time
import signal
from functools import partial


def interrupt_handler(signum, frame, ask=True):
    print(f'Handling signal {signum} ({signal.Signals(signum).name}).')
    if ask:
        signal.signal(signal.SIGINT, partial(interrupt_handler, ask=False))
        print('To confirm interrupt, press ctrl-c again.')
        return

    print('Cleaning/exiting...')
    # do whatever...
    time.sleep(1)
    sys.exit(0)


def main():
    while True:
        print('.', end='', flush=True)
        time.sleep(0.3)


if __name__ == '__main__':
    signal.signal(signal.SIGINT, interrupt_handler)

    main()

Running the program should now look like this:

$ python3 demo.py
....^CHandling signal 2 (SIGINT).
To confirm interrupt, press ctrl-c again.
...^CHandling signal 2 (SIGINT).
Cleaning/exiting...

Instead of functoolspartial, we could also use a lambda expression like signal.signal(signal.SIGINT, lambda sig, frame: interrupt_handler(sig, frame, ask=False)), create a separate function altogether or wrap it in a class. The important thing is just to register a different behavior for the same signal.

Ignoring signals

We still have the problem that repeatably pressing <ctrl-c> will lead to multiple calls to the same handler, which might be problematic when doing some important cleanup actions, releasing resources, etc.

A simple way of stopping further calls to the handler is to reset the handler for the signal yet again – this time to ignore interrupts by using the SIG_IGN handler:

if ask:
    ...
else:
    signal.signal(signum, signal.SIG_IGN)

Running the program now you will see that the “Cleaning/exiting” part only happens once, even when you keep pressing <ctrl-c>.

Please note: The behavior you are going to implement should always take user expectations and the context of the actions that are being performed by your script into consideration. Depending on what your program does, explicitly ignoring interrupts can also be anything from annoying to dangerous. If an interrupted clean-up is not a big deal, I would recommend that you just register the default handler again before starting your clean-up (remember that signal.signal returns the previously registered signal handler). This way, the running program can be interrupted again just like anyone would expect.

Extending to other signals like TSTP and CONT

Just like we handled the INT signal above, you can handle other signals in the same way as well.

For example, if you want your script to also do some cleanup and re-setup (say, for re-establishing connections) when suspended or continued, respectively, you could just register your handler for these signals:

signal.signal(signal.SIGTSTP, handler)
signal.signal(signal.SIGCONT, handler)

A few caveats at the end

While the above use-cases work just fine, there can be situations where signals are not executed in the way/at the time you might expect. This is due to how Python itself handles the signal, as in it is rather setting a flag that a signal should be handled at the next bytecode instruction than handling it directly at a lower level. As the documentation points out, this might cause a (possibly noticeable) delay until your handler is called (due to a block in the lower-level execution).

Another behavior to know is that signals are always executed/received in the main thread and must only be set there.

Like to comment? Feel free to send me an email or reach out on Twitter.

Did this or another article help you? If you like and can afford it, you can buy me a coffee (3 EUR) ☕️ to support me in writing more posts. In case you would like to contribute more or I helped you directly via email or coding/troubleshooting session, you can opt to give a higher amount through the following links or adjust the quantity: 50 EUR, 100 EUR, 500 EUR. You'll be able to get a receipt for your business.