Skip to content

MetaApi.close() leaks background tasks (throttler intervals) and can't tear down a wedged socket — fire-and-forget, not awaitable #10

Description

@maparham

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.pyMetaApi.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()
  1. 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.

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions