Skip to content

Commit 5650620

Browse files
committed
fix(socket-mode): settle aiohttp tasks on close
Stop connect attempts once client shutdown begins, discard sessions that complete after close, and await cancellation of SDK-owned tasks before closing the aiohttp session.\n\nAdds lifecycle regressions for closed clients, close races, current-task ownership, backoff, and task/session cleanup.\n\nRefs #1913
1 parent c1e2c10 commit 5650620

2 files changed

Lines changed: 255 additions & 8 deletions

File tree

slack_sdk/socket_mode/aiohttp/__init__.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ async def connect(self):
349349
# a new monitor and a new message receiver are also created.
350350
# If a new session is created but we failed to create the new
351351
# monitor or the new message, we should try it.
352-
while True:
352+
while not self.closed:
353353
try:
354354
old_session: Optional[ClientWebSocketResponse] = (
355355
None if self.current_session is None else self.current_session
@@ -369,18 +369,28 @@ async def connect(self):
369369
except Exception as e:
370370
self.logger.exception(f"Failed to close the old session : {e}")
371371

372+
if self.closed:
373+
break
374+
372375
if self.wss_uri is None:
373376
# If the underlying WSS URL does not exist,
374377
# acquiring a new active WSS URL from the server-side first
375378
self.wss_uri = await self.issue_new_wss_url()
376379

377-
self.current_session = await self.aiohttp_client_session.ws_connect(
380+
if self.closed:
381+
break
382+
383+
new_session = await self.aiohttp_client_session.ws_connect(
378384
self.wss_uri,
379385
autoping=False,
380386
heartbeat=self.ping_interval,
381387
proxy=self.proxy,
382388
ssl=self.web_client.ssl if self.web_client.ssl is not None else True,
383389
)
390+
if self.closed:
391+
await new_session.close()
392+
break
393+
self.current_session = new_session
384394
session_id: str = await self.session_id()
385395
self.auto_reconnect_enabled = self.default_auto_reconnect_enabled
386396
self.stale = False
@@ -405,6 +415,8 @@ async def connect(self):
405415
self.logger.debug(f"A new receive_messages() executor has been recreated for {session_id}")
406416
break
407417
except Exception as e:
418+
if self.closed:
419+
break
408420
self.logger.exception(f"Failed to connect (error: {e}); Retrying...")
409421
await asyncio.sleep(self.ping_interval)
410422

@@ -447,12 +459,17 @@ async def close(self):
447459
self.closed = True
448460
self.auto_reconnect_enabled = False
449461
await self.disconnect()
450-
if self.message_processor is not None:
451-
self.message_processor.cancel()
452-
if self.current_session_monitor is not None:
453-
self.current_session_monitor.cancel()
454-
if self.message_receiver is not None:
455-
self.message_receiver.cancel()
462+
463+
current_task = asyncio.current_task()
464+
owned_tasks = []
465+
for task in (self.message_processor, self.current_session_monitor, self.message_receiver):
466+
if task is not None and task is not current_task and task not in owned_tasks:
467+
if not task.done():
468+
task.cancel()
469+
owned_tasks.append(task)
470+
if owned_tasks:
471+
await asyncio.gather(*owned_tasks, return_exceptions=True)
472+
456473
if self.aiohttp_client_session is not None:
457474
await self.aiohttp_client_session.close()
458475

tests/slack_sdk_async/socket_mode/test_aiohttp.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import time
23
import unittest
34

45
from slack_sdk.socket_mode.aiohttp import SocketModeClient
@@ -8,6 +9,74 @@
89
from tests.mock_web_api_server import setup_mock_web_api_server_async, cleanup_mock_web_api_server_async
910

1011

12+
class ControlledClientSession:
13+
def __init__(self):
14+
self.closed = False
15+
self.attempts = 0
16+
self.attempt_started = asyncio.Event()
17+
self.closed_event = asyncio.Event()
18+
19+
async def ws_connect(self, *args, **kwargs):
20+
self.attempts += 1
21+
self.attempt_started.set()
22+
await self.closed_event.wait()
23+
raise RuntimeError("Session is closed")
24+
25+
async def close(self):
26+
self.closed = True
27+
self.closed_event.set()
28+
29+
30+
class CompletingClientSession:
31+
def __init__(self):
32+
self.closed = False
33+
self.attempts = 0
34+
self.attempt_started = asyncio.Event()
35+
self.closed_event = asyncio.Event()
36+
self.websocket = ConnectedWebSocket()
37+
38+
async def ws_connect(self, *args, **kwargs):
39+
self.attempts += 1
40+
self.attempt_started.set()
41+
await self.closed_event.wait()
42+
return self.websocket
43+
44+
async def close(self):
45+
self.closed = True
46+
self.closed_event.set()
47+
48+
49+
class ConnectedWebSocket:
50+
def __init__(self):
51+
self.closed = False
52+
self.receive_forever = asyncio.Event()
53+
54+
async def close(self):
55+
self.closed = True
56+
57+
async def ping(self, data):
58+
pass
59+
60+
async def receive(self):
61+
await self.receive_forever.wait()
62+
63+
64+
class RetryingClientSession:
65+
def __init__(self):
66+
self.closed = False
67+
self.attempt_times = []
68+
self.websocket = ConnectedWebSocket()
69+
70+
async def ws_connect(self, *args, **kwargs):
71+
self.attempt_times.append(time.monotonic())
72+
if len(self.attempt_times) == 1:
73+
raise ConnectionError("temporary failure")
74+
return self.websocket
75+
76+
async def close(self):
77+
self.closed = True
78+
79+
1180
class TestAiohttp(unittest.TestCase):
1281
def setUp(self):
1382
setup_mock_web_api_server_async(self, MockHandler)
@@ -44,6 +113,167 @@ async def test_init_with_loop(self):
44113
finally:
45114
await client.close()
46115

116+
@async_test
117+
async def test_connect_returns_when_client_is_already_closed(self):
118+
client = SocketModeClient(
119+
app_token="xapp-A111-222-xyz",
120+
web_client=self.web_client,
121+
auto_reconnect_enabled=False,
122+
ping_interval=0.01,
123+
)
124+
client.wss_uri = "ws://127.0.0.1:1/link"
125+
126+
await client.close()
127+
await asyncio.wait_for(client.connect(), timeout=0.05)
128+
129+
self.assertTrue(client.closed)
130+
self.assertTrue(client.aiohttp_client_session.closed)
131+
132+
@async_test
133+
async def test_connect_stops_when_close_lands_during_an_attempt(self):
134+
client = SocketModeClient(
135+
app_token="xapp-A111-222-xyz",
136+
web_client=self.web_client,
137+
auto_reconnect_enabled=False,
138+
ping_interval=0.01,
139+
)
140+
await client.aiohttp_client_session.close()
141+
controlled_session = ControlledClientSession()
142+
client.aiohttp_client_session = controlled_session
143+
client.wss_uri = "ws://127.0.0.1:1/link"
144+
145+
connect_task = asyncio.create_task(client.connect())
146+
await controlled_session.attempt_started.wait()
147+
await client.close()
148+
149+
await asyncio.wait_for(asyncio.shield(connect_task), timeout=0.05)
150+
self.assertTrue(connect_task.done())
151+
self.assertEqual(1, controlled_session.attempts)
152+
self.assertTrue(controlled_session.closed)
153+
154+
@async_test
155+
async def test_connect_discards_session_completed_after_close(self):
156+
client = SocketModeClient(
157+
app_token="xapp-A111-222-xyz",
158+
web_client=self.web_client,
159+
auto_reconnect_enabled=False,
160+
ping_interval=0.01,
161+
)
162+
await client.aiohttp_client_session.close()
163+
completing_session = CompletingClientSession()
164+
client.aiohttp_client_session = completing_session
165+
client.wss_uri = "ws://127.0.0.1:1/link"
166+
167+
connect_task = asyncio.create_task(client.connect())
168+
await completing_session.attempt_started.wait()
169+
await client.close()
170+
await asyncio.wait_for(connect_task, timeout=0.05)
171+
172+
self.assertTrue(completing_session.websocket.closed)
173+
self.assertIsNone(client.current_session)
174+
self.assertIsNone(client.current_session_monitor)
175+
self.assertIsNone(client.message_receiver)
176+
177+
@async_test
178+
async def test_connect_preserves_ping_interval_backoff_for_live_client(self):
179+
client = SocketModeClient(
180+
app_token="xapp-A111-222-xyz",
181+
web_client=self.web_client,
182+
auto_reconnect_enabled=False,
183+
ping_interval=0.03,
184+
)
185+
await client.aiohttp_client_session.close()
186+
retrying_session = RetryingClientSession()
187+
client.aiohttp_client_session = retrying_session
188+
client.wss_uri = "ws://127.0.0.1:1/link"
189+
190+
try:
191+
await client.connect()
192+
self.assertEqual(2, len(retrying_session.attempt_times))
193+
self.assertGreaterEqual(
194+
retrying_session.attempt_times[1] - retrying_session.attempt_times[0],
195+
client.ping_interval,
196+
)
197+
finally:
198+
await client.close()
199+
200+
@async_test
201+
async def test_close_awaits_owned_task_settlement_before_session_close(self):
202+
client = SocketModeClient(
203+
app_token="xapp-A111-222-xyz",
204+
web_client=self.web_client,
205+
auto_reconnect_enabled=False,
206+
)
207+
client.message_processor.cancel()
208+
await asyncio.gather(client.message_processor, return_exceptions=True)
209+
await client.aiohttp_client_session.close()
210+
controlled_session = ControlledClientSession()
211+
client.aiohttp_client_session = controlled_session
212+
213+
started = [asyncio.Event() for _ in range(3)]
214+
cancelling = [asyncio.Event() for _ in range(3)]
215+
release_cleanup = asyncio.Event()
216+
217+
async def owned_task(index):
218+
started[index].set()
219+
try:
220+
await asyncio.Event().wait()
221+
except asyncio.CancelledError:
222+
cancelling[index].set()
223+
await release_cleanup.wait()
224+
raise
225+
226+
owned_tasks = [asyncio.create_task(owned_task(index)) for index in range(3)]
227+
client.message_processor, client.current_session_monitor, client.message_receiver = owned_tasks
228+
for event in started:
229+
await event.wait()
230+
231+
close_task = asyncio.create_task(client.close())
232+
for event in cancelling:
233+
await event.wait()
234+
235+
self.assertFalse(close_task.done())
236+
self.assertFalse(controlled_session.closed)
237+
238+
release_cleanup.set()
239+
await asyncio.wait_for(close_task, timeout=0.05)
240+
241+
self.assertTrue(controlled_session.closed)
242+
self.assertTrue(all(task.done() for task in owned_tasks))
243+
self.assertTrue(all(task.cancelled() for task in owned_tasks))
244+
245+
@async_test
246+
async def test_close_does_not_cancel_or_await_current_reconnect_owner(self):
247+
client = SocketModeClient(
248+
app_token="xapp-A111-222-xyz",
249+
web_client=self.web_client,
250+
auto_reconnect_enabled=False,
251+
)
252+
client.message_processor.cancel()
253+
await asyncio.gather(client.message_processor, return_exceptions=True)
254+
await client.aiohttp_client_session.close()
255+
controlled_session = ControlledClientSession()
256+
client.aiohttp_client_session = controlled_session
257+
258+
async def settled_task():
259+
pass
260+
261+
settled = asyncio.create_task(settled_task())
262+
await settled
263+
client.message_processor = settled
264+
client.message_receiver = settled
265+
266+
async def reconnect_owner():
267+
client.current_session_monitor = asyncio.current_task()
268+
await client.close()
269+
270+
owner_task = asyncio.create_task(reconnect_owner())
271+
await asyncio.wait_for(asyncio.shield(owner_task), timeout=0.05)
272+
273+
self.assertTrue(owner_task.done())
274+
self.assertFalse(owner_task.cancelled())
275+
self.assertTrue(controlled_session.closed)
276+
47277
@async_test
48278
async def test_issue_new_wss_url(self):
49279
client = SocketModeClient(

0 commit comments

Comments
 (0)