|
| 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() |
0 commit comments