|
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 | | -""" |
| 1 | +"""Regression tests for weakref races in subtype_getweakref() (gh-149816).""" |
20 | 2 |
|
21 | 3 | import gc |
22 | 4 | import sys |
23 | | -import threading |
24 | 5 | import unittest |
25 | 6 | import weakref |
26 | 7 |
|
27 | 8 | from test.support import threading_helper |
28 | 9 |
|
29 | 10 |
|
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 | 11 | @threading_helper.requires_working_threading() |
53 | 12 | class TestWeakrefRaces(unittest.TestCase): |
54 | | - """Race-condition tests for subtype_getweakref() (gh-149816 finding #61).""" |
55 | 13 |
|
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 | 14 | ITERATIONS = 5_000 |
60 | 15 |
|
61 | 16 | 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 | | - """ |
| 17 | + """obj.__weakref__ racing with concurrent weakref creation/destruction.""" |
75 | 18 | class Target: |
76 | 19 | pass |
77 | 20 |
|
78 | 21 | 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 | 22 |
|
83 | 23 | def reader(): |
84 | | - barrier.wait() |
85 | 24 | for _ in range(self.ITERATIONS): |
86 | | - # Calls subtype_getweakref() in C — the previously buggy path. |
87 | 25 | 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. |
| 26 | + self.assertIn(type(ref), (type(None), weakref.ref)) |
95 | 27 | 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 | | - ) |
| 28 | + self.assertIn(ref(), (None, obj)) |
102 | 29 |
|
103 | 30 | def mutator(): |
104 | | - barrier.wait() |
105 | 31 | 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 | 32 | weakref.ref(obj, lambda _: None) |
112 | 33 |
|
113 | | - run_in_threads([reader, mutator]) |
| 34 | + threading_helper.run_concurrently([reader, mutator]) |
114 | 35 |
|
115 | 36 | 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 | | - """ |
| 37 | + """__weakref__ must return None or a live weakref.ref.""" |
121 | 38 | class Target: |
122 | 39 | pass |
123 | 40 |
|
124 | 41 | obj = Target() |
125 | 42 |
|
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 | | - ) |
| 43 | + self.assertIsNone(obj.__weakref__) |
132 | 44 |
|
133 | | - # State 2: one live weakref exists → must return it and it must |
134 | | - # resolve back to obj. |
135 | 45 | ref = weakref.ref(obj) |
136 | 46 | result = obj.__weakref__ |
137 | | - self.assertIsNotNone(result, "__weakref__ should not be None with a live ref") |
138 | 47 | 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. |
| 48 | + self.assertIs(result(), obj) |
| 49 | + |
148 | 50 | del ref, result |
149 | 51 | gc.collect() |
150 | | - result3 = obj.__weakref__ |
151 | | - self.assertIsNone( |
152 | | - result3, |
153 | | - "__weakref__ should be None after all weakrefs are deleted", |
154 | | - ) |
| 52 | + self.assertIsNone(obj.__weakref__) |
155 | 53 |
|
156 | 54 | 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 | | - """ |
| 55 | + """Each __weakref__ access must not inflate the weakref's refcount.""" |
165 | 56 | class Target: |
166 | 57 | pass |
167 | 58 |
|
168 | 59 | obj = Target() |
169 | 60 | ref = weakref.ref(obj) |
170 | | - |
171 | | - # Baseline: one ref from `ref`, one transient from getrefcount(). |
172 | 61 | before = sys.getrefcount(ref) |
173 | 62 |
|
174 | 63 | for _ in range(1_000): |
175 | 64 | r = obj.__weakref__ |
176 | | - del r # release the returned reference immediately |
| 65 | + del r |
177 | 66 |
|
178 | 67 | 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 | | - ) |
| 68 | + self.assertEqual(sys.getrefcount(ref), before) |
188 | 69 |
|
189 | 70 | 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 | | - |
| 71 | + """Multiple concurrent readers and mutators must not crash.""" |
198 | 72 | class Target: |
199 | 73 | pass |
200 | 74 |
|
201 | 75 | 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 |
| 76 | + n = 4 |
| 77 | + iters = self.ITERATIONS // n |
205 | 78 |
|
206 | 79 | def reader(): |
207 | | - barrier.wait() |
208 | 80 | for _ in range(iters): |
209 | 81 | ref = obj.__weakref__ |
210 | 82 | self.assertIn(type(ref), (type(None), weakref.ref)) |
211 | 83 |
|
212 | 84 | def mutator(): |
213 | | - barrier.wait() |
214 | 85 | for _ in range(iters): |
215 | 86 | weakref.ref(obj, lambda _: None) |
216 | 87 |
|
217 | | - run_in_threads([reader] * n + [mutator] * n) |
| 88 | + threading_helper.run_concurrently([reader] * n + [mutator] * n) |
218 | 89 |
|
219 | 90 |
|
220 | 91 | if __name__ == "__main__": |
|
0 commit comments