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() does await instance['socket'].disconnect() per socket; on a
half-open socket that disconnect() 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.
-
SynchronizationThrottler intervals are never stopped by close.
MetaApiWebsocketClient.stop() only cancels _clearAccountCacheInterval,
_clearInactiveSyncDataInterval, and the latency service. Neither stop()
nor the async close() calls throttler.stop() on the per-socket
synchronizationThrottler instances, 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 an aclose()) that
awaits ws.close() under a bounded timeout, rather than
asyncio.create_task(...) + returning immediately.
- In
MetaApiWebsocketClient.close() (or stop()), call
throttler.stop() on every per-socket synchronizationThrottler so 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.
Summary
MetaApi.close()does not fully tear down a client. It (a) fires the websocketclose as an un-awaitable orphan task, and (b) never stops the per-socket
SynchronizationThrottlerinterval tasks. As a result, closing a client leavesbackground 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():close()is not awaitable.ws.close()is scheduled withasyncio.create_task(...)and never awaited (the caller has no handle to it).ws.close()doesawait instance['socket'].disconnect()per socket; on ahalf-open socket that
disconnect()does not resolve, so the orphan tasklives 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 async
close()callsthrottler.stop()on the per-socketsynchronizationThrottlerinstances, so their two interval tasks(
process_queue_interval,remove_old_sync_ids_interval, each awhile True: ... await asyncio.sleep(1)) keep running after the client is"closed".
There's a related sharp edge in engineio's
_ping_loop, which swallowsCancelledErrorwhilestate == 'connected'— so cancelling those taskswithout 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 awedged socket.
Minimal reproduction
Observed (one healthy connect/close cycle):
The throttler intervals (
process_queue_interval,remove_old_sync_ids_interval)and the 60s status-timer
disconnecttasks are still running afterclose(). In a process that recreates the client to reconnect, theseaccumulate ~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_connectionkeeps handing back the same dead connection). Each recreateleaked ~6 tasks. On a code reload, uvicorn's reloader
join()s the old processwith 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 everyrequest, including static endpoints, hangs until the process is killed by hand.
Suggested fix
async def close(self)(or anaclose()) thatawaitsws.close()under a bounded timeout, rather thanasyncio.create_task(...)+ returning immediately.MetaApiWebsocketClient.close()(orstop()), callthrottler.stop()on every per-socketsynchronizationThrottlerso its twointerval tasks are cancelled.
disconnect()is bounded so close can't hang forever.Happy to send a PR if that direction is acceptable.