Skip to content

Commit 9e50d98

Browse files
committed
gh-149816: Fix race in subtype_getweakref in free-threading build
1 parent 28eac9a commit 9e50d98

3 files changed

Lines changed: 240 additions & 3 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Free-threading regression tests for weakref races in subtype_getweakref().
4+
5+
Regression test for https://github.com/python/cpython/issues/149816
6+
finding #61: "Racy weakref head load before incref in Objects/typeobject.c".
7+
8+
subtype_getweakref() is the C implementation of the obj.__weakref__
9+
descriptor getter (Objects/typeobject.c). Before the fix it performed an
10+
unsynchronized load of *weaklistptr followed by Py_NewRef(), creating a
11+
use-after-free window: a concurrent weakref_dealloc() could free the
12+
weakref list head between the load and the incref.
13+
14+
The fix holds LOCK_WEAKREFS(obj) across the read+incref and uses
15+
_Py_TryIncref() instead of Py_NewRef() so that a weakref whose refcount
16+
has already hit zero is handled gracefully (returns None semantics).
17+
LOCK_WEAKREFS / UNLOCK_WEAKREFS are no-ops in GIL builds, so the fix has
18+
zero overhead outside the free-threaded build.
19+
"""
20+
21+
import gc
22+
import sys
23+
import threading
24+
import unittest
25+
import weakref
26+
27+
from test.support import threading_helper
28+
29+
30+
def run_in_threads(targets, *, barrier=None):
31+
"""Start all callables in *targets* in separate threads and join them.
32+
33+
If *barrier* is given it must be a threading.Barrier whose party count
34+
equals len(targets); each target is wrapped so that it waits at the
35+
barrier before doing any work, maximising contention.
36+
"""
37+
if barrier is not None:
38+
def wrap(fn):
39+
def wrapper():
40+
barrier.wait()
41+
fn()
42+
return wrapper
43+
targets = [wrap(t) for t in targets]
44+
45+
threads = [threading.Thread(target=t) for t in targets]
46+
for th in threads:
47+
th.start()
48+
for th in threads:
49+
th.join()
50+
51+
52+
@threading_helper.requires_working_threading()
53+
class TestWeakrefRaces(unittest.TestCase):
54+
"""Race-condition tests for subtype_getweakref() (gh-149816 finding #61)."""
55+
56+
# Number of iterations for the hot-loop race tests. High enough to
57+
# exercise the race window reliably on multi-core machines, low enough
58+
# to keep the test suite fast (no time-based loops).
59+
ITERATIONS = 5_000
60+
61+
def test_getweakref_no_crash(self):
62+
"""obj.__weakref__ racing with concurrent weakref creation/destruction.
63+
64+
Regression test for gh-149816 #61. Without the fix,
65+
subtype_getweakref() could incref a freed PyWeakReference object
66+
(use-after-free), causing a native crash or memory corruption.
67+
68+
The callback on weakref.ref() forces CPython to allocate a fresh
69+
PyWeakReference each time (instead of reusing an existing one),
70+
keeping list-head creation and destruction hot so the race window
71+
is exercised as often as possible.
72+
73+
A crash here means the LOCK_WEAKREFS + _Py_TryIncref fix is broken.
74+
"""
75+
class Target:
76+
pass
77+
78+
obj = Target()
79+
# Barrier ensures all threads enter their hot loops simultaneously,
80+
# maximising the chance of hitting the race window.
81+
barrier = threading.Barrier(2)
82+
83+
def reader():
84+
barrier.wait()
85+
for _ in range(self.ITERATIONS):
86+
# Calls subtype_getweakref() in C — the previously buggy path.
87+
ref = obj.__weakref__
88+
# Must be None or a weakref.ref — never corrupted memory.
89+
self.assertIn(
90+
type(ref),
91+
(type(None), weakref.ref),
92+
f"__weakref__ returned unexpected type {type(ref)!r}",
93+
)
94+
# If it is a live weakref it must still point to obj.
95+
if ref is not None:
96+
target = ref()
97+
self.assertIn(
98+
target,
99+
(None, obj),
100+
"__weakref__ returned a weakref pointing to wrong object",
101+
)
102+
103+
def mutator():
104+
barrier.wait()
105+
for _ in range(self.ITERATIONS):
106+
# Create a weakref with a callback. Because no variable
107+
# holds the result the refcount drops to 0 immediately,
108+
# triggering weakref_dealloc() — the "freeing" side of
109+
# the race. The callback prevents CPython from reusing
110+
# an existing weakref object, keeping alloc/free hot.
111+
weakref.ref(obj, lambda _: None)
112+
113+
run_in_threads([reader, mutator])
114+
115+
def test_getweakref_return_type(self):
116+
"""__weakref__ must return None or a weakref.ref, never garbage.
117+
118+
Verifies the correctness of subtype_getweakref() return values
119+
across the three possible states of the weakref list.
120+
"""
121+
class Target:
122+
pass
123+
124+
obj = Target()
125+
126+
# State 1: no weakrefs exist → must return None.
127+
result = obj.__weakref__
128+
self.assertIsNone(
129+
result,
130+
"__weakref__ should be None when no weakrefs exist",
131+
)
132+
133+
# State 2: one live weakref exists → must return it and it must
134+
# resolve back to obj.
135+
ref = weakref.ref(obj)
136+
result = obj.__weakref__
137+
self.assertIsNotNone(result, "__weakref__ should not be None with a live ref")
138+
self.assertIsInstance(result, weakref.ref)
139+
self.assertIs(
140+
result(),
141+
obj,
142+
"__weakref__ returned a weakref that does not point to obj",
143+
)
144+
145+
# State 3: all strong refs to the weakref object dropped → must
146+
# return None again. Both `ref` AND `result` (from State 2) must
147+
# be deleted — either one alone keeps the weakref alive.
148+
del ref, result
149+
gc.collect()
150+
result3 = obj.__weakref__
151+
self.assertIsNone(
152+
result3,
153+
"__weakref__ should be None after all weakrefs are deleted",
154+
)
155+
156+
def test_getweakref_no_refcount_leak(self):
157+
"""Each __weakref__ access must not inflate the weakref's refcount.
158+
159+
A double-incref bug in subtype_getweakref() would add one extra
160+
reference on every call, preventing the weakref from ever being
161+
freed (reference leak). sys.getrefcount() includes one transient
162+
reference for the function argument, so the count should be stable
163+
across repeated accesses.
164+
"""
165+
class Target:
166+
pass
167+
168+
obj = Target()
169+
ref = weakref.ref(obj)
170+
171+
# Baseline: one ref from `ref`, one transient from getrefcount().
172+
before = sys.getrefcount(ref)
173+
174+
for _ in range(1_000):
175+
r = obj.__weakref__
176+
del r # release the returned reference immediately
177+
178+
gc.collect()
179+
after = sys.getrefcount(ref)
180+
181+
self.assertEqual(
182+
before,
183+
after,
184+
f"Refcount grew from {before} to {after} after 1000 "
185+
f"__weakref__ accesses — possible double-incref in "
186+
f"subtype_getweakref()",
187+
)
188+
189+
def test_getweakref_many_readers(self):
190+
"""Multiple concurrent readers of __weakref__ must not crash.
191+
192+
Extends test_getweakref_no_crash to N reader threads + N mutator
193+
threads, matching the repro script's default cpu_count-per-role
194+
configuration, to stress true multi-core parallelism.
195+
"""
196+
import os
197+
198+
class Target:
199+
pass
200+
201+
obj = Target()
202+
n = max(2, (os.cpu_count() or 2) // 2) # at least 2 of each role
203+
barrier = threading.Barrier(n * 2)
204+
iters = max(1_000, self.ITERATIONS // n) # scale down per-thread iters
205+
206+
def reader():
207+
barrier.wait()
208+
for _ in range(iters):
209+
ref = obj.__weakref__
210+
self.assertIn(type(ref), (type(None), weakref.ref))
211+
212+
def mutator():
213+
barrier.wait()
214+
for _ in range(iters):
215+
weakref.ref(obj, lambda _: None)
216+
217+
run_in_threads([reader] * n + [mutator] * n)
218+
219+
220+
if __name__ == "__main__":
221+
unittest.main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix a Use-After-Free race condition in ``subtype_getweakref()`` during
2+
concurrent weakref creation and destruction in the free-threading build.

Objects/typeobject.c

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4070,12 +4070,26 @@ subtype_getweakref(PyObject *obj, void *context)
40704070
_PyObject_ASSERT((PyObject *)type,
40714071
((type->tp_weaklistoffset + (Py_ssize_t)sizeof(PyObject *))
40724072
<= type->tp_basicsize));
4073+
4074+
/* In free-threaded builds, the weakref list head can be freed by another
4075+
* thread between our load of *weaklistptr and the Py_NewRef() call
4076+
* (use-after-free / heap corruption). Hold the weakref striped lock across
4077+
* the read+incref to prevent this. LOCK_WEAKREFS/UNLOCK_WEAKREFS are no-ops
4078+
* in GIL builds, so this has zero overhead in the traditional build. */
4079+
LOCK_WEAKREFS(obj);
40734080
weaklistptr = (PyObject **)((char *)obj + type->tp_weaklistoffset);
40744081
if (*weaklistptr == NULL)
4075-
result = Py_None;
4076-
else
4082+
result = Py_NewRef(Py_None);
4083+
else if (_Py_TryIncref(*weaklistptr)) {
40774084
result = *weaklistptr;
4078-
return Py_NewRef(result);
4085+
}
4086+
else {
4087+
result = Py_NewRef(Py_None);
4088+
}
4089+
4090+
UNLOCK_WEAKREFS(obj);
4091+
4092+
return result;
40794093
}
40804094

40814095
/* getset definitions for common descriptors */

0 commit comments

Comments
 (0)