What happens inside asyncio.run() when the user presses Ctrl-C?
answer
- the keyboard does not reach your coroutine directly
- the runner intercepts it before the loop
- the first press cancels one specific task
- the second press does not wait
- SIGTERM needs add_signal_handler
basics
~20 sSince CPython 3.11, asyncio.run() installs its own SIGINT handler: the first Ctrl-C cancels the main task instead of raising immediately, so your coroutine sees CancelledError and its finally blocks run; KeyboardInterrupt is raised afterwards. A second Ctrl-C raises at once.
solid answer
~40 s`asyncio.Runner.run()`, which `asyncio.run()` wraps, replaces `signal.default_int_handler` with its own handler — but only when it is on the main thread and the handler is still the default. The first `SIGINT` calls `cancel()` on the *main* task and wakes a loop that may be blocked in `select()`; nothing is raised at that instant. Your coroutine therefore receives `CancelledError` at its next `await`, runs its cleanup, and the standard teardown then cancels the remaining tasks. When the main task really did end cancelled, `Runner.run()` raises `KeyboardInterrupt`, so a `try/except KeyboardInterrupt` around `asyncio.run()` still works. A second Ctrl-C raises `KeyboardInterrupt` straight from the handler, possibly mid-teardown. `SIGTERM` gets none of this — you install `loop.add_signal_handler()` yourself.
code
python · 22 linesimport asyncio
import os
import signal
import threading
import time
def press_ctrl_c_soon():
time.sleep(0.3)
os.kill(os.getpid(), signal.SIGINT)
async def main():
threading.Thread(target=press_ctrl_c_soon, daemon=True).start()
try:
await asyncio.sleep(5)
except asyncio.CancelledError:
print("main task saw CancelledError, not KeyboardInterrupt")
raise
try:
asyncio.run(main())
except KeyboardInterrupt:
print("asyncio.run re-raised KeyboardInterrupt after teardown")go deeper
Know that Ctrl-C sends SIGINT, that KeyboardInterrupt derives from BaseException rather than Exception, and that a program started with asyncio.run() gets a chance to clean up rather than dying at the keypress.
Walk through the sequence: the runner's handler cancels the main task, your coroutine sees CancelledError at its next await, cleanup runs, and KeyboardInterrupt is raised afterwards. Say why a second press behaves differently.
Show that you design for SIGTERM, not just the keyboard: an add_signal_handler callback that sets an event, a drain path you control, and cleanup that survives being interrupted halfway through by an impatient second signal.
Decide the contract between your process and whatever supervises it — which signals mean drain versus abort, what exit code each path yields, and how that is verified, since a swallowed cancellation silently turns an operator stop into a reported success.
## How a signal reaches Python at all Ctrl-C makes the terminal send `SIGINT` to the foreground process group. CPython's C-level handler does almost nothing: it sets a flag and writes a byte to the wakeup file descriptor. The *Python* handler runs later, in the main thread, between bytecodes. Two consequences matter for asyncio. First, only the main thread ever runs signal handlers — an event loop running in a worker thread receives no signal handling whatsoever. Second, a loop blocked in `select()` with a long timeout has to be woken, which is what the wakeup file descriptor (`signal.set_wakeup_fd()`) is for. ## The default handler is hostile to a loop `signal.default_int_handler` raises `KeyboardInterrupt` wherever the main thread happens to be. Inside an asyncio program that point is almost always somewhere in the event loop's own machinery, not in your code. Before 3.11 that is exactly what happened: `KeyboardInterrupt` propagated out of `run_until_complete()`, the main task was left un-cancelled and never got its `finally` blocks, and you were rewarded with "Task was destroyed but it is pending" on the way out. ## What 3.11 changed, and 3.14 still does `asyncio.Runner.run()` installs its own `SIGINT` handler before driving the main task, guarded twice: it must be on the main thread, and the current handler must still be `signal.default_int_handler` — if you installed your own, yours is respected and left alone. The handler restores the previous one in a `finally`, so the change does not leak past the call. On the **first** interrupt the handler does two things and returns: it calls `cancel()` on the main task, and it schedules a no-op with `loop.call_soon_threadsafe()` purely to wake a loop sitting in `select()`. No exception is raised at that moment. Your coroutine then receives `CancelledError` at its next suspension point, exactly like any other cancellation, and its `finally` blocks and `async with` exits run normally while the loop is alive. When the main task ends cancelled, `Runner.run()` inspects it: if the interrupt did the cancelling and `Task.uncancel()` brings the count back to zero, it raises `KeyboardInterrupt`. That is what keeps the familiar shape working: ```python try: asyncio.run(main()) except KeyboardInterrupt: print("stopped by the operator") ``` There is a sharp edge here. If your main coroutine catches the `CancelledError` and returns a value instead of letting it propagate, `asyncio.run()` returns normally and **no** `KeyboardInterrupt` is raised — the program reports success to whatever ran it. Suppressing cancellation in `main()` therefore quietly changes your exit semantics. On the **second** interrupt the handler raises `KeyboardInterrupt` immediately, from wherever the main thread is. That may be in the middle of teardown, between two cleanup awaits. Operators do press Ctrl-C twice when the first press appears to do nothing, so shutdown work must be safe to interrupt: write to a temporary file and rename, mark records complete only after the write, and never leave a half-updated structure between two awaits. ## SIGTERM is a different story Nothing in asyncio handles `SIGTERM`. Its default disposition kills the process outright — no `finally`, no generator cleanup, no flush. Any service that is stopped by a supervisor rather than by a keyboard must install a handler itself. On Unix: ```python loop = asyncio.get_running_loop() stop = asyncio.Event() for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, stop.set) ``` `loop.add_signal_handler()` is the asyncio-native route: the callback is scheduled on the loop and runs on the loop thread like any other callback, so it may touch loop state safely. It is Unix-only — on Windows the proactor loop raises `NotImplementedError`, and you fall back to `signal.signal()` plus `loop.call_soon_threadsafe()` to hop from the signal context onto the loop. Note what the handler above does *not* do: it does not cancel anything. It sets an `asyncio.Event`, and the program's own shutdown path decides what to drain and for how long. That separation is why installing your own `SIGINT` handler in a service is common: you want a controlled drain, not an immediate cancel of the main task. ## Interview-sized summary Ctrl-C is not an exception thrown into your coroutine; it is a signal that the runner turns into a cancellation of one specific task, with `KeyboardInterrupt` re-raised at the end for the caller's benefit. `KeyboardInterrupt` derives from `BaseException`, not `Exception`, so a blanket `except Exception` around your loop body never swallows it — and a blanket `except BaseException` will, which is a reliable way to build a program that cannot be stopped.
- Why does loop.add_signal_handler() fail on Windows, and what do you use instead?It is implemented only on Unix event loops; the Windows proactor loop raises `NotImplementedError`. There you register a plain handler with `signal.signal()` and immediately hop onto the loop with `loop.call_soon_threadsafe()`, because the handler body runs in the interpreter's signal context rather than on the loop and must not touch loop state directly.
- What does a second Ctrl-C do while shutdown is already in progress?The runner's handler raises `KeyboardInterrupt` immediately, wherever the main thread happens to be — potentially between two cleanup awaits. Treat teardown as interruptible: write-then-rename, commit records only after the data is durable, and never leave a half-updated structure across an `await` in a cleanup path.
- Why does a program that catches CancelledError in main() stop reporting Ctrl-C?The runner raises `KeyboardInterrupt` only when the main task actually ended cancelled. If `main()` swallows the `CancelledError` and returns a value, `asyncio.run()` returns normally, the caller sees success and the exit code is zero — so an operator's Ctrl-C is indistinguishable from a clean run to whatever supervises the process.
saying these in an interview costs you the question
- Says Ctrl-C raises KeyboardInterrupt inside every running task
- Assumes asyncio handles SIGTERM the way it handles SIGINT
- Thinks a bare except Exception catches KeyboardInterrupt
- Believes the first and second Ctrl-C behave identically
- Calls loop.stop() directly from a signal handler in another thread
- Claims signal handlers run in whichever thread is busiest