#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 , 8 months ago
Cc: | added |
---|---|
Component: | Uncategorized → Core (Other) |
Severity: | Normal → Release blocker |
Summary: | The newly introduced Signal.asend returns TypeError: unhashable type: 'list' if all receivers are asynchronous functions → asend() crashes when all receivers are asynchronous functions. |
Triage Stage: | Unreviewed → Accepted |
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.