Skip to content

Track and cancel WebSocket reconnect tasks cleanly (#73)#81

Merged
Faerkeren merged 1 commit into
mainfrom
fix/73-track-reconnect-task
May 27, 2026
Merged

Track and cancel WebSocket reconnect tasks cleanly (#73)#81
Faerkeren merged 1 commit into
mainfrom
fix/73-track-reconnect-task

Conversation

@Faerkeren

Copy link
Copy Markdown
Contributor

Closes #73.

Validity assessment

Valid bug. AiohttpWebSocketAdapter._reader_loop spawned the reconnect
loop with a fire-and-forget asyncio.create_task call (ws_aiohttp.py:399-400),
so close() (ws_aiohttp.py:142-182) had no handle to cancel or await.
If close() ran while the loop was blocked in its 1-60s back-off sleep
(ws_aiohttp.py:473-476), the reconnect task survived shutdown and could
race against the closing session.

Reconnect also accumulated stale ids in _subscriptions because
subscribe_events() adds the new id (ws_aiohttp.py:325) but the
_reconnect_loop resubscribe path never removed the old one
(ws_aiohttp.py:484-486).

Fix

  • Added self._reconnect_task: asyncio.Task[None] | None and store the
    task when reconnect starts.
  • Guard against a second concurrent reconnect task.
  • close() now cancels and awaits _reconnect_task after the reader
    task, so a sleeping reconnect loop is woken and shut down
    deterministically.
  • _reconnect_loop re-raises CancelledError and clears
    self._reconnect_task = None in a finally block.
  • Before resubscribing, the loop drops stale subscription ids from
    _subscriptions so it only ever contains ids that are valid on the
    live session.

Tests added

  • test_close_cancels_reconnect_while_sleeping — forces reconnect into
    the back-off sleep, calls close(), and asserts the task is done and
    no reference leaks.
  • test_only_one_reconnect_task_active — asserts a single reconnect
    task reference is held.
  • test_reconnect_clears_stale_subscription_ids — after a forced drop
    and reconnect, the old id is gone from _subscriptions and only the
    fresh id from resubscribe remains.

Checks run

  • pytest tests/ --cov=haclient --cov-report=term --cov-fail-under=95
    — 294 passed, 97.09% coverage.
  • ruff check src tests — passed.
  • ruff format --check src tests — passed.
  • mypy srcSuccess: no issues found in 37 source files.

The aiohttp WebSocket adapter started its reconnect loop with a
fire-and-forget asyncio.create_task call, so close() could not
cancel or await it. If close() ran while the loop was sleeping in
its exponential back-off branch, the reconnect task survived
shutdown and could touch a closing session.

Reconnect also accumulated stale subscription ids in
_subscriptions because resubscribe registered fresh ids without
removing the dropped ones.

Fix:

- Store the reconnect task in self._reconnect_task and refuse to
  spawn a second one while one is active.
- In close(), cancel and await the reconnect task so no background
  task survives shutdown.
- Drop stale subscription ids before resubscribing so
  _subscriptions only ever contains ids that are valid on the
  live session.
- Re-raise CancelledError from the reconnect loop so close() can
  unblock the sleep deterministically.

Tests added:

- close() cancels a reconnect task that is sleeping in back-off.
- Only one reconnect task is active at a time.
- _subscriptions contains only live ids after reconnect.
@Faerkeren Faerkeren merged commit 499d209 into main May 27, 2026
12 checks passed
@Faerkeren Faerkeren deleted the fix/73-track-reconnect-task branch May 27, 2026 02:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track and cancel WebSocket reconnect tasks cleanly

1 participant