Skip to content

Commit ba61be2

Browse files
authored
Merge pull request #450 from njloof/fix/signalr-disconnect-attributeerror
Fix AttributeError on SignalR disconnect during HA shutdown
2 parents d3deed3 + da21a5c commit ba61be2

2 files changed

Lines changed: 86 additions & 4 deletions

File tree

pyhilo/signalr.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class SignalRHub:
7272
Lifecycle:
7373
``run()`` — negotiate fresh token, build client, block until disconnect
7474
``invoke()`` — send a hub method invocation
75-
``disconnect()`` — stop the transport cleanly
75+
``disconnect()`` — cancel the task awaiting ``run()`` to stop cleanly
7676
"""
7777

7878
def __init__(
@@ -85,6 +85,7 @@ def __init__(
8585
"""
8686
self._negotiate = negotiate_callback
8787
self._client: Optional[SignalRClient] = None
88+
self._task: Optional[asyncio.Task] = None
8889
self._connect_callbacks: list[Callable[..., Any]] = []
8990
self._disconnect_callbacks: list[Callable[..., Any]] = []
9091
self._event_callbacks: list[Callable[..., Any]] = []
@@ -177,10 +178,12 @@ async def _handler(arguments: Any) -> None:
177178
self._client.on_close(self._on_close)
178179
self._client.on_error(self._on_error)
179180

181+
self._task = asyncio.current_task()
180182
try:
181183
await self._client.run()
182184
finally:
183185
self._client = None
186+
self._task = None
184187

185188
async def invoke(self, method: str, args: list[Any]) -> None:
186189
"""Invoke a hub method on the server.
@@ -195,10 +198,23 @@ async def invoke(self, method: str, args: list[Any]) -> None:
195198
await self._client.send(method, args)
196199

197200
async def disconnect(self) -> None:
198-
"""Request the client to stop."""
199-
if self._client is not None:
201+
"""Request the client to stop.
202+
203+
``pysignalr.SignalRClient`` has no ``stop()``/``close()`` method --
204+
its ``run()`` coroutine blocks until the connection ends and is
205+
meant to be cancelled by the caller (see pysignalr's
206+
``WebsocketTransport.run()``, an unconditional reconnect loop with
207+
no external stop hook). Cancel the task that's awaiting ``run()``
208+
instead of calling a nonexistent client method.
209+
"""
210+
task = self._task
211+
if task is not None:
200212
LOG.info("SignalRHub: disconnecting")
201-
await self._client.stop()
213+
task.cancel()
214+
try:
215+
await task
216+
except asyncio.CancelledError:
217+
pass
202218

203219
# ------------------------------------------------------------------
204220
# Internal pysignalr hooks

tests/test_signalr.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import asyncio
2+
3+
import pytest
4+
5+
from pyhilo.signalr import SignalRHub
6+
7+
8+
async def _fake_negotiate() -> tuple[str, str]:
9+
return ("wss://example.invalid/hub", "fake-token")
10+
11+
12+
@pytest.mark.asyncio
13+
async def test_disconnect_cancels_running_task(monkeypatch: pytest.MonkeyPatch) -> None:
14+
"""disconnect() must cancel the task running run(), not call client.stop().
15+
16+
pysignalr.SignalRClient has no stop()/close() method -- its run()
17+
coroutine blocks until cancelled. Calling a nonexistent stop() method
18+
raised AttributeError on every Home Assistant shutdown.
19+
"""
20+
21+
client_connected = asyncio.Event()
22+
23+
class FakeSignalRClient:
24+
def __init__(self, *args: object, **kwargs: object) -> None:
25+
self._message_handlers: dict = {}
26+
27+
def on_open(self, callback: object) -> None:
28+
pass
29+
30+
def on_close(self, callback: object) -> None:
31+
pass
32+
33+
def on_error(self, callback: object) -> None:
34+
pass
35+
36+
async def run(self) -> None:
37+
# Block forever, like the real pysignalr transport's reconnect loop.
38+
client_connected.set()
39+
await asyncio.Event().wait()
40+
41+
monkeypatch.setattr("pyhilo.signalr.SignalRClient", FakeSignalRClient)
42+
monkeypatch.setattr("pyhilo.signalr.ssl.create_default_context", lambda: object())
43+
44+
hub = SignalRHub(negotiate_callback=_fake_negotiate)
45+
task = asyncio.create_task(hub.run())
46+
47+
# Wait until run() has reached the blocking await inside
48+
# FakeSignalRClient.run() (negotiation and the executor hop for the SSL
49+
# context are real awaits, so a fixed number of sleep(0)s is fragile).
50+
await asyncio.wait_for(client_connected.wait(), timeout=5)
51+
assert hub.connected
52+
53+
# Must not raise -- this previously called the nonexistent
54+
# SignalRClient.stop() and raised AttributeError.
55+
await hub.disconnect()
56+
57+
assert task.done()
58+
assert not hub.connected
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_disconnect_without_a_running_task_is_a_no_op() -> None:
63+
"""disconnect() before run() has ever been called should not raise."""
64+
hub = SignalRHub(negotiate_callback=_fake_negotiate)
65+
await hub.disconnect()
66+
assert not hub.connected

0 commit comments

Comments
 (0)