﻿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
36714	Async signals lose ContextVar state due to use of asyncio.gather	Mykhailo Havelia	Mykhailo Havelia	"The natural way to share global, per-request state in asyncio is through contextvars. In Django, this is typically used via `asgiref.local.Local`. However, Django's async signal dispatch currently uses `asyncio.gather`, which internally creates new tasks (`asyncio.create_task`). This breaks context propagation, since each task gets its own copy of the context. As a result, it's impossible to set a global (context-based) variable inside a signal handler and have it shared with other signal handlers or parts of the same request/response cycle.

Example


{{{
from django.core import signals
from django.http import (
    HttpRequest,
    HttpResponse,
)
import contextvars
from django.urls import path


request_id = contextvars.ContextVar('request_id', default=None)


async def set_global_variable(*args, **kwargs):
    # set global variable
    request_id.set('request_id_value')
    print('get value', request_id.get())

signals.request_started.connect(set_global_variable)


async def index(request: HttpRequest) -> HttpResponse:
    # get global variable
    print('request_id', request_id.get())
    return HttpResponse(content=request_id.get())


urlpatterns = [path("""", index), ]
}}}

result


{{{
get value request_id_value
request_id None
}}}

The value set inside the signal handler is lost, because the handler runs in a separate task with its own context.

If we are talking exactly about `signals.request_started` and `signals.request_finished`, they are typically used for setting up and cleaning up per-request resources. With `asyncio.gather`, cleanup logic that relies on `ContextVar` cannot work properly.


{{{
from django.core import signals
from django.http import (
    HttpRequest,
    HttpResponse,
)
import contextvars
from django.urls import path


db_connection = contextvars.ContextVar('db_connection', default=None)

async def get_or_create_connection():
    if not db_connection.get():
        db_connection.set('connection')

    return db_connection.get()

async def close_connection(*args, **kwargs):
    connection = db_connection.get()

    if not connection:
        print('cannot clean - connection does not exist')
        return

    print('close connection')
    connection.set(None)


signals.request_finished.connect(close_connection)


async def index(request: HttpRequest) -> HttpResponse:
    # create connection inside handler
    connection = await get_or_create_connection()
    # await get_data(connection)
    return HttpResponse(content=""ok"")


urlpatterns = [path("""", index), ]
}}}

result


{{{
cannot clean - connection does not exist
}}}

**Expected behavior**

Signal handlers should run in the same async context as the request, preserving `ContextVar` and `asgiref.local.Local` state.

**Proposed solution**

Signal:

Dispatch async signal handlers sequentially (or via direct await) instead of using `asyncio.gather`, so that the existing execution context is preserved throughout the request lifecycle. Yes, this change removes parallelism, but that shouldn’t be a major concern. The only real benefit of running signal handlers in parallel would be for IO-bound operations - yet in most cases, these handlers interact with the same database connection. Since database operations aren’t truly parallel under the hood, the performance gain from `asyncio.gather` is negligible.

ASGIHandler:


{{{
async def handle(self, scope, receive, send):
    ...
    await signals.request_started.asend(sender=self.__class__, scope=scope)
    
    tasks = [
        asyncio.create_task(self.listen_for_disconnect(receive)),
        asyncio.create_task(process_request(request, send)),
    ]

    ...

    await signals.request_finished.asend(sender=self.__class__)
}}}

Global variables created inside `process_request` are not visible to `request_finished`, because each task runs in a separate context. We can try using `contextvars.copy_context()` to preserve and share the same context between tasks and signal handlers.


{{{
async def handle(self, scope, receive, send):
    ...
    await signals.request_started.asend(sender=self.__class__, scope=scope)

    ctx = contextvars.copy_context()
    
    tasks = [
        asyncio.create_task(self.listen_for_disconnect(receive)),
        asyncio.create_task(process_request(request, send), context=ctx),
    ]

    ...

    await asyncio.create_task(signals.request_finished.asend(sender=self.__class__), context=ctx)
}}}

Here is a simple example


{{{
import asyncio
import contextvars

global_state = contextvars.ContextVar('stage', default=0)


async def inc():
    value = global_state.get()
    print('value: ', value)
    global_state.set(value + 1)


async def main():
    await asyncio.create_task(inc())
    await asyncio.create_task(inc())
    await asyncio.create_task(inc())
    
    print('first: ', global_state.get())
    
    ctx = contextvars.copy_context()
    
    await asyncio.create_task(inc(), context=ctx)
    await asyncio.create_task(inc(), context=ctx)
    await asyncio.create_task(inc(), context=ctx)
    
    print('second: ', ctx.get(global_state))


await main()
}}}

result


{{{
value:  0
value:  0
value:  0
first:  0
value:  0
value:  1
value:  2
second:  3
}}}

"	Bug	closed	Core (Other)	dev	Normal	fixed	asyncio, signals	Mykhailo Havelia Carlton Gibson	Ready for checkin	1	0	0	0	0	0
