#30463·ccxt

WebSocket cleanup can leave the replaced client and its pending state unreleased during reconnect races

Author: alert101Created Sep 16, 2026Updated Sep 17, 2026
Labelsbugwsc#

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 clients registry

Steps to reproduce

  1. Start a C# WebSocket exchange with at least one pending watch future or subscription.
  2. Trigger a reconnect while cleanup is running, so a new WebSocketClient replaces the old client under the same registry URL.
  3. Allow CleanupClients or Close to remove the registry entry and finish cleanup.
  4. 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.