Howwise RSS
Trading Software5 min read

Asyncio KeyboardInterrupt: Close Resources on Ctrl+C or SIGTERM

Short answer

To close resources on an asyncio KeyboardInterrupt, put cleanup in a try/finally inside the main coroutine, where main owns every client and awaits close(). A sync except around asyncio.run() runs after the loop is gone.

Key takeaways
  • On Python 3.11+, asyncio.run() turns Ctrl+C into cancellation of the main task, so a finally block inside main() runs with a live loop.
  • Before 3.11 the KeyboardInterrupt surfaces inside run_until_complete, so a sync except around it can never await close().
  • Create each ccxt or aiohttp client in main, pass it down, and close it once in main's finally; lower layers never own it.
  • Cancel and await child tasks before closing clients, or a trader may use a closed session.
  • Docker and systemd send SIGTERM, so register it explicitly; Ctrl+C handling alone does nothing in containers.

Why does try/except KeyboardInterrupt never await my cleanup?

KeyboardInterrupt is a BaseException that Python raises from the default SIGINT handler in whatever code is running on the main thread. In an asyncio program, that is usually the event loop's selector wait, not your coroutine. On Python before 3.11, it propagates out of run_until_complete() or run_forever(), through asyncio.run(), and only then reaches your except clause.

By that point asyncio.run() has already cancelled leftover tasks and closed the loop, so any await in the handler has nothing to run on. The aiohttp session inside ccxt is bound to that dead loop. Python then warns 'Unclosed client session' and 'Unclosed connector', and ccxt prints that close() was not called.

Writing `except KeyboardInterrupt: await ex.close()` in plain synchronous code is a SyntaxError outside an async function. Inside a coroutine it usually never runs, because the exception was not raised there.

Asyncio KeyboardInterrupt: how do I close resources in Python 3.11 and later?

Since 3.11, asyncio.run() and asyncio.Runner install their own SIGINT handler. The first Ctrl+C cancels the main task, so your coroutine receives CancelledError at its current await. Your finally blocks run on a live loop. After run() finishes, it re-raises KeyboardInterrupt to the caller.

This means cleanup belongs inside the coroutine, and the outer `except KeyboardInterrupt: pass` is only there to keep the traceback quiet.

CancelledError has derived from BaseException since 3.8, so `except Exception` will not catch it. If you catch it to do work, re-raise it. Swallowing it breaks cancellation, TaskGroup and timeouts.

Warning: A second Ctrl+C during cleanup raises KeyboardInterrupt again and interrupts the close calls midway, leaking sessions. Keep cleanup short and bounded.

Which shutdown mechanism fits my Python version and OS?

Pick by version first, then by platform. The Windows limitation exists because the default Proactor loop does not implement add_signal_handler.

PythonOSMechanismNotes
3.11+Linux/macOSasyncio.run(); finally inside main; add_signal_handler for SIGTERMSIGINT is handled by run() already
3.11+Windowsasyncio.run(); finally inside mainCtrl+C works; SIGTERM is not delivered the Unix way
3.8 to 3.10Linux/macOSloop.add_signal_handler(SIGINT/SIGTERM, main_task.cancel)Await the task, clean up in its finally
3.8 to 3.10Windowssignal.signal plus loop.call_soon_threadsafe(task.cancel)add_signal_handler raises NotImplementedError
Tip: If you can, upgrade to 3.11+ and use asyncio.Runner. It removes most of the platform-specific handler code.

How do I structure main, broker, strategy and harvester so every client closes?

Use an ownership rule: main creates every exchange, passes it down, and closes it in one finally. Broker, strategy and harvester receive clients and never close them. Then a failure in one layer cannot skip another exchange's cleanup, and there is exactly one place to look.

AsyncExitStack gives the same guarantee with reverse-order cleanup, even when startup fails halfway. Register each client right after creating it.

import asyncio
import ccxt.async_support as ccxt
from contextlib import AsyncExitStack

class Broker:
    def __init__(self, ex):
        self.ex = ex            # borrowed, never closed here
        self.tasks = set()
    async def run(self):
        try:
            for sym in await self._symbols():
                t = asyncio.create_task(self.trader(sym))
                self.tasks.add(t)
                t.add_done_callback(self.tasks.discard)
            await asyncio.gather(*self.tasks)
        finally:
            # stop children BEFORE main closes the client
            for t in list(self.tasks):
                t.cancel()
            await asyncio.gather(*self.tasks, return_exceptions=True)
    async def _symbols(self):
        return ['BTC/USDT', 'ETH/USDT']
    async def trader(self, sym):
        while True:
            await self.ex.fetch_ticker(sym)
            await asyncio.sleep(1)

async def main():
    async with AsyncExitStack() as stack:
        exchanges = []
        for cls in (ccxt.binance, ccxt.kraken):
            ex = cls({'enableRateLimit': True})
            stack.push_async_callback(ex.close)  # runs in reverse order
            exchanges.append(ex)
        await asyncio.gather(*(Broker(e).run() for e in exchanges))

if __name__ == '__main__':
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass

Why must child tasks be cancelled before the clients close?

Tasks spawned with create_task are not cancelled just because the parent was. If main closes the exchange while a trader is mid-request, that trader hits a closed session and raises confusing errors, or recreates a connection that is never closed.

Track tasks in a set and cancel then await them with gather(return_exceptions=True), as in the Broker above. On 3.11+, asyncio.TaskGroup can replace that bookkeeping. If the block exits with an exception or the parent is cancelled (as Ctrl+C does), it cancels the remaining children and awaits them. On a normal exit it simply waits for every child to finish, so endless trader loops keep the block open until something cancels it.

Nest the TaskGroup inside the scope that owns the clients so it exits first. The fragment below is a drop-in replacement for Broker.run() and reuses the same `_symbols()` and `trader()` methods.

# Drop-in replacement for Broker.run() on Python 3.11+
async def run(self):
    async with asyncio.TaskGroup() as tg:
        for sym in await self._symbols():
            tg.create_task(self.trader(sym))

How do I handle SIGTERM, Windows and slow closes?

Docker and systemd stop services with SIGTERM, which raises no KeyboardInterrupt, so a Ctrl+C-only design does nothing in production. On Unix, convert signals into cancellation of the main task. On pre-3.11 Windows, route through call_soon_threadsafe because the handler runs outside the loop.

Harden the close step: bound it with a timeout and collect per-client errors so one failing close does not hide the others.

import asyncio, signal, sys, logging

async def close_all(clients, timeout=10):
    results = await asyncio.wait_for(
        asyncio.gather(*(c.close() for c in clients), return_exceptions=True),
        timeout)
    for c, r in zip(clients, results):
        if isinstance(r, BaseException):
            logging.error('close failed for %r: %r', c, r)

async def runner():
    loop = asyncio.get_running_loop()
    task = asyncio.current_task()
    if sys.platform != 'win32':
        loop.add_signal_handler(signal.SIGTERM, task.cancel)
        if sys.version_info < (3, 11):
            loop.add_signal_handler(signal.SIGINT, task.cancel)
    else:
        if sys.version_info < (3, 11):
            signal.signal(signal.SIGINT,
                lambda *_: loop.call_soon_threadsafe(task.cancel))
    await main()
Warning: Never call asyncio.run(exchange.close()) after the main loop has ended. It creates a new loop, while the aiohttp session belongs to the old one, and the close fails or is skipped. Also do not rely on __del__; it cannot await.

How can I verify that close() was really awaited?

Run with `python -X dev` or set PYTHONASYNCIODEBUG=1. Any 'Unclosed client session' or 'Unclosed connector' warning on exit means a client escaped your finally. Then automate it with a test that sends SIGINT to itself and asserts the flag.

Other mistakes to look for: catching only at the lowest layer so other exchanges never close, and swallowing CancelledError in a retry loop that uses a bare `except BaseException`.

  1. Wrap a fake client whose close() sets a flag.
  2. Schedule os.kill(os.getpid(), signal.SIGINT) after a short sleep.
  3. Run asyncio.run(main()) inside try/except KeyboardInterrupt.
  4. Assert the flag is set after the call returns.
import asyncio, os, signal, threading

class Fake:
    closed = False
    async def close(self): self.closed = True

def test_close_on_sigint():
    fake = Fake()
    async def main():
        try:
            await asyncio.sleep(60)
        finally:
            await fake.close()
    threading.Timer(0.3, os.kill, (os.getpid(), signal.SIGINT)).start()
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass
    assert fake.closed

Common questions

How do I catch KeyboardInterrupt in asyncio?

On 3.11+ you do not catch it in the coroutine; you catch CancelledError or use finally, because run() cancels the main task. Catch KeyboardInterrupt only around asyncio.run() to silence the traceback after cleanup has finished.

Does ccxt support async with for closing exchanges?

Recent ccxt versions support async context managers on async exchanges, so `async with ccxt.binance() as ex:` calls close() on exit. If your version lacks it, use try/finally or AsyncExitStack.push_async_callback(ex.close), which works on any version.

Why do I still see 'Unclosed client session' after adding finally?

Usually a client was created in a layer that your finally does not cover, or a task kept running after close and reopened a session. Run with -X dev and check that every client is registered in main and all child tasks are awaited first.

Can I use loop.add_signal_handler on Windows?

No, it raises NotImplementedError on the Windows event loops. Use signal.signal with loop.call_soon_threadsafe(task.cancel), or run on Python 3.11+ where asyncio.run handles Ctrl+C for you.

Related topics:asyncioccxtaiohttpgraceful-shutdownpython