I am trying to understand some unexpected behavior in asyncio. My goal is to
use a custom signal handler to cleanly unwind an asyncio program that has many
different tasks running. Here's a simplified test case:

     1  import asyncio, logging, random, signal, sys
     2  
     3  logging.basicConfig(level=logging.DEBUG)
     4  logger = logging.getLogger()
     5  
     6  async def main():
     7      try:
     8          await asyncio.Event().wait()
     9      except asyncio.CancelledError:
    10          logger.info('cancelled main()')
    11          # cleanup logic
    12  
    13  def handle_sigint(signal, frame):
    14      global sigint_count
    15      global main_coro
    16      sigint_count += 1
    17      if sigint_count == 1:
    18          logger.warn('Received interrupt: shutting down...')
    19          main_coro.throw(asyncio.CancelledError())
    20          # missing event loop logic?
    21      else:
    22          logger.warn('Received 2nd interrupt: exiting!')
    23          main_coro.throw(SystemExit(1))
    24  
    25  sigint_count = 0
    26  signal.signal(signal.SIGINT, handle_sigint)
    27  loop = asyncio.get_event_loop()
    28  main_coro = main()
    29  try:
    30      loop.run_until_complete(main_coro)
    31  except StopIteration:
    32      logger.info('run_until_complete() finished')

The main() function is a placeholder that represents some long running task,
e.g. a server that is waiting for new connections. The handle_sigint() function
is supposed to attempt to cancel main() so that it can gracefully exit, but if
it receives a second interrupt, then the process exits immediately.

Here's an example running the program and then typing Ctrl+C.

    $ python test.py
    DEBUG:asyncio:Using selector: EpollSelector
    ^CWARNING:root:Received interrupt: shutting down...
    INFO:root:cancelled main()
    INFO:root:run_until_complete() finished

This works as I expect it to. Of course my cleanup logic (line 10) isn't
actually doing anything. In a real server, I might want to send goodbye messages
to connected clients. To mock this behavior, I'll modify line 11:

    11          await asyncio.sleep(0)

Surprisingly, now my cleanup code hangs:

    $ python test.py
    DEBUG:asyncio:Using selector: EpollSelector
    ^CWARNING:root:Received interrupt: shutting down...
    INFO:root:cancelled main()
    ^CWARNING:root:Received 2nd interrupt: exiting!

Notice that the program doesn't exit after the first interrupt. It enters the
exception handler and appears to hang on the await expression on line 11. I have
to interrupt it a second time, which throws SystemExit instead.

I puzzled over this for quite some time until I realized that I can force main()
to resume by changing line 20:

    20          main_coro.send(None)

With this change, the interrupt causes the cleanup logic to run to completion
and the program exits normally. Of course, if I add a second await expression:

    11          await asyncio.sleep(0); await asyncio.sleep(0)

Then I also have to step twice:

    20          main_coro.send(None); main_coro.send(None)

My mental model of how the event loop works is pretty poor, but I roughly
understand that the event loop is responsible for driving coroutines. It appears
here that the event loop has stopped driving my main() coroutine, and so the
only way to force it to complete is to call send() from my code.

Can somebody explain *why* the event loop is not driving my coroutine? Is this a
bug or am I missing something conceptually?

More broadly, handling KeyboardInterrupt in async code seems very tricky,
but I also cannot figure out how to make this interrupt approach work. Is one
of these better than the other? What is the best practice here? Would it be
terrible to add `while True: main_coro.send(None)` to my signal handler?

Thanks,
Mark