WebSocket cleanup can leave the replaced client and its pending state unreleased during reconnect races
Operating System
Windows 11 x64
Programming Language
C#
CCXT Version
4.5.77
Description
Affected file(s)
cs/ccxt/base/Exchange.cs(closeClient)cs/ccxt/ws/Exchange.WsBridge.cs(CleanupClients, client retirement)- WebSocket reconnect and shutdown paths that use the shared
clientsregistry
Steps to reproduce
- Start a C# WebSocket exchange with at least one pending watch future or subscription.
- Trigger a reconnect while cleanup is running, so a new
WebSocketClientreplaces the old client under the same registry URL. - Allow
CleanupClientsorCloseto remove the registry entry and finish cleanup. - Repeat the reconnect and cleanup cycle while retaining references to the replaced clients, then inspect the clients' futures, subscriptions, rejection state, and transports.
Expected behavior
Every client detached by cleanup must be retired exactly once, including both the caller's client reference and a different client concurrently installed under the same registry key. Retirement must reject and clear pending futures, close the WebSocket transport, and leave no active or in-flight lifecycle state after cleanup completes.
Actual behavior
Cleanup selects a client by registry key but retires only the client reference supplied by the current caller. If a reconnect replaces the registry entry between the lookup and cleanup, the detached client and its pending futures, subscriptions, or transport can remain alive. Repeated reconnects therefore retain WebSocket object graphs and unresolved consumers even though the registry reports no active client.
This was observed as retained-memory growth in a long-running C# WebSocket workload. The race is internal to ccxt's client registry and cannot be repaired by a caller that has no public API to inspect or release the client's private futures, subscriptions, and rejection state.
Root cause
Client identity was treated as equivalent to the registry key. A concurrent reconnect can make those identities differ: the cleanup caller still holds the original WebSocketClient, while clients[key] or the value returned by TryRemove is a replacement client. The old closeClient path removed the key and closed only the supplied reference, leaving the other object graph unmanaged.
The retirement path also lacked complete lifecycle accounting around asynchronous cleanup, making it difficult to prove that futures were rejected, transports were closed, and all retirement work had finished.
Suggested fix
When removing a client by registry key, retire the supplied client and the removed registry client when they are different references. Centralize retirement so it rejects and clears every pending future, closes the transport, and decrements in-flight client/state counters in a finally block. Expose lifecycle counts for diagnostics and add reconnect/liveness coverage that asserts zero active clients, futures, subscriptions, retired clients, and retired state after repeated retirement cycles.
Source: ccxt/ccxt