From cc0f6c4f74cc278fdab79b269401127f2d869334 Mon Sep 17 00:00:00 2001 From: Arfey Date: Mon, 10 Nov 2025 01:10:32 +0200 Subject: [PATCH 1/2] Fixed #36714 -- Fixed context sharing among async signal handlers. --- django/dispatch/dispatcher.py | 57 +++++--- tests/signals/tests.py | 236 ++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 20 deletions(-) diff --git a/django/dispatch/dispatcher.py b/django/dispatch/dispatcher.py index 63fb75285ec3..21d77bd88429 100644 --- a/django/dispatch/dispatcher.py +++ b/django/dispatch/dispatcher.py @@ -1,4 +1,5 @@ import asyncio +import contextvars import logging import threading import weakref @@ -22,13 +23,30 @@ def _make_id(target): NO_RECEIVERS = object() -async def _gather(*coros): +def _restore_context(context): + """ + Check for changes in contextvars, and set them to the current + context for downstream consumers. + """ + for cvar in context: + cvalue = context.get(cvar) + try: + if cvar.get() != cvalue: + cvar.set(cvalue) + except LookupError: + cvar.set(cvalue) + + +async def _run_parallel(*coros): + """ + Execute multiple asynchronous coroutines in parallel, + sharing the current context between them. + """ + context = contextvars.copy_context() + if len(coros) == 0: return [] - if len(coros) == 1: - return [await coros[0]] - async def run(i, coro): results[i] = await coro @@ -36,12 +54,14 @@ async def run(i, coro): async with asyncio.TaskGroup() as tg: results = [None] * len(coros) for i, coro in enumerate(coros): - tg.create_task(run(i, coro)) + tg.create_task(run(i, coro), context=context) return results except BaseExceptionGroup as exception_group: if len(exception_group.exceptions) == 1: raise exception_group.exceptions[0] raise + finally: + _restore_context(context=context) class Signal: @@ -233,7 +253,7 @@ def send(self, sender, **named): if async_receivers: async def asend(): - async_responses = await _gather( + async_responses = await _run_parallel( *( receiver(signal=self, sender=sender, **named) for receiver in async_receivers @@ -275,6 +295,7 @@ async def asend(self, sender, **named): ): return [] sync_receivers, async_receivers = self._live_receivers(sender) + if sync_receivers: @sync_to_async @@ -290,14 +311,12 @@ def sync_send(): async def sync_send(): return [] - responses, async_responses = await _gather( - sync_send(), - _gather( - *( - receiver(signal=self, sender=sender, **named) - for receiver in async_receivers - ) - ), + responses = await sync_send() + async_responses = await _run_parallel( + *( + receiver(signal=self, sender=sender, **named) + for receiver in async_receivers + ) ) responses.extend(zip(async_receivers, async_responses)) return responses @@ -362,7 +381,7 @@ async def asend_and_wrap_exception(receiver): return response async def asend(): - async_responses = await _gather( + async_responses = await _run_parallel( *( asend_and_wrap_exception(receiver) for receiver in async_receivers @@ -436,11 +455,9 @@ async def asend_and_wrap_exception(receiver): return err return response - responses, async_responses = await _gather( - sync_send(), - _gather( - *(asend_and_wrap_exception(receiver) for receiver in async_receivers), - ), + responses = await sync_send() + async_responses = await _run_parallel( + *(asend_and_wrap_exception(receiver) for receiver in async_receivers), ) responses.extend(zip(async_receivers, async_responses)) return responses diff --git a/tests/signals/tests.py b/tests/signals/tests.py index 7cb64f6e05ad..907612459af6 100644 --- a/tests/signals/tests.py +++ b/tests/signals/tests.py @@ -1,3 +1,4 @@ +import contextvars from unittest import mock from asgiref.sync import markcoroutinefunction @@ -645,3 +646,238 @@ async def test_asend_robust_only_async_receivers(self): result = await signal.asend_robust(self.__class__) self.assertEqual(result, [(async_handler, 1)]) + + +class TestReceiversContextVarsSharing(SimpleTestCase): + def setUp(self): + self.ctx_var = contextvars.ContextVar("test_var", default=0) + + class CtxSyncHandler: + def __init__(self, ctx_var): + self.ctx_var = ctx_var + self.values = [] + + def __call__(self, **kwargs): + val = self.ctx_var.get() + self.ctx_var.set(val + 1) + self.values.append(self.ctx_var.get()) + return self.ctx_var.get() + + class CtxAsyncHandler: + def __init__(self, ctx_var): + self.ctx_var = ctx_var + self.values = [] + markcoroutinefunction(self) + + async def __call__(self, **kwargs): + val = self.ctx_var.get() + self.ctx_var.set(val + 1) + self.values.append(self.ctx_var.get()) + return self.ctx_var.get() + + self.CtxSyncHandler = CtxSyncHandler + self.CtxAsyncHandler = CtxAsyncHandler + + async def test_asend_correct_contextvars_sharing_async_receivers(self): + handler1 = self.CtxAsyncHandler(self.ctx_var) + handler2 = self.CtxAsyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + await signal.asend(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + async def test_asend_correct_contextvars_sharing_sync_receivers(self): + handler1 = self.CtxSyncHandler(self.ctx_var) + handler2 = self.CtxSyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + await signal.asend(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + async def test_asend_correct_contextvars_sharing_mix_receivers(self): + handler1 = self.CtxSyncHandler(self.ctx_var) + handler2 = self.CtxAsyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + await signal.asend(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + async def test_asend_robust_correct_contextvars_sharing_async_receivers(self): + handler1 = self.CtxAsyncHandler(self.ctx_var) + handler2 = self.CtxAsyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + await signal.asend_robust(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + async def test_asend_robust_correct_contextvars_sharing_sync_receivers(self): + handler1 = self.CtxSyncHandler(self.ctx_var) + handler2 = self.CtxSyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + await signal.asend_robust(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + async def test_asend_robust_correct_contextvars_sharing_mix_receivers(self): + handler1 = self.CtxSyncHandler(self.ctx_var) + handler2 = self.CtxAsyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + await signal.asend_robust(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + def test_send_correct_contextvars_sharing_async_receivers(self): + handler1 = self.CtxAsyncHandler(self.ctx_var) + handler2 = self.CtxAsyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + signal.send(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + def test_send_correct_contextvars_sharing_sync_receivers(self): + handler1 = self.CtxSyncHandler(self.ctx_var) + handler2 = self.CtxSyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + signal.send(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + def test_send_correct_contextvars_sharing_mix_receivers(self): + handler1 = self.CtxSyncHandler(self.ctx_var) + handler2 = self.CtxAsyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + signal.send(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + def test_send_robust_correct_contextvars_sharing_async_receivers(self): + handler1 = self.CtxAsyncHandler(self.ctx_var) + handler2 = self.CtxAsyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + signal.send_robust(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + def test_send_robust_correct_contextvars_sharing_sync_receivers(self): + handler1 = self.CtxSyncHandler(self.ctx_var) + handler2 = self.CtxSyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + signal.send_robust(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) + + def test_send_robust_correct_contextvars_sharing_mix_receivers(self): + handler1 = self.CtxSyncHandler(self.ctx_var) + handler2 = self.CtxAsyncHandler(self.ctx_var) + signal = dispatch.Signal() + signal.connect(handler1) + signal.connect(handler2) + + # set custom value outer signal + self.ctx_var.set(1) + + signal.send_robust(self.__class__) + + self.assertEqual(len(handler1.values), 1) + self.assertEqual(len(handler2.values), 1) + self.assertEqual(sorted([*handler1.values, *handler2.values]), [2, 3]) + self.assertEqual(self.ctx_var.get(), 3) From ccf74f7dc771313b41e7c2912a71f9c5b0ae5e1d Mon Sep 17 00:00:00 2001 From: Pravin Kamble Date: Sat, 20 Dec 2025 12:12:06 +0530 Subject: [PATCH 2/2] Bumped checkout version in Github actions configuration. --- .github/workflows/benchmark.yml | 2 +- .github/workflows/check_commit_messages.yml | 2 +- .github/workflows/coverage_tests.yml | 2 +- .github/workflows/docs.yml | 6 +++--- .github/workflows/labels.yml | 2 +- .github/workflows/linters.yml | 8 ++++---- .github/workflows/postgis.yml | 2 +- .github/workflows/python_matrix.yml | 4 ++-- .github/workflows/schedule_tests.yml | 12 ++++++------ .github/workflows/screenshots.yml | 2 +- .github/workflows/selenium.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- 12 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 7ffb02259366..f00cb44860bd 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -14,7 +14,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout Benchmark Repo - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: django/django-asv path: "." diff --git a/.github/workflows/check_commit_messages.yml b/.github/workflows/check_commit_messages.yml index fe67536eb547..32f155846452 100644 --- a/.github/workflows/check_commit_messages.yml +++ b/.github/workflows/check_commit_messages.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/coverage_tests.yml b/.github/workflows/coverage_tests.yml index 89b671b3cbc1..d572860cee96 100644 --- a/.github/workflows/coverage_tests.yml +++ b/.github/workflows/coverage_tests.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bfa4f9cb52e3..ffbb8450881f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,7 +26,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -47,7 +47,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -71,7 +71,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 807563322fa1..2f319627c72c 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 0f916fa24f16..03fc5de90ab4 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -24,7 +24,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -44,7 +44,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -64,7 +64,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: black @@ -75,7 +75,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Run zizmor diff --git a/.github/workflows/postgis.yml b/.github/workflows/postgis.yml index 0970d02ab473..ae5559616801 100644 --- a/.github/workflows/postgis.yml +++ b/.github/workflows/postgis.yml @@ -39,7 +39,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python diff --git a/.github/workflows/python_matrix.yml b/.github/workflows/python_matrix.yml index 1139b19b2e80..921497698f3a 100644 --- a/.github/workflows/python_matrix.yml +++ b/.github/workflows/python_matrix.yml @@ -23,7 +23,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - id: set-matrix @@ -40,7 +40,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/schedule_tests.yml b/.github/workflows/schedule_tests.yml index ce313444e451..1d5c5410226f 100644 --- a/.github/workflows/schedule_tests.yml +++ b/.github/workflows/schedule_tests.yml @@ -25,7 +25,7 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -46,7 +46,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -75,7 +75,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Node.js @@ -93,7 +93,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -132,7 +132,7 @@ jobs: --health-retries 5 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -180,7 +180,7 @@ jobs: --health-retries 5 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index 1180e9818dde..a581d129f995 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python diff --git a/.github/workflows/selenium.yml b/.github/workflows/selenium.yml index 69de70ae598e..a42c0ea2c82e 100644 --- a/.github/workflows/selenium.yml +++ b/.github/workflows/selenium.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -61,7 +61,7 @@ jobs: --health-retries 5 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 079fae63283c..3012ccb83ea1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Python @@ -50,7 +50,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Set up Node.js