Repository: HypothesisWorks/hypothesis

Issue:
Use of gc.get_referrers() in register_random breaks in free-threaded CPython 3.13
See https://github.com/python/cpython/issues/118522 for where I initially thought this was a CPython bug.

In the Python 3.13 free-threaded build, the current plan is for certain objects to be marked as immortal if any threads are created at runtime. See https://github.com/python/cpython/issues/117783. In Python 3.14 these objects will be reference counted using a deferred reference counting strategy.

This change in Python 3.13 breaks the check in `register_random` based on `gc.get_referrers()`. You can reproduce this by first setting up a free-threaded build of python 3.13 (see https://github.com/quansight-labs/free-threaded-compatibility for more details on setting up a python 3.13 free-threaded dev environment) and then applying the following patch to a checkout of the numpy repo:

```
diff --git a/numpy/_core/tests/test_arrayprint.py b/numpy/_core/tests/test_arrayprint.py
index d9caced3c1..c70d68c9d7 100644
--- a/numpy/_core/tests/test_arrayprint.py
+++ b/numpy/_core/tests/test_arrayprint.py
@@ -1,6 +1,7 @@
 import sys
+import threading
 import gc
-from hypothesis import given
+from hypothesis import given, reproduce_failure
 from hypothesis.extra import numpy as hynp
 import pytest
 
@@ -12,6 +13,13 @@
 from numpy._core.arrayprint import _typelessdata
 import textwrap
 
+def dummy():
+    return
+
+t = threading.Thread(target=dummy)
+t.start()
+t.join()
+
 class TestArrayRepr:
     def test_nan_inf(self):
         x = np.array([np.nan, np.inf])
@@ -500,6 +508,7 @@ def test_nested_array_repr(self):
             '                     [1.]])]], dtype=object)'
         )
 
+    @reproduce_failure('6.100.2', b'AA==')
     @given(hynp.from_dtype(np.dtype("U")))
     def test_any_text(self, text):
         # This test checks that, given any value that can be represented in an
```

You can then execute the test with

```
spin test -- "numpy/_core/tests/test_arrayprint.py::TestArray2String::test_any_text"
```

And you'll see the warning about an RNG possibly getting garbage collected spuriously raised for hypothesis' own default global RNG instance:


```
>                   warnings.warn(
                        "It looks like `register_random` was passed an object that could "
                        "be garbage collected immediately after `register_random` creates "
                        "a weakref to it. This will prevent Hypothesis from managing this "
                        "PRNG. See the docs for `register_random` for more details.",
                        HypothesisWarning,
                        stacklevel=2,
                    )
E                   hypothesis.errors.HypothesisWarning: It looks like `register_random` was passed an object that could be garbage collected immediately after `register_random` creates a weakref to it. This will prevent Hypothesis from managing this PRNG. See the docs for `register_random` for more details.

r          = <random.Random object at 0x415d8f81e10>

/Users/goldbaum/.pyenv/versions/3.13-dev-bisect/lib/python3.13/site-packages/hypothesis/internal/entropy.py:131: HypothesisWarning
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> PDB post_mortem (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /Users/goldbaum/.pyenv/versions/3.13-dev-bisect/lib/python3.13/site-packages/hypothesis/internal/entropy.py(131)register_random()
-> warnings.warn(
(Pdb) up
> /Users/goldbaum/.pyenv/versions/3.13-dev-bisect/lib/python3.13/site-packages/hypothesis/internal/entropy.py(192)deterministic_PRNG()
-> register_random(hypothesis.core._hypothesis_global_random)
```

I think maybe the easiest fix is to add an additional check like the one that's already there for pypy for the free-threaded python build. Note that this behavior will only happen if a thread is spawned before the RNG is registered, so the behavior of this function will be impacted by whether or not unrelated code spawned a thread.


Discussion:
Wow, that's a tricky interaction - thanks for tracking it down and writing up such a great report 😍 

I agree that disabling the check on affected builds seems like the best solution here; it'll still be useful to most people most of the time but without this tricky false alarm.

Would you be interested in opening a PR to add that extra condition?

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/internal/compat.py b/hypothesis-python/src/hypothesis/internal/compat.py
--- a/hypothesis-python/src/hypothesis/internal/compat.py
+++ b/hypothesis-python/src/hypothesis/internal/compat.py
@@ -14,6 +14,7 @@
 import inspect
 import platform
 import sys
+import sysconfig
 import typing
 from functools import partial
 from typing import Any, ForwardRef, List, Optional, get_args
@@ -43,6 +44,8 @@
 PYPY = platform.python_implementation() == "PyPy"
 GRAALPY = platform.python_implementation() == "GraalVM"
 WINDOWS = platform.system() == "Windows"
+# First defined in CPython 3.13, defaults to False
+FREE_THREADED_CPYTHON = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
 
 
 def add_note(exc, note):
diff --git a/hypothesis-python/src/hypothesis/internal/entropy.py b/hypothesis-python/src/hypothesis/internal/entropy.py
--- a/hypothesis-python/src/hypothesis/internal/entropy.py
+++ b/hypothesis-python/src/hypothesis/internal/entropy.py
@@ -19,7 +19,7 @@
 
 import hypothesis.core
 from hypothesis.errors import HypothesisWarning, InvalidArgument
-from hypothesis.internal.compat import GRAALPY, PYPY
+from hypothesis.internal.compat import FREE_THREADED_CPYTHON, GRAALPY, PYPY
 
 if TYPE_CHECKING:
     from typing import Protocol
@@ -116,7 +116,7 @@ def my_WORKING_hook():
         return
 
     if not (PYPY or GRAALPY):  # pragma: no branch
-        # PYPY and GRAALPY do not have `sys.getrefcount`
+        # PYPY and GRAALPY do not have `sys.getrefcount`.
         gc.collect()
         if not gc.get_referrers(r):
             if sys.getrefcount(r) <= _PLATFORM_REF_COUNT:
@@ -127,7 +127,12 @@ def my_WORKING_hook():
                     "PRNG. See the docs for `register_random` for more "
                     "details."
                 )
-            else:
+            elif not FREE_THREADED_CPYTHON:
+                # On CPython, check for the free-threaded build because
+                # gc.get_referrers() ignores objects with immortal refcounts
+                # and objects are immortalized in the Python 3.13
+                # free-threading implementation at runtime.
+
                 warnings.warn(
                     "It looks like `register_random` was passed an object that could "
                     "be garbage collected immediately after `register_random` creates "


Test patch: