deer-flow/backend/tests/blocking_io/test_channel_stop_offloop.py
Totoro c5bbaea02f
fix(channels): clean up interrupted Telegram initialization (#6810)
* fix(channels): clean up interrupted Telegram initialization

* fix(channels): preserve Telegram shutdown failures and finalize generators

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-14 06:20:33 +08:00

138 lines
4.7 KiB
Python

"""Regression anchor: channel ``stop()`` must not block the event loop on SDK teardown.
``stop()`` runs on the Gateway event loop, both at shutdown and from
``POST /api/channels/{name}/restart``, so a blocking teardown call there stalls
every run, stream and channel the Gateway serves:
- Slack: ``SocketModeClient.close()`` joins the SDK's message-processor thread
and waits for in-flight listeners.
- Feishu / DingTalk: ``stop()`` asks the SDK thread's private event loop to
shut down, then joins that thread with a 5s timeout.
- Discord: ``stop()`` joins the client thread with a 10s timeout, which a
timed-out client close or a slow ``_run_client()`` drain can use up.
Each test drives the real ``stop()`` (and, for Slack, a real slack-sdk client).
If a join or close regresses back onto the event loop, the strict Blockbuster
gate raises ``BlockingError`` from the thread-join lock wait.
"""
from __future__ import annotations
import asyncio
import threading
import pytest
from app.channels.base import ChannelStopTimeout
from app.channels.dingtalk import DingTalkChannel
from app.channels.discord import DiscordChannel
from app.channels.feishu import FeishuChannel
from app.channels.message_bus import MessageBus
from app.channels.slack import SlackChannel
from app.channels.telegram import TelegramChannel
pytestmark = pytest.mark.asyncio
async def test_slack_stop_closes_real_socket_mode_client_off_loop() -> None:
from slack_sdk.socket_mode import SocketModeClient
# Never connected: construction alone starts the SDK's worker threads,
# which is what close() has to join.
socket_client = SocketModeClient(app_token="xapp-test")
channel = SlackChannel(bus=MessageBus(), config={})
channel._socket_client = socket_client
channel._running = True
await channel.stop()
assert socket_client.closed is True
assert channel._socket_client is None
async def test_slack_stop_keeps_connect_cleanup_off_loop(monkeypatch) -> None:
started, release = threading.Event(), threading.Event()
loop_thread = threading.get_ident()
close_threads = []
class SocketClient:
def connect(self):
started.set()
release.wait(5)
def close(self):
close_threads.append(threading.get_ident())
monkeypatch.setattr("app.channels.slack.SLACK_STOP_TIMEOUT_SECONDS", 0.02)
channel = SlackChannel(MessageBus(), {})
client = SocketClient()
channel._socket_client = client
channel._running = True
future = asyncio.get_running_loop().run_in_executor(None, channel._connect_socket_mode, client)
channel._socket_connect_future = future
try:
assert await asyncio.to_thread(started.wait, 2)
with pytest.raises(ChannelStopTimeout):
await channel.stop()
assert not future.cancelled()
assert close_threads == []
release.set()
await asyncio.wait_for(asyncio.shield(channel._socket_close_task), 2)
await channel.stop()
assert len(close_threads) == 1
assert close_threads[0] != loop_thread
assert channel._socket_client is None
finally:
release.set()
await asyncio.wait_for(asyncio.shield(future), 5)
monkeypatch.setattr("app.channels.slack.SLACK_STOP_TIMEOUT_SECONDS", 3)
await channel.stop()
@pytest.mark.parametrize("channel_cls", [FeishuChannel, DingTalkChannel, DiscordChannel, TelegramChannel])
async def test_stop_joins_sdk_thread_off_loop(channel_cls) -> None:
release = threading.Event()
sdk_thread = threading.Thread(target=release.wait, daemon=True)
sdk_thread.start()
channel = channel_cls(MessageBus(), {})
channel._thread = sdk_thread
channel._running = True
release.set()
await channel.stop()
assert not sdk_thread.is_alive()
assert channel._thread is None
@pytest.mark.parametrize(
("channel_cls", "loop_attr"),
[(FeishuChannel, "_ws_loop"), (DingTalkChannel, "_stream_loop")],
)
async def test_stop_ends_live_sdk_loop_off_loop(channel_cls, loop_attr) -> None:
sdk_loop = asyncio.new_event_loop()
ready = threading.Event()
def run_sdk_loop() -> None:
asyncio.set_event_loop(sdk_loop)
sdk_loop.call_soon(ready.set)
try:
sdk_loop.run_forever()
finally:
sdk_loop.close()
sdk_thread = threading.Thread(target=run_sdk_loop, daemon=True)
sdk_thread.start()
assert await asyncio.to_thread(ready.wait, 5)
channel = channel_cls(MessageBus(), {})
channel._thread = sdk_thread
channel._running = True
setattr(channel, loop_attr, sdk_loop)
await channel.stop()
assert not sdk_thread.is_alive()
assert sdk_loop.is_closed()
assert channel._thread is None
assert getattr(channel, loop_attr) is None