#35174: The newly introduced Signal.asend returns TypeError: unhashable type:
'list' if all receivers are asynchronous functions
-----------------------------------------+------------------------
               Reporter:  VaĊĦek Dohnal   |          Owner:  nobody
                   Type:  Bug            |         Status:  new
              Component:  Uncategorized  |        Version:  5.0
               Severity:  Normal         |       Keywords:
           Triage Stage:  Unreviewed     |      Has patch:  0
    Needs documentation:  0              |    Needs tests:  0
Patch needs improvement:  0              |  Easy pickings:  0
                  UI/UX:  0              |
-----------------------------------------+------------------------
 == 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
 }}}
-- 
Ticket URL: <https://code.djangoproject.com/ticket/35174>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.

-- 
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to django-updates+unsubscr...@googlegroups.com.
To view this discussion on the web visit 
https://groups.google.com/d/msgid/django-updates/0107018d83bc5bef-dfb4faa3-4dce-4fb1-af3a-f0b4fc8b4b2a-000000%40eu-central-1.amazonses.com.

Reply via email to