Skip to content

Unbounded: core/src/exchanges/probable/websocket.ts — orderBookResolvers leak on watchTimeout #550

@realfishsam

Description

@realfishsam

Location

core/src/exchanges/probable/websocket.ts:73-84

Code

async watchOrderBook(tokenId: string): Promise<OrderBook> {
    const client = await this.ensureClient();

    if (!this.subscriptions.has(tokenId)) {
        const sub = client.subscribePublicStream(
            [`book:${tokenId}`],
            (data: any) => { this.handleOrderBookUpdate(tokenId, data); }
        );
        this.subscriptions.set(tokenId, sub);
    }

    // Push resolver into array — never removed if timeout wins the race
    const dataPromise = new Promise<OrderBook>((resolve, reject) => {
        if (!this.orderBookResolvers.has(tokenId)) {
            this.orderBookResolvers.set(tokenId, []);
        }
        this.orderBookResolvers.get(tokenId)!.push({ resolve, reject }); // ← stale on timeout
    });

    return withWatchTimeout(
        dataPromise,
        this.config.watchTimeoutMs ?? DEFAULT_WATCH_TIMEOUT_MS,
        `watchOrderBook('${tokenId}')`,
    );
}

resolveOrderBook (line 116) clears the array only when a WS update fires:

private resolveOrderBook(tokenId: string, orderBook: OrderBook) {
    const resolvers = this.orderBookResolvers.get(tokenId);
    if (resolvers && resolvers.length > 0) {
        resolvers.forEach(r => r.resolve(orderBook));
        this.orderBookResolvers.set(tokenId, []); // ← only cleared on WS update
    }
}

Growth Pattern

withWatchTimeout races dataPromise against a timer. When the timeout wins (sparse market, WS not yet delivering), dataPromise is abandoned but its { resolve, reject } closure is still referenced inside orderBookResolvers.get(tokenId). The next call to watchOrderBook pushes another closure. Over time the array accumulates one stale closure per timed-out call.

This is the exact same structural defect as issue #372 (Limitless websocket) — timeout wins the race but doesn't clean up its resolver.

OOM Estimate

  • Each { resolve, reject } closure: ~1–2 KB of V8 heap (captured Promise internals)
  • CCXT Pro loop at 1 call/sec, 30-second timeout for a sparse market: up to 30 stale closures per WS update cycle
  • 100 active tokenIds × 30 closures × 2 KB = 6 MB permanently held between WS updates
  • Over a 24-hour session with occasional WS gaps (e.g., reconnects): hundreds of closures accumulate per token — 50–200 MB

Suggested Fix

In the timeout handler inside withWatchTimeout (or in watchOrderBook itself), remove the specific { resolve, reject } from orderBookResolvers before letting the timeout resolve. Additionally, add a close() path that calls this.orderBookResolvers.clear() and rejects all pending resolvers.


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