Skip to content

WS: run_forever() lacks graceful shutdown / signal-driven stop path #177

Description

@TexasCoding

From #106 polish backlog. Severity: low UX foot-gun.

Code

kalshi/ws/client.py:529-532:

async def run_forever(self) -> None:
    # docstring: "Block until the connection is closed. Use with callback API."
    if self._recv_task:
        await self._recv_task

No cooperative-shutdown hook. A SIGINT during run_forever() propagates CancelledError through await self._recv_task, which cancels the recv loop mid-frame. The application sees CancelledError leak out and there is no way to say "shut down cleanly: drain in-flight dispatches, close the WS, return."

The silent-return-on-missing-subscription case is tracked in a sibling foot-gun issue.

Fix

  1. Add a stop() (or request_stop()) coroutine that:

    • Sets an internal _stop_requested event.
    • Sends a WS-level close frame.
    • Awaits recv loop completion bounded by a configurable timeout.
  2. Optionally accept an asyncio.Event parameter on run_forever(stop_event=...) so callers can wire their own signal handlers cleanly:

    stop = asyncio.Event()
    asyncio.get_running_loop().add_signal_handler(signal.SIGINT, stop.set)
    await ws.run_forever(stop_event=stop)

Acceptance

  • run_forever() can be terminated cooperatively without CancelledError leaking out.
  • New test covering: register SIGINT handler → set stop event → run_forever() returns cleanly with is_connected is False.
  • README callback-mode example updated to show the stop-event pattern.

Metadata

Metadata

Assignees

No one assigned

    Labels

    polishCode-quality and DX improvements; non-functionalwsWebSocket-related

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions