Skip to content

Commit ea46685

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. This change also causes a `CONTEXT_SWITCHED` event to be emitted when the default context is created, which might be important in some use cases. Also exit the default 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 624be86 commit ea46685

File tree

5 files changed

+143
-11
lines changed

5 files changed

+143
-11
lines changed

Include/internal/pycore_context.h

+11-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ 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).
22+
void _PyContext_ExitThreadOwned(PyThreadState *);
23+
1824

1925
/* other API */
2026

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

3343

Lib/test/test_capi/test_watchers.py

+73-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import threading
12
import unittest
23
import contextvars
34

45
from contextlib import contextmanager, ExitStack
56
from test.support import (
67
catch_unraisable_exception, import_helper,
7-
gc_collect, suppress_immortalization)
8+
gc_collect, suppress_immortalization, threading_helper)
89

910

1011
# Skip this test if the _testcapi module isn't available.
@@ -659,5 +660,76 @@ def test_exit_base_context(self):
659660
ctx.run(lambda: None)
660661
self.assertEqual(switches, [ctx, None])
661662

663+
def test_reenter_default_context(self):
664+
_testcapi.clear_context_stack()
665+
# contextvars.copy_context() creates the thread's default context (via
666+
# the context_get C function).
667+
ctx = contextvars.copy_context()
668+
with self.context_watcher(0) as switches:
669+
ctx.run(lambda: None)
670+
self.assertEqual(len(switches), 2)
671+
self.assertEqual(switches[0], ctx)
672+
base_ctx = switches[1]
673+
self.assertIsNotNone(base_ctx)
674+
self.assertIsNot(base_ctx, ctx)
675+
with self.assertRaisesRegex(RuntimeError, 'already entered'):
676+
base_ctx.run(lambda: None)
677+
678+
def test_default_context_enter(self):
679+
_testcapi.clear_context_stack()
680+
with self.context_watcher(0) as switches:
681+
ctx = contextvars.copy_context()
682+
ctx.run(lambda: None)
683+
self.assertEqual(len(switches), 3)
684+
base_ctx = switches[0]
685+
self.assertIsNotNone(base_ctx)
686+
self.assertEqual(switches, [base_ctx, ctx, base_ctx])
687+
688+
@threading_helper.requires_working_threading()
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+
threading_helper.join_thread(thread)
705+
self.assertEqual(switches, [None])
706+
707+
@threading_helper.requires_working_threading()
708+
def test_thread_cleanup_with_entered_context(self):
709+
unraisables = []
710+
try:
711+
with catch_unraisable_exception() as cm:
712+
with self.context_watcher(0) as switches:
713+
def _thread_main():
714+
_testcapi.clear_context_stack()
715+
ctx = contextvars.copy_context()
716+
_testcapi.context_enter(ctx)
717+
switches.clear()
718+
719+
thread = threading.Thread(target=_thread_main)
720+
thread.start()
721+
threading_helper.join_thread(thread)
722+
unraisables.append(cm.unraisable)
723+
self.assertEqual(switches, [])
724+
self.assertEqual(len(unraisables), 1)
725+
self.assertIsNotNone(unraisables[0])
726+
self.assertRegex(unraisables[0].err_msg,
727+
r'^Exception ignored during reset of thread state')
728+
self.assertRegex(str(unraisables[0].exc_value), r'still entered')
729+
finally:
730+
# Break reference cycle
731+
unraisables = None
732+
733+
662734
if __name__ == "__main__":
663735
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

+45-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,33 @@ PyContext_Exit(PyObject *octx)
257259
}
258260

259261

262+
void
263+
_PyContext_ExitThreadOwned(PyThreadState *ts)
264+
{
265+
assert(ts != NULL);
266+
while (ts->context != NULL
267+
&& PyContext_CheckExact(ts->context)
268+
&& ((PyContext *)ts->context)->ctx_owned_by_thread) {
269+
if (_PyContext_Exit(ts, ts->context)) {
270+
Py_UNREACHABLE();
271+
}
272+
}
273+
if (ts->context != NULL) {
274+
// This intentionally does not use _PyErr_GetRaisedException(ts)
275+
// because ts might not belong to the current thread, and there is no
276+
// tstate variant of PyErr_FormatUnraisable (probably because it calls
277+
// sys.unraisablehook).
278+
PyObject *exc = PyErr_GetRaisedException();
279+
PyErr_SetString(PyExc_RuntimeError,
280+
"contextvars.Context object(s) still entered during "
281+
"thread state reset");
282+
PyErr_FormatUnraisable(
283+
"Exception ignored during reset of thread state %p", ts);
284+
PyErr_SetRaisedException(exc);
285+
}
286+
}
287+
288+
260289
PyObject *
261290
PyContextVar_New(const char *name, PyObject *def)
262291
{
@@ -433,6 +462,7 @@ _context_alloc(void)
433462
ctx->ctx_vars = NULL;
434463
ctx->ctx_prev = NULL;
435464
ctx->ctx_entered = 0;
465+
ctx->ctx_owned_by_thread = 0;
436466
ctx->ctx_weakreflist = NULL;
437467

438468
return ctx;
@@ -478,15 +508,21 @@ context_get(void)
478508
{
479509
PyThreadState *ts = _PyThreadState_GET();
480510
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;
511+
if (ts->context == NULL) {
512+
PyContext *ctx = context_new_empty();
513+
if (ctx != NULL) {
514+
if (_PyContext_Enter(ts, (PyObject *)ctx)) {
515+
Py_UNREACHABLE();
516+
}
517+
ctx->ctx_owned_by_thread = 1;
486518
}
487-
ts->context = (PyObject *)current_ctx;
519+
assert(ts->context == (PyObject *)ctx);
520+
Py_CLEAR(ctx); // _PyContext_Enter created its own ref.
488521
}
489-
return current_ctx;
522+
// The current context may be NULL if the above context_new_empty() call
523+
// failed.
524+
assert(ts->context == NULL || PyContext_CheckExact(ts->context));
525+
return (PyContext *)ts->context;
490526
}
491527

492528
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)