Skip to content

support free-threaded python3.14#1224

Merged
agronholm merged 21 commits into
agronholm:masterfrom
EmmanuelNiyonshuti:free-threaded
Jul 19, 2026
Merged

support free-threaded python3.14#1224
agronholm merged 21 commits into
agronholm:masterfrom
EmmanuelNiyonshuti:free-threaded

Conversation

@EmmanuelNiyonshuti

@EmmanuelNiyonshuti EmmanuelNiyonshuti commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

NOTE Erasing or replacing the contents of this template will result in your pull
request being summarily closed without consideration!

Changes

Fixes #1220

Add Python 3.14t to the test matrix so the test suite is also exercised on the free-threaded build. This also fixes the referenced issue as a side effect.
I am leaving 3.15t out of the matrix for now, since the current released test dependencies do not install successfully on that interpreter yet. Specifically, the failure happens while building cryptography against CPython 3.15t.

Checklist

If this is a user-facing code change, like a bugfix or a new feature, please ensure that
you've fulfilled the following conditions (where applicable):

  • You've added tests (in tests/) which would fail without your patch
  • You've updated the documentation (in docs/), in case of behavior changes or new
    features
  • You've added a new changelog entry (in docs/versionhistory.rst).

If this is a trivial change, like a typo fix or a code reformatting, then you can ignore
these instructions.

Updating the changelog

If there are no entries after the last release, use **UNRELEASED** as the version.
If, say, your patch fixes issue #123, the entry should look like this:

- Fix big bad boo-boo in task groups
  (`#123 <https://github.com/agronholm/anyio/issues/123>`_; PR by @yourgithubaccount)

If there's no issue linked, just link to your pull request instead by updating the
changelog after you've created the PR.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

This specific test added in #887 is behaving a bit weird on free-threaded build. hamt_bitmap_node seems to still be around and causes the assertion to fail? Not sure about whats going on yet!

@agronholm

Copy link
Copy Markdown
Owner

Yeah, this needs a more complete fix. I had something locally that, IIRC, fixes the test that fails here. I'll check that tonight.

@agronholm

Copy link
Copy Markdown
Owner

Try this:

--- a/src/anyio/_backends/_asyncio.py	(revision c384f99687c64c59ed8a11c3a0f11a2d57daff71)
+++ b/src/anyio/_backends/_asyncio.py	(date 1783968974553)
@@ -987,7 +987,11 @@
         workers: set[WorkerThread],
         idle_workers: deque[WorkerThread],
     ):
-        super().__init__(name="AnyIO worker thread")
+        kwargs: dict[str, Any] = {}
+        if sys.version_info >= (3, 14):
+            kwargs["context"] = Context()
+
+        super().__init__(name="AnyIO worker thread", **kwargs)
         self.root_task = root_task
         self.workers = workers
         self.idle_workers = idle_workers

@agronholm

Copy link
Copy Markdown
Owner

The same structure should be used for the other part of the fix too.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

ah yes, that will work. I might have been confused and looking at the wrong side of the problem.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

did you also notice that running the test multiple times sometimes fails?

@agronholm

Copy link
Copy Markdown
Owner

No, this one has been pretty consistent for me. Granted, I haven't spent that much time with it.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

I will investigate. When I run it multiple times, sometimes the first assertion errors out.

Comment thread src/anyio/_backends/_asyncio.py
Co-authored-by: Alex Grönholm <alex.gronholm@nextday.fi>
@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

I will investigate. When I run it multiple times, sometimes the first assertion errors out.

Okay, this got a little hairy; apparently running the test in question in isolation (pytest -k) fails on trio consistently and sometimes on asyncio+eager. Will look into it again later today.

@EmmanuelNiyonshuti

EmmanuelNiyonshuti commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Failure on trio is fixed python-trio/trio#3473

eager task factory backend(asyncio + eager) is still flaky.

Comment thread docs/versionhistory.rst Outdated
@agronholm

Copy link
Copy Markdown
Owner

Failure on trio is fixed python-trio/trio#3473

eager task factory backend(asyncio + eager) is still flaky.

So is test_run_sync_worker_cyclic_references still sometimes failing with the eager task factory? Can you post a traceback?

@EmmanuelNiyonshuti

EmmanuelNiyonshuti commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author
============================================================================ FAILURES =============================================================================
______________________________________________________ test_run_sync_worker_cyclic_references[asyncio+eager] ______________________________________________________
tests/test_to_thread.py:386: in test_run_sync_worker_cyclic_references
    assert gc.get_referrers(contextval) == no_other_refs()
E   assert [<coroutine o...x5b65659b1a0>] == [<coroutine o...x5b65630a980>]
E     
E     Left contains one more item: <hamt_bitmap_node object at 0x5b65659b1a0>
E     
E     Full diff:
E       [
E           <coroutine object test_run_sync_worker_cyclic_references at 0x5b65630a980>,
E     +     <hamt_bitmap_node object at 0x5b65659b1a0>,
E       ]
===================================================================== short test summary info =====================================================================
FAILED tests/test_to_thread.py::test_run_sync_worker_cyclic_references[asyncio+eager] - assert [<coroutine o...x5b65659b1a0>] == [<coroutine o...x5b65630a980>]
================================================================== 1 failed, 63 passed in 4.72s ===================================================================
Test failed on run 8

Here it crashed at 8th run. I have a little bash script for the reason of the flakiness of this test.

#!/usr/bin/env bash

RUNS=15

for ((i=1; i<=RUNS; i++)); do
    echo "Run $i of $RUNS"

    pytest tests/test_to_thread.py -v

    # Stop immediately if the test fails
    if [ $? -ne 0 ]; then
        echo "Test failed on run $i"
        exit 1
    fi
done

echo "All $RUNS runs passed!"

@EmmanuelNiyonshuti

EmmanuelNiyonshuti commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author
============================================================================ FAILURES =============================================================================
______________________________________________________ test_run_sync_worker_cyclic_references[asyncio+eager] ______________________________________________________
tests/test_to_thread.py:386: in test_run_sync_worker_cyclic_references
    assert gc.get_referrers(contextval) == no_other_refs()
E   assert [<coroutine o...x5b65659b1a0>] == [<coroutine o...x5b65630a980>]
E     
E     Left contains one more item: <hamt_bitmap_node object at 0x5b65659b1a0>
E     
E     Full diff:
E       [
E           <coroutine object test_run_sync_worker_cyclic_references at 0x5b65630a980>,
E     +     <hamt_bitmap_node object at 0x5b65659b1a0>,
E       ]
===================================================================== short test summary info =====================================================================
FAILED tests/test_to_thread.py::test_run_sync_worker_cyclic_references[asyncio+eager] - assert [<coroutine o...x5b65659b1a0>] == [<coroutine o...x5b65630a980>]
================================================================== 1 failed, 63 passed in 4.72s ===================================================================
Test failed on run 8

Here it crashed at 8th run. I have a little bash script for the reason of the flakiness of this test.

sometimes it fail on uvloop backend too. but its more prevalent on the eager task factory. I haven't figured out whats going on yet. I am not sure why that object is bleeding out.

@EmmanuelNiyonshuti

EmmanuelNiyonshuti commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

gc.collect()

I could also add a second gc.collect() and sometimes get the test to passes for like 10+ runs.

@agronholm

Copy link
Copy Markdown
Owner

Sounds like it's time-sensitive then. I'd really like to understand the cause first before patching just the symptoms.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

If it is, wouldn't my assumption of adding a check point fix that? Letting worker thread have time to clear these objects out? It doesn't seem to work for me. this object bleeding out is also something of cpython implementation detail yet I get tempted to think that the problem might not be comming from worker thread implementation being threadsafe or not?

I totally agree on understanding the cause before this patch.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

The red warning on usage of gc.get_referrers in the docs also make me suspicious but not yet able to prove my suspicions!

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

If it is, wouldn't my assumption of adding a check point fix that? Letting worker thread have time to clear these objects out? It doesn't seem to work for me. this object bleeding out is also something of cpython implementation detail yet I get tempted to think that the problem might not be comming from worker thread implementation being threadsafe or not?

I totally agree on understanding the cause before this patch.

I would say though, checkpointing before we do assertion in this test does quite significantly reduce the frequency. I think its buying a little bit of time for the worker thread to remove references?

del item, context, func, args, future, cancel_scope

I think the cause is presumably a race happening in removing the references. given that gc.get_referrers can return objects that are still under construction and it is advised to be used for only debugging purposes(per the docs) and the fact that the only object bleeding out is a contextvars implementation detail hamt_bitmap_node, @agronholm I am being more inclined to presuppose that this is something that we would expect to happen intermittently for running this test when the GIL is disabled. I don't think a user can ever hit this, though I might be biased; plus, even if they see it, it would have nothing to do with the background worker returning the wrong result.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

Failure on trio is fixed python-trio/trio#3473

eager task factory backend(asyncio + eager) is still flaky.

for the context here, the issue on trio was also about context inheritance.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

We could add a checkpoint,

index 93c956b..efee878 100644
--- a/tests/test_to_thread.py
+++ b/tests/test_to_thread.py
@@ -24,7 +24,7 @@ from anyio import (
 )
 from anyio._core._eventloop import current_async_library
 from anyio.from_thread import BlockingPortalProvider
-
+from anyio.lowlevel import checkpoint
 from .conftest import asyncio_params, no_other_refs
 
 
@@ -382,6 +382,11 @@ async def test_run_sync_worker_cyclic_references() -> None:
     await to_thread.run_sync(foo, arg)
     cvar.set(Foo())
     gc.collect()
+    await checkpoint()
 
     assert gc.get_referrers(contextval) == no_other_refs()
     assert gc.get_referrers(foo) == no_other_refs()

and probably add a clarifying comment and link this PR about it. But, I would like to know your thoughts on my premise.

@agronholm agronholm left a comment

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'd like two changes here:

  1. Add a tox configuration for 3.14t (one-line modification)
  2. Add a free-threading classifier to pyproject.toml: Programming Language :: Python :: Free Threading :: 2 - Beta OR Programming Language :: Python :: Free Threading :: 3 - Stable depending on how you feel about this

@agronholm

Copy link
Copy Markdown
Owner

There's a strange number of jobs failing due to a timeout error around the file stream tests. I don't see how these changes could have caused that, especially on non-free-threading builds.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

yeah, I was just looking at it too!

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

locally for me. They are hanging actually.

@agronholm

Copy link
Copy Markdown
Owner

The only change I made was to pass False insted of Context(). According to the docs, those two should be equivalent.

@agronholm

Copy link
Copy Markdown
Owner

And given that only 3.14+ jobs timeout, this tracks.

@EmmanuelNiyonshuti

EmmanuelNiyonshuti commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

I'd like two changes here:

  1. Add a tox configuration for 3.14t (one-line modification)
  2. Add a free-threading classifier to pyproject.toml: Programming Language :: Python :: Free Threading :: 2 - Beta OR Programming Language :: Python :: Free Threading :: 3 - Stable depending on how you feel about this

we can go with Programming Language :: Python :: Free Threading :: 2 - Beta

in the porting guide for this classifier they state: Free threaded usage is supported, but documentation of constraints and limitations may be incomplete.

on the Stable classifier, I am not entirely sure on the clearly documented thread safety issues as they put it Supported for production use, multithreaded use is tested, and thread safety issues are clearly documented.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

I assume it should behave the same too, long as the flag for inheriting context is set to false.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

Perhaps I misunderstood what the docs say because I don't understand how you expected to get the same result by setting context to False.

Comment thread tests/test_to_thread.py Outdated
gc.collect()
# This checkpoint only exist to give the worker thread a moment to finish releasing its own
# references before we check. on free threaded python builds, this can
# otherwise race and intermittently show a leftover reference.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Actually, It was not ai generated😅. I thought it might a bit strange for someone to stumble on a checkpoint there.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

The only change I made was to pass False insted of Context(). According to the docs, those two should be equivalent.

Okay, I had a quick spin through the cpython Thread implementation and this is not equivalent to an explicit empty context, with this context setup is not happening at all.

@agronholm I think we need to go back to an explicit empty context?

@agronholm

Copy link
Copy Markdown
Owner

The only change I made was to pass False insted of Context(). According to the docs, those two should be equivalent.

Okay, I had a quick spin through the cpython Thread implementation and this is not equivalent to an explicit empty context, with this context setup is not happening at all.

@agronholm I think we need to go back to an explicit empty context?

Help me out here. Here's what the docs say:

If the flag is true, threads will start with a copy of the context of the caller of start(). If false, they will start with an empty context. To explicitly start with an empty context, pass a new instance of Context().

My interpretation is that False will start with an empty context, as it says. How is that any different from passing context=Context()?

@agronholm

Copy link
Copy Markdown
Owner

The only change I made was to pass False insted of Context(). According to the docs, those two should be equivalent.

Okay, I had a quick spin through the cpython Thread implementation and this is not equivalent to an explicit empty context, with this context setup is not happening at all.
@agronholm I think we need to go back to an explicit empty context?

Help me out here. Here's what the docs say:

If the flag is true, threads will start with a copy of the context of the caller of start(). If false, they will start with an empty context. To explicitly start with an empty context, pass a new instance of Context().

My interpretation is that False will start with an empty context, as it says. How is that any different from passing context=Context()?

Ah, I see where I went wrong. The parameter doesn't take any values other than Context instances or None. The flag refers to sys.flags.thread_inherit_context rather than a True or False passed explicitly.

@agronholm

Copy link
Copy Markdown
Owner

So by all means revert my commits, and we should be good then.

Comment thread docs/versionhistory.rst Outdated
@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

The only change I made was to pass False insted of Context(). According to the docs, those two should be equivalent.

Okay, I had a quick spin through the cpython Thread implementation and this is not equivalent to an explicit empty context, with this context setup is not happening at all.
@agronholm I think we need to go back to an explicit empty context?

Help me out here. Here's what the docs say:

If the flag is true, threads will start with a copy of the context of the caller of start(). If false, they will start with an empty context. To explicitly start with an empty context, pass a new instance of Context().

My interpretation is that False will start with an empty context, as it says. How is that any different from passing context=Context()?

False being whatever the flag sys.flags.thread_inherit_context will be set to. yes, False will start with an empty context but only from this flag. and this flag is False on python 314 so context is set to empty context here but on free threaded build, it is set to True and this doesn't pass an empty context instead it does context.copy_context() . thats why we explicitly passed in the empty context. the thing is Thread context is controlled by this flag.

@EmmanuelNiyonshuti

Copy link
Copy Markdown
Collaborator Author

The only change I made was to pass False insted of Context(). According to the docs, those two should be equivalent.

Okay, I had a quick spin through the cpython Thread implementation and this is not equivalent to an explicit empty context, with this context setup is not happening at all.
@agronholm I think we need to go back to an explicit empty context?

Help me out here. Here's what the docs say:

If the flag is true, threads will start with a copy of the context of the caller of start(). If false, they will start with an empty context. To explicitly start with an empty context, pass a new instance of Context().

My interpretation is that False will start with an empty context, as it says. How is that any different from passing context=Context()?

Ah, I see where I went wrong. The parameter doesn't take any values other than Context instances or None. The flag refers to sys.flags.thread_inherit_context rather than a True or False passed explicitly.

exactly! and context creation is controlled by this flag.

@agronholm agronholm left a comment

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.

Alright, we're set. Thanks!

@agronholm
agronholm merged commit 9f456ce into agronholm:master Jul 19, 2026
37 of 38 checks passed
@EmmanuelNiyonshuti
EmmanuelNiyonshuti deleted the free-threaded branch July 20, 2026 05:10
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.

Test failures with Python 3.14 freethreading

2 participants