Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Tests for Slack Socket Mode teardown (issue #46990).
|
||||
|
||||
slack_sdk's SocketModeClient.connect() is an unconditional retry loop that
|
||||
swallows connection errors and never checks the client's ``closed`` flag. If a
|
||||
task is still inside that loop when the client's shared aiohttp session is
|
||||
closed, it keeps retrying forever and logs
|
||||
``Failed to connect (error: Session is closed); Retrying...`` against a session
|
||||
that can never work again.
|
||||
|
||||
These tests pin the ordering and cleanup that keep old-client background work
|
||||
from outliving a teardown.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock the slack-bolt package if it's not installed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_slack_mock():
|
||||
"""Install mock slack modules so SlackAdapter can be imported."""
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return # Real library installed
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
(
|
||||
"slack_bolt.adapter.socket_mode.async_handler",
|
||||
slack_bolt.adapter.socket_mode.async_handler,
|
||||
),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
sys.modules.setdefault("aiohttp", MagicMock())
|
||||
|
||||
|
||||
_ensure_slack_mock()
|
||||
|
||||
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
|
||||
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
from gateway.config import PlatformConfig # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal stand-ins for the slack_sdk objects involved in teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Stands in for the ``aiohttp.ClientSession`` SocketModeClient holds."""
|
||||
|
||||
def __init__(self, client=None) -> None:
|
||||
self.closed = False
|
||||
self.reachable = False
|
||||
self.ws_connect_after_close = 0
|
||||
self._client = client
|
||||
self.live_tasks_at_close: list = []
|
||||
|
||||
async def ws_connect(self):
|
||||
if self.closed:
|
||||
# This is the exact failure recorded in #46990.
|
||||
self.ws_connect_after_close += 1
|
||||
raise RuntimeError("Session is closed")
|
||||
if not self.reachable:
|
||||
raise ConnectionError("connection refused")
|
||||
return object()
|
||||
|
||||
async def close(self) -> None:
|
||||
# Record which client tasks were still alive at the instant the shared
|
||||
# session went away. Anything listed here could be inside connect().
|
||||
if self._client is not None:
|
||||
self.live_tasks_at_close = self._client.live_task_names()
|
||||
self.closed = True
|
||||
# Closing a real session performs I/O and yields control back to the
|
||||
# loop, which is what gives a surviving retry task a chance to run.
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
class _FakeSocketModeClient:
|
||||
"""Mirrors the parts of SocketModeClient that matter during teardown."""
|
||||
|
||||
_TASK_ATTRS = ("message_processor", "current_session_monitor", "message_receiver")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.aiohttp_client_session = _FakeSession(self)
|
||||
self.closed = False
|
||||
self.close_should_raise = False
|
||||
self.message_processor = None
|
||||
self.current_session_monitor = None
|
||||
self.message_receiver = None
|
||||
|
||||
def live_task_names(self) -> list:
|
||||
return [
|
||||
attr
|
||||
for attr in self._TASK_ATTRS
|
||||
if getattr(self, attr) is not None and not getattr(self, attr).done()
|
||||
]
|
||||
|
||||
async def connect_to_new_endpoint(self) -> None:
|
||||
# monitor_current_session() (on staleness) and receive_messages() (on a
|
||||
# CLOSE frame) both reach connect() through here, independently.
|
||||
await self.connect()
|
||||
|
||||
async def monitor_current_session(self) -> None:
|
||||
while not self.closed:
|
||||
await asyncio.sleep(0.001)
|
||||
await self.connect_to_new_endpoint()
|
||||
|
||||
async def connect(self) -> None:
|
||||
# Mirrors SocketModeClient.connect(): ``while True`` with a broad
|
||||
# ``except Exception``, so neither the closed flag nor a closed session
|
||||
# ends the loop.
|
||||
while True:
|
||||
try:
|
||||
await self.aiohttp_client_session.ws_connect()
|
||||
return
|
||||
except Exception:
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
if self.close_should_raise:
|
||||
# SocketModeClient.close() calls disconnect() before it cancels its
|
||||
# background tasks. A broken session makes disconnect() raise, so
|
||||
# the SDK never reaches those cancel() calls at all.
|
||||
raise RuntimeError("Session is closed")
|
||||
for task in (
|
||||
self.message_processor,
|
||||
self.current_session_monitor,
|
||||
self.message_receiver,
|
||||
):
|
||||
if task is not None:
|
||||
# The SDK requests cancellation but never awaits it.
|
||||
task.cancel()
|
||||
await self.aiohttp_client_session.close()
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
"""Stands in for AsyncSocketModeHandler."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.client = _FakeSocketModeClient()
|
||||
|
||||
async def start_async(self) -> None:
|
||||
await self.client.connect()
|
||||
await asyncio.sleep(float("inf"))
|
||||
|
||||
async def close_async(self) -> None:
|
||||
await self.client.close()
|
||||
|
||||
|
||||
async def _spin() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def adapter():
|
||||
config = PlatformConfig(enabled=True, token="xoxb-fake-token")
|
||||
a = SlackAdapter(config)
|
||||
a._app = MagicMock()
|
||||
a._app_token = "xapp-fake"
|
||||
a._proxy_url = None
|
||||
a._running = True
|
||||
a.handle_message = AsyncMock()
|
||||
return a
|
||||
|
||||
|
||||
def _attach(adapter, handler):
|
||||
"""Wire a handler into the adapter the way _start_socket_mode_handler does."""
|
||||
adapter._handler = handler
|
||||
task = asyncio.create_task(handler.start_async())
|
||||
adapter._socket_mode_task = task
|
||||
return task
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSocketModeTeardown:
|
||||
@pytest.mark.asyncio
|
||||
async def test_socket_task_stops_before_session_is_closed(self, adapter):
|
||||
"""The socket task must be stopped before close_async() kills the session.
|
||||
|
||||
The task is parked in the SDK's connect() retry loop, which is the state
|
||||
#46990 describes. If teardown closes the shared session first, that loop
|
||||
wakes up and retries against a session that is already gone.
|
||||
"""
|
||||
handler = _FakeHandler()
|
||||
task = _attach(adapter, handler)
|
||||
# Let the task settle into the retry loop.
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
await adapter._stop_socket_mode_handler()
|
||||
# Give anything that survived a chance to make itself known.
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
session = handler.client.aiohttp_client_session
|
||||
assert session.ws_connect_after_close == 0, (
|
||||
"the old socket task retried against a closed session "
|
||||
f"{session.ws_connect_after_close} time(s) after close_async()"
|
||||
)
|
||||
assert task.done(), "the old socket task outlived teardown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_tasks_are_dead_before_the_session_closes(self, adapter):
|
||||
"""Nothing may still be inside connect() when the shared session closes.
|
||||
|
||||
monitor_current_session() and receive_messages() each reach
|
||||
connect_to_new_endpoint() on their own, and connect() rebinds
|
||||
current_session_monitor and message_receiver to fresh tasks on success.
|
||||
The live task set therefore changes across the awaits inside
|
||||
SocketModeClient.close(), so cancelling from a snapshot taken partway
|
||||
through races a moving target. Everything has to be stopped before the
|
||||
session is closed. See slackapi/python-slack-sdk#1913.
|
||||
"""
|
||||
handler = _FakeHandler()
|
||||
client = handler.client
|
||||
client.message_processor = asyncio.create_task(_spin())
|
||||
client.current_session_monitor = asyncio.create_task(
|
||||
client.monitor_current_session()
|
||||
)
|
||||
client.message_receiver = asyncio.create_task(client.monitor_current_session())
|
||||
|
||||
_attach(adapter, handler)
|
||||
# Let both reconnect loops settle inside connect().
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
await adapter._stop_socket_mode_handler()
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
session = client.aiohttp_client_session
|
||||
assert session.live_tasks_at_close == [], (
|
||||
"client tasks were still running when the shared session was closed: "
|
||||
f"{session.live_tasks_at_close}"
|
||||
)
|
||||
assert session.ws_connect_after_close == 0, (
|
||||
"a client task retried against a closed session "
|
||||
f"{session.ws_connect_after_close} time(s)"
|
||||
)
|
||||
|
||||
|
||||
class TestSocketModeRestart:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watchdog_restarts_when_transport_disconnected(self, adapter):
|
||||
"""A transport that reports itself down still triggers a reconnect."""
|
||||
live_task = MagicMock()
|
||||
live_task.done.return_value = False
|
||||
adapter._socket_mode_task = live_task
|
||||
adapter._handler = MagicMock()
|
||||
|
||||
reasons: list[str] = []
|
||||
|
||||
async def _fake_restart(reason: str) -> None:
|
||||
reasons.append(reason)
|
||||
adapter._running = False
|
||||
|
||||
adapter._restart_socket_mode = _fake_restart
|
||||
adapter._socket_transport_connected = AsyncMock(return_value=False)
|
||||
adapter._socket_watchdog_interval_s = 0.01
|
||||
|
||||
await adapter._socket_watchdog_loop()
|
||||
|
||||
assert reasons == ["transport disconnected"]
|
||||
Reference in New Issue
Block a user