deer-flow/backend/tests/test_discord_channel.py
Sunshine 9600414002
fix(channels): retain strong reference to Discord ack-reaction tasks (#5049)
* fix(channels): retain strong reference to Discord ack-reaction tasks

The event loop keeps only weak references to scheduled tasks, so the bare
asyncio.create_task(self._add_reaction(message)) at both message-acceptance
sites could be garbage-collected mid-flight and silently drop the 
acknowledgment. The module-level retention set holds each task until
completion and logs failures, mirroring the deferred subagent cleanup fix
in #4928 and the stream bridge cleanup fix in #4931.

* fix(channels): drain in-flight ack reactions on Discord channel shutdown

Adversarial follow-up to the retention fix: stop() cancelled typing tasks
but left in-flight ack-reaction tasks in the module retention set when the
channel stopped mid-HTTP-call, pinning the channel instance and the discord
Message object graph across restart cycles. _cancel_ephemeral_tasks /
_discard_ephemeral_tasks now cover both families at every stop() cleanup
point and the _run_client finally. Also tests the done-callback error-log
branch and documents the GC test as a contract pin.

* test(channels): pin Discord ack-reaction drain wiring and sharpen wording

Second adversarial pass found the drain test cancelled a never-started
task (no yield between schedule and cancel), nothing exercised the real
stop() cross-loop wiring, and the cleanup log messages still said
"typing-task" while now covering both families. The drain test now yields
so the task genuinely suspends first, a new test drives stop() through a
real background loop and fails if the wiring reverts to typing-only
cleanup, wording covers ephemeral tasks, and the discard path snapshots
the set before iterating.

* refactor(channels): scope Discord ack-reaction retention to the channel instance

The retention set was module-level, so one DiscordChannel's stop() would
cancel every other instance's in-flight ack reactions. Instance-level
matches the typing-task bookkeeping and keeps shutdown scoped; pinned by
a two-channel isolation test.

* test(channels): drive two-channel ack isolation through full stop()

Independent review of the instance-scoping refactor confirmed the P2 fix
but suggested matching the review's letter exactly: the isolation test now
shuts the first channel down via stop() instead of the drain helper, and
one stale docstring reference to the former module-level set is corrected.
2026-08-27 17:56:56 +08:00

436 lines
15 KiB
Python

"""Tests for Discord channel integration wiring."""
from __future__ import annotations
import asyncio
import builtins
import gc
import threading
import weakref
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from app.channels.discord import DiscordChannel
from app.channels.manager import CHANNEL_CAPABILITIES
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
from app.channels.service import _CHANNEL_REGISTRY
def test_discord_channel_registered() -> None:
assert "discord" in _CHANNEL_REGISTRY
def test_discord_channel_capabilities() -> None:
assert "discord" in CHANNEL_CAPABILITIES
def test_discord_channel_init() -> None:
bus = MessageBus()
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
assert channel.name == "discord"
def _make_discord_message(text: str):
return SimpleNamespace(
id=111,
content=text,
author=SimpleNamespace(id=123, bot=False, display_name="alice"),
guild=SimpleNamespace(id=321),
channel=SimpleNamespace(id=456),
add_reaction=lambda _emoji: None,
)
@pytest.mark.asyncio
async def test_discord_bot_mention_slash_skill_routes_as_chat() -> None:
bus = MessageBus()
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
channel._running = True
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
channel._main_loop = asyncio.get_running_loop()
async def noop(*_args, **_kwargs):
return None
channel._start_typing = noop
channel._add_reaction = noop
await channel._on_message(_make_discord_message("<@999> /data-analysis analyze uploads/foo.csv"))
await asyncio.sleep(0)
inbound = bus.get_inbound_nowait()
bus.inbound_task_done()
assert inbound.text == "/data-analysis analyze uploads/foo.csv"
assert inbound.msg_type == InboundMessageType.CHAT
assert inbound.topic_id == "456"
@pytest.mark.asyncio
async def test_discord_bot_mention_known_command_routes_as_command() -> None:
bus = MessageBus()
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
channel._running = True
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
channel._main_loop = asyncio.get_running_loop()
async def noop(*_args, **_kwargs):
return None
channel._start_typing = noop
channel._add_reaction = noop
await channel._on_message(_make_discord_message("<@999> /help"))
await asyncio.sleep(0)
inbound = bus.get_inbound_nowait()
bus.inbound_task_done()
assert inbound.text == "/help"
assert inbound.msg_type == InboundMessageType.COMMAND
assert inbound.topic_id == "456"
@pytest.mark.asyncio
async def test_discord_full_queue_rejects_before_thread_or_identity_side_effects() -> None:
bus = MessageBus(inbound_queue_maxsize=1)
await bus.publish_inbound(
InboundMessage(
channel_name="slack",
chat_id="C1",
user_id="U1",
text="already queued",
)
)
channel = DiscordChannel(bus=bus, config={"bot_token": "token", "thread_mode": True})
channel._running = True
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
channel._main_loop = asyncio.get_running_loop()
channel._create_thread = AsyncMock()
channel._attach_connection_identity = AsyncMock()
await channel._on_message(_make_discord_message("hello"))
channel._create_thread.assert_not_awaited()
channel._attach_connection_identity.assert_not_awaited()
assert channel._active_threads == {}
assert bus.inbound_queue.qsize() == 1
@pytest.mark.asyncio
async def test_discord_releases_early_reservation_when_thread_creation_raises() -> None:
bus = MessageBus(inbound_queue_maxsize=1)
channel = DiscordChannel(bus=bus, config={"bot_token": "token", "thread_mode": True})
channel._running = True
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
channel._main_loop = asyncio.get_running_loop()
channel._create_thread = AsyncMock(side_effect=RuntimeError("thread create failed"))
with pytest.raises(RuntimeError, match="thread create failed"):
await channel._on_message(_make_discord_message("hello"))
# The exception path must return the capacity slot to the shared bus.
await bus.publish_inbound(
InboundMessage(
channel_name="slack",
chat_id="C1",
user_id="U1",
text="capacity was released",
)
)
assert bus.inbound_queue.qsize() == 1
# ---------------------------------------------------------------------------
# send_file file-handle lifecycle
# ---------------------------------------------------------------------------
def _start_bg_loop() -> tuple[asyncio.AbstractEventLoop, threading.Thread]:
"""Spin up a real background event loop, mirroring ``DiscordChannel._discord_loop``.
``send_file`` schedules work onto ``_discord_loop`` via
``run_coroutine_threadsafe`` and awaits the result with ``wrap_future``, so a
real running loop is the most faithful way to exercise that path.
"""
loop = asyncio.new_event_loop()
ready = threading.Event()
def _runner() -> None:
loop.call_soon(ready.set)
loop.run_forever()
thread = threading.Thread(target=_runner, daemon=True)
thread.start()
ready.wait()
return loop, thread
def _stop_bg_loop(loop: asyncio.AbstractEventLoop, thread: threading.Thread) -> None:
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=5)
loop.close()
def _build_send_file_channel(bg_loop: asyncio.AbstractEventLoop) -> DiscordChannel:
channel = DiscordChannel(bus=MessageBus(), config={"bot_token": "token"})
channel._discord_loop = bg_loop
channel._discord_module = SimpleNamespace(File=lambda fp, filename=None: fp)
async def _noop(*_args, **_kwargs):
return None
channel._stop_typing = _noop
return channel
def _tracking_open():
"""Wrap ``builtins.open`` to record every handle it returns."""
handles: list = []
real_open = builtins.open
def _open(path, *args, **kwargs):
handle = real_open(path, *args, **kwargs)
handles.append(handle)
return handle
return handles, _open
async def _noop_coro(*_args, **_kwargs):
return None
def _resolve_to(target):
async def _resolve_target(_msg):
return target
return _resolve_target
@pytest.mark.asyncio
async def test_send_file_closes_file_handle(tmp_path) -> None:
"""The file handle opened for upload is closed once send_file returns (success path)."""
bg_loop, bg_thread = _start_bg_loop()
try:
channel = _build_send_file_channel(bg_loop)
target = SimpleNamespace(send=_noop_coro)
channel._resolve_target = _resolve_to(target)
path = tmp_path / "upload.txt"
path.write_bytes(b"hello")
att = ResolvedAttachment("/mnt/user-data/outputs/upload.txt", path, "upload.txt", "text/plain", 5, False)
msg = OutboundMessage(channel_name="discord", chat_id="c1", thread_id="t1", text="t")
handles, tracking_open = _tracking_open()
with patch("builtins.open", tracking_open):
result = await channel.send_file(msg, att)
assert result is True
assert len(handles) == 1
assert handles[0].closed is True
finally:
_stop_bg_loop(bg_loop, bg_thread)
@pytest.mark.asyncio
async def test_send_file_closes_handle_when_send_fails(tmp_path) -> None:
"""The file handle is still closed when target.send raises (failure path)."""
bg_loop, bg_thread = _start_bg_loop()
try:
channel = _build_send_file_channel(bg_loop)
async def _failing_send(*, file=None):
raise RuntimeError("upload failed")
target = SimpleNamespace(send=_failing_send)
channel._resolve_target = _resolve_to(target)
path = tmp_path / "upload.txt"
path.write_bytes(b"hello")
att = ResolvedAttachment("/mnt/user-data/outputs/upload.txt", path, "upload.txt", "text/plain", 5, False)
msg = OutboundMessage(channel_name="discord", chat_id="c1", thread_id="t1", text="t")
handles, tracking_open = _tracking_open()
with patch("builtins.open", tracking_open):
result = await channel.send_file(msg, att)
assert result is False
assert len(handles) == 1
assert handles[0].closed is True
finally:
_stop_bg_loop(bg_loop, bg_thread)
@pytest.mark.asyncio
async def test_ack_reaction_task_retained_under_gc() -> None:
"""The ack-reaction task is retained and runs to completion.
This pins the retention contract (scheduled → in the set, done →
discarded): the fake coroutine never suspends on an unrooted future, so
actual mid-flight GC cannot be reproduced deterministically here — the
same limitation the #4928 precedent test has.
A bare ``asyncio.create_task`` holds only a weak loop reference, so the
✅ acknowledgment could be garbage-collected mid-flight. The instance-level
retention set keeps the task strongly referenced until completion (same
pattern as #4928 / #4931).
"""
bus = MessageBus()
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
reacted = asyncio.Event()
async def _add_reaction(_emoji: str) -> None:
reacted.set()
message = SimpleNamespace(id=111, add_reaction=_add_reaction)
task = channel._schedule_ack_reaction(message)
weak_task = weakref.ref(task)
del task
gc.collect()
retained = weak_task()
assert retained is not None, "ack reaction task was garbage-collected mid-flight"
assert retained in channel._ack_reaction_tasks
await asyncio.wait_for(retained, timeout=1.0)
assert reacted.is_set()
# Completed tasks are discarded so the retention set cannot grow unboundedly.
assert retained not in channel._ack_reaction_tasks
@pytest.mark.asyncio
async def test_ack_reaction_retention_is_isolated_per_channel() -> None:
"""One channel's shutdown must not cancel another instance's in-flight reactions.
The retention set is instance-level (matching ``_typing_tasks``): a
module-level set would let ``stop()`` on one channel drain every other
channel's pending ack tasks.
"""
first = DiscordChannel(bus=MessageBus(), config={"bot_token": "token"})
second = DiscordChannel(bus=MessageBus(), config={"bot_token": "token"})
release = asyncio.Event()
async def _hang(_emoji: str) -> None:
await release.wait()
task = second._schedule_ack_reaction(SimpleNamespace(id=666, add_reaction=_hang))
await asyncio.sleep(0) # let the task start and suspend
assert task in second._ack_reaction_tasks
assert task not in first._ack_reaction_tasks
# Full shutdown of the first channel must leave the second instance's
# in-flight reaction alone (the review asked for independent shutdown).
await first.stop()
assert not task.done()
assert task in second._ack_reaction_tasks
release.set()
await asyncio.wait_for(task, timeout=1.0)
@pytest.mark.asyncio
async def test_ack_reaction_task_survives_reaction_failure() -> None:
"""A failing add_reaction completes quietly and is discarded from the set."""
bus = MessageBus()
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
async def _boom(_emoji: str) -> None:
raise RuntimeError("discord api down")
message = SimpleNamespace(id=222, add_reaction=_boom)
task = channel._schedule_ack_reaction(message)
await asyncio.wait_for(task, timeout=1.0)
assert task not in channel._ack_reaction_tasks
@pytest.mark.asyncio
async def test_stop_drains_in_flight_ack_reaction_tasks() -> None:
"""stop()'s cleanup path cancels in-flight ack reactions on the owning loop.
Without the drain, a task interrupted mid-HTTP-call would sit in the
module retention set forever, pinning the channel and Message graph.
"""
release = asyncio.Event()
async def _hang(_emoji: str) -> None:
await release.wait()
channel = DiscordChannel(bus=MessageBus(), config={"bot_token": "token"})
message = SimpleNamespace(id=333, add_reaction=_hang)
task = channel._schedule_ack_reaction(message)
assert task in channel._ack_reaction_tasks
# Yield once so the task actually starts and suspends inside _hang —
# cancelling a never-started task would not exercise the mid-flight path.
await asyncio.sleep(0)
assert not task.done()
await channel._cancel_ephemeral_tasks()
assert task.cancelled()
assert task not in channel._ack_reaction_tasks
@pytest.mark.asyncio
async def test_ack_reaction_task_failure_is_logged_and_discarded(caplog) -> None:
"""An exception escaping _add_reaction is logged at error and discarded."""
bus = MessageBus()
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
async def _explode(_self, _message) -> None:
raise RuntimeError("unexpected boom")
with patch.object(DiscordChannel, "_add_reaction", _explode), caplog.at_level("ERROR", logger="app.channels.discord"):
task = channel._schedule_ack_reaction(SimpleNamespace(id=444))
with pytest.raises(RuntimeError, match="unexpected boom"):
await asyncio.wait_for(task, timeout=1.0)
assert task not in channel._ack_reaction_tasks
assert any("ack reaction task failed" in record.message for record in caplog.records)
@pytest.mark.asyncio
async def test_stop_wiring_drains_ack_tasks_across_loops() -> None:
"""The real stop() path (cross-loop branch) drains in-flight ack reactions.
Guards the wiring itself: reverting stop() to typing-only cleanup must
fail here, not just at the helper level.
"""
bg_loop, bg_thread = _start_bg_loop()
try:
channel = DiscordChannel(bus=MessageBus(), config={"bot_token": "token"})
channel._discord_loop = bg_loop
release = asyncio.Event()
async def _hang(_emoji: str) -> None:
await release.wait()
async def _schedule_on_bg_loop() -> asyncio.Task:
task = channel._schedule_ack_reaction(SimpleNamespace(id=555, add_reaction=_hang))
await asyncio.sleep(0.05) # let the task start and suspend on the event
return task
schedule_future = asyncio.run_coroutine_threadsafe(_schedule_on_bg_loop(), bg_loop)
task = await asyncio.wrap_future(schedule_future)
assert task in channel._ack_reaction_tasks
await channel.stop()
assert task.cancelled()
assert not channel._ack_reaction_tasks
finally:
_stop_bg_loop(bg_loop, bg_thread)