deer-flow/backend/tests/test_channel_intake_backpressure.py
Nan Gao 6c4c74a504
fix(channels): share chat-to-thread bindings through the database (#6558)
* fix(channels): share chat-to-thread bindings through the database

ChannelManager kept its IM chat-to-thread bindings
(channel_name:chat_id[:topic_id] -> thread_id) in a per-process JSON file,
channels/store.json, loaded once and rewritten whole on every change. With
several Gateway processes a binding created on one replica was invisible to
the others (the next message for the same chat opened a second thread) and
concurrent writers clobbered each other's file.

- New channel_thread_bindings table (migration 0036_channel_thread_bindings)
  and harness package deerflow.persistence.channel_thread_bindings: key PK
  (the legacy composite string), channel_name, chat_id, nullable topic_id,
  thread_id, user_id, epoch created_at/updated_at; index (channel_name,
  chat_id). Dialect upsert keeps created_at; prefix remove and batched
  ON CONFLICT DO NOTHING import.
- app.channels.store.ChannelStore is an async protocol: SqlChannelStore for
  sqlite/postgres, JsonChannelStore (file I/O via asyncio.to_thread) for
  database.backend: memory. resolve_channel_store picks at ChannelService
  construction; ChannelService.start() imports an existing store.json once
  into an empty table and renames it store.json.migrated (a concurrently
  importing peer neither duplicates nor clobbers; a populated table leaves
  the file alone).
- Callers updated: manager, connection_identity.lookup_thread_id, Feishu
  (send() awaits the mapping writer; the synchronous lark callback bridges
  its two store calls to the Gateway loop with a bounded wait), Discord
  (channels_dir).
- Head pins in the 0027_0028/0029/0032/0034/0035 migration tests point at
  the new head; docs (channels and migrations AGENTS.md, CONFIGURATION.md,
  README) and CHANGELOG updated.

* docs(changelog): link the channel thread bindings entry to #6558

* fix(channels): reject only the Gateway loop in the Feishu store bridge

lark-oapi 1.5.5 invokes the message callback synchronously from its async
frame handler, on the private event loop that FeishuChannel._run_ws starts
for the SDK thread. The bridge's guard treated any running loop as unsafe,
so every real inbound message skipped the stored-topic lookup and the
pending-mapping restore: a reply whose parent card was mapped but whose
root was not fell back to the unknown root, and could consume an
unrelated pending clarification.

Only a wait issued from the Gateway loop itself (the loop that executes
the submitted coroutine) is refused now; the SDK's own loop may block for
the bounded wait, as that thread blocked on the JSON rewrite before.
Tests drive _on_message from a worker thread running its own loop, the
production shape, for both the lookup and the write path, and pin the
Gateway-loop refusal.

* fix(channels): defer unresolved Feishu lookups and harden the store.json import

Review follow-ups on the chat-to-thread binding store (#6558):

- Import: a legacy entry whose key, channel_name, chat_id, topic_id,
  thread_id or user_id exceeds its column width is skipped and counted
  with the malformed ones instead of failing the whole batch on
  PostgreSQL (SQLite silently accepted the same row, so the backends
  diverged on one file). The corrupt-file warning now says what each
  caller does: the JSON store starts fresh, the import is skipped and
  the file kept.
- Feishu bridge: store calls run on the Gateway loop under a 3s
  asyncio.wait_for (the SDK loop also services ws keepalives; the old
  10s bound could stall it per message), and the lark-thread wait is
  that budget plus 1s. A deadline or store error is StoreLookupUnresolved,
  never a miss: _on_message no longer routes such a reply onto its
  unknown root (which created a second thread and later overwrote the
  parent alias). The message joins a per-chat FIFO drained on the
  Gateway loop with backoff for up to 30s, then is dropped with a
  warning. _on_message is split into parse / resolve / dispatch so the
  deferred path dispatches the same way; later messages for the chat
  queue behind the deferred one.

* fix(tests): expect follow_redirects in the Jina client kwargs after #5808

#5808 made JinaClient pass follow_redirects=True to httpx.AsyncClient and
updated test_jina_client.py, but test_jina_response_limit.py and
test_jina_retry_after.py still pin the exact client kwargs to proxy and
trust_env, so every backend-unit-tests shard that lands on them fails on
main (the backend unit-test workflow only runs on pull requests, which is
why the push to main did not surface it). Update the two expectations to
include the new keyword.

(cherry picked from commit d5aa23156ce19ce36d95df03953442f83ae6d050)

* Revert "fix(tests): expect follow_redirects in the Jina client kwargs after #5808"

This reverts commit 5bee00bfb09c65a30f77fb4df47a1f82c47ecef7.

* fix(channels): bound deferred Feishu routing by the inbound queue capacity

Review follow-up on #6558: a store outage let every subsequent message for
a chat join the deferred queue with no capacity check (the bus reservation
was only taken after routing succeeded), and each queued message started a
fresh 30s retry budget when it reached the head, so a burst could be
retained for N x 30s and grow without bound.

- Intake is reserved BEFORE routing for every store-backed message
  (bus.reserve_inbound is thread-safe and already used from SDK threads),
  so deferred and live messages share the one configured bound,
  channels.inbound_queue_maxsize; a message that cannot be admitted is
  dropped at once with a warning, never queued or routed as a miss.
- The reservation travels with the message into dispatch (committed by
  _prepare_inbound), is released on the batch path (the flush reserves at
  publish time), on drop after the budget, on a failed deferral, on
  dispatch failure and for every leftover when the drain task is cancelled
  by stop().
- The retry budget runs from the message's arrival: a queued message whose
  budget is spent when it reaches the head is dropped without a lookup.
- The bounded resolver returns the unresolved outcome instead of raising
  it through the tracked task, which logged an ERROR for an expected,
  handled store timeout.
- Upstream's new blocking-io test constructs the store by its old class
  name; it now uses JsonChannelStore.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 14:00:00 +08:00

622 lines
21 KiB
Python

"""Regression tests for bounded IM-channel intake and handler ownership."""
from __future__ import annotations
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from app.channels import service as service_module
from app.channels.manager import ChannelManager
from app.channels.message_bus import (
InboundMessage,
InboundQueueClosedError,
InboundQueueFullError,
InboundReservationExpiredError,
MessageBus,
)
from app.channels.service import ChannelService
from app.channels.slack import SlackChannel
from app.channels.store import JsonChannelStore
def _message(index: int, *, with_dedupe_identity: bool = False) -> InboundMessage:
metadata = {"team_id": "T1", "message_id": f"m-{index}"} if with_dedupe_identity else {}
return InboundMessage(
channel_name="slack",
chat_id="C1",
user_id="U1",
text=f"message-{index}",
metadata=metadata,
)
@pytest.mark.asyncio
async def test_inbound_queue_rejects_immediately_when_capacity_is_reserved() -> None:
bus = MessageBus(inbound_queue_maxsize=1)
reservation = bus.reserve_inbound(_message(1))
with pytest.raises(InboundQueueFullError):
bus.reserve_inbound(_message(2))
reservation.commit(_message(1))
assert bus.inbound_queue.qsize() == 1
assert bus.get_inbound_nowait().text == "message-1"
bus.inbound_task_done()
await bus.publish_inbound(_message(2))
assert (await bus.get_inbound()).text == "message-2"
bus.inbound_task_done()
await bus.join_inbound()
def test_shutdown_invalidates_provider_side_reservations() -> None:
bus = MessageBus(inbound_queue_maxsize=2)
direct_reservation = bus.reserve_inbound(_message(1))
adapter_reservation = bus.reserve_inbound(_message(2))
channel = SlackChannel(bus=bus, config={})
assert bus.close_inbound() == 2
with pytest.raises(InboundReservationExpiredError):
direct_reservation.commit(_message(1))
direct_reservation.release()
assert channel._commit_reserved_inbound(adapter_reservation, _message(2)) is False
assert bus.inbound_queue.empty()
def test_provider_thread_reservations_share_one_hard_capacity_limit() -> None:
capacity = 8
contenders = 32
bus = MessageBus(inbound_queue_maxsize=capacity)
barrier = threading.Barrier(contenders)
def reserve(index: int):
barrier.wait()
try:
return bus.reserve_inbound(_message(index))
except InboundQueueFullError:
return None
with ThreadPoolExecutor(max_workers=contenders) as executor:
reservations = list(executor.map(reserve, range(contenders)))
admitted = [reservation for reservation in reservations if reservation is not None]
assert len(admitted) == capacity
for reservation in admitted:
reservation.release()
@pytest.mark.asyncio
async def test_realtime_provider_drops_before_ack_when_queue_is_full() -> None:
bus = MessageBus(inbound_queue_maxsize=1)
await bus.publish_inbound(_message(0))
channel = SlackChannel(bus=bus, config={})
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
channel._add_reaction = MagicMock()
channel._send_running_reply = MagicMock()
channel._handle_message_event(
{
"user": "U1",
"text": "overloaded",
"channel": "C1",
"ts": "1710000000.000100",
}
)
channel._add_reaction.assert_not_called()
channel._send_running_reply.assert_not_called()
channel._loop.call_soon_threadsafe.assert_not_called()
assert bus.inbound_queue.qsize() == 1
@pytest.mark.asyncio
async def test_fixed_worker_pool_bounds_handler_and_queue_tasks(tmp_path: Path) -> None:
bus = MessageBus(inbound_queue_maxsize=3)
manager = ChannelManager(
bus=bus,
store=JsonChannelStore(path=tmp_path / "store.json"),
max_concurrency=2,
)
release_handlers = asyncio.Event()
started: list[str] = []
async def hold_handler(msg: InboundMessage) -> None:
started.append(msg.text)
await release_handlers.wait()
manager._handle_message = hold_handler # type: ignore[method-assign]
await manager.start()
try:
assert len(manager._worker_tasks) == 2
await bus.publish_inbound(_message(0))
await bus.publish_inbound(_message(1))
async with asyncio.timeout(1):
while len(started) < 2:
await asyncio.sleep(0)
for index in range(2, 5):
await bus.publish_inbound(_message(index))
with pytest.raises(InboundQueueFullError):
await bus.publish_inbound(_message(5))
assert bus.inbound_queue.qsize() == 3
assert len(manager._worker_tasks) == 2
# The handlers execute inline in the fixed workers. There must not be a
# separate task per admitted message waiting on a semaphore.
assert not any(getattr(task.get_coro(), "__name__", "") == "hold_handler" for task in asyncio.all_tasks())
release_handlers.set()
await asyncio.wait_for(bus.join_inbound(), timeout=1)
assert sorted(started) == [f"message-{index}" for index in range(5)]
finally:
release_handlers.set()
await manager.stop()
@pytest.mark.asyncio
async def test_stop_gracefully_drains_every_accepted_message_before_cancelling_workers(tmp_path: Path) -> None:
bus = MessageBus(inbound_queue_maxsize=2)
manager = ChannelManager(
bus=bus,
store=JsonChannelStore(path=tmp_path / "store.json"),
max_concurrency=1,
shutdown_grace_period_seconds=0.5,
)
first_started = asyncio.Event()
release_first = asyncio.Event()
completed: list[str] = []
cancelled = False
async def graceful_handler(msg: InboundMessage) -> None:
nonlocal cancelled
if msg.text == "message-1":
first_started.set()
try:
await release_first.wait()
except asyncio.CancelledError:
cancelled = True
raise
completed.append(msg.text)
manager._handle_message = graceful_handler # type: ignore[method-assign]
await manager.start()
await bus.publish_inbound(_message(1))
await asyncio.wait_for(first_started.wait(), timeout=1)
await bus.publish_inbound(_message(2))
stop_task = asyncio.create_task(manager.stop())
await asyncio.sleep(0.02)
assert not stop_task.done()
assert cancelled is False
release_first.set()
await asyncio.wait_for(stop_task, timeout=1)
assert completed == ["message-1", "message-2"]
assert cancelled is False
assert bus.inbound_queue.empty()
await asyncio.wait_for(bus.join_inbound(), timeout=1)
@pytest.mark.asyncio
async def test_stop_cancels_after_grace_drops_queue_and_releases_dedupe(tmp_path: Path) -> None:
bus = MessageBus(inbound_queue_maxsize=2)
manager = ChannelManager(
bus=bus,
store=JsonChannelStore(path=tmp_path / "store.json"),
max_concurrency=1,
shutdown_grace_period_seconds=0,
)
handler_started = asyncio.Event()
handler_cancelled = asyncio.Event()
async def blocked_handler(_msg: InboundMessage) -> None:
handler_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
handler_cancelled.set()
raise
manager._handle_message = blocked_handler # type: ignore[method-assign]
await manager.start()
active = _message(1, with_dedupe_identity=True)
await bus.publish_inbound(active)
await asyncio.wait_for(handler_started.wait(), timeout=1)
await bus.publish_inbound(_message(2))
await bus.publish_inbound(_message(3))
workers = tuple(manager._worker_tasks)
await manager.stop()
assert handler_cancelled.is_set()
assert manager._worker_tasks == set()
assert all(task.done() for task in workers)
assert bus.inbound_queue.empty()
await asyncio.wait_for(bus.join_inbound(), timeout=1)
with pytest.raises(InboundQueueClosedError):
await bus.publish_inbound(_message(4))
dedupe_key = manager._inbound_dedupe_key(active)
assert dedupe_key is not None
# Cancellation must make the delivery retryable instead of black-holing it
# in the dedupe store until TTL expiry.
assert await manager._inbound_dedupe_store.try_record(dedupe_key) is False
@pytest.mark.asyncio
async def test_successful_stop_waits_for_cancel_resistant_workers_and_watchers(tmp_path: Path) -> None:
bus = MessageBus(inbound_queue_maxsize=1)
manager = ChannelManager(
bus=bus,
store=JsonChannelStore(path=tmp_path / "store.json"),
max_concurrency=1,
shutdown_grace_period_seconds=0.01,
)
handler_started = asyncio.Event()
handler_cancelled = asyncio.Event()
watcher_started = asyncio.Event()
watcher_cancelled = asyncio.Event()
release_after_cancel = asyncio.Event()
async def cancellation_resistant_handler(_msg: InboundMessage) -> None:
handler_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
handler_cancelled.set()
await release_after_cancel.wait()
async def cancellation_resistant_watcher() -> None:
watcher_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
watcher_cancelled.set()
await release_after_cancel.wait()
manager._handle_message = cancellation_resistant_handler # type: ignore[method-assign]
await manager.start()
watcher = asyncio.create_task(cancellation_resistant_watcher())
manager._followup_watcher_tasks.add(watcher)
watcher.add_done_callback(manager._followup_watcher_tasks.discard)
await bus.publish_inbound(_message(1))
await asyncio.wait_for(handler_started.wait(), timeout=1)
await asyncio.wait_for(watcher_started.wait(), timeout=1)
workers = tuple(manager._worker_tasks)
stop_task = asyncio.create_task(manager.stop())
await asyncio.wait_for(handler_cancelled.wait(), timeout=1)
await asyncio.wait_for(watcher_cancelled.wait(), timeout=1)
await asyncio.sleep(0.02)
assert not stop_task.done()
assert any(not worker.done() for worker in workers)
assert not watcher.done()
release_after_cancel.set()
await asyncio.wait_for(stop_task, timeout=1)
assert manager._worker_tasks == set()
assert manager._followup_watcher_tasks == set()
assert all(worker.done() for worker in workers)
assert watcher.done()
await asyncio.wait_for(bus.join_inbound(), timeout=1)
@pytest.mark.asyncio
async def test_outer_shutdown_cancellation_does_not_start_an_unbounded_second_join(tmp_path: Path) -> None:
bus = MessageBus(inbound_queue_maxsize=1)
manager = ChannelManager(
bus=bus,
store=JsonChannelStore(path=tmp_path / "store.json"),
max_concurrency=1,
shutdown_grace_period_seconds=60,
)
handler_started = asyncio.Event()
handler_cancelled = asyncio.Event()
release_after_cancel = asyncio.Event()
async def cancellation_resistant_handler(_msg: InboundMessage) -> None:
handler_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
handler_cancelled.set()
await release_after_cancel.wait()
manager._handle_message = cancellation_resistant_handler # type: ignore[method-assign]
await manager.start()
await bus.publish_inbound(_message(1))
await asyncio.wait_for(handler_started.wait(), timeout=1)
workers = tuple(manager._worker_tasks)
stop_task = asyncio.create_task(manager.stop())
await asyncio.sleep(0)
started_at = asyncio.get_running_loop().time()
stop_task.cancel()
with pytest.raises(asyncio.CancelledError):
await stop_task
assert asyncio.get_running_loop().time() - started_at < 0.2
assert handler_cancelled.is_set()
assert any(not worker.done() for worker in workers)
assert manager._worker_tasks
release_after_cancel.set()
await asyncio.wait_for(manager.stop(), timeout=1)
assert manager._worker_tasks == set()
assert all(worker.done() for worker in workers)
await asyncio.wait_for(bus.join_inbound(), timeout=1)
@pytest.mark.asyncio
async def test_provider_stop_drains_cross_thread_preparation_futures() -> None:
from app.channels.dingtalk import DingTalkChannel
from app.channels.feishu import FeishuChannel
from app.channels.telegram import TelegramChannel
loop = asyncio.get_running_loop()
providers = (
FeishuChannel(MessageBus(), config={}),
DingTalkChannel(MessageBus(), config={}),
TelegramChannel(MessageBus(), config={}),
SlackChannel(MessageBus(), config={}),
)
for channel in providers:
started = asyncio.Event()
cancelled = asyncio.Event()
release_after_cancel = asyncio.Event()
finished = asyncio.Event()
async def preparation() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelled.set()
await release_after_cancel.wait()
finally:
finished.set()
channel._running = True
channel._main_loop = loop
channel._open_threadsafe_future_intake()
scheduled = await asyncio.to_thread(
channel._submit_threadsafe_coroutine,
preparation(),
loop,
name="test_preparation",
msg_id="message-1",
)
assert scheduled is True
await asyncio.wait_for(started.wait(), timeout=1)
stop_task = asyncio.create_task(channel.stop())
await asyncio.wait_for(cancelled.wait(), timeout=1)
await asyncio.sleep(0)
assert not stop_task.done()
assert not finished.is_set()
release_after_cancel.set()
await asyncio.wait_for(stop_task, timeout=1)
assert finished.is_set()
assert channel._threadsafe_submissions == set()
@pytest.mark.asyncio
async def test_shutdown_closes_cross_thread_submission_before_task_start() -> None:
channel = SlackChannel(MessageBus(), config={})
channel._open_threadsafe_future_intake()
coroutine_started = False
async def preparation() -> None:
nonlocal coroutine_started
coroutine_started = True
scheduled = channel._submit_threadsafe_coroutine(
preparation(),
asyncio.get_running_loop(),
name="test_preparation",
msg_id="message-1",
)
assert scheduled is True
await asyncio.wait_for(channel._close_and_drain_threadsafe_futures(), timeout=1)
assert coroutine_started is False
assert channel._threadsafe_submissions == set()
@pytest.mark.asyncio
async def test_cancelled_cross_thread_drain_remains_retryable() -> None:
channel = SlackChannel(MessageBus(), config={})
channel._open_threadsafe_future_intake()
started = asyncio.Event()
cancellation_seen = asyncio.Event()
release_after_cancel = asyncio.Event()
finished = asyncio.Event()
async def preparation() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancellation_seen.set()
await release_after_cancel.wait()
finally:
finished.set()
assert channel._submit_threadsafe_coroutine(
preparation(),
asyncio.get_running_loop(),
name="test_preparation",
msg_id="message-1",
)
await asyncio.wait_for(started.wait(), timeout=1)
submission = next(iter(channel._threadsafe_submissions))
first_drain = asyncio.create_task(channel._close_and_drain_threadsafe_futures())
await asyncio.wait_for(cancellation_seen.wait(), timeout=1)
first_drain.cancel()
with pytest.raises(asyncio.CancelledError):
await first_drain
assert not finished.is_set()
assert channel._threadsafe_submissions
assert not submission.completion.done()
second_drain = asyncio.create_task(channel._close_and_drain_threadsafe_futures())
await asyncio.sleep(0)
assert not second_drain.done()
release_after_cancel.set()
await asyncio.wait_for(second_drain, timeout=1)
assert finished.is_set()
assert channel._threadsafe_submissions == set()
def test_channel_service_threads_intake_limits_into_bus_and_worker_pool() -> None:
service = ChannelService(
channels_config={
"inbound_queue_maxsize": 17,
"max_concurrency": 3,
"shutdown_grace_period_seconds": 2.5,
}
)
assert service.bus.inbound_queue_maxsize == 17
assert service.manager._max_concurrency == 3
assert service.manager._shutdown_grace_period_seconds == 2.5
assert "inbound_queue_maxsize" not in service._config
assert "max_concurrency" not in service._config
assert "shutdown_grace_period_seconds" not in service._config
@pytest.mark.parametrize(
"invalid_value",
[True, -1, float("nan"), float("inf"), "3"],
)
def test_invalid_shutdown_grace_period_uses_finite_default(invalid_value: object) -> None:
service = ChannelService(channels_config={"shutdown_grace_period_seconds": invalid_value})
assert service.manager._shutdown_grace_period_seconds == 3.0
@pytest.mark.asyncio
async def test_cancelled_service_stop_preserves_unfinished_channel_for_retry() -> None:
service = ChannelService(channels_config={"inbound_queue_maxsize": 1, "max_concurrency": 1})
await service.start()
channel_stop_started = asyncio.Event()
release_channel_stop = asyncio.Event()
class SlowChannel:
async def stop(self) -> None:
channel_stop_started.set()
await release_channel_stop.wait()
slow_channel = SlowChannel()
service._channels["slow"] = slow_channel # type: ignore[assignment]
workers = tuple(service.manager._worker_tasks)
stop_task = asyncio.create_task(service.stop())
await asyncio.wait_for(channel_stop_started.wait(), timeout=1)
stop_task.cancel()
with pytest.raises(asyncio.CancelledError):
await stop_task
assert service.manager._worker_tasks == set()
assert all(worker.done() for worker in workers)
assert service._channels == {"slow": slow_channel}
with pytest.raises(InboundQueueClosedError):
await service.bus.publish_inbound(_message(9))
release_channel_stop.set()
await asyncio.wait_for(service.stop(), timeout=1)
assert service._channels == {}
@pytest.mark.asyncio
async def test_cancelled_manager_drain_preserves_service_channels() -> None:
service = ChannelService(channels_config={})
manager_stop_started = asyncio.Event()
channel_stop_calls = 0
async def blocked_manager_stop() -> None:
manager_stop_started.set()
await asyncio.Event().wait()
class Transport:
async def stop(self) -> None:
nonlocal channel_stop_calls
channel_stop_calls += 1
transport = Transport()
service.manager.stop = blocked_manager_stop # type: ignore[method-assign]
service._channels["transport"] = transport # type: ignore[assignment]
stop_task = asyncio.create_task(service.stop())
await asyncio.wait_for(manager_stop_started.wait(), timeout=1)
stop_task.cancel()
with pytest.raises(asyncio.CancelledError):
await stop_task
assert service._channels == {"transport": transport}
assert channel_stop_calls == 0
@pytest.mark.asyncio
async def test_failed_channel_stop_preserves_service_ownership() -> None:
service = ChannelService(channels_config={})
class BrokenTransport:
async def stop(self) -> None:
raise RuntimeError("provider cleanup failed")
transport = BrokenTransport()
service._channels["transport"] = transport # type: ignore[assignment]
with pytest.raises(ExceptionGroup, match="one or more channels failed to stop"):
await service.stop()
assert service._channels == {"transport": transport}
@pytest.mark.asyncio
async def test_global_service_is_retained_until_shutdown_succeeds() -> None:
service = ChannelService(channels_config={})
stop_started = asyncio.Event()
release_stop = asyncio.Event()
async def blocked_stop() -> None:
stop_started.set()
await release_stop.wait()
service.stop = blocked_stop # type: ignore[method-assign]
service_module._channel_service = service
stop_task = asyncio.create_task(service_module.stop_channel_service())
await asyncio.wait_for(stop_started.wait(), timeout=1)
assert service_module.get_channel_service() is service
stop_task.cancel()
with pytest.raises(asyncio.CancelledError):
await stop_task
assert service_module.get_channel_service() is service
release_stop.set()
await service_module.stop_channel_service()
assert service_module.get_channel_service() is None