Repository navigation
MetaApi.close() leaks background tasks (throttler intervals) and can't tear down a wedged socket — fire-and-forget, not awaitable #10
Description
Summary
MetaApi.close() does not fully tear down a client. It (a) fires the websocket
close as an un-awaitable orphan task, and (b) never stops the per-socket
SynchronizationThrottler interval tasks. As a result, closing a client leaves
background asyncio tasks running, and on a wedged/half-open socket the client is
never torn down at all. In a long-lived process that recreates the client to
force a reconnect, these tasks accumulate; and because some of them survive
task cancellation, they can hang a graceful shutdown (e.g. uvicorn --reload)
indefinitely.
Tested with metaapi-cloud-sdk==29.1.1, Python 3.14.4, on macOS.
Where it comes from
metaapi_cloud_sdk/metaapi/metaapi.py — MetaApi.close():
def close(self):
"""Closes all clients and connections and stops all internal jobs"""
if hasattr(self, '_latency_monitor'):
self._metaapi_websocket_client.remove_latency_listener(self._latency_monitor)
asyncio.create_task(self._metaapi_websocket_client.close()) # (1) fire-and-forget
self._metaapi_websocket_client.stop()
self._terminal_hash_manager._stop()-
close()is not awaitable.ws.close()is scheduled with
asyncio.create_task(...)and never awaited (the caller has no handle to it).
ws.close()doesawait instance['socket'].disconnect()per socket; on a
half-open socket thatdisconnect()does not resolve, so the orphan task
lives forever and the teardown never completes. The caller has no way to know
whether close finished, or to bound it. -
SynchronizationThrottlerintervals are never stopped by close.
MetaApiWebsocketClient.stop()only cancels_clearAccountCacheInterval,
_clearInactiveSyncDataInterval, and the latency service. Neitherstop()
nor the asyncclose()callsthrottler.stop()on the per-socket
synchronizationThrottlerinstances, so their two interval tasks
(process_queue_interval,remove_old_sync_ids_interval, each a
while True: ... await asyncio.sleep(1)) keep running after the client is
"closed".
There's a related sharp edge in engineio's _ping_loop, which swallows
CancelledError while state == 'connected' — so cancelling those tasks
without first flipping the socket state does not stop them. That's engineio, not
this SDK, but it compounds the leak because close() doesn't disconnect a
wedged socket.
Minimal reproduction
import asyncio, os
from collections import Counter
from metaapi_cloud_sdk import MetaApi
TOKEN = os.environ["METAAPI_TOKEN"]
ACCOUNT = os.environ["METAAPI_ACCOUNT_ID"]
def dump(label):
c = Counter()
for t in asyncio.all_tasks():
code = getattr(t.get_coro(), "cr_code", None)
c[code.co_name if code else "?"] += 1
print(f"{label}: {sum(c.values())} tasks -> {dict(c)}")
async def main():
api = MetaApi(TOKEN)
acct = await api.metatrader_account_api.get_account(ACCOUNT)
conn = acct.get_rpc_connection()
await conn.connect()
await conn.wait_synchronized(60)
dump("after connect")
api.close()
await asyncio.sleep(0.5) # give close()'s orphan task time to run
dump("after close()+sleep")
asyncio.run(main())Observed (one healthy connect/close cycle):
after connect: 24 tasks
after close()+sleep: 9 tasks -> {'process_queue_interval': 2,
'remove_old_sync_ids_interval': 2,
'disconnect': 2, 'main': 1, 'check_long_event': 2}
The throttler intervals (process_queue_interval, remove_old_sync_ids_interval)
and the 60s status-timer disconnect tasks are still running after
close(). In a process that recreates the client to reconnect, these
accumulate ~1 throttler-pair + timers per cycle.
Production impact
We drive MetaApi from a FastAPI/uvicorn backend. When the long-lived RPC socket
goes half-open, the documented recovery is to recreate the client (otherwise
get_rpc_connection keeps handing back the same dead connection). Each recreate
leaked ~6 tasks. On a code reload, uvicorn's reloader join()s the old process
with no timeout while the parent keeps the listening socket; the old process
never exits because these leaked tasks (notably the engineio ping loop that
ignores cancellation) survive asyncio's shutdown cancellation — so every
request, including static endpoints, hangs until the process is killed by hand.
Suggested fix
- Make teardown awaitable:
async def close(self)(or anaclose()) that
awaitsws.close()under a bounded timeout, rather than
asyncio.create_task(...)+ returning immediately. - In
MetaApiWebsocketClient.close()(orstop()), call
throttler.stop()on every per-socketsynchronizationThrottlerso its two
interval tasks are cancelled. - Ensure a wedged socket's
disconnect()is bounded so close can't hang forever.
Happy to send a PR if that direction is acceptable.
Activity