﻿id	summary	reporter	owner	description	type	status	component	version	severity	resolution	keywords	cc	stage	has_patch	needs_docs	needs_tests	needs_better_patch	easy	ui_ux
35174	asend() crashes when all receivers are asynchronous functions.	Vašek Dohnal	Vašek Dohnal	"== About this issue

Django 5 added support for asynchronous signals using the `asend` and `asend_robust` methods ([https://docs.djangoproject.com/en/5.0/releases/5.0/#signals]). If a signal is created with **only asynchronous receivers**, the `asend` (or `asend_robust`) function call will crash on this error:


{{{
Traceback (most recent call last):
  File ""C:\Users\***-py3.11\Lib\site-packages\asgiref\sync.py"", line 534, in thread_handler
    raise exc_info[1]
  File ""C:\Users\***-py3.11\Lib\site-packages\django\core\handlers\exception.py"", line 42, in inner
    response = await get_response(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ""C:\Users\***-py3.11\Lib\site-packages\asgiref\sync.py"", line 534, in thread_handler
    raise exc_info[1]
  File ""C:\Users\***-py3.11\Lib\site-packages\django\core\handlers\base.py"", line 253, in _get_response_async
    response = await wrapped_callback(
               ^^^^^^^^^^^^^^^^^^^^^^^
  File ""C:\work\contrib\django-asgi-lifespan\signals.py"", line 47, in root
    await my_signal.asend_robust(sender=None)
  File ""C:\Users\***-py3.11\Lib\site-packages\django\dispatch\dispatcher.py"", line 393, in asend_robust
    responses, async_responses = await asyncio.gather(
                                       ^^^^^^^^^^^^^^^
  File ""C:\Users\***\AppData\Local\Programs\Python\Python311\Lib\asyncio\tasks.py"", line 826, in gather
    if arg not in arg_to_fut:
       ^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'list'
}}}


== Sample project demonstrating the error

Create file `signals.py` with this content:

{{{#!python
import asyncio
import logging

from django import conf, http, urls
from django.core.handlers.asgi import ASGIHandler
from django.dispatch import Signal, receiver

logging.basicConfig(level=logging.DEBUG)
conf.settings.configure(
    ALLOWED_HOSTS=""*"",
    ROOT_URLCONF=__name__,
    LOGGING=None,
)

app = ASGIHandler()
my_signal = Signal()


@receiver(my_signal)
async def my_async_receiver(sender, **kwargs):
    logging.info(""my_async_receiver::started"")
    await asyncio.sleep(1)
    logging.info(""my_async_receiver::finished"")


async def root(request):
    logging.info(""root::started"")
    await my_signal.asend_robust(sender=None)
    logging.info(""root::ended"")
    return http.JsonResponse({""message"": ""Hello World""})


urlpatterns = [urls.path("""", root)]
}}}


Run this file:


{{{
uvicorn signals:app --log-level=DEBUG --reload
}}}

Execute the view:

{{{
curl -v http://127.0.0.1:8000
}}}

See the error.


If we modify the above code and add at least one synchronous receiver, everything will work fine.

{{{#!python
import asyncio
import logging
import time

from django import conf, http, urls
from django.core.handlers.asgi import ASGIHandler
from django.dispatch import Signal, receiver

logging.basicConfig(level=logging.DEBUG)
conf.settings.configure(
    ALLOWED_HOSTS=""*"",
    ROOT_URLCONF=__name__,
    LOGGING=None,
)

app = ASGIHandler()
my_signal = Signal()


@receiver(my_signal)
async def my_async_receiver(sender, **kwargs):
    logging.info(""my_async_receiver::started"")
    await asyncio.sleep(1)
    logging.info(""my_async_receiver::finished"")


@receiver(my_signal)
def my_standard_receiver(sender, **kwargs):
    logging.info(""my_standard_receiver::started"")
    time.sleep(1)
    logging.info(""my_standard_receiver::finished"")


async def root(request):
    logging.info(""root::started"")
    await my_signal.asend_robust(sender=None)
    logging.info(""root::ended"")
    return http.JsonResponse({""message"": ""Hello World""})


urlpatterns = [urls.path("""", root)]
}}}

Output of uvicorn in this case:

{{{
INFO:     Started server process [80144]
INFO:     Waiting for application startup.
INFO:     ASGI 'lifespan' protocol appears unsupported.
INFO:     Application startup complete.
INFO:root:root::started
INFO:root:my_async_receiver::started
INFO:root:my_standard_receiver::started
INFO:root:my_standard_receiver::finished
INFO:root:my_async_receiver::finished
INFO:root:root::ended
INFO:     127.0.0.1:60295 - ""GET / HTTP/1.1"" 200 OK
}}}

== Proposed solution

In my opinion, the error is located here: [https://github.com/django/django/blob/main/django/dispatch/dispatcher.py#L205-L259]. The method groups receivers into synchronous and asynchronous (which is documented behavior). If there are no synchronous receivers, the type is assigned to the variable: `sync_send = list`, and I think this is where the problem lies. 

Here is my proposed fix to the method mentioned. If there are no synchronous receivers, an empty asynchronous function is used as placeholder. With this change, everything works as it should.

{{{#!python
    async def asend(self, sender, **named):
        """"""
        Send signal from sender to all connected receivers in async mode.

        All sync receivers will be wrapped by sync_to_async()
        If any receiver raises an error, the error propagates back through
        send, terminating the dispatch loop. So it's possible that all
        receivers won't be called if an error is raised.

        If any receivers are synchronous, they are grouped and called behind a
        sync_to_async() adaption before executing any asynchronous receivers.

        If any receivers are asynchronous, they are grouped and executed
        concurrently with asyncio.gather().

        Arguments:

            sender
                The sender of the signal. Either a specific object or None.

            named
                Named arguments which will be passed to receivers.

        Return a list of tuple pairs [(receiver, response), ...].
        """"""
        if (
            not self.receivers
            or self.sender_receivers_cache.get(sender) is NO_RECEIVERS
        ):
            return []
        sync_receivers, async_receivers = self._live_receivers(sender)
        if sync_receivers:

            @sync_to_async
            def sync_send():
                responses = []
                for receiver in sync_receivers:
                    response = receiver(signal=self, sender=sender, **named)
                    responses.append((receiver, response))
                return responses

        else:
            async def sync_send():
                return []

        responses, async_responses = await asyncio.gather(
            sync_send(),
            asyncio.gather(
                *(
                    receiver(signal=self, sender=sender, **named)
                    for receiver in async_receivers
                )
            ),
        )
        responses.extend(zip(async_receivers, async_responses))
        return responses
}}}"	Bug	closed	Core (Other)	5.0	Release blocker	fixed		Jon Janzen Carlton Gibson	Ready for checkin	1	0	0	0	0	0
