C# exchange Close detaches a replacement WebSocket client without closing it
Operating System
Windows 11 x64
Programming Language
C#
CCXT Version
4.5.84
Description
Affected file(s)
cs/ccxt/base/Exchange.cs(closeClient,Close)cs/ccxt/ws/Exchange.WsBridge.cs(clientsregistry)
Steps to reproduce
No network is required. Create two clients with the same URL. Register the first and arrange for its onError callback (invoked by Close) to install the second under that URL:
var exchange = new BaseExchange();
var url = "ws://localhost:1234/replacement";
var replacement = new BaseExchange.WebSocketClient(url, null, (client, message) => { });
var original = new BaseExchange.WebSocketClient(url, null, (client, message) => { },
null, null, (client, error) => exchange.clients[url] = replacement);
exchange.clients[url] = original;
await exchange.Close();
Console.WriteLine(exchange.clients.ContainsKey(url)); // false
Console.WriteLine(replacement.error == null); // true: replacement was not retiredAn offline regression can additionally check that a subsequent replacement.connect() is canceled once exchange-wide close completes. On the v4.5.84 implementation, it can still start a dial.
Expected behavior
BaseExchange.Close() must retire every client it removes from the registry, including the original client and a different client installed under its URL during shutdown. The replacement must not retain a live socket or unresolved consumers after its registry slot is removed. The already-fixed CleanupClients error path must continue to preserve a healthy replacement when only an older, failed client is being cleaned up.
Actual behavior
closeClient(key, client) awaits client.Close() and then executes clients.TryRemove(key, out _). If the callback or a concurrent reconnect has replaced the registry entry, TryRemove detaches the new client and discards its reference. Exchange close finishes with an empty registry even though the replacement was never closed. This behavior is independent of #30545's error-path fix, which removes a failed client by reference.
Root cause
Exchange-wide close uses a URL key for removal but only retires the client captured before the await. It ignores the actual client returned by TryRemove after the await, so a replacement installed in that interval is silently detached.
Suggested fix
After closing the captured client, use the value returned by TryRemove and also close it if it is a different reference. Preserve #30545's remove-by-reference behavior for error-only cleanup: that path must not evict a healthy replacement. Add an offline two-client replacement-race test for exchange-wide close alongside the existing error-path test, asserting both clients are retired and no unclosed client is detached.
Source: ccxt/ccxt