#30479·ccxt

Generated C# enumerates live subscription dictionary key views during WebSocket watch setup and cleanup

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

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 live client.subscriptions enumerations, including the clientSubscriptions alias 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, and ts/src/pro/lighter.ts (live client.subscriptions enumerations)
  • ts/src/pro/extended.ts (three live client.subscriptions enumerations)
  • ts/src/pro/nado.ts (two live client.subscriptions enumerations)
  • ts/src/pro/onetrading.ts (two nested live subscription-map enumerations)
  • ts/src/pro/upbit.ts (two nested live subscription-map enumerations, including watchPublicMultiple)
  • The corresponding generated C# files under cs/ccxt/exchanges/pro/*.cs, plus the generated base-method output for ts/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

  1. Start a C# WebSocket watch that uses a shared subscription dictionary, for example Upbit's watchPublicMultiple with multiple symbols.
  2. Let the WebSocket receive, unsubscribe, reconnect, or concurrent-watch path mutate the subscription map while the setup or handler path resolves its channel keys.
  3. 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:

csharp
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 182

The broad generated-pattern count is reproducible by counting unique matching files:

bash
grep -l 'new List<object>(((IDictionary<string,object>)[a-zA-Z]*).Keys)' ccxt/cs/ccxt/exchanges/pro/*.cs | wc -l

This returns 44 generated exchange files. The recursive occurrence count for the same broad pattern is 99:

bash
grep -rn 'new List<object>(((IDictionary<string,object>)[a-zA-Z]*).Keys)' ccxt/cs/ccxt/exchanges/pro/*.cs | wc -l

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