summaryrefslogtreecommitdiff
path: root/dev-python/asgiref/files/asgiref-3.11.1-ft.patch
blob: af3fb5e38e1362e8d4ee296a2a409e1fa9d9c3e2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
From 05125d34a139db0d5b9392786ba3bc6d00c79826 Mon Sep 17 00:00:00 2001
From: Carlton Gibson <carlton.gibson@noumenal.es>
Date: Fri, 19 Jun 2026 12:25:46 +0200
Subject: [PATCH] Test free-threading builds and fix Local data leak for
 thread_inherit_context.

--- a/asgiref/local.py
+++ b/asgiref/local.py
@@ -2,21 +2,58 @@
 import contextlib
 import contextvars
 import threading
-from typing import Any, Dict, Union
+from typing import Any, Union
+
+
+class _Storage:
+    """Thread-tagged storage for a non-thread-critical ``Local``.
+
+    The data is tagged with the identity of the thread that owns it. This lets
+    ``_CVar`` ignore data that leaked into an unrelated thread.
+
+    Python 3.14 added ``sys.flags.thread_inherit_context``, which is enabled by
+    default on free-threaded builds. When set, a new thread starts with a copy
+    of the spawning thread's context instead of an empty one, so the contextvar
+    backing a ``Local`` would otherwise be visible in any thread spawned from
+    one that had set it -- breaking the documented "thread-local in sync
+    threads" behaviour. asgiref re-homes the storage to the current thread at
+    the points where it *intentionally* moves work between threads (see
+    ``asgiref.sync._restore_context``); data merely inherited by an unrelated
+    thread is never re-homed and so stays isolated.
+    """
+
+    __slots__ = ("thread_id", "data")
+
+    def __init__(self, thread_id: int, data: dict[str, Any]) -> None:
+        self.thread_id = thread_id
+        self.data = data
+
+
+def _rehome(storage: "_Storage") -> "_Storage":
+    """Return a copy of *storage* owned by the current thread."""
+    return _Storage(threading.get_ident(), storage.data)
 
 
 class _CVar:
     """Storage utility for Local."""
 
     def __init__(self) -> None:
-        self._data: "contextvars.ContextVar[Dict[str, Any]]" = contextvars.ContextVar(
+        self._data: "contextvars.ContextVar[_Storage]" = contextvars.ContextVar(
             "asgiref.local"
         )
 
+    def _storage(self) -> "_Storage":
+        # Only return storage that belongs to the current thread. Storage with
+        # a different thread id was inherited by this thread (rather than
+        # intentionally moved here by asgiref) and must not be visible.
+        storage = self._data.get(None)
+        if storage is None or storage.thread_id != threading.get_ident():
+            return _Storage(threading.get_ident(), {})
+        return storage
+
     def __getattr__(self, key):
-        storage_object = self._data.get({})
         try:
-            return storage_object[key]
+            return self._storage().data[key]
         except KeyError:
             raise AttributeError(f"{self!r} object has no attribute {key!r}")
 
@@ -24,15 +61,15 @@ def __setattr__(self, key: str, value: Any) -> None:
         if key == "_data":
             return super().__setattr__(key, value)
 
-        storage_object = self._data.get({}).copy()
-        storage_object[key] = value
-        self._data.set(storage_object)
+        data = self._storage().data.copy()
+        data[key] = value
+        self._data.set(_Storage(threading.get_ident(), data))
 
     def __delattr__(self, key: str) -> None:
-        storage_object = self._data.get({}).copy()
-        if key in storage_object:
-            del storage_object[key]
-            self._data.set(storage_object)
+        data = self._storage().data.copy()
+        if key in data:
+            del data[key]
+            self._data.set(_Storage(threading.get_ident(), data))
         else:
             raise AttributeError(f"{self!r} object has no attribute {key!r}")
 
--- a/asgiref/sync.py
+++ b/asgiref/sync.py
@@ -23,7 +23,7 @@
 )
 
 from .current_thread_executor import CurrentThreadExecutor
-from .local import Local
+from .local import Local, _rehome, _Storage
 
 if TYPE_CHECKING:
     # This is not available to import at runtime
@@ -39,6 +39,12 @@ def _restore_context(context: contextvars.Context) -> None:
     # context for downstream consumers
     for cvar in context:
         cvalue = context.get(cvar)
+        # asgiref is deliberately moving this context onto the current thread,
+        # so re-home any Local storage to it. This keeps Local data visible
+        # across async_to_sync / sync_to_async boundaries while leaving data
+        # merely inherited by an unrelated thread isolated (see asgiref.local).
+        if isinstance(cvalue, _Storage):
+            cvalue = _rehome(cvalue)
         try:
             if cvar.get() != cvalue:
                 cvar.set(cvalue)
@@ -486,7 +492,15 @@ async def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
             executor = self._executor
 
         context = contextvars.copy_context() if self.context is None else self.context
-        child = functools.partial(self.func, *args, **kwargs)
+        inner = functools.partial(self.func, *args, **kwargs)
+
+        def child() -> _R:
+            # The sync function runs inside ``context`` (a copy of the calling
+            # async context) on a worker thread. Re-home any Local storage to
+            # this thread so it stays visible here (see _restore_context).
+            _restore_context(context)
+            return inner()
+
         func = context.run
         task_context: list[asyncio.Task[Any]] = []
 
--- a/tests/test_garbage_collection.py
+++ b/tests/test_garbage_collection.py
@@ -35,6 +35,14 @@ def clean_up_after_garbage_collection_test() -> None:
 @pytest.mark.skipif(
     sys.implementation.name == "pypy", reason="Test relies on CPython GC internals"
 )
+@pytest.mark.skipif(
+    not getattr(sys, "_is_gil_enabled", lambda: True)(),
+    reason=(
+        "On a free-threaded build the cyclic collector may reclaim objects that "
+        "are freed immediately by reference counting under the GIL, so they show "
+        "up in gc.garbage with DEBUG_SAVEALL even without a real leak."
+    ),
+)
 def test_thread_critical_Local_remove_all_reference_cycles() -> None:
     try:
         # given
--- a/tests/test_local.py
+++ b/tests/test_local.py
@@ -1,4 +1,5 @@
 import asyncio
+import contextvars
 import gc
 import threading
 from threading import Thread
@@ -359,6 +360,45 @@ def _test() -> None:
     assert test_local.value == 0
 
 
+def test_local_not_inherited_by_new_thread() -> None:
+    """
+    A value set in one sync thread must not be visible in a separately spawned
+    sync thread.
+
+    On Python 3.14+ a new thread inherits a copy of the spawning thread's
+    context when ``sys.flags.thread_inherit_context`` is enabled. That flag
+    defaults to on for free-threaded builds and off for the regular GIL build
+    (where it is opt-in via ``-X thread_inherit_context=1`` /
+    ``PYTHON_THREAD_INHERIT_CONTEXT=1``). When enabled it would otherwise leak
+    non-thread-critical ``Local`` data across unrelated threads.
+    """
+    test_local = Local()
+    test_local.value = "parent"
+
+    # Capture the child's observation in the parent so an in-thread assertion
+    # failure actually fails the test.
+    observed: "dict[str, object]" = {}
+
+    def child() -> None:
+        observed["has_value"] = hasattr(test_local, "value")
+        # The child gets its own isolated storage.
+        test_local.value = "child"
+        observed["child_value"] = test_local.value
+
+    # Force the worst case by running the child in a copy of this thread's
+    # context (mirroring thread_inherit_context), so the test exercises the leak
+    # path on every build regardless of the flag default.
+    parent_context = contextvars.copy_context()
+    thread = Thread(target=lambda: parent_context.run(child))
+    thread.start()
+    thread.join()
+
+    assert observed["has_value"] is False
+    assert observed["child_value"] == "child"
+    # The child's write must not leak back to the parent either.
+    assert test_local.value == "parent"
+
+
 @pytest.mark.asyncio
 async def test_visibility_task() -> None:
     """Check visibility with asyncio tasks."""