Generated C# enumerates live subscription dictionary key views during WebSocket watch setup and cleanup
Operating System
Windows 11 x64
Programming Language
C#
CCXT Version
4.5.77
Description
Affected file(s)
ts/src/base/Exchange.ts(cleanUnsubscription, shared prefix-cleanup path)ts/src/pro/binance.ts(seven liveclient.subscriptionsenumerations, including theclientSubscriptionsalias used by listen-key keepalive handling)ts/src/pro/bitvavo.ts,ts/src/pro/bybit.ts,ts/src/pro/bydfi.ts,ts/src/pro/cex.ts,ts/src/pro/cryptocom.ts,ts/src/pro/gate.ts,ts/src/pro/kucoin.ts, andts/src/pro/lighter.ts(liveclient.subscriptionsenumerations)ts/src/pro/extended.ts(three liveclient.subscriptionsenumerations)ts/src/pro/nado.ts(two liveclient.subscriptionsenumerations)ts/src/pro/onetrading.ts(two nested live subscription-map enumerations)ts/src/pro/upbit.ts(two nested live subscription-map enumerations, includingwatchPublicMultiple)- The corresponding generated C# files under
cs/ccxt/exchanges/pro/*.cs, plus the generated base-method output forts/src/base/Exchange.ts
The source audit found 13 affected exchange implementations and 25 confirmed live or nested-live enumeration sites. The generated pattern is broader than the proven blast radius because it also occurs for ordinary local, response, and cache dictionaries.
Steps to reproduce
- Start a C# WebSocket watch that uses a shared subscription dictionary, for example Upbit's
watchPublicMultiplewith multiple symbols. - Let the WebSocket receive, unsubscribe, reconnect, or concurrent-watch path mutate the subscription map while the setup or handler path resolves its channel keys.
- Sustain the watch under normal reconnect and subscription activity.
The same shape is present in the affected Binance, Bitvavo, Bybit, Bydfi, CEX, Crypto.com, Extended, Gate, KuCoin, Lighter, Nado, and OneTrading implementations, as well as the shared unsubscribe cleanup path.
The generated C# path is equivalent to:
List<object> channelKeys =
new List<object>(((IDictionary<string,object>)subscriptions).Keys);The production failure was captured while WatchOrderBook was resolving subscriptions:
System.InvalidOperationException: Collection was modified; enumeration operation may not execute.
at System.Collections.Generic.List`1..ctor(IEnumerable`1 collection)
at ccxt.pro.upbit.watchPublicMultiple(Object symbols, Object channel, Object parameters)
in cs/ccxt/exchanges/pro/upbit.cs:line 81
at ccxt.pro.upbit.WatchOrderBook(String symbol, Nullable`1 limit, Object parameters)
in cs/ccxt/exchanges/pro/upbit.cs:line 182The broad generated-pattern count is reproducible by counting unique matching files:
grep -l 'new List<object>(((IDictionary<string,object>)[a-zA-Z]*).Keys)' ccxt/cs/ccxt/exchanges/pro/*.cs | wc -lThis returns 44 generated exchange files. The recursive occurrence count for the same broad pattern is 99:
grep -rn 'new List<object>(((IDictionary<string,object>)[a-zA-Z]*).Keys)' ccxt/cs/ccxt/exchanges/pro/*.cs | wc -lThose counts must not be read as 44 affected exchanges: they include ordinary dictionaries. The confirmed source-level scope is the 13 exchange files and 25 live subscription sites listed above, plus the shared base helper.
Expected behavior
A WebSocket watch should obtain a stable snapshot of the subscription keys. Concurrent receive-path mutation must not make watch setup fail with an unhandled collection-enumeration exception.
Actual behavior
List<T>(IEnumerable<T>) enumerates the dictionary's live Keys view. The same subscription map can be mutated by watchPublicMultiple and by the WebSocket receive path while that constructor is enumerating it, producing System.InvalidOperationException: Collection was modified; enumeration operation may not execute.
Root cause
The TypeScript sources use Object.keys(...) on client.subscriptions or on a nested map retrieved from it. The generated C# port translates that operation into enumeration of ((IDictionary<string,object>)subscriptions).Keys through a List<object> constructor. Keys is a live dictionary view, not an immutable snapshot. No lock or snapshot boundary covers these enumerations relative to concurrent subscription-map mutation. The shared cleanUnsubscription helper has the same issue for prefix-based cleanup.
Suggested fix
Materialize the keys through a snapshot operation synchronized with the subscription-map mutation, or use a subscription collection abstraction that exposes an atomic key snapshot. Merely catching InvalidOperationException would hide the race and is not sufficient. The fix should preserve the subscription lock or equivalent ownership boundary across the snapshot operation, then use the detached key list for channel setup.
Source: ccxt/ccxt