Skip to content

Commit 25c272e

Browse files
authored
Merge pull request #4453 from tybug/free-threading-random-deprecation
Make `deprecate_random_in_strategy` thread safe
2 parents 052c879 + 65e3f5e commit 25c272e

File tree

4 files changed

+80
-16
lines changed

4 files changed

+80
-16
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
RELEASE_TYPE: patch
2+
3+
Makes the deprecation warning for using the global random instance thread-safe, as part of our work towards thread safety (:issue:`4451`).

hypothesis-python/src/hypothesis/control.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,40 @@ def __call__(self, x):
112112

113113
@contextmanager
114114
def deprecate_random_in_strategy(fmt, *args):
115-
_global_rand_state = random.getstate()
115+
from hypothesis.internal import entropy
116+
117+
state_before = random.getstate()
116118
yield (checker := _Checker())
117-
if _global_rand_state != random.getstate() and not checker.saw_global_random:
118-
# raise InvalidDefinition
119+
state_after = random.getstate()
120+
if (
121+
# there is a threading race condition here with deterministic_PRNG. Say
122+
# we have two threads 1 and 2. We start in global random state A, and
123+
# deterministic_PRNG sets to global random state B (which is constant across
124+
# threads since we seed to 0 unconditionally). Then we might have state
125+
# transitions:
126+
#
127+
# [1] [2]
128+
# A -> B deterministic_PRNG().__enter__
129+
# B ->B deterministic_PRNG().__enter__
130+
# state_before = B deprecate_random_in_strategy.__enter__
131+
# B -> A deterministic_PRNG().__exit__
132+
# state_after = A deprecate_random_in_strategy.__exit__
133+
#
134+
# where state_before != state_after because a different thread has reset
135+
# the global random state.
136+
#
137+
# To fix this, we track the latest set global random state in
138+
# deterministic_PRNG, and will not note a deprecation (or error, in the
139+
# future) if the state afterwards is the same as the state that
140+
# deterministic_PRNG set to.
141+
state_after
142+
not in [
143+
state_before,
144+
entropy._most_recent_random_state_enter,
145+
entropy._most_recent_random_state_exit,
146+
]
147+
and not checker.saw_global_random
148+
):
119149
note_deprecation(
120150
"Do not use the `random` module inside strategies; instead "
121151
"consider `st.randoms()`, `st.sampled_from()`, etc. " + fmt.format(*args),

hypothesis-python/src/hypothesis/internal/conjecture/junkdrawer.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,12 @@ def stack_depth_of_caller() -> int:
292292
# recursion limit on exit to be any of the following:
293293
#
294294
# * the recursion limit on enter.
295-
# * the recursion limit as set by any other enter of ensure_free_stackframes.
295+
# * the recursion limit as set by the enter of the most recent
296+
# ensure_free_stackframes.
296297
# * the recursion limit as set by the exit of the most recent
297298
# ensure_free_stackframes.
298-
global_maxdepth_enters: list[int] = []
299-
most_recent_maxdepth_exit: Optional[int] = None
299+
_most_recent_maxdepth_enter: Optional[int] = None
300+
_most_recent_maxdepth_exit: Optional[int] = None
300301

301302

302303
class ensure_free_stackframes:
@@ -305,6 +306,7 @@ class ensure_free_stackframes:
305306
"""
306307

307308
def __enter__(self) -> None:
309+
global _most_recent_maxdepth_enter
308310
cur_depth = stack_depth_of_caller()
309311
self.old_maxdepth = sys.getrecursionlimit()
310312
# The default CPython recursionlimit is 1000, but pytest seems to bump
@@ -321,23 +323,22 @@ def __enter__(self) -> None:
321323
"avoid extending the stack limit in an infinite loop..."
322324
% (self.new_maxdepth - self.old_maxdepth, self.old_maxdepth)
323325
)
324-
global_maxdepth_enters.append(self.new_maxdepth)
326+
_most_recent_maxdepth_enter = self.new_maxdepth
325327
sys.setrecursionlimit(self.new_maxdepth)
326328

327329
def __exit__(self, *args, **kwargs):
328-
global most_recent_maxdepth_exit
330+
global _most_recent_maxdepth_exit
329331

330332
# in single-threaded uses, we expect sys.getrecursionlimit == self.maxdepth.
331333
# The other checks are to avoid spurious warnings in multi-threaded
332334
# environments. Adding them slightly weakens this check, but acceptably so.
333335
if sys.getrecursionlimit() in [
334336
self.new_maxdepth,
335-
*global_maxdepth_enters,
336-
most_recent_maxdepth_exit,
337+
_most_recent_maxdepth_enter,
338+
_most_recent_maxdepth_exit,
337339
]:
338-
most_recent_maxdepth_exit = self.old_maxdepth
340+
_most_recent_maxdepth_exit = self.old_maxdepth
339341
sys.setrecursionlimit(self.old_maxdepth)
340-
global_maxdepth_enters.remove(self.new_maxdepth)
341342
else: # pragma: no cover
342343
warnings.warn(
343344
"The recursion limit will not be reset, since it was changed "

hypothesis-python/src/hypothesis/internal/entropy.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,13 @@ def setstate(self, *args: Any, **kwargs: Any) -> Any: ...
3535
else: # pragma: no cover
3636
RandomLike = random.Random
3737

38+
_RKEY = count()
39+
_global_random_rkey = next(_RKEY)
3840
# This is effectively a WeakSet, which allows us to associate the saved states
3941
# with their respective Random instances even as new ones are registered and old
4042
# ones go out of scope and get garbage collected. Keys are ascending integers.
41-
_RKEY = count()
4243
RANDOMS_TO_MANAGE: WeakValueDictionary[int, RandomLike] = WeakValueDictionary(
43-
{next(_RKEY): random}
44+
{_global_random_rkey: random}
4445
)
4546

4647

@@ -148,6 +149,17 @@ def my_WORKING_hook():
148149
RANDOMS_TO_MANAGE[next(_RKEY)] = r
149150

150151

152+
# the most recent state of the global random instance, as set by hypothesis.
153+
# This might not be the current state of the global random instance if its
154+
# state changed since hypothesis seeded it. If nobody other than hypothesis
155+
# is touching the global random instance, then this will be the state of the global
156+
# random instance.
157+
#
158+
# This is used to address a threading race condition in deprecate_random_in_strategy.
159+
_most_recent_random_state_enter: Optional[Any] = None
160+
_most_recent_random_state_exit: Optional[Any] = None
161+
162+
151163
def get_seeder_and_restorer(
152164
seed: Hashable = 0,
153165
) -> tuple[Callable[[], None], Callable[[], None]]:
@@ -171,16 +183,34 @@ def get_seeder_and_restorer(
171183
NP_RANDOM = RANDOMS_TO_MANAGE[next(_RKEY)] = NumpyRandomWrapper()
172184

173185
def seed_all() -> None:
186+
global _most_recent_random_state_enter
174187
assert not states
175188
for k, r in RANDOMS_TO_MANAGE.items():
176189
states[k] = r.getstate()
177190
r.seed(seed)
191+
if k == _global_random_rkey:
192+
# setting a seed is equivalent to setting setstate, so we need
193+
# to track it globally for race conditions.
194+
#
195+
# I think there's still a race here if a thread switch occurs
196+
# after r.seed but before we set _most_recent_random_state_enter.
197+
# We'd need to seed a dummy Random instance to figure out the
198+
# seed -> state mapping, then set _most_recent_random_state_enter,
199+
# then call setstate (or equivalently .seed) on the real random.
200+
_most_recent_random_state_enter = r.getstate()
178201

179202
def restore_all() -> None:
203+
global _most_recent_random_state_exit
204+
180205
for k, state in states.items():
181206
r = RANDOMS_TO_MANAGE.get(k)
182-
if r is not None: # i.e., hasn't been garbage-collected
183-
r.setstate(state)
207+
if r is None: # i.e., has been garbage-collected
208+
continue
209+
210+
if k == _global_random_rkey:
211+
_most_recent_random_state_exit = state
212+
r.setstate(state)
213+
184214
states.clear()
185215

186216
return seed_all, restore_all

0 commit comments

Comments
 (0)