From bc16ad66ba0ae23397dc8f5a41c351fcc24f505f Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:41:15 +0000 Subject: [PATCH 1/2] fix: await cancelled transport tasks in dispose --- ably/transport/websockettransport.py | 18 +++++++++------- test/unit/websockettransport_test.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/ably/transport/websockettransport.py b/ably/transport/websockettransport.py index cd048475..45892288 100644 --- a/ably/transport/websockettransport.py +++ b/ably/transport/websockettransport.py @@ -257,10 +257,14 @@ async def dispose(self): if self.idle_timer: self.idle_timer.cancel() - # Schedule cleanup of cancelled tasks in the background to avoid blocking dispose() - # This prevents deadlock when dispose() is called from within these tasks + # Await the cancelled tasks so none is left unfinalized when dispose() returns. When + # dispose() runs inside one of them, awaiting it would deadlock, so that case is + # cleaned up in the background instead. if tasks_to_await: - asyncio.create_task(self._cleanup_tasks(tasks_to_await)) + if asyncio.current_task() in tasks_to_await: + asyncio.create_task(self._cleanup_tasks(tasks_to_await)) + else: + await self._cleanup_tasks(tasks_to_await) if self.websocket: try: @@ -270,11 +274,9 @@ async def dispose(self): async def _cleanup_tasks(self, tasks): """Wait for cancelled tasks to complete their cleanup.""" - for task in tasks: - try: - await task - except Exception: - pass # Ignore all exceptions from cancelled/failed tasks + # return_exceptions ignores what the cancelled or failed tasks raise, but still lets + # this coroutine be cancelled itself + await asyncio.gather(*tasks, return_exceptions=True) async def close(self): await self.send({'action': ProtocolMessageAction.CLOSE}) diff --git a/test/unit/websockettransport_test.py b/test/unit/websockettransport_test.py index d45c3ca8..14978ad9 100644 --- a/test/unit/websockettransport_test.py +++ b/test/unit/websockettransport_test.py @@ -1,3 +1,4 @@ +import asyncio from unittest.mock import MagicMock, patch from ably.transport.websockettransport import WebSocketTransport @@ -37,3 +38,33 @@ def test_websocket_url_defaults_to_wss_and_443(): def test_websocket_url_defaults_to_ws_and_80_when_tls_disabled(): url = _connect_url(tls=False) assert url == 'ws://example.com:80?format=json' + + +# RTN12 +async def test_dispose_finishes_cancelled_tasks_before_returning(): + transport = WebSocketTransport(MagicMock(), 'example.com', {'format': 'json'}) + started = asyncio.Event() + + async def read_loop(): + started.set() + await asyncio.Event().wait() + + transport.read_loop = asyncio.create_task(read_loop()) + await started.wait() + + await transport.dispose() + + assert transport.read_loop.done() + + +# RTN12 +async def test_dispose_called_from_the_read_loop_does_not_deadlock(): + transport = WebSocketTransport(MagicMock(), 'example.com', {'format': 'json'}) + + async def read_loop(): + await transport.dispose() + + transport.read_loop = asyncio.create_task(read_loop()) + + await asyncio.wait_for(asyncio.gather(transport.read_loop, return_exceptions=True), timeout=1) + assert transport.read_loop.done() From 11c9a13f2b8228b377101c18ea0f0e2726ac6c27 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:14:08 +0000 Subject: [PATCH 2/2] test: assert the disposed read loop is cancelled --- test/unit/websockettransport_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/unit/websockettransport_test.py b/test/unit/websockettransport_test.py index 14978ad9..b400f4a7 100644 --- a/test/unit/websockettransport_test.py +++ b/test/unit/websockettransport_test.py @@ -54,7 +54,7 @@ async def read_loop(): await transport.dispose() - assert transport.read_loop.done() + assert transport.read_loop.cancelled() # RTN12 @@ -66,5 +66,7 @@ async def read_loop(): transport.read_loop = asyncio.create_task(read_loop()) - await asyncio.wait_for(asyncio.gather(transport.read_loop, return_exceptions=True), timeout=1) - assert transport.read_loop.done() + done, pending = await asyncio.wait({transport.read_loop}, timeout=1) + + assert not pending + assert transport.read_loop.cancelled()