Handle SIGHUP with Python asyncio

Python's asyncio enables a variety of use cases and workflows. In this post we'll explore the ability to send a SIGHUP signal so one async task (or more) can reload its configuration and continue. It's similar in concept to the nginx -s reload command where nginx reloads its configuration without restarting.

The POSIX world makes use of signals to notify a process or a thread within a process of an event. There are a variety of signals, each with its own purpose. One of the most interesting signals is SIGHUP, which is sent when the controlling terminal of a process is closed. More recently it has been used as a way to notify the process to reload its configuration (for example, Reload configuration files on SIGHUP signal).

This blog post has a much smaller scope than Graceful Shutdowns with asyncio by Lynn Root but you should read that one first (or maybe only read that series because it's great). I have tried to pare down to the smallest details to explain the concept as I imperfectly understand it.

Here's the code,

import asyncio
import signal


async def main():
    with open("/path/to/config/file") as f:
        lines = f.readlines()
    try:
        while True:
            print(lines)
            await asyncio.sleep(3)
    except asyncio.CancelledError:
        print("main() is cancelled")


async def sighup():
    pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
    for task in pending:
        task.cancel()
    await asyncio.gather(*pending)
    print("sighup() is complete")


def start():
    loop = asyncio.get_event_loop()
    loop.add_signal_handler(signal.SIGHUP, lambda: asyncio.create_task(sighup()))
    while True:
        loop.run_until_complete(main())
        print("run_until_complete() is done")


if __name__ == "__main__":
    start()

Let's begin with the start() function. Use the default event loop and add a signal handler, aptly named sighup(), to it. Later, in an infinite loop, call the main() function, which does the bulk of the work. It's this function which we will reload with the SIGHUP signal.

The sighup() function makes a list of all the tasks in the event loop, except itself. It calls cancel on all these tasks and waits for them to complete.

The contrived main() function reads a configuration file and then does some work. In this case its in an infinite loop of printing lines in config file and async sleeping for a little bit. When it receives a cancel it raises an exception, asyncio.CancelledError.

Run this code and then send it a SIGHUP, kill -SIGHUP "${PID}". The event loop calls sighup() which sends a cancel to main(). main() completes and control is passed to start() where loop.run_until_complete(main()) completes. Finally, sighup() completes. This is visible from the output of the script but I don't know for sure that this is how it's designed.

In this case don't handle SIGTERM or SIGINT signals. If desire is to keep running the process for better uptime then use a process supervisor like supervisord, systemd, or Kubernetes to start a new process when this dies.

Thanks to Lynn Root for writing the amazing series of blog posts. I'm sure I'll be referencing that work even more in the future.