Skip to content

Unbounded: core/src/exchanges/myriad/websocket.ts — orderBookResolvers and tradeResolvers arrays grow without cap #684

@realfishsam

Description

@realfishsam

Location

core/src/exchanges/myriad/websocket.ts:19-22 (Maps) and :41-58 (watchOrderBook), :64-82 (watchTrades)

Code

private orderBookResolvers: Map<string, ((value: OrderBook) => void)[]> = new Map();
private orderBookRejecters: Map<string, ((reason: unknown) => void)[]> = new Map();
private tradeResolvers: Map<string, ((value: Trade[]) => void)[]> = new Map();
private tradeRejecters: Map<string, ((reason: unknown) => void)[]> = new Map();

async watchOrderBook(outcomeId: string): Promise<OrderBook> {
    return new Promise<OrderBook>((resolve, reject) => {
        const resolvers = this.orderBookResolvers.get(outcomeId);
        resolvers.push(resolve);  // ← unbounded push per call
        rejecters.push(reject);
        // ...
    });
}

The resolver/rejecter arrays are cleared in startOrderBookPolling() on each poll tick:

const resolvers = this.orderBookResolvers.get(id) || [];
this.orderBookResolvers.set(id, []);  // clear after poll
for (const resolve of resolvers) { resolve(book); }

Growth Pattern

watchOrderBook() and watchTrades() are called by the server-side streaming loop (streamSingle in ws-handler.ts) in a tight while (!signal.aborted) loop — once per tick. Under production load, multiple concurrent WebSocket subscribers watching the same outcomeId each spawn their own streaming loop. Between poll ticks (default 5 s), every one of those loops appends a resolve/reject pair to the arrays.

  • N concurrent subscribers × 1 call/subscriber between ticks = N resolver pairs per 5 s window
  • Arrays are reset per tick, so steady-state size = N at the moment of polling
  • If the poll fails and hits MAX_CONSECUTIVE_FAILURES = 5, the timer is cleared and resolvers/rejecters are flushed via rejecters, but new subscribers can still call watchOrderBook() after this and push to now-orphaned arrays that will never be drained (the timer is gone)

OOM Estimate

  • 500 concurrent subscribers per outcomeId × 5 s poll × ~200 bytes per resolver pair = 500 KB steady-state per outcomeId
  • 100 distinct outcomeIds watched simultaneously = 50 MB steady-state
  • Resolver leak scenario (poll fails permanently after 5 consecutive errors): arrays grow at 500 items/5 s indefinitely — OOM in < 10 minutes at 500 req/s

Suggested Fix

  • Add a max depth (e.g. 1000) to each resolver array; reject the oldest waiter with a backpressure error when exceeded.
  • After the polling timer is cleared due to MAX_CONSECUTIVE_FAILURES, also null out the resolver/rejecter Maps for that id so future callers fail-fast rather than accumulating silently.

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