Search code examples
pythonconcurrent.futureskeyboardinterrupt

Is there any graceful way to interrupt a python concurrent future result() call?


The only mechanism I can find for handling a keyboard interrupt is to poll. Without the while loop below, the signal processing never happens and the process hangs forever.

Is there any graceful mechanism for allowing a keyboard interrupt to function when given a concurrent future object?

Putting polling loops all over my code base seems to defeat the purpose of using futures at all.

More info:

  • Waiting on the future in the main thread in Windows blocks all signal handling, even if it's fully cancellable and even if it has not "started" yet. The word "exiting" doesn't even print. So 'cancellability' is only part (the easy part) of the issue.
  • In my real code, I obtain futures via executors (run coro threadsafe, in this case), this was just a simplified example
import concurrent.futures
import signal
import time
import sys


fut = concurrent.futures.Future()


def handler(signum, frame):
    print("exiting")
    fut.cancel()
    signal.signal(signal.SIGINT, orig)
    sys.exit()

orig = signal.signal(signal.SIGINT, handler)

# a time sleep is fully interruptible with a signal... but a future isnt
# time.sleep(100)

while True:
    try:
        fut.result(.03)
    except concurrent.futures.TimeoutError:
        pass

Solution

  • OK, I wrote a solution to this based on digging in cypython source and some bug reports - but it's not pretty.

    If you want to be able to interrupt a future, especially on Windows, the following seems to work:

    @contextlib.contextmanager
    def interrupt_futures(futures):  # pragma: no cover
        """Allows a list of futures to be interrupted.
    
        If an interrupt happens, they will all have their exceptions set to KeyboardInterrupt
        """
    
        # this has to be manually tested for now, because the tests interfere with the test runner
    
        def do_interr(*_):
            for ent in futures:
                try:
                    ent.set_exception(KeyboardInterrupt)
                except:
                    # if the future is already resolved or cancelled, ignore it
                    pass
            return 1
    
        if sys.platform == "win32":
            from ctypes import wintypes  # pylint: disable=import-outside-toplevel
    
            kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
    
            CTRL_C_EVENT = 0
            CTRL_BREAK_EVENT = 1
    
            HANDLER_ROUTINE = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD)
    
            @HANDLER_ROUTINE
            def handler(ctrl):
                if ctrl == CTRL_C_EVENT:
                    handled = do_interr()
                elif ctrl == CTRL_BREAK_EVENT:
                    handled = do_interr()
                else:
                    handled = False
                # If not handled, call the next handler.
                return handled
    
            if not kernel32.SetConsoleCtrlHandler(handler, True):
                raise ctypes.WinError(ctypes.get_last_error())
    
            was = signal.signal(signal.SIGINT, do_interr)
    
            yield
    
            signal.signal(signal.SIGINT, was)
    
            # restore default handler
            kernel32.SetConsoleCtrlHandler(handler, False)
        else:
            was = signal.signal(signal.SIGINT, do_interr)
            yield
            signal.signal(signal.SIGINT, was)
    

    This allows you to do this:

    with interrupt_futures([fut]):
        fut.result()
    

    For the duration of that call, interrupt signals will be intercepted and will result in the future raising a KeyboardInterrupt to the caller requesting the result - instead of simply ignoring all interrupts.