Skip to content

Commit 1e60975

Browse files
committed
Ensure decimal.getcontext() returns fresh copy.
We need a per-task copy of decimal.Context() so that mutations are isolated between asyncio tasks and threads using sys.flags.thread_inherit_context. This is done by adding a "depth" counter to PyContext and copying the decimal.Context() instance whenever it doesn't match the depth of the current PyContext.
1 parent 1f4b728 commit 1e60975

8 files changed

Lines changed: 296 additions & 15 deletions

File tree

Include/internal/pycore_context.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ struct _pycontextobject {
3333
PyHamtObject *ctx_thread_inheritable_vars;
3434
// used to emit warnings about thread_inherit_context
3535
PyHamtObject *ctx_starter_vars;
36+
// Nesting depth in the context inheritance tree. Assigned at creation
37+
// time: an empty/base context has depth 0, and a context produced by
38+
// copying another (copy_context(), Context.copy(), thread/async context
39+
// inheritance) has depth one greater than its source.
40+
uint64_t ctx_depth;
3641
};
3742

3843

@@ -67,5 +72,9 @@ PyAPI_FUNC(PyObject*) _PyContext_NewForThread(void);
6772
PyAPI_FUNC(int) _PyContext_Enter(PyThreadState *ts, PyObject *octx);
6873
PyAPI_FUNC(int) _PyContext_Exit(PyThreadState *ts, PyObject *octx);
6974

75+
/* Return the depth (see struct _pycontextobject.ctx_depth) of the current
76+
context, or 0 if there is no current context. */
77+
PyAPI_FUNC(uint64_t) _PyContext_CurrentDepth(void);
78+
7079

7180
#endif /* !Py_INTERNAL_CONTEXT_H */

Lib/_pydecimal.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ class FloatOperation(DecimalException, TypeError):
348348
# current context.
349349

350350
import contextvars
351+
from _contextvars import _current_context_depth
351352

352353
_current_context_var = contextvars.ContextVar('decimal_context')
353354

@@ -362,18 +363,32 @@ def getcontext():
362363
a new context and sets this thread's context.
363364
New contexts are copies of DefaultContext.
364365
"""
366+
cur_depth = _current_context_depth()
365367
try:
366-
return _current_context_var.get()
368+
context = _current_context_var.get()
367369
except LookupError:
368370
context = Context()
371+
context._local_depth = cur_depth
369372
_current_context_var.set(context)
370373
return context
374+
if context._local_depth != cur_depth:
375+
# The context value was inherited from another task/thread. Because
376+
# the Context() instance is mutable, copy it to ensure that if it is
377+
# changed, those changes are isolated from other tasks/threads.
378+
context = context.copy()
379+
context._local_depth = cur_depth
380+
_current_context_var.set(context)
381+
return context
382+
371383

372384
def setcontext(context):
373385
"""Set this thread's context to context."""
374386
if context in (DefaultContext, BasicContext, ExtendedContext):
375387
context = context.copy()
376388
context.clear_flags()
389+
# Mark the context as owned by the current context scope, so a following
390+
# getcontext() returns this very object rather than a copy.
391+
context._local_depth = _current_context_depth()
377392
_current_context_var.set(context)
378393

379394
del contextvars # Don't contaminate the namespace
@@ -3869,6 +3884,10 @@ class Context(object):
38693884
clamp - If 1, change exponents if too high (Default 0)
38703885
"""
38713886

3887+
# Depth of the contextvars context this object was bound into by
3888+
# getcontext()/setcontext().
3889+
_local_depth = 0
3890+
38723891
def __init__(self, prec=None, rounding=None, Emin=None, Emax=None,
38733892
capitals=None, clamp=None, flags=None, traps=None,
38743893
_ignored_flags=None):
@@ -3951,6 +3970,8 @@ def __setattr__(self, name, value):
39513970
return self._set_signal_dict(name, value)
39523971
elif name == '_ignored_flags':
39533972
return object.__setattr__(self, name, value)
3973+
elif name == '_local_depth':
3974+
return object.__setattr__(self, name, value)
39543975
else:
39553976
raise AttributeError(
39563977
"'decimal.Context' object has no attribute '%s'" % name)

Lib/test/test_context.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,99 @@ def __eq__(self, other):
857857
ctx2.run(var.set, ReentrantHash())
858858
ctx1 == ctx2
859859

860+
def test_context_depth_increases_on_run(self):
861+
# Entering a copied context reports a depth one greater than the
862+
# context it was copied from; the depth is restored after the run.
863+
from _contextvars import _current_context_depth as depth
864+
base = depth()
865+
got = []
866+
contextvars.copy_context().run(lambda: got.append(depth()))
867+
self.assertEqual(got, [base + 1])
868+
self.assertEqual(depth(), base)
869+
870+
def test_context_depth_nested_run(self):
871+
# Nested copied contexts increase the depth by one per level.
872+
from _contextvars import _current_context_depth as depth
873+
base = depth()
874+
got = []
875+
876+
def outer():
877+
got.append(depth())
878+
contextvars.copy_context().run(
879+
lambda: got.append(depth()))
880+
contextvars.copy_context().run(outer)
881+
self.assertEqual(got, [base + 1, base + 2])
882+
883+
def test_context_depth_reenter_same_context(self):
884+
# The depth is fixed when the context is created, so running the
885+
# same context object again reports the same depth.
886+
from _contextvars import _current_context_depth as depth
887+
ctx = contextvars.copy_context()
888+
got = []
889+
ctx.run(lambda: got.append(depth()))
890+
ctx.run(lambda: got.append(depth()))
891+
self.assertEqual(got[0], got[1])
892+
893+
def test_context_depth_copy_method(self):
894+
# Context.copy() produces a context one level deeper than its source.
895+
from _contextvars import _current_context_depth as depth
896+
base = depth()
897+
# copy_context() -> depth base+1; .copy() of that -> depth base+2,
898+
# regardless of where it is entered (depth is a creation property).
899+
ctx = contextvars.copy_context().copy()
900+
got = []
901+
ctx.run(lambda: got.append(depth()))
902+
self.assertEqual(got, [base + 2])
903+
904+
def test_context_depth_empty_context_is_zero(self):
905+
# A freshly created (empty) Context has depth 0 and reports it
906+
# independently of the context it is entered from.
907+
from _contextvars import _current_context_depth as depth
908+
got = []
909+
contextvars.Context().run(lambda: got.append(depth()))
910+
# Entered from within a deeper context, still its own depth (0).
911+
contextvars.copy_context().run(
912+
lambda: contextvars.Context().run(
913+
lambda: got.append(depth())))
914+
self.assertEqual(got, [0, 0])
915+
916+
def test_context_depth_inherited_value_is_shared(self):
917+
# A value set before copying is inherited by the copied context as
918+
# the same object, while the depth differs -- this is the signal a
919+
# mutable value (e.g. a decimal context) uses to copy for isolation.
920+
from _contextvars import _current_context_depth as depth
921+
v = contextvars.ContextVar('v')
922+
sentinel = object()
923+
v.set(sentinel)
924+
base = depth()
925+
ctx = contextvars.copy_context()
926+
927+
def check():
928+
self.assertEqual(depth(), base + 1)
929+
self.assertIs(v.get(), sentinel)
930+
ctx.run(check)
931+
932+
@threading_helper.requires_working_threading()
933+
def test_context_depth_with_threads(self):
934+
# A thread running a copied context sees a deeper context than the
935+
# parent, and each thread's depth is independent.
936+
import threading
937+
from _contextvars import _current_context_depth as depth
938+
base = depth()
939+
results = {}
940+
941+
def thread_func(name):
942+
results[name] = depth()
943+
944+
t1 = threading.Thread(target=contextvars.copy_context().run,
945+
args=(lambda: thread_func('t1'),))
946+
t2 = threading.Thread(target=contextvars.copy_context().run,
947+
args=(lambda: thread_func('t2'),))
948+
t1.start(); t2.start()
949+
t1.join(); t2.join()
950+
self.assertEqual(results['t1'], base + 1)
951+
self.assertEqual(results['t2'], base + 1)
952+
860953

861954
@threading_helper.requires_working_threading()
862955
class ThreadInheritableVarTest(unittest.TestCase):

Lib/test/test_decimal.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1770,6 +1770,98 @@ def test_threading(self):
17701770
DefaultContext.Emax = save_emax
17711771
DefaultContext.Emin = save_emin
17721772

1773+
@threading_helper.requires_working_threading()
1774+
def test_inherited_context_isolation(self):
1775+
# Test that when threads inherit contextvars (e.g. via
1776+
# sys.flags.thread_inherit_context), each thread gets its own
1777+
# copy of the decimal context so mutations don't leak between
1778+
# threads. Also verifies correct behavior with asyncio tasks.
1779+
Decimal = self.decimal.Decimal
1780+
getcontext = self.decimal.getcontext
1781+
setcontext = self.decimal.setcontext
1782+
Context = self.decimal.Context
1783+
Underflow = self.decimal.Underflow
1784+
1785+
# Set up parent context with specific precision
1786+
parent_ctx = getcontext()
1787+
parent_ctx.prec = 20
1788+
1789+
barrier = threading.Barrier(2, timeout=2)
1790+
results = {}
1791+
1792+
def child(name, prec_delta):
1793+
barrier.wait()
1794+
ctx = getcontext()
1795+
# Each child should see a context with the parent's precision
1796+
results[name + '_initial_prec'] = ctx.prec
1797+
# Keep the context alive until both threads have finished so an
1798+
# allocator-reused address cannot make distinct contexts appear
1799+
# to have the same identity.
1800+
results[name + '_ctx'] = ctx
1801+
# Mutate this thread's context
1802+
ctx.prec += prec_delta
1803+
results[name + '_modified_prec'] = ctx.prec
1804+
1805+
# Spawn threads that inherit the parent's contextvars.
1806+
t1 = threading.Thread(target=child, args=('t1', 5),
1807+
context=contextvars.copy_context())
1808+
t2 = threading.Thread(target=child, args=('t2', 10),
1809+
context=contextvars.copy_context())
1810+
t1.start()
1811+
t2.start()
1812+
t1.join()
1813+
t2.join()
1814+
1815+
# Each thread should have started with the parent's precision
1816+
self.assertEqual(results['t1_initial_prec'], 20)
1817+
self.assertEqual(results['t2_initial_prec'], 20)
1818+
1819+
# Each thread should have its own context.
1820+
self.assertIsNot(results['t1_ctx'], results['t2_ctx'])
1821+
1822+
# Mutations should be independent
1823+
self.assertEqual(results['t1_modified_prec'], 25)
1824+
self.assertEqual(results['t2_modified_prec'], 30)
1825+
1826+
# Parent context should be unaffected
1827+
self.assertEqual(getcontext().prec, 20)
1828+
1829+
def test_inherited_context_isolation_async(self):
1830+
# An asyncio child task inherits the parent task's context object
1831+
# (create_task copies the current context). Each task must get its
1832+
# own decimal context so mutations stay isolated. This is the case
1833+
# where every task step runs at the same context nesting level, so a
1834+
# per-entry depth would collide -- the depth is assigned when the
1835+
# context is *copied*, which keeps parent and child distinct.
1836+
import asyncio
1837+
getcontext = self.decimal.getcontext
1838+
1839+
async def child(results):
1840+
ctx = getcontext()
1841+
results['child_initial_prec'] = ctx.prec
1842+
results['child_ctx_id'] = id(ctx)
1843+
ctx.prec = 7
1844+
results['child_modified_prec'] = ctx.prec
1845+
1846+
async def parent():
1847+
results = {}
1848+
ctx = getcontext()
1849+
ctx.prec = 33
1850+
results['parent_ctx_id'] = id(ctx)
1851+
await asyncio.create_task(child(results))
1852+
results['parent_after_prec'] = getcontext().prec
1853+
return results
1854+
1855+
results = asyncio.run(parent())
1856+
1857+
# Child inherits the parent's precision value...
1858+
self.assertEqual(results['child_initial_prec'], 33)
1859+
# ...but in its own context object.
1860+
self.assertNotEqual(results['parent_ctx_id'], results['child_ctx_id'])
1861+
# The child's mutation does not leak back to the parent.
1862+
self.assertEqual(results['child_modified_prec'], 7)
1863+
self.assertEqual(results['parent_after_prec'], 33)
1864+
17731865

17741866
@requires_cdecimal
17751867
class CThreadingTest(ThreadingTest, unittest.TestCase):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Ensure that :func:`decimal.getcontext` returns a per-task copy of the
2+
:class:`decimal.Context` so that mutations are isolated between asyncio
3+
tasks and threads using :data:`sys.flags.thread_inherit_context`.

Modules/_decimal/_decimal.c

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
#endif
3131

3232
#include <Python.h>
33+
#include "pycore_context.h" // _PyContext_CurrentDepth()
3334
#include "pycore_object.h" // _PyObject_VisitType()
3435
#include "pycore_pystate.h" // _PyThreadState_GET()
3536
#include "pycore_tuple.h" // _PyTuple_FromPair
@@ -224,6 +225,11 @@ typedef struct PyDecContextObject {
224225
int capitals;
225226
PyThreadState *tstate;
226227
decimal_state *modstate;
228+
/* Depth of the contextvars context this context object was bound into
229+
(see _pycontextobject.ctx_depth). Used to detect when the current
230+
context object was inherited from an outer context/task and therefore
231+
must be copied to keep mutations isolated. */
232+
uint64_t ctx_depth;
227233
} PyDecContextObject;
228234

229235
#define _PyDecContextObject_CAST(op) ((PyDecContextObject *)(op))
@@ -247,6 +253,7 @@ typedef struct {
247253
#define SdFlags(v) (*_PyDecSignalDictObject_CAST(v)->flags)
248254
#define CTX(v) (&_PyDecContextObject_CAST(v)->ctx)
249255
#define CtxCaps(v) (_PyDecContextObject_CAST(v)->capitals)
256+
#define CtxDepth(v) (_PyDecContextObject_CAST(v)->ctx_depth)
250257

251258
static inline decimal_state *
252259
get_module_state_from_ctx(PyObject *v)
@@ -1477,6 +1484,7 @@ context_new(PyTypeObject *type,
14771484
CtxCaps(self) = 1;
14781485
self->tstate = NULL;
14791486
self->modstate = state;
1487+
self->ctx_depth = 0;
14801488

14811489
if (type == state->PyDecContext_Type) {
14821490
PyObject_GC_Track(self);
@@ -1929,13 +1937,17 @@ PyDec_SetCurrentContext(PyObject *self, PyObject *v)
19291937
}
19301938
#else
19311939
static PyObject *
1932-
init_current_context(decimal_state *state)
1940+
init_current_context(decimal_state *state, PyObject *prev_context,
1941+
uint64_t depth)
19331942
{
1934-
PyObject *tl_context = context_copy(state, state->default_context_template);
1943+
PyObject *tl_context = context_copy(state, prev_context);
19351944
if (tl_context == NULL) {
19361945
return NULL;
19371946
}
19381947
CTX(tl_context)->status = 0;
1948+
/* Stamp the copy with the current context's depth so that subsequent
1949+
lookups in this same context recognize it as locally owned. */
1950+
CtxDepth(tl_context) = depth;
19391951

19401952
PyObject *tok = PyContextVar_Set(state->current_context_var, tl_context);
19411953
if (tok == NULL) {
@@ -1955,11 +1967,24 @@ current_context(decimal_state *state)
19551967
return NULL;
19561968
}
19571969

1970+
uint64_t cur_depth = _PyContext_CurrentDepth();
1971+
19581972
if (tl_context != NULL) {
1959-
return tl_context;
1973+
if (CtxDepth(tl_context) == cur_depth) {
1974+
/* The context object was created for this same context scope. */
1975+
return tl_context;
1976+
}
1977+
/* The context object was inherited from an outer context (e.g. a
1978+
parent thread or asyncio task); copy it so that mutations stay
1979+
isolated from the context that shared it. */
1980+
PyObject *new_context = init_current_context(state, tl_context,
1981+
cur_depth);
1982+
Py_DECREF(tl_context);
1983+
return new_context;
19601984
}
19611985

1962-
return init_current_context(state);
1986+
return init_current_context(state, state->default_context_template,
1987+
cur_depth);
19631988
}
19641989

19651990
/* ctxobj := borrowed reference to the current context */
@@ -2002,6 +2027,10 @@ PyDec_SetCurrentContext(PyObject *self, PyObject *v)
20022027
Py_INCREF(v);
20032028
}
20042029

2030+
/* Mark the context object as owned by the current context scope, so a
2031+
following getcontext() returns this very object rather than a copy. */
2032+
CtxDepth(v) = _PyContext_CurrentDepth();
2033+
20052034
PyObject *tok = PyContextVar_Set(state->current_context_var, v);
20062035
Py_DECREF(v);
20072036
if (tok == NULL) {

0 commit comments

Comments
 (0)