Opened 3 weeks ago

Closed 3 weeks ago

Last modified 3 weeks ago

#37385 closed Cleanup/optimization (invalid)

ASGI client disconnects log "CancelledError exception in shielded future" on Python 3.14+

Reported by: Natalia Bidart Owned by: Carlton Gibson
Component: HTTP handling Version: dev
Severity: Normal Keywords: async
Cc: Carlton Gibson Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

Since ​a013c821ea (#37177), built-in middleware are sync-only, so under ASGI the default middleware chain runs in a thread and calls the view back through async_to_sync(). On Python 3.14+, a client disconnecting while the view is still running makes the asyncio logger emit an ERROR with a traceback:

CancelledError exception in shielded future
...
  File ".../asgiref/sync.py", line 526, in __call__
    ret = await asyncio.shield(exec_coro)
asyncio.exceptions.CancelledError

The request itself is handled correctly. Python 3.14 changed asyncio.shield() to log the shielded future's exception if the outer task was cancelled, and asgiref's SyncToAsync shields the executor future, which ends with CancelledError when the handler cancels the request.

To reproduce, in a startproject project with the default MIDDLEWARE, add:

async def slow_async(request):
    await asyncio.sleep(3)
    return HttpResponse("ok")


def slow_sync(request):
    time.sleep(3)
    return HttpResponse("ok")


urlpatterns += [
    path("slow-async/", slow_async),
    path("slow-sync/", slow_sync),
]

Serve it with an async server (uvicorn mysite.asgi:application) and run curl --max-time 1 http://127.0.0.1:8000/slow-async/ (or /slow-sync/). The traceback is logged once the view finishes. The asgi tests also show it (./runtests.py asgi --parallel=1), though they pass.

Change History (6)

comment:1 by Natalia Bidart, 3 weeks ago

Cc: Carlton Gibson added

One solution could be to close this ticket as wontfix and instead change asgiref to not use asyncio.shield(). Since Python 3.14, shield() logs the inner future's exception when the outer task is cancelled, which is what ​SyncToAsync.__call__ hits here. #37177 only made the default middleware hit it; older Django versions like stable/6.1.x show the same log with any sync-only middleware. asyncio.wait() protects the executor future the same way without logging, but shield() was also implicitly marking the inner exception as retrieved, so I think the except branch needs to do that explicitly or asyncio logs Future exception was never retrieved instead:

         try:
-            ret = await asyncio.shield(exec_coro)
+            await asyncio.wait({exec_coro})
+            ret = exec_coro.result()
         except asyncio.CancelledError:
             ...
             if exec_coro.done():
+                if not exec_coro.cancelled():
+                    # Mark the exception as retrieved? Or do what instead?
+                    exec_coro.exception()
                 raise

The downside is that if the sync function fails with a real error after the request was cancelled, that error is now silently discarded, as it was before Python 3.14; with shield() on 3.14+ it would be logged. If that matters, the except branch could still log anything other than CancelledError. These design decisions are outside my comfort zone so perhaps we should move to an asgiref issue. Carlton, what do you think?

comment:2 by Jacob Walls, 3 weeks ago

Keywords: async added

comment:3 by Carlton Gibson, 3 weeks ago

Owner: set to Carlton Gibson
Status: new → assigned

OK, thanks. I’ll take this. Not sure of the resolution yet.

Ultimately (quick search!) several people seem to be hitting this, and it looks like a regression in CPython

​https://github.com/python/cpython/issues/156321

I need to dig in further, input upstream, and assess.

Given that this pre-exists #37177, I don’t believe it’s correct to classify as a Release Blocker.

comment:4 by Carlton Gibson, 3 weeks ago

One solution could be to close this ticket as wontfix and instead...

Yeah, I think this is invalid for Django itself, independently of whether we can get it fixed in CPython, or work around it in asgiref.

comment:5 by Natalia Bidart, 3 weeks ago

Resolution: → invalid
Severity: Release blocker → Normal
Status: assigned → closed

Thanks Carlton, I appreciate your feedback!

comment:6 by Carlton Gibson, 3 weeks ago

Just to close the loop, this will be resolved in CPython, and is marked for backport to 3.14 and 3.15 branches.

​https://github.com/python/cpython/pull/158590

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