modal-labs / synchronicity

Synchronicity lets you interoperate with asynchronous Python APIs.
Apache License 2.0
80 stars 3 forks source link

Proof of concept: keyboard interrupt "propagation" #166

Closed freider closed 1 week ago

freider commented 1 week ago

The current Synchronizer._run_function_sync() implementation relies on run_coroutine_threadsafe which returns a concurrent.futures.Future that we then wait for blockingly using .result()

However, if a KeyboardInterrupt (or any other "external" exception, i.e. not from the called async code) stops the .result() call, the underlying async call in the synchronicity event loop/thread isn't cancelled. That won't happen until the synchronizer teardown which is much later in the lifecycle.

This means that:

async def async_func():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("bye")

sync_func = synchronizer.create_blocking(async_func)

try:
    sync_func()
except KeyboardInterrupt:
    print("keyboard interrupt")

Will print:

keyboard interrupt
< --- interpreter shutdown + synchronizer.atexit handler
bye

instead of

bye
keyboard interrupt
< --- interpreter shutdown + synchronizer.atexit handler

This naturally also means any context managers surrounding the wrapped call would have their exit handlers trigger before the called code is cancelled, e.g.

with modal.enable_output():
    run_app()  # <- ctrl C during this call would tear down the output manager before the app can write to it!

My first idea was to fix this by simply running fut.cancel() on the concurrent.futures.Future on KeyboardInterrupt, but that wouldn't leave is with a way to wait for potential cancellation handling within the coroutine to complete before reraising in the main thread (so there would be a race between the threads on handling of the error).

This patch instead fixes the issue through some semi-hacky two-way communication with the called coroutine, by means of an asyncio.Event and a asyncio.wait(), that tracks interruption of the blocking result() call 😵