Skip to content

explicitly start ASGI run with empty context#2742

Merged
Kludex merged 13 commits intoKludex:mainfrom
pmeier:reset-context
Dec 21, 2025
Merged

explicitly start ASGI run with empty context#2742
Kludex merged 13 commits intoKludex:mainfrom
pmeier:reset-context

Conversation

@pmeier
Copy link
Copy Markdown
Contributor

@pmeier pmeier commented Oct 29, 2025

Summary

One possible solution for #2167.

Checklist

  • I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
  • I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
  • I've updated the documentation accordingly.

@Kludex
Copy link
Copy Markdown
Owner

Kludex commented Oct 29, 2025

This does make sense, but do you know why it happens?

@pmeier
Copy link
Copy Markdown
Contributor Author

pmeier commented Oct 29, 2025

I think so, yes. asyncio does not clean up the context when starting a new task. This is why they added the context keyword argument in 3.11 to make this simpler. The documentation states

> The current context copy is created when no context is provided.

So far I only have a not-so-minimal reproducer: https://github.com/pmeier/asyncio-contextvars-pollution. Using the patch in this PR, the pollution is resolved.

How exactly it happens in the first place, I don't know yet. My goal is to get to a reproducer that just depends on uvicorn that I hopefully can put into a unittest. This could serve as testing ground to find the underlying reason.

Finally, my working hypothesis as for why this is not happening by uvloop is that they are not a drop-in replacement for this specific behavior of asyncio. On the surface it seems that they are replicating it correctly, but I haven't dug any deeper just yet.

@Kludex I was wrong. This is a bug in asyncio. See #2167 (comment) for details.

@pmeier pmeier marked this pull request as ready for review November 3, 2025 15:10
@pmeier pmeier changed the title explicitly start with empty context explicitly start ASGI run with empty context Nov 3, 2025
@pmeier
Copy link
Copy Markdown
Contributor Author

pmeier commented Nov 16, 2025

Ping @Kludex

@Kludex
Copy link
Copy Markdown
Owner

Kludex commented Nov 17, 2025

We need to fix it in httptools as well, and do you think you can create a test that reproduces the issue?

@pmeier
Copy link
Copy Markdown
Contributor Author

pmeier commented Nov 17, 2025

@Kludex

We need to fix it in httptools as well

Can you explain that? Are you talking about

uvicorn/pyproject.toml

Lines 41 to 44 in 8ae0bcb

[project.optional-dependencies]
standard = [
"colorama>=0.4; sys_platform == 'win32'",
"httptools>=0.6.3",

https://github.com/MagicStack/httptools

do you think you can create a test that reproduces the issue?

Yeah, likely. I haven't written one, because the solution is not the only one that is possible to fix the issue. See #2167 (comment).

Can you decide if you want to go with the solution before I put in more work?

@Kludex
Copy link
Copy Markdown
Owner

Kludex commented Nov 21, 2025

Can you decide if you want to go with the solution before I put in more work?

It's a bit unfortunate we need to do it here, but I guess it's fine.


Can you explain that? Are you talking about

We have a h11_impl.py and httptools_impl.py that has analogous logic.

@pmeier
Copy link
Copy Markdown
Contributor Author

pmeier commented Nov 21, 2025

@Kludex

It's a bit unfortunate we need to do it here, but I guess it's fine.

There is an open PR python/cpython#141158 that'll hopefully fix this for Python >=3.15. But until that there is no way around doing this in uvicorn if we don't want to tell users to use uvloop by default.

can create a test that reproduces the issue

I've added a test that should reproduce the issue. However, it also won't pass right now I don't know why yet. Trying my fix on reproducers outside of the test suite works just fine. Thus, my working assumption is that one of the mock components does not behave like the real component would. I'll try to find out which one.

Copy link
Copy Markdown
Contributor Author

@pmeier pmeier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kludex I opted to avoid using the mock components, because in order to reproduce the issue, we'd need to implement the buggy behavior for them. Instead, the regression test now uses the outer asyncio loop.

The CI for d099a87 (just the test without the changes to the task handling) shows nicely that

  1. The regression test correctly fails if we do not start the ASGI task with an empty context
  2. Windows is not affected by this bug (see #2167 (comment))

Comment thread tests/protocols/test_http.py Outdated


@contextlib.asynccontextmanager
async def server(*, app, port, http_protocol_cls):
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming TBD. Just wanted to get feedback first if the test architecture is ok.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use run_server from utils.py.

@pmeier
Copy link
Copy Markdown
Contributor Author

pmeier commented Dec 8, 2025

Ping @Kludex

Comment on lines +251 to +256
# For the asyncio loop, we need to explicitly start with an empty context
# as it can be polluted from previous ASGI runs.
# See https://github.com/python/cpython/issues/140947 for details.
task = contextvars.Context().run(self.loop.create_task, self.cycle.run_asgi(app))
# TODO: Replace the line above with the line below for Python >= 3.11
# task = self.loop.create_task(self.cycle.run_asgi(app), context=contextvars.Context())
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, the lifespan is a sibling task, but that's not the case in Hypercorn. It may be the case in the future that we refactor the server to make the lifespan task a parent of the whole process instead of a sibling task. Which means that the context would need to come from there. We still don't want the context to leak between sibling tasks as it's currently happening in asyncio...

Since this is going to be fixed in Python 3.15, can we have a note about it? I would like to revert the context= when it lands, or at least remember that we can do it if we decide to make the lifespan task a parent from this one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm aware of python/cpython#141158, but since this is not merged or ready to be merged, do we already want to assume that 3.15 will fix this?

Comment thread tests/protocols/test_http.py Outdated
await task


async def test_no_contextvars_pollution(http_protocol_cls: type[HTTPProtocol], unused_tcp_port: int):
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async def test_no_contextvars_pollution(http_protocol_cls: type[HTTPProtocol], unused_tcp_port: int):
async def test_no_contextvars_pollution_asyncio(http_protocol_cls: type[HTTPProtocol], unused_tcp_port: int):

Comment thread tests/protocols/test_http.py Outdated


@contextlib.asynccontextmanager
async def server(*, app, port, http_protocol_cls):
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async def server(*, app, port, http_protocol_cls):
async def server(*, app: ASGIApplication, port: int, http_protocol_cls: type[HTTPProtocol]):

Comment thread tests/protocols/test_http.py Outdated
await task


async def test_no_contextvars_pollution(http_protocol_cls: type[HTTPProtocol], unused_tcp_port: int):
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to remove this test when 3.15 reaches EOF, given it's the event loop's job to make sure this is the case. 😎

Can we have a mark to not run this on >=3.15? 🙏

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can depending on the answer of #2742 (comment).

@Kludex
Copy link
Copy Markdown
Owner

Kludex commented Dec 17, 2025

Ping @Kludex

Sorry. I was on a short vacation.

@marctc
Copy link
Copy Markdown

marctc commented Jan 22, 2026

hey @Kludex @pmeier I dont have much expertise on asyncio stuff, but is this a temporary fix or the path this project will go? I'm asking because I'm working on implementing automatic context propagation for python asyncio with eBPF and I found out that this PR breaks the use case of FasAPI+uvicorn. The conflicting bit is where "we need to explicitly start with an empty context". We are using eBPF to track the execution of context_run and context_create from CPython to create connected spans for a distributed trace and from version 0.39 of uvicorn tests are failing.

thanks!

@pmeier
Copy link
Copy Markdown
Contributor Author

pmeier commented Jan 22, 2026

is this a temporary fix or the path this project will go

I can't answer for the project / @Kludex, but IMO this is just a temporary fix. This is only required, because asyncio is leaking context vars out of tasks.

I'm interested in your use case though: are you setting a "global" context var, i.e. not on a per-request basis, and by starting with an empty context this global context is hidden?

@marctc
Copy link
Copy Markdown

marctc commented Jan 22, 2026

Hey @pmeier thanks for quick response!

I'm interested in your use case though: are you setting a "global" context var, i.e. not on a per-request basis, and by starting with an empty context this global context is hidden?

We hook context_run (which is called when Context.run() executes) and context_new_from_vars (when a new context is created). When a request comes in, we capture the Python context pointer and store it in a BPF map, associating it with the trace. When that same request makes an outgoing HTTP call, we look up the current context pointer to find the parent trace and link the spans together.

Before 0.39, this worked because the request handler inherited whatever context was active. The context pointer we captured at request start was the same one (or a child of it) when the handler made HTTP calls.
After 0.39, with contextvars.Context().run(...), each request gets a brand new empty context. So the context pointer we see inside the request handler has no relationship to anything we tracked before - it's a fresh object created just for that request. We can't follow the parent chain because there isn't one.

So we're not setting a "global" contextvar. Instead, we intercept the C functions in libpython that manage context objects and track the raw memory addresses (pointers) of those PyContext objects.

@Kludex
Copy link
Copy Markdown
Owner

Kludex commented Jan 22, 2026

I think we should make lifespan a parent task, and then use the context from there.


We can also make this fix only for asyncio, and leave uvloop out of it, since it's what people will use in production.

@marctc
Copy link
Copy Markdown

marctc commented Jan 22, 2026

Hey thanks also for quick response!

We can also make this fix only for asyncio, and leave uvloop out of it, since it's what people will use in production.

Unless I'm doing something wrong or I missunderstood your message, it's also broken for uvicorn + uvloop. I never tested with uvloop before and I had to tweak the code, but it works for versions prior 0.39.

@Kludex
Copy link
Copy Markdown
Owner

Kludex commented Jan 22, 2026

Hey thanks also for quick response!

We can also make this fix only for asyncio, and leave uvloop out of it, since it's what people will use in production.

Unless I'm doing something wrong or I missunderstood your message, it's also broken for uvicorn + uvloop. I never tested with uvloop before and I had to tweak the code, but it works for versions prior 0.39.

What I meant is to "revert" this PR for uvloop, since the problem this PR tried to solve was not an issue with uvloop.

@Kludex
Copy link
Copy Markdown
Owner

Kludex commented Jan 22, 2026

By "revert" here I mean wrap the line that runs the app, check what event loop is running, and apply a behavior accordingly.

@marctc
Copy link
Copy Markdown

marctc commented Jan 22, 2026

I see, I understand now, thanks for clarifying!

Kludex added a commit that referenced this pull request Jan 30, 2026
theseriff pushed a commit to theseriff/uvicorn that referenced this pull request Mar 9, 2026
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
@tubarao312
Copy link
Copy Markdown

tubarao312 commented Apr 3, 2026

Hi, this fix has actually broken some metaprogramming features I was relying on - I was accessing a single variable for multiple uvicorn apps in my monorepo and then changing the variable depending on the context it was in. This context is no longer accessible inside ASGI function calls. Any idea what I should do?

Pinging @Kludex since this is a rather old thread.

Also I realize this might seem like an insane use of context vars but from my point of view it made a lot of sense that I could use a FastAPI lifespan to inject whatever I wanted into the context. Classic Hyrum's Law 😅:

With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviours of your system will be depended on by somebody.

@Kludex
Copy link
Copy Markdown
Owner

Kludex commented Apr 3, 2026

@pmeier I know it's just one user complaining (for now), but I think it's a fair point. I'm inclined to revert the changes (given that I've seen progress on the CPython side for 3.15).

[...] seem like an insane use of context vars

It does, but my reasoning was that the current bug in CPython (and the non-predictability behavior) overweights that.

@pmeier
Copy link
Copy Markdown
Contributor Author

pmeier commented Apr 10, 2026

Yeah, I get it. TBH when I started this I was 90%+ sure that this was a bug in uvicorn rather than CPython. And after I was finally able to reproduce it with just CPython, I just wanted "closure".

But I guess your reasoning to revert is sound. We just need to make sure to also push this to downstream projects. For example, I originally stumbled over this bug when instrumenting with opentelemetry. And after my fix here was merged and released, I posted there that the bug reports can be closed: open-telemetry/opentelemetry-python-contrib#2852 (comment)

@pmeier pmeier deleted the reset-context branch April 10, 2026 20:37
@Kludex Kludex mentioned this pull request Apr 21, 2026
4 tasks
Kludex added a commit that referenced this pull request Apr 21, 2026
Reintroduces the behavior from #2742 as an opt-in flag. When set, each
ASGI request runs in a fresh contextvars.Context, which works around
the asyncio context leak in python/cpython#140947
(expected to be fixed upstream for Python >=3.15 via python/cpython#141158).

Default is off so context set in the lifespan or by external
instrumentation (e.g. OpenTelemetry eBPF propagation) remains visible
to ASGI handlers.
@Kludex
Copy link
Copy Markdown
Owner

Kludex commented Apr 21, 2026

I've reverted this PR and merged #2912.

To have this behavior, you need to opt-in with --reset-contextvars.


I'm sorry for the delay. I'll post a message on related closed issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants