Skip to content

Commit 1726539

Browse files
committed
pythongh-124872: Mark the thread's default context as entered
Starting with commit 843d28f (temporarily reverted in d3c82b9 and restored in commit bee112a), it is now technically possible to access a thread's default context created by `context_get`. Mark that context as entered so that users cannot push that context onto the thread's stack a second time, which would cause a cycle. Also exit that context when the thread exits, for symmetry and in case the user wants to re-enter it for some reason. (Even if the `CONTEXT_SWITCHED` event is removed, entering the default context is good defensive practice, and the consistent treatment of all contexts on the stack makes it easier to understand the code.)
1 parent 8e7b2a1 commit 1726539

File tree

5 files changed

+141
-10
lines changed

5 files changed

+141
-10
lines changed

Include/internal/pycore_context.h

+12-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ extern PyTypeObject _PyContextTokenMissing_Type;
1515

1616
PyStatus _PyContext_Init(PyInterpreterState *);
1717

18+
// Exits any thread-owned contexts (see context_get) at the top of the thread's
19+
// context stack. Logs a warning via PyErr_FormatUnraisable if the thread's
20+
// context stack is non-empty afterwards (those contexts can never be exited or
21+
// re-entered). The given thread state must belong to the thread calling this
22+
// function.
23+
void _PyContext_ExitThreadOwned(PyThreadState *);
24+
1825

1926
/* other API */
2027

@@ -27,7 +34,11 @@ struct _pycontextobject {
2734
PyContext *ctx_prev;
2835
PyHamtObject *ctx_vars;
2936
PyObject *ctx_weakreflist;
30-
int ctx_entered;
37+
_Bool ctx_entered:1;
38+
// True for the thread's default context created by context_get. Used to
39+
// safely determine whether the base context can be exited when clearing a
40+
// PyThreadState.
41+
_Bool ctx_owned_by_thread:1;
3142
};
3243

3344

Lib/test/test_capi/test_watchers.py

+71
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import sys
2+
import threading
13
import unittest
24
import contextvars
35

@@ -659,5 +661,74 @@ def test_exit_base_context(self):
659661
ctx.run(lambda: None)
660662
self.assertEqual(switches, [ctx, None])
661663

664+
def test_reenter_default_context(self):
665+
_testcapi.clear_context_stack()
666+
# contextvars.copy_context() creates the thread's default context (via
667+
# the context_get C function).
668+
ctx = contextvars.copy_context()
669+
with self.context_watcher(0) as switches:
670+
ctx.run(lambda: None)
671+
self.assertEqual(len(switches), 2)
672+
self.assertEqual(switches[0], ctx)
673+
base_ctx = switches[1]
674+
self.assertIsNotNone(base_ctx)
675+
self.assertIsNot(base_ctx, ctx)
676+
with self.assertRaisesRegex(RuntimeError, 'already entered'):
677+
base_ctx.run(lambda: None)
678+
679+
def test_default_context_enter(self):
680+
_testcapi.clear_context_stack()
681+
with self.context_watcher(0) as switches:
682+
ctx = contextvars.copy_context()
683+
ctx.run(lambda: None)
684+
self.assertEqual(len(switches), 3)
685+
base_ctx = switches[0]
686+
self.assertIsNotNone(base_ctx)
687+
self.assertEqual(switches, [base_ctx, ctx, base_ctx])
688+
689+
def test_default_context_exit_during_thread_cleanup(self):
690+
# Context watchers are per-interpreter, not per-thread.
691+
# https://discuss.python.org/t/v3-14a1-design-limitations-of-pycontext-addwatcher/68177
692+
with self.context_watcher(0) as switches:
693+
def _thread_main():
694+
_testcapi.clear_context_stack()
695+
# contextvars.copy_context() creates the thread's default
696+
# context (via the context_get C function).
697+
contextvars.copy_context()
698+
# This test only cares about the final switch that happens when
699+
# exiting the thread's default context during thread cleanup.
700+
switches.clear()
701+
702+
thread = threading.Thread(target=_thread_main)
703+
thread.start()
704+
thread.join()
705+
self.assertEqual(switches, [None])
706+
707+
def test_thread_cleanup_with_entered_context(self):
708+
unraisables = []
709+
try:
710+
with catch_unraisable_exception() as cm:
711+
with self.context_watcher(0) as switches:
712+
def _thread_main():
713+
_testcapi.clear_context_stack()
714+
ctx = contextvars.copy_context()
715+
_testcapi.context_enter(ctx)
716+
switches.clear()
717+
718+
thread = threading.Thread(target=_thread_main)
719+
thread.start()
720+
thread.join()
721+
unraisables.append(cm.unraisable)
722+
self.assertEqual(switches, [])
723+
self.assertEqual(len(unraisables), 1)
724+
self.assertIsNotNone(unraisables[0])
725+
self.assertRegex(unraisables[0].err_msg,
726+
r'^Exception ignored during reset of thread state')
727+
self.assertRegex(str(unraisables[0].exc_value), r'still entered')
728+
finally:
729+
# Break reference cycle
730+
unraisables = None
731+
732+
662733
if __name__ == "__main__":
663734
unittest.main()

Modules/_testcapi/watchers.c

+10
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,15 @@ clear_context_stack(PyObject *Py_UNUSED(self), PyObject *Py_UNUSED(args))
724724
Py_RETURN_NONE;
725725
}
726726

727+
static PyObject *
728+
context_enter(PyObject *self, PyObject *ctx)
729+
{
730+
if (PyContext_Enter(ctx)) {
731+
return NULL;
732+
}
733+
Py_RETURN_NONE;
734+
}
735+
727736
static PyObject *
728737
get_context_switches(PyObject *Py_UNUSED(self), PyObject *watcher_id)
729738
{
@@ -841,6 +850,7 @@ static PyMethodDef test_methods[] = {
841850
{"add_context_watcher", add_context_watcher, METH_O, NULL},
842851
{"clear_context_watcher", clear_context_watcher, METH_O, NULL},
843852
{"clear_context_stack", clear_context_stack, METH_NOARGS, NULL},
853+
{"context_enter", context_enter, METH_O, NULL},
844854
{"get_context_switches", get_context_switches, METH_O, NULL},
845855
{"allocate_too_many_context_watchers",
846856
(PyCFunction) allocate_too_many_context_watchers, METH_NOARGS, NULL},

Python/context.c

+44-9
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,9 @@ static inline void
184184
context_switched(PyThreadState *ts)
185185
{
186186
ts->context_ver++;
187-
// ts->context is used instead of context_get() because context_get() might
188-
// throw if ts->context is NULL.
187+
// ts->context is used instead of context_get() because if ts->context is
188+
// NULL, context_get() will either call context_switched -- causing a
189+
// double notification -- or throw.
189190
notify_context_watchers(ts, Py_CONTEXT_SWITCHED, ts->context);
190191
}
191192

@@ -244,6 +245,7 @@ _PyContext_Exit(PyThreadState *ts, PyObject *octx)
244245

245246
ctx->ctx_prev = NULL;
246247
ctx->ctx_entered = 0;
248+
ctx->ctx_owned_by_thread = 0;
247249
context_switched(ts);
248250
return 0;
249251
}
@@ -257,6 +259,32 @@ PyContext_Exit(PyObject *octx)
257259
}
258260

259261

262+
void
263+
_PyContext_ExitThreadOwned(PyThreadState *ts)
264+
{
265+
assert(ts != NULL);
266+
// ts must belong to the current thread because PyErr_FormatUnraisable
267+
// operates on the current thread (it calls sys.unraisablehook).
268+
assert(ts == _PyThreadState_GET());
269+
while (ts->context != NULL
270+
&& PyContext_CheckExact(ts->context)
271+
&& ((PyContext *)ts->context)->ctx_owned_by_thread) {
272+
if (_PyContext_Exit(ts, ts->context)) {
273+
Py_UNREACHABLE();
274+
}
275+
}
276+
if (ts->context != NULL) {
277+
PyObject *exc = _PyErr_GetRaisedException(ts);
278+
_PyErr_SetString(ts, PyExc_RuntimeError,
279+
"contextvars.Context object(s) still entered during "
280+
"thread state reset");
281+
PyErr_FormatUnraisable(
282+
"Exception ignored during reset of thread state %p", ts);
283+
_PyErr_SetRaisedException(ts, exc);
284+
}
285+
}
286+
287+
260288
PyObject *
261289
PyContextVar_New(const char *name, PyObject *def)
262290
{
@@ -433,6 +461,7 @@ _context_alloc(void)
433461
ctx->ctx_vars = NULL;
434462
ctx->ctx_prev = NULL;
435463
ctx->ctx_entered = 0;
464+
ctx->ctx_owned_by_thread = 0;
436465
ctx->ctx_weakreflist = NULL;
437466

438467
return ctx;
@@ -478,15 +507,21 @@ context_get(void)
478507
{
479508
PyThreadState *ts = _PyThreadState_GET();
480509
assert(ts != NULL);
481-
PyContext *current_ctx = (PyContext *)ts->context;
482-
if (current_ctx == NULL) {
483-
current_ctx = context_new_empty();
484-
if (current_ctx == NULL) {
485-
return NULL;
510+
if (ts->context == NULL) {
511+
PyContext *ctx = context_new_empty();
512+
if (ctx != NULL) {
513+
if (_PyContext_Enter(ts, (PyObject *)ctx)) {
514+
Py_UNREACHABLE();
515+
}
516+
ctx->ctx_owned_by_thread = 1;
486517
}
487-
ts->context = (PyObject *)current_ctx;
518+
assert(ts->context == (PyObject *)ctx);
519+
Py_CLEAR(ctx); // _PyContext_Enter created its own ref.
488520
}
489-
return current_ctx;
521+
// The current context may be NULL if the above context_new_empty() call
522+
// failed.
523+
assert(ts->context == NULL || PyContext_CheckExact(ts->context));
524+
return (PyContext *)ts->context;
490525
}
491526

492527
static int

Python/pystate.c

+4
Original file line numberDiff line numberDiff line change
@@ -1650,6 +1650,10 @@ PyThreadState_Clear(PyThreadState *tstate)
16501650
"PyThreadState_Clear: warning: thread still has a frame\n");
16511651
}
16521652

1653+
// This calls callbacks registered with PyContext_AddWatcher and can call
1654+
// sys.unraisablehook.
1655+
_PyContext_ExitThreadOwned(tstate);
1656+
16531657
/* At this point tstate shouldn't be used any more,
16541658
neither to run Python code nor for other uses.
16551659

0 commit comments

Comments
 (0)