Opened 10 months ago

Closed 10 months ago

Last modified 10 months ago

#35174 closed Bug (fixed)

asend() crashes when all receivers are asynchronous functions.

Reported by: Vašek Dohnal Owned by: Vašek Dohnal
Component: Core (Other) Version: 5.0
Severity: Release blocker Keywords:
Cc: Jon Janzen, Carlton Gibson Triage Stage: Ready for checkin
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

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:

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.

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.

    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

Change History (6)

comment:1 by Mariusz Felisiak, 10 months ago

Cc: Jon Janzen Carlton Gibson added
Component: UncategorizedCore (Other)
Severity: NormalRelease blocker
Summary: The newly introduced Signal.asend returns TypeError: unhashable type: 'list' if all receivers are asynchronous functionsasend() crashes when all receivers are asynchronous functions.
Triage Stage: UnreviewedAccepted

Great catch! Thanks for the report. Would you like to prepare a patch and submit it via GitHub PR? (a regression test is required)

Regression in e83a88566a71a2353cebc35992c110be0f8628af.

comment:2 by Vašek Dohnal, 10 months ago

I'll do my best and prepare a PR. 💪

comment:3 by Mariusz Felisiak, 10 months ago

Owner: changed from nobody to Vašek Dohnal
Status: newassigned

Great, thanks!

comment:4 by Mariusz Felisiak, 10 months ago

Has patch: set
Triage Stage: AcceptedReady for checkin

comment:5 by Mariusz Felisiak <felisiak.mariusz@…>, 10 months ago

Resolution: fixed
Status: assignedclosed

In 1b5338d:

Fixed #35174 -- Fixed Signal.asend()/asend_robust() crash when all receivers are asynchronous.

Regression in e83a88566a71a2353cebc35992c110be0f8628af.

comment:6 by Mariusz Felisiak <felisiak.mariusz@…>, 10 months ago

In 761e913:

[5.0.x] Fixed #35174 -- Fixed Signal.asend()/asend_robust() crash when all receivers are asynchronous.

Regression in e83a88566a71a2353cebc35992c110be0f8628af.

Backport of 1b5338d03ecc962af8ab4678426bc60b0672b8dd from main

Note: See TracTickets for help on using tickets.
Back to Top