Skip to content

Commit 7f44a5f

Browse files
author
Laura Long
committed
fix(ble): cancel the disconnect future when it times out
Follow-up to the teardown-hang fix: when the bounded disconnect wait times out, cancel the future so a stalled call isn't left running on the event loop. Adds tests for the timeout, the bounded close() join, and the cancel. Signed-off-by: Laura Long <lauralonggone@gmail.com>
1 parent 4e90848 commit 7f44a5f

2 files changed

Lines changed: 45 additions & 5 deletions

File tree

meshtastic/ble_interface.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,13 @@ def async_await(self, coro, timeout=None): # pylint: disable=C0116
354354
# On macOS without debug logging, callbacks may not be delivered
355355
# unless we trigger some I/O. This is a known quirk of CoreBluetooth.
356356
sys.stdout.flush()
357-
result = future.result(timeout)
357+
try:
358+
result = future.result(timeout)
359+
except FutureTimeoutError:
360+
# The coroutine is still queued/running on the event loop; cancel it
361+
# so a stalled call (e.g. a hung disconnect) is not left pending.
362+
future.cancel()
363+
raise
358364
logger.debug("async_await: complete")
359365
return result
360366

meshtastic/tests/test_ble_interface.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77
from bleak.exc import BleakDBusError, BleakError
88

9-
from ..ble_interface import BLEClient, BLEInterface
9+
from ..ble_interface import BLE_DISCONNECT_TIMEOUT, BLEClient, BLEInterface
1010

1111

1212
@pytest.mark.unit
@@ -88,11 +88,45 @@ def test_ble_receive_disconnect_mid_read_unwinds_cleanly():
8888

8989
@pytest.mark.unit
9090
def test_ble_client_disconnect_swallows_stalled_teardown():
91-
"""BLEClient.disconnect must not propagate a stalled-teardown timeout,
92-
so BLEInterface.close() can always finish."""
91+
"""BLEClient.disconnect must bound the wait with BLE_DISCONNECT_TIMEOUT and
92+
not propagate a stalled-teardown timeout, so BLEInterface.close() can always
93+
finish."""
9394
client = object.__new__(BLEClient)
9495
client.bleak_client = MagicMock()
95-
with patch.object(BLEClient, "async_await", side_effect=FutureTimeoutError()):
96+
with patch.object(
97+
BLEClient, "async_await", side_effect=FutureTimeoutError()
98+
) as async_await:
9699
client.disconnect() # must not raise
100+
# the wait must actually be bounded, not left unbounded
101+
assert async_await.call_args.kwargs["timeout"] == BLE_DISCONNECT_TIMEOUT
97102
with patch.object(BLEClient, "async_await", side_effect=BleakError("gone")):
98103
client.disconnect() # must not raise
104+
105+
106+
@pytest.mark.unit
107+
def test_ble_client_close_bounds_event_thread_join():
108+
"""BLEClient.close must bound the event-loop thread join so a stuck loop
109+
cannot block teardown forever."""
110+
client = object.__new__(BLEClient)
111+
client._eventThread = MagicMock()
112+
# Force a plain (non-async) mock for the coroutine method so we don't create
113+
# an un-awaited coroutine when close() calls it.
114+
with patch.object(BLEClient, "async_run") as async_run, patch.object(
115+
BLEClient, "_stop_event_loop", new=MagicMock()
116+
):
117+
client.close()
118+
async_run.assert_called_once()
119+
client._eventThread.join.assert_called_once_with(timeout=BLE_DISCONNECT_TIMEOUT)
120+
121+
122+
@pytest.mark.unit
123+
def test_ble_client_async_await_cancels_future_on_timeout():
124+
"""On timeout, async_await must cancel the pending future so a stalled
125+
coroutine is not left running on the event loop."""
126+
client = object.__new__(BLEClient)
127+
future = MagicMock()
128+
future.result.side_effect = FutureTimeoutError()
129+
with patch.object(BLEClient, "async_run", return_value=future):
130+
with pytest.raises(FutureTimeoutError):
131+
client.async_await("coro", timeout=BLE_DISCONNECT_TIMEOUT)
132+
future.cancel.assert_called_once()

0 commit comments

Comments
 (0)