Skip to content

Unbounded: sdks/typescript/pmxt/ws-client.ts — subscriptions and dataStore never evicted in subscribeBatch #553

@realfishsam

Description

@realfishsam

Location

sdks/typescript/pmxt/ws-client.ts:248-304 (subscribeBatch) and lines 43-46 (the Maps)

Code

// Instance-level Maps — grow without bound during session lifetime
private dataStore: Map<string, any> = new Map();      // grows forever
private subscriptions: Map<string, WsSubscription> = new Map(); // grows forever
// (activeSubs Map is only used by subscribe(), not subscribeBatch())

async subscribeBatch(
    exchange: string,
    method: string,
    args: any[],
    credentials?: Record<string, any>,
    timeoutMs = 30000,
): Promise<Record<string, any>> {
    const symbols: string[] = Array.isArray(args[0]) ? args[0] : [];

    await this.ensureConnected();

    const requestId = `req-${Math.random().toString(36).slice(2, 14)}`; // ← fresh ID every call

    const sub: WsSubscription = {
        requestId,
        method,
        symbols,
        resolve: null,
        reject: null,
    };
    this.subscriptions.set(requestId, sub);  // ← never removed

    this.ws!.send(JSON.stringify(message));

    await this.waitForData(requestId, timeoutMs);

    // Collect per-symbol data — entries never deleted afterward
    const result: Record<string, any> = {};
    for (const symbol of symbols) {
        const storeKey = `${requestId}:${symbol}`;
        const data = this.dataStore.get(storeKey); // ← read but never deleted
        if (data !== undefined) {
            result[symbol] = data;
        }
    }
    return result;
}

Growth Pattern

Unlike subscribe(), which deduplicates via activeSubs and reuses the same requestId for the same symbol set, subscribeBatch() generates a fresh UUID on every call. This means:

  1. this.subscriptions[requestId] gets a new entry per subscribeBatch() call, never removed.
  2. this.dataStore["${requestId}:${symbol}"] gets a new entry per symbol per call, never deleted after being read.
  3. this.dataStore[requestId] (the fallback at line 303) also persists.

subscribeBatch is the backing transport for watchOrderBooks() in the TypeScript SDK. In the CCXT Pro pattern, users call watchOrderBooks() in a tight while (true) loop — one call per desired tick. Each iteration silently leaks entries.

This is the exact TypeScript analog of Python issue #363.

OOM Estimate

  • Per subscribeBatch() call: 1 WsSubscription object (~200 bytes) + N dataStore entries (each an order book dict, ~500 bytes per symbol)
  • Watching 50 symbols, polling at 1 Hz:
    • After 1 hour: 3,600 subscription entries + 180,000 dataStore entries
    • Memory: 3,600 × 200 B + 180,000 × 500 B = ~90 MB after 1 hour
  • After 24 hours: ~2.2 GB — OOM on typical Node.js deployments with default heap limits
  • V8 GC cannot collect these entries because this.subscriptions and this.dataStore hold live references

Suggested Fix

After subscribeBatch() collects its result, delete the consumed entries:

// After collecting result:
this.subscriptions.delete(requestId);
for (const symbol of symbols) {
    this.dataStore.delete(`${requestId}:${symbol}`);
}
this.dataStore.delete(requestId);

Also add deduplication via activeSubs (same as subscribe()) so repeated batch calls for the same symbol set reuse the existing subscription instead of creating a new one each time.


Found by automated unbounded operations audit

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions