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
Location
core/src/exchanges/probable/websocket.ts:73-84Code
resolveOrderBook(line 116) clears the array only when a WS update fires:Growth Pattern
withWatchTimeoutracesdataPromiseagainst a timer. When the timeout wins (sparse market, WS not yet delivering),dataPromiseis abandoned but its{ resolve, reject }closure is still referenced insideorderBookResolvers.get(tokenId). The next call towatchOrderBookpushes 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
{ resolve, reject }closure: ~1–2 KB of V8 heap (captured Promise internals)Suggested Fix
In the timeout handler inside
withWatchTimeout(or inwatchOrderBookitself), remove the specific{ resolve, reject }fromorderBookResolversbefore letting the timeout resolve. Additionally, add aclose()path that callsthis.orderBookResolvers.clear()and rejects all pending resolvers.Found by automated unbounded operations audit