Skip to content

bug(ws): KalshiWebSocket._start() silently clobbers managers on repeat/nested connect() #297

Closed

Description

@TexasCoding

Summary

KalshiWebSocket._start() (kalshi/ws/client.py:124-148) unconditionally re-instantiates _connection, _sub_mgr, _seq_tracker, _orderbook_mgr, and _dispatcher. The _WebSocketSession.__aenter__ wrapper (lines 979-981) just calls _start() without checking prior state. Result: nesting or re-using a single KalshiWebSocket instance silently clobbers any subscriptions / recv task / sequence tracker that the outer session was relying on, leaking the in-flight task and orphaning the user's consumer iterators.

Reproducer

import asyncio
from kalshi import KalshiAuth, KalshiConfig
from kalshi.ws import KalshiWebSocket

async def main():
    auth = KalshiAuth.from_key_path("kid", "key.pem")
    ws = KalshiWebSocket(auth=auth, config=KalshiConfig.demo())

    # Pattern 1: accidental nested re-entry (e.g. helper function picked up a
    # cached `ws` from outer scope)
    async with ws.connect() as outer:
        stream_a = await outer.subscribe_ticker(tickers=["AAA"])
        async with ws.connect() as inner:  # ← silently clobbers outer's state
            stream_b = await inner.subscribe_ticker(tickers=["BBB"])
            # stream_a is now orphaned — its sid is no longer in the new sub_mgr
        # outer.__aexit__ closes the freshly-replaced connection; stream_a never
        # exits its `async for` (no sentinel was put on the orphaned queue).

Pattern 2 (more realistic): a long-lived service holds a KalshiWebSocket as an attribute, reconnects after a transient error by calling connect() again on the same instance, and the prior subscriptions disappear without an explicit error.

Suggested fix

Guard _start() so a second entry fails loudly:

async def _start(self) -> None:
    """Connect and initialize managers. Does NOT start recv_loop yet."""
    if self._connection is not None or self._running:
        raise RuntimeError(
            "KalshiWebSocket session is already started. Each WS instance "
            "supports exactly one active session — create a new "
            "KalshiWebSocket() for a new session, or exit the existing "
            "`async with ws.connect()` first."
        )
    self._connection = ConnectionManager(...)
    ...

Add a regression test in tests/ws/test_client.py:

async def test_double_start_raises(test_auth, config):
    ws = KalshiWebSocket(auth=test_auth, config=config)
    async with ws.connect():
        with pytest.raises(RuntimeError, match="already started"):
            async with ws.connect():
                pass

Optionally also assert _stop() clears the managers so a clean re-connect() on the same instance after the prior __aexit__ is still supported (current code does not reset to None in _stop — verify and decide whether to support or forbid).

Discovered by

Post-v2.5 multi-reviewer audit (WebSocket surface).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingwsWebSocket-related

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions