Problem
Each iteration of _recv_loop wraps the raw socket recv() in asyncio.wait_for(...) with a 50 ms deadline so _pause_pending / _running get re-checked on quiet channels. CPython's wait_for registers a TimerHandle for the deadline, sets up a cancellation callback, wraps the awaitable in a task on 3.10 (and an asyncio.timeout() context manager on 3.11+ — still allocates an internal handle), then tears the timer down whenever the inner coroutine completes — even when it completes via a frame arriving in microseconds.
On a busy channel with 5–10 k frames/second the recv loop is allocating that many TimerHandles and cancellations per second. #245 explicitly traded shield/Task per-frame allocation for this poll, but the per-frame timer cost is non-zero and the comment on _RECV_POLL_S undersells it ("adds no overhead in the hot path").
Evidence
kalshi/ws/client.py:284-291:
try:
# Poll with a short timeout so ``_pause_pending`` and
# ``_running`` get re-checked on quiet channels. On a
# busy channel the timeout is cancelled before firing,
# so polling adds no overhead in the hot path.
raw = await asyncio.wait_for(
self._connection.recv(), timeout=_RECV_POLL_S,
)
kalshi/ws/client.py:65: _RECV_POLL_S: float = 0.05
Suggested fix
Two options:
- Cheap (docs only): amend the
_RECV_POLL_S comment to acknowledge the per-frame TimerHandle allocation cost and explain why the cooperative pause approach was still chosen over shield/Task pairs.
- Real fix: replace per-frame
wait_for with a single long-lived recv_task = asyncio.ensure_future(self._connection.recv()) paired with an asyncio.Event that _pause_recv_loop sets. Race them via asyncio.wait({recv_task, pause_event.wait()}, return_when=FIRST_COMPLETED). The recv_task is reused on the next iteration (or recreated only after a frame is consumed) so allocation amortizes across many frames.
Validate with scripts/bench_ws_recv.py on a high-rate orderbook_delta scenario and only land the real fix if the bench shows a meaningful improvement.
Source
Round-3 independent audit (reviewer: performance).
Problem
Each iteration of
_recv_loopwraps the raw socketrecv()inasyncio.wait_for(...)with a 50 ms deadline so_pause_pending/_runningget re-checked on quiet channels. CPython'swait_forregisters aTimerHandlefor the deadline, sets up a cancellation callback, wraps the awaitable in a task on 3.10 (and anasyncio.timeout()context manager on 3.11+ — still allocates an internal handle), then tears the timer down whenever the inner coroutine completes — even when it completes via a frame arriving in microseconds.On a busy channel with 5–10 k frames/second the recv loop is allocating that many
TimerHandles and cancellations per second. #245 explicitly traded shield/Task per-frame allocation for this poll, but the per-frame timer cost is non-zero and the comment on_RECV_POLL_Sundersells it ("adds no overhead in the hot path").Evidence
kalshi/ws/client.py:284-291:kalshi/ws/client.py:65:_RECV_POLL_S: float = 0.05Suggested fix
Two options:
_RECV_POLL_Scomment to acknowledge the per-frameTimerHandleallocation cost and explain why the cooperative pause approach was still chosen over shield/Task pairs.wait_forwith a single long-livedrecv_task = asyncio.ensure_future(self._connection.recv())paired with anasyncio.Eventthat_pause_recv_loopsets. Race them viaasyncio.wait({recv_task, pause_event.wait()}, return_when=FIRST_COMPLETED). Therecv_taskis reused on the next iteration (or recreated only after a frame is consumed) so allocation amortizes across many frames.Validate with
scripts/bench_ws_recv.pyon a high-rateorderbook_deltascenario and only land the real fix if the bench shows a meaningful improvement.Source
Round-3 independent audit (reviewer:
performance).