feat(ws): cooperative shutdown via run_forever(stop_event=...) (#177)#186
Conversation
`KalshiWebSocket.run_forever()` gains an optional `stop_event: asyncio.Event | None = None` parameter. When the event fires — typically from a SIGINT handler via `add_signal_handler(SIGINT, stop.set)` — run_forever() clears `_running`, closes the connection, and awaits the recv loop's natural exit. The recv task is NOT cancelled, so no CancelledError leaks out. The mechanism: asyncio.wait races recv_task vs stop_event.wait(); if stop wins, _running=False then connection.close() makes the recv loop see ConnectionClosed on its next read and exit via the existing `if not self._running: break` branch — same teardown path as a deliberate _stop() call, just initiated externally. No behavior change when stop_event is omitted; external cancellation still propagates as before. The #175 missing-subscription guard remains the first check. Sibling foot-gun to #175 (silent-no-op-on-no-subscribe) — both target the same method but different failure modes. Together they make run_forever() actually usable as a long-running callback-mode loop: loud when misused, cooperative when terminated.
Code Review — PR #186: cooperative shutdown via
|
Bot raised two real items:
1. Cooperative shutdown skipped _stop()'s put_sentinel broadcast. Today
this is masked by __aexit__ -> _stop() running after run_forever()
returns, but a caller using run_forever() outside `async with` would
leave iterator consumers hanging on their queues. PR description
claimed "same teardown path" — not quite true. Fixed: cooperative
path now broadcasts sentinels too.
2. `await connection.close()` raising would leave _recv_task un-awaited.
In practice close() shouldn't throw, but a nested try/finally costs
nothing and makes the contract honest. Now structured so drain +
sentinels happen regardless of close() outcome.
self._running = False
try:
await self._connection.close()
finally:
try:
await self._recv_task
finally:
# broadcast queue sentinels
Added regression test (test_run_forever_with_stop_event_broadcasts_sentinels):
subscribes to a channel, stops via stop_event, then asserts the iterator's
`async for` terminates without further input. Pre-fix this hung; post-fix
it drains cleanly.
Also added docstring note about external cancellation of run_forever()
itself while stop_event is provided: the cancellation cleans up
stop_waiter but does NOT trigger cooperative shutdown — _recv_task keeps
running until __aexit__ runs _stop() for full teardown. Use the event
for graceful exit; rely on __aexit__ for hard cancellation.
|
Both real items addressed in ee0aeab: 1. Sentinel divergence — fixed. You're right that the PR description's "same teardown path" was imprecise: cooperative shutdown was skipping Sequencing is: I went with the inline duplication rather than extracting a shared helper because the cooperative path and 2. close() exception safety — fixed. Nested self._running = False
try:
if self._connection is not None:
await self._connection.close()
finally:
try:
await self._recv_task
finally:
if self._sub_mgr is not None:
for sub in self._sub_mgr.active_subscriptions.values():
await sub.queue.put_sentinel()If Regression test — added Docstring note — added a paragraph explicitly covering the "external cancellation with stop_event provided" case: cancellation propagates, Minor note on Verification: 2117 unit tests pass (+1 for the new sentinel regression test). |
Code Review — PR #186:
|
Round-2 bot review on PR #186: 1. Simultaneous-completion edge case (medium). If server-side close lands in the same asyncio.wait() round as stop_event.set(), my previous condition `if stop_waiter in done and self._recv_task not in done` fell through to the `elif self._recv_task in done:` branch which re-raises but does NOT broadcast sentinels. Combined with the recv loop's clean `not self._running: break` exits also not broadcasting, iterators outside `async with` would hang. Verified by reading the recv loop: only its error paths broadcast sentinels; clean ConnectionClosed exits do not. Fix: stop_event fires now ALWAYS takes the cooperative-shutdown branch, regardless of recv_task state. If recv_task happens to be done already, we skip `await self._recv_task` and call `.result()` instead (re-raises any exception). Sentinel broadcast happens in the inner finally either way. 2. Duplicate sentinel-broadcast logic (low). Bot suggested extracting to a private helper. Done: `_broadcast_sentinels()` is now the single point where the loop runs. Six callers consolidated: _stop(), the new cooperative shutdown path, and 4 fatal-error paths in _recv_loop and _handle_reconnect. The helper's docstring documents the idempotent-put_sentinel invariant and which paths use it. 3. Sleep bump in test (nit). asyncio.sleep(0.05) -> 0.1 with a comment explaining the trade-off. Reduces flake risk on resource-constrained CI runners. 4. Empty-collected assertion in sentinel test (nit). The drain test now explicitly asserts `collected == []` to document what "clean termination" means here. Verification: 2117 unit tests pass. ruff + mypy clean.
|
Round 2 addressed in 407a43a: Simultaneous-completion (medium) — confirmed real and fixed. Verified by reading Restructured so if stop_waiter in done:
self._running = False
try:
if self._connection is not None:
await self._connection.close()
finally:
try:
if not self._recv_task.done():
await self._recv_task
else:
# Already done. .result() re-raises any exception,
# surfaced as run_forever's exception.
self._recv_task.result()
finally:
await self._broadcast_sentinels()
elif self._recv_task in done:
# Server-side close / crash without stop event firing.
self._recv_task.result()Now the broadcast is unconditional once stop fires, and the
Sleep bump (nit) — Empty Not actioned: External cancellation with stop_event set — already covered by the docstring paragraph added in the prior round. The bot acknowledged this above; no change needed. Verification: 2117 unit tests pass. |
Code Review — PR #186:
|
Bot round-2 found one item to action and one based on a misreading of
convention.
Actioned:
* ROADMAP.md "Open trackers" trimmed from four lines of tombstone prose
("None. The previous entries here (#45, #53, #106) are all closed:
... ") down to "None." The why-they-closed history will rot by the
next release; anyone curious can run `gh issue view 45` or check git
history.
Not actioned:
* Bot suggested changing CHANGELOG.md "Pre-release docs audit (#179)"
to "(#188)" on the claim that "every other CHANGELOG entry uses the
PR number." This is backwards. Existing CHANGELOG entries
(#175, #176, #177, #178, #183) are all *issue* numbers; the PRs that
closed them were #185, #187, #186, #182, #184 respectively. Verified
via `gh issue view <N> --json closedByPullRequestsReferences`. The
convention is "(#<issue-number>)" — using #179 is correct.
mkdocs --strict still clean.
Closes #177.
Sibling to the #175 fix: same method (
run_forever()), different failure mode. #175 was "silent no-op on missing subscribe"; #177 is "no clean way to terminate."The problem
SIGINT(or any external cancellation) duringawait self._recv_taskpropagatesCancelledErrorthrough both the run_forever caller AND the recv task itself, cancelling the loop mid-frame. The application seesCancelledErrorleak out and has no way to ask for a clean drain.Fix (option B from the issue)
run_forever()gains an optionalstop_event: asyncio.Event | None = Noneparameter. When set — typically from a SIGINT handler —run_forever()clears_running, closes the connection, and awaits the recv loop's natural exit via its existingif not self._running: breakbranch onConnectionClosed. The recv task is NOT cancelled.Key invariant: the recv task is not cancelled. We close the connection, which makes the recv loop's existing
ConnectionClosedhandler seenot self._runningand break cleanly. Same teardown path as a deliberate_stop()call, just initiated externally instead of from the session's__aexit__.Canonical SIGINT pattern (now in docs/websockets.md)
Ctrl+C → handler sets
stop→run_forever()closes the WS gracefully → recv loop drains → method returns →async withruns_stop()for full teardown. NoCancelledError, no half-dispatched frames, no orphaned tasks.Tests
Two new in
tests/ws/test_client.py::TestRunForever:test_run_forever_with_stop_event_returns_cleanly— subscribe, kick offrun_forever(stop_event=stop)as task, sleep briefly to confirm it's blocking,stop.set(), assert clean return +ConnectionState.CLOSED+ no exception on the task.test_run_forever_with_pre_set_stop_event_returns_immediately— stop event already set before the call; same cooperative-shutdown path, just no wait. Guards against future refactors of theasyncio.wait()arrangement breaking the race-free case.The existing
test_run_forever_blocks_until_close(no stop_event) and the #175test_run_forever_without_subscription_raisesare unchanged — confirms the new path is fully additive.Verification
uv run pytest tests/ --ignore=tests/integration→ 2116 passed, 0 warnings.uv run ruff check .→ clean.uv run mypy kalshi/→Success: no issues found in 76 source files.Acceptance criteria
run_forever()can be terminated cooperatively withoutCancelledErrorleaking out.run_forever()returns cleanly + connection is closed.docs/websockets.mdcallback section shows the canonical SIGINT + stop-event pattern.(README WS section has no callback-mode example — only iterator-style — so no foot-gun to fix there. The cooperative-shutdown pattern lives in the full docs.)
Soft-breaking footprint
None.
stop_eventis an optional parameter; existing callers (await session.run_forever()) are unaffected. The recv-loop teardown path on stop_event reuses the existing_running + ConnectionClosedflow — no new code paths in the recv loop itself.