Skip to content

Commit 2430475

Browse files
committed
fix(socket-mode): only release connect_operation_lock in send_message when acquired
#1926 fixed this in AsyncBaseSocketModeClient.connect_to_new_endpoint, but the same `if self.connect_operation_lock.locked() is True: release()` shape is still in send_message() in both async backends, and neither had a test. websockets is the worse of the two: its retry block never acquires the lock at all, it only releases it. So whenever a reconnect holds the lock and a send_message() falls into the retry path, send_message() releases the reconnect's lock and two reconnects can run at once. The aiohttp sibling does acquire here, which is what makes the omission visible. aiohttp acquires but releases on locked(), so a task cancelled while waiting on acquire() releases whichever task does hold the lock. That is exactly the case #1926 describes. Both now track whether this task acquired, matching #1926 and the sync backends, which take the lock with `with` and cannot get this wrong.
1 parent ab4e498 commit 2430475

4 files changed

Lines changed: 106 additions & 3 deletions

File tree

‎slack_sdk/socket_mode/aiohttp/__init__.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,15 +438,16 @@ async def send_message(self, message: str):
438438
)
439439
# Although acquiring self.connect_operation_lock also for the first method call is the safest way,
440440
# we avoid synchronizing a lot for better performance. That's why we are doing a retry here.
441+
acquired = False
441442
try:
442-
await self.connect_operation_lock.acquire()
443+
acquired = await self.connect_operation_lock.acquire()
443444
if await self.is_connected():
444445
await self.current_session.send_str(message) # type: ignore[union-attr]
445446
else:
446447
self.logger.warning(f"The current session ({session_id}) is no longer active. Failed to send a message")
447448
raise e
448449
finally:
449-
if self.connect_operation_lock.locked() is True:
450+
if acquired:
450451
self.connect_operation_lock.release()
451452

452453
async def close(self):

‎slack_sdk/socket_mode/websockets/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,14 +245,16 @@ async def send_message(self, message: str):
245245
)
246246
# Although acquiring self.connect_operation_lock also for the first method call is the safest way,
247247
# we avoid synchronizing a lot for better performance. That's why we are doing a retry here.
248+
acquired = False
248249
try:
250+
acquired = await self.connect_operation_lock.acquire()
249251
if await self.is_connected():
250252
await self.current_session.send(message) # type: ignore[union-attr]
251253
else:
252254
self.logger.warning(f"The current session ({session_id}) is no longer active. Failed to send a message")
253255
raise e
254256
finally:
255-
if self.connect_operation_lock.locked() is True:
257+
if acquired:
256258
self.connect_operation_lock.release()
257259

258260
async def close(self):

‎tests/slack_sdk_async/socket_mode/test_aiohttp.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,52 @@ async def test_enqueue_message(self):
192192

193193
async def listener(self, message, raw_message):
194194
pass
195+
196+
197+
class _LockProbeClient(SocketModeClient):
198+
"""Just enough state for send_message(), with no real connection."""
199+
200+
def __init__(self):
201+
self.connect_operation_lock = asyncio.Lock()
202+
self.logger = logging.getLogger(__name__)
203+
self.closed = False
204+
self.current_session = MagicMock()
205+
206+
async def is_connected(self) -> bool:
207+
return True
208+
209+
@classmethod
210+
def build_session_id(cls, session) -> str:
211+
return "test-session"
212+
213+
async def session_id(self) -> str:
214+
return "test-session"
215+
216+
217+
class TestAiohttpSendMessageLock(unittest.TestCase):
218+
@async_test
219+
async def test_send_message_leaves_another_tasks_lock_alone(self):
220+
client = _LockProbeClient()
221+
222+
async def send_str(message):
223+
raise ConnectionError("the underlying connection was replaced")
224+
225+
client.current_session.send_str = send_str
226+
227+
# Stand in for a reconnect holding the lock for the whole call.
228+
await client.connect_operation_lock.acquire()
229+
230+
task = asyncio.ensure_future(client.send_message("hello"))
231+
await asyncio.sleep(0.1)
232+
233+
task.cancel()
234+
try:
235+
await task
236+
except (asyncio.CancelledError, ConnectionError):
237+
pass
238+
239+
self.assertTrue(
240+
client.connect_operation_lock.locked(),
241+
"send_message() released the connect lock held by another task",
242+
)
243+
client.connect_operation_lock.release()

‎tests/slack_sdk_async/socket_mode/test_websockets.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import asyncio
2+
import logging
13
import unittest
4+
from unittest.mock import MagicMock
5+
6+
from websockets.exceptions import WebSocketException
27

38
from slack_sdk.socket_mode.websockets import SocketModeClient
49
from slack_sdk.web.async_client import AsyncWebClient
@@ -73,3 +78,49 @@ async def test_enqueue_message(self):
7378

7479
async def listener(message, raw_message):
7580
pass
81+
82+
83+
class _LockProbeClient(SocketModeClient):
84+
"""Just enough state for send_message(), with no real connection."""
85+
86+
def __init__(self):
87+
self.connect_operation_lock = asyncio.Lock()
88+
self.logger = logging.getLogger(__name__)
89+
self.closed = False
90+
self.current_session = MagicMock()
91+
92+
async def is_connected(self) -> bool:
93+
return True
94+
95+
@classmethod
96+
def build_session_id(cls, session) -> str:
97+
return "test-session"
98+
99+
100+
class TestWebsocketsSendMessageLock(unittest.TestCase):
101+
@async_test
102+
async def test_send_message_leaves_another_tasks_lock_alone(self):
103+
client = _LockProbeClient()
104+
105+
async def send(message):
106+
raise WebSocketException("the underlying connection was replaced")
107+
108+
client.current_session.send = send
109+
110+
# Stand in for a reconnect holding the lock for the whole call.
111+
await client.connect_operation_lock.acquire()
112+
113+
task = asyncio.ensure_future(client.send_message("hello"))
114+
await asyncio.sleep(0.1)
115+
116+
task.cancel()
117+
try:
118+
await task
119+
except (asyncio.CancelledError, WebSocketException):
120+
pass
121+
122+
self.assertTrue(
123+
client.connect_operation_lock.locked(),
124+
"send_message() released the connect lock held by another task",
125+
)
126+
client.connect_operation_lock.release()

0 commit comments

Comments
 (0)