Skip to content

Add a lock to deterministic_PRNG #4455

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
RELEASE_TYPE: patch

Improve threading compatibility of an internal helper for managing deterministic rng seeding.
14 changes: 9 additions & 5 deletions hypothesis-python/src/hypothesis/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
SearchStrategy,
check_strategy,
)
from hypothesis.utils.threading import ThreadLocal
from hypothesis.vendor.pretty import RepresentationPrinter
from hypothesis.version import __version__

Expand All @@ -149,7 +150,11 @@
running_under_pytest = False
pytest_shows_exceptiongroups = True
global_force_seed = None
_hypothesis_global_random = None
# `threadlocal` stores "engine-global" constants, which are global relative to a
# ConjectureRunner instance (roughly speaking). Since only one conjecture runner
# instance can be active per thread, making engine constants thread-local prevents
# the ConjectureRunner instances of concurrent threads from treading on each other.
threadlocal = ThreadLocal(_hypothesis_global_random=None)


@dataclass
Expand Down Expand Up @@ -703,10 +708,9 @@ def get_random_for_wrapped_test(test, wrapped_test):
elif global_force_seed is not None:
return Random(global_force_seed)
else:
global _hypothesis_global_random
if _hypothesis_global_random is None: # pragma: no cover
_hypothesis_global_random = Random()
seed = _hypothesis_global_random.getrandbits(128)
if threadlocal._hypothesis_global_random is None: # pragma: no cover
threadlocal._hypothesis_global_random = Random()
seed = threadlocal._hypothesis_global_random.getrandbits(128)
wrapped_test._hypothesis_internal_use_generated_seed = seed
return Random(seed)

Expand Down
8 changes: 5 additions & 3 deletions hypothesis-python/src/hypothesis/internal/entropy.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,11 @@ def deterministic_PRNG(seed: int = 0) -> Generator[None, None, None]:
bad idea in principle, and breaks all kinds of independence assumptions
in practice.
"""
if hypothesis.core._hypothesis_global_random is None: # pragma: no cover
hypothesis.core._hypothesis_global_random = random.Random()
register_random(hypothesis.core._hypothesis_global_random)
if (
hypothesis.core.threadlocal._hypothesis_global_random is None
): # pragma: no cover
hypothesis.core.threadlocal._hypothesis_global_random = random.Random()
register_random(hypothesis.core.threadlocal._hypothesis_global_random)

seed_all, restore_all = get_seeder_and_restorer(seed)
seed_all()
Expand Down
44 changes: 44 additions & 0 deletions hypothesis-python/src/hypothesis/utils/threading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# This file is part of Hypothesis, which may be found at
# https://github.com/HypothesisWorks/hypothesis/
#
# Copyright the Hypothesis Authors.
# Individual contributors are listed in AUTHORS.rst and the git log.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.

import threading
from typing import Any


class ThreadLocal:
"""
Manages thread-local state. ThreadLocal forwards getattr and setattr to a
threading.local() instance. The passed kwargs defines the available attributes
on the threadlocal and their default values.

The only supported names to geattr and setattr are the keys of the passed kwargs.
"""

def __init__(self, **kwargs: Any) -> None:
self.__initialized = False
self.__kwargs = kwargs
self.__threadlocal = threading.local()
self.__initialized = True

def __getattr__(self, name: str) -> Any:
if name not in self.__kwargs:
raise AttributeError(f"No attribute {name}")
if not hasattr(self.__threadlocal, name):
setattr(self.__threadlocal, name, self.__kwargs[name])
return getattr(self.__threadlocal, name)

def __setattr__(self, name: str, value: Any) -> None:
# disable attribute-forwarding while initializing
if "_ThreadLocal__initialized" not in self.__dict__ or not self.__initialized:
super().__setattr__(name, value)
else:
if name not in self.__kwargs:
raise AttributeError(f"No attribute {name}")
setattr(self.__threadlocal, name, value)
8 changes: 4 additions & 4 deletions hypothesis-python/tests/cover/test_random_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,11 @@ def test(r):

test()
state_a = random.getstate()
state_a2 = core._hypothesis_global_random.getstate()
state_a2 = core.threadlocal._hypothesis_global_random.getstate()

test()
state_b = random.getstate()
state_b2 = core._hypothesis_global_random.getstate()
state_b2 = core.threadlocal._hypothesis_global_random.getstate()

assert state_a == state_b
assert state_a2 != state_b2
Expand All @@ -147,11 +147,11 @@ def test_find_does_not_pollute_state():
with deterministic_PRNG():
find(st.random_module(), lambda r: True)
state_a = random.getstate()
state_a2 = core._hypothesis_global_random.getstate()
state_a2 = core.threadlocal._hypothesis_global_random.getstate()

find(st.random_module(), lambda r: True)
state_b = random.getstate()
state_b2 = core._hypothesis_global_random.getstate()
state_b2 = core.threadlocal._hypothesis_global_random.getstate()

assert state_a == state_b
assert state_a2 != state_b2
Expand Down
47 changes: 47 additions & 0 deletions hypothesis-python/tests/cover/test_threading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# This file is part of Hypothesis, which may be found at
# https://github.com/HypothesisWorks/hypothesis/
#
# Copyright the Hypothesis Authors.
# Individual contributors are listed in AUTHORS.rst and the git log.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.

import pytest

from hypothesis.utils.threading import ThreadLocal


def test_threadlocal_setattr_and_getattr():
threadlocal = ThreadLocal(a=1, b=2)
assert threadlocal.a == 1
assert threadlocal.b == 2
# check that we didn't add attributes to the ThreadLocal instance itself
# instead of its threading.local() variable
assert set(threadlocal.__dict__) == {
"_ThreadLocal__initialized",
"_ThreadLocal__kwargs",
"_ThreadLocal__threadlocal",
}

threadlocal.a = 3
assert threadlocal.a == 3
assert threadlocal.b == 2
assert set(threadlocal.__dict__) == {
"_ThreadLocal__initialized",
"_ThreadLocal__kwargs",
"_ThreadLocal__threadlocal",
}


def test_nonexistent_getattr_raises():
threadlocal = ThreadLocal(a=1)
with pytest.raises(AttributeError):
_c = threadlocal.c


def test_nonexistent_setattr_raises():
threadlocal = ThreadLocal(a=1)
with pytest.raises(AttributeError):
threadlocal.c = 2
6 changes: 3 additions & 3 deletions hypothesis-python/tests/nocover/test_randomization.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ def f1(n):
def f2(n):
choices2.append(n)

core._hypothesis_global_random = Random(0)
state = core._hypothesis_global_random.getstate()
core.threadlocal._hypothesis_global_random = Random(0)
state = core.threadlocal._hypothesis_global_random.getstate()
f1()

core._hypothesis_global_random.setstate(state)
core.threadlocal._hypothesis_global_random.setstate(state)
f2()

assert choices1 == choices2
Expand Down