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
Location
core/src/exchanges/myriad/websocket.ts:19-22(Maps) and:41-58(watchOrderBook),:64-82(watchTrades)Code
The resolver/rejecter arrays are cleared in
startOrderBookPolling()on each poll tick:Growth Pattern
watchOrderBook()andwatchTrades()are called by the server-side streaming loop (streamSingleinws-handler.ts) in a tightwhile (!signal.aborted)loop — once per tick. Under production load, multiple concurrent WebSocket subscribers watching the sameoutcomeIdeach spawn their own streaming loop. Between poll ticks (default 5 s), every one of those loops appends a resolve/reject pair to the arrays.MAX_CONSECUTIVE_FAILURES = 5, the timer is cleared and resolvers/rejecters are flushed via rejecters, but new subscribers can still callwatchOrderBook()after this and push to now-orphaned arrays that will never be drained (the timer is gone)OOM Estimate
Suggested Fix
MAX_CONSECUTIVE_FAILURES, also null out the resolver/rejecter Maps for thatidso future callers fail-fast rather than accumulating silently.Found by automated unbounded operations audit