Fixed #535: ThreadSensitiveContext.__aexit__ blocking the event loop. - #563
Conversation
|
Thanks for this @helgefmi. Just about to disappear on holiday, so will resolve this when I return 🏖️ |
| pass | ||
|
|
||
|
|
||
| def cancel_inside_thread_sensitive_context(): |
There was a problem hiding this comment.
Nice, the new unit test makes the error obvious 👍
|
Still working on the code review. Investigating a cancellation problem with nested async_to_sync |
|
Finished my investigation 😅 First, thanks again for the test case, it really helped. Running We run sync code through Now the parent reaches
So the real issue: we "cancel" the sync function, but it doesn't actually stop, it runs on into async_to_sync and parks. I want to float a different angle: fix the cause instead. We can't truly cancel running sync code anyway, so instead of cancelling mid-flight, we let it run until it finishes or reaches the I don't have a strong opinion on which approach is better. Thoughts? |
|
Thanks for your time digging into this @Arfey. Honest disclosure: This is a bit over my paygrade. I hit the deadlock running https://github.com/django/daphne, used py-spy and Claude Code to work out what was happening, and it looked like the same issue @patx had already proposed a solution for in #535. So I don't want to pretend I have a strong architectural opinion here. You guys know asgiref's cancellation semantics far better than I do, and I'm happy to defer to whatever you think is best. 😊 |
|
I think @Arfey has a very valid point here.
So delaying cancellation until the sync function either finishes or reaches the That said, I am in the same boat as @helgefmi and I do not know asgiref’s cancellation semantics deeply enough to judge the compatibility impact. If changing that behavior is acceptable for a core lib liek this, I would lean toward fixing the cancellation path rather than only moving |
|
Just a quick comment: Yes, @Arfey is right on this. (It's similar in kind to various Argh! Where did my event loop go? issues we've been working on recently...) I'm working on a release now, and will look at the various suggested options here as part of that. (So we'll do something here.) |
|
@carltongibson Sounds good, thanks for picking this up! Happy to help test or take a piece of it if that's useful. just ping me. |
… loop. executor.shutdown() is a blocking join, and the executor's worker thread may itself be waiting on the event loop, deadlocking it. Join in a separate thread instead, as asyncio's shutdown_default_executor() does.
Avoids potential default executor starvation.
525855d to
2944ba2
Compare
carltongibson
left a comment
There was a problem hiding this comment.
Right, thanks for this @helgefmi and @patx.
I've pushed an amended version runs the shutdown in its own thread, rather then the default shared executor, which might itself be busy.
Whilst related, I think @Arfey's suggestion in #567 is orthogonal, and needs a little bit more work to pull in.
This will be part of the next release, which I'm working on at the moment.

Fixes #535.
ThreadSensitiveContext.__aexit__is async, but it callsexecutor.shutdown(), which blocks the thread (it's aThread.join). Running a blocking call straight on the event loop can deadlock the whole loop, in this case when the executor's worker thread is itself waiting on that loop. When it happens the server stops responding to everything.The fix runs the shutdown off the loop with
await loop.run_in_executor(None, executor.shutdown), so the loop stays free. This is what asyncio itself does inloop.shutdown_default_executor(). I also moved the contextvar reset to before the await.My test deadlocks on current main and passes with the change. It runs in a separate process because the hung thread would otherwise hang the test suite.
AI disclosure: I used Claude Opus 4.8 to help track down the deadlock, write the fix and the test.