From 25bfa7feca91bc287d60234d9997b2c717bb72c9 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 15:05:41 +0200 Subject: [PATCH 01/15] Fix ThreadCache workers leaking spawner's Context on py314+ free-threaded builds --- src/trio/_core/_thread_cache.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/trio/_core/_thread_cache.py b/src/trio/_core/_thread_cache.py index c2b2315bd3..6565b6e6a1 100644 --- a/src/trio/_core/_thread_cache.py +++ b/src/trio/_core/_thread_cache.py @@ -2,6 +2,7 @@ import ctypes import ctypes.util +from contextvars import Context import os import sys import traceback @@ -151,8 +152,10 @@ def __init__(self, thread_cache: ThreadCache) -> None: self._worker_lock = Lock() self._worker_lock.acquire() self._default_name = f"Trio thread {next(name_counter)}" - - self._thread = Thread(target=self._work, name=self._default_name, daemon=True) + kwargs: dict[str. Any] = {} + if sys.version_info >= (3, 14): + kwargs["context"] = Context() + self._thread = Thread(target=self._work, name=self._default_name, daemon=True, **kwargs) if set_os_thread_name: set_os_thread_name(self._thread.ident, self._default_name) From df6fdaa6925cfe1088b1d9f1f5f7f5d44b2aae13 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:08:48 +0000 Subject: [PATCH 02/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/trio/_core/_thread_cache.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/trio/_core/_thread_cache.py b/src/trio/_core/_thread_cache.py index 6565b6e6a1..9529eddff2 100644 --- a/src/trio/_core/_thread_cache.py +++ b/src/trio/_core/_thread_cache.py @@ -2,10 +2,10 @@ import ctypes import ctypes.util -from contextvars import Context import os import sys import traceback +from contextvars import Context from functools import partial from itertools import count from threading import Lock, Thread @@ -152,10 +152,12 @@ def __init__(self, thread_cache: ThreadCache) -> None: self._worker_lock = Lock() self._worker_lock.acquire() self._default_name = f"Trio thread {next(name_counter)}" - kwargs: dict[str. Any] = {} + kwargs: dict[str.Any] = {} if sys.version_info >= (3, 14): kwargs["context"] = Context() - self._thread = Thread(target=self._work, name=self._default_name, daemon=True, **kwargs) + self._thread = Thread( + target=self._work, name=self._default_name, daemon=True, **kwargs + ) if set_os_thread_name: set_os_thread_name(self._thread.ident, self._default_name) From 7bfb77b644b51bc36be5ef33bef5f21e7d876b23 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 15:33:28 +0200 Subject: [PATCH 03/15] fix typo --- src/trio/_core/_thread_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trio/_core/_thread_cache.py b/src/trio/_core/_thread_cache.py index 9529eddff2..5755d80d4e 100644 --- a/src/trio/_core/_thread_cache.py +++ b/src/trio/_core/_thread_cache.py @@ -152,7 +152,7 @@ def __init__(self, thread_cache: ThreadCache) -> None: self._worker_lock = Lock() self._worker_lock.acquire() self._default_name = f"Trio thread {next(name_counter)}" - kwargs: dict[str.Any] = {} + kwargs: dict[str, Any] = {} if sys.version_info >= (3, 14): kwargs["context"] = Context() self._thread = Thread( From b43e4480249857c388baa65b40834ddb1bf727a8 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 15:48:51 +0200 Subject: [PATCH 04/15] add newsfragments --- newsfragments/3472.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/3472.bugfix.rst diff --git a/newsfragments/3472.bugfix.rst b/newsfragments/3472.bugfix.rst new file mode 100644 index 0000000000..9909c70ba8 --- /dev/null +++ b/newsfragments/3472.bugfix.rst @@ -0,0 +1 @@ +Fixed `ThreadCache` workers leaking spawner's `Context` on py314t+ free-threaded builds. \ No newline at end of file From 31aa1b3798e9af3933a545cd9ee62025f3383d84 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:49:17 +0000 Subject: [PATCH 05/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- newsfragments/3472.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsfragments/3472.bugfix.rst b/newsfragments/3472.bugfix.rst index 9909c70ba8..62fbd59c63 100644 --- a/newsfragments/3472.bugfix.rst +++ b/newsfragments/3472.bugfix.rst @@ -1 +1 @@ -Fixed `ThreadCache` workers leaking spawner's `Context` on py314t+ free-threaded builds. \ No newline at end of file +Fixed `ThreadCache` workers leaking spawner's `Context` on py314t+ free-threaded builds. From e3881ce1b810275d59cdb8969b8f88d32bbdc46c Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 16:18:03 +0200 Subject: [PATCH 06/15] fix typing and reformat newfragments entry --- newsfragments/3472.bugfix.rst | 2 +- src/trio/_core/_thread_cache.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/newsfragments/3472.bugfix.rst b/newsfragments/3472.bugfix.rst index 9909c70ba8..3fbf794bf7 100644 --- a/newsfragments/3472.bugfix.rst +++ b/newsfragments/3472.bugfix.rst @@ -1 +1 @@ -Fixed `ThreadCache` workers leaking spawner's `Context` on py314t+ free-threaded builds. \ No newline at end of file +Fixed ``ThreadCache``` workers leaking spawner's ``Context`` on ``py314t+`` free-threaded builds. \ No newline at end of file diff --git a/src/trio/_core/_thread_cache.py b/src/trio/_core/_thread_cache.py index 5755d80d4e..61b7834aa3 100644 --- a/src/trio/_core/_thread_cache.py +++ b/src/trio/_core/_thread_cache.py @@ -152,7 +152,7 @@ def __init__(self, thread_cache: ThreadCache) -> None: self._worker_lock = Lock() self._worker_lock.acquire() self._default_name = f"Trio thread {next(name_counter)}" - kwargs: dict[str, Any] = {} + kwargs: dict[str, Any] = {} # type: ignore[explicit-any] if sys.version_info >= (3, 14): kwargs["context"] = Context() self._thread = Thread( From 306edad9dd36cf66ba8285e1294dcca304870033 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:25:20 +0000 Subject: [PATCH 07/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/trio/_core/_thread_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trio/_core/_thread_cache.py b/src/trio/_core/_thread_cache.py index 61b7834aa3..3bf6e92e12 100644 --- a/src/trio/_core/_thread_cache.py +++ b/src/trio/_core/_thread_cache.py @@ -152,7 +152,7 @@ def __init__(self, thread_cache: ThreadCache) -> None: self._worker_lock = Lock() self._worker_lock.acquire() self._default_name = f"Trio thread {next(name_counter)}" - kwargs: dict[str, Any] = {} # type: ignore[explicit-any] + kwargs: dict[str, Any] = {} # type: ignore[explicit-any] if sys.version_info >= (3, 14): kwargs["context"] = Context() self._thread = Thread( From 0cb179c84b53f389d2d822584a086d16d980e118 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 16:43:30 +0200 Subject: [PATCH 08/15] add a regression test --- src/trio/_tests/test_threads.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/trio/_tests/test_threads.py b/src/trio/_tests/test_threads.py index 0ad1163011..b430ab2df9 100644 --- a/src/trio/_tests/test_threads.py +++ b/src/trio/_tests/test_threads.py @@ -1,6 +1,7 @@ from __future__ import annotations import contextvars +import gc import queue as stdlib_queue import re import sys @@ -687,6 +688,28 @@ def g() -> tuple[str, str, threading.Thread]: assert sniffio.current_async_library() == "trio" +async def test_worker_thread_context_not_leaked() -> None: + # Regression test for: https://github.com/python-trio/trio/issues/3472 + class Foo: + pass + + def sync_fn() -> None: + pass + + cvar: contextvars.ContextVar[Foo] = contextvars.ContextVar("cvar") + contextval = Foo() + cvar.set(contextval) + await to_thread_run_sync(sync_fn) + cvar.set(Foo()) + gc.collect() + + referrers = [ + r for r in gc.get_referrers(contextval) + if type(r).__name__ != "coroutine" + ] + assert not referrers, referrers + + async def test_trio_from_thread_run_sync() -> None: # Test that to_thread_run_sync correctly "hands off" the trio token to # trio.from_thread.run_sync() From f6c01df442af2d4d53964895e80c5ec519404b1a Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 16:48:12 +0200 Subject: [PATCH 09/15] update newfragments entry --- newsfragments/3472.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsfragments/3472.bugfix.rst b/newsfragments/3472.bugfix.rst index c54c284074..a3803690e1 100644 --- a/newsfragments/3472.bugfix.rst +++ b/newsfragments/3472.bugfix.rst @@ -1 +1 @@ -Fixed ``ThreadCache``` workers leaking spawner's ``Context`` on ``py314t+`` free-threaded builds. +Fixed ``trio.to_thread.run_sync``` workers leaking spawner's ``Context`` on ``py314t+`` free-threaded builds. From eec08bb499d1809a22b0f0abe49a1759752d8f1b Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 16:50:22 +0200 Subject: [PATCH 10/15] update _thread_cache --- src/trio/_core/_thread_cache.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/trio/_core/_thread_cache.py b/src/trio/_core/_thread_cache.py index 61b7834aa3..c18bb09abb 100644 --- a/src/trio/_core/_thread_cache.py +++ b/src/trio/_core/_thread_cache.py @@ -152,12 +152,13 @@ def __init__(self, thread_cache: ThreadCache) -> None: self._worker_lock = Lock() self._worker_lock.acquire() self._default_name = f"Trio thread {next(name_counter)}" - kwargs: dict[str, Any] = {} # type: ignore[explicit-any] + if sys.version_info >= (3, 14): - kwargs["context"] = Context() - self._thread = Thread( - target=self._work, name=self._default_name, daemon=True, **kwargs - ) + self._thread = Thread( + target=self._work, name=self._default_name, daemon=True, context=Context()) + else: + self._thread = Thread( + target=self._work, name=self._default_name, daemon=True) if set_os_thread_name: set_os_thread_name(self._thread.ident, self._default_name) From e114fc09d36760769a001ae42f74c4b28558b87c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:52:58 +0000 Subject: [PATCH 11/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/trio/_core/_thread_cache.py | 9 +++++++-- src/trio/_tests/test_threads.py | 5 ++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/trio/_core/_thread_cache.py b/src/trio/_core/_thread_cache.py index 6f6012f9e0..f48aa8eab4 100644 --- a/src/trio/_core/_thread_cache.py +++ b/src/trio/_core/_thread_cache.py @@ -154,10 +154,15 @@ def __init__(self, thread_cache: ThreadCache) -> None: self._default_name = f"Trio thread {next(name_counter)}" if sys.version_info >= (3, 14): self._thread = Thread( - target=self._work, name=self._default_name, daemon=True, context=Context()) + target=self._work, + name=self._default_name, + daemon=True, + context=Context(), + ) else: self._thread = Thread( - target=self._work, name=self._default_name, daemon=True) + target=self._work, name=self._default_name, daemon=True + ) if set_os_thread_name: set_os_thread_name(self._thread.ident, self._default_name) diff --git a/src/trio/_tests/test_threads.py b/src/trio/_tests/test_threads.py index b430ab2df9..00722a0f00 100644 --- a/src/trio/_tests/test_threads.py +++ b/src/trio/_tests/test_threads.py @@ -692,7 +692,7 @@ async def test_worker_thread_context_not_leaked() -> None: # Regression test for: https://github.com/python-trio/trio/issues/3472 class Foo: pass - + def sync_fn() -> None: pass @@ -704,8 +704,7 @@ def sync_fn() -> None: gc.collect() referrers = [ - r for r in gc.get_referrers(contextval) - if type(r).__name__ != "coroutine" + r for r in gc.get_referrers(contextval) if type(r).__name__ != "coroutine" ] assert not referrers, referrers From 114eb57c0915d53cacf616955173579f1527d99c Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 17:08:23 +0200 Subject: [PATCH 12/15] update the test to be skipped on pypi --- src/trio/_tests/test_threads.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/trio/_tests/test_threads.py b/src/trio/_tests/test_threads.py index b430ab2df9..fdcbaa6dc4 100644 --- a/src/trio/_tests/test_threads.py +++ b/src/trio/_tests/test_threads.py @@ -688,6 +688,13 @@ def g() -> tuple[str, str, threading.Thread]: assert sniffio.current_async_library() == "trio" +@pytest.mark.skipif( + sys.implementation.name == "pypy", + reason=( + "gc.get_referrers is broken on PyPy (see " + "https://github.com/pypy/pypy/issues/5075)" + ), +) async def test_worker_thread_context_not_leaked() -> None: # Regression test for: https://github.com/python-trio/trio/issues/3472 class Foo: From edf43b6ff33fa8ec883b37aa91884b7d5db330c7 Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 18:48:24 +0200 Subject: [PATCH 13/15] update the test --- src/trio/_tests/test_threads.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/trio/_tests/test_threads.py b/src/trio/_tests/test_threads.py index c649c0ff69..109242b474 100644 --- a/src/trio/_tests/test_threads.py +++ b/src/trio/_tests/test_threads.py @@ -697,6 +697,7 @@ def g() -> tuple[str, str, threading.Thread]: ) async def test_worker_thread_context_not_leaked() -> None: # Regression test for: https://github.com/python-trio/trio/issues/3472 + class Foo: pass @@ -710,10 +711,15 @@ def sync_fn() -> None: cvar.set(Foo()) gc.collect() - referrers = [ - r for r in gc.get_referrers(contextval) if type(r).__name__ != "coroutine" - ] - assert not referrers, referrers + def no_other_refs() -> list[object]: + if sys.version_info >= (3, 14): + return [sys._getframe(1).f_generator] + elif sys.version_info >= (3, 11): + return [] + else: + return [sys._getframe(1)] + + assert gc.get_referrers(contextval) == no_other_refs() async def test_trio_from_thread_run_sync() -> None: From 1c527cf5d3ae33d2a318ee86756079e3292b2948 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:50:16 +0000 Subject: [PATCH 14/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/trio/_tests/test_threads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trio/_tests/test_threads.py b/src/trio/_tests/test_threads.py index 109242b474..20a98c3222 100644 --- a/src/trio/_tests/test_threads.py +++ b/src/trio/_tests/test_threads.py @@ -718,7 +718,7 @@ def no_other_refs() -> list[object]: return [] else: return [sys._getframe(1)] - + assert gc.get_referrers(contextval) == no_other_refs() From 72aa082f74dc585b07b0c5a096f423ca2adfec0b Mon Sep 17 00:00:00 2001 From: EmmanuelNiyonshuti Date: Wed, 15 Jul 2026 20:54:47 +0200 Subject: [PATCH 15/15] simply regression test --- src/trio/_tests/test_threads.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/trio/_tests/test_threads.py b/src/trio/_tests/test_threads.py index 109242b474..532a20d36f 100644 --- a/src/trio/_tests/test_threads.py +++ b/src/trio/_tests/test_threads.py @@ -688,13 +688,6 @@ def g() -> tuple[str, str, threading.Thread]: assert sniffio.current_async_library() == "trio" -@pytest.mark.skipif( - sys.implementation.name == "pypy", - reason=( - "gc.get_referrers is broken on PyPy (see " - "https://github.com/pypy/pypy/issues/5075)" - ), -) async def test_worker_thread_context_not_leaked() -> None: # Regression test for: https://github.com/python-trio/trio/issues/3472 @@ -706,20 +699,15 @@ def sync_fn() -> None: cvar: contextvars.ContextVar[Foo] = contextvars.ContextVar("cvar") contextval = Foo() + ref = weakref.ref(contextval) cvar.set(contextval) await to_thread_run_sync(sync_fn) cvar.set(Foo()) + + del contextval gc.collect() - def no_other_refs() -> list[object]: - if sys.version_info >= (3, 14): - return [sys._getframe(1).f_generator] - elif sys.version_info >= (3, 11): - return [] - else: - return [sys._getframe(1)] - - assert gc.get_referrers(contextval) == no_other_refs() + assert ref() is None async def test_trio_from_thread_run_sync() -> None: