diff --git a/Doc/c-api/init.rst b/Doc/c-api/init.rst index 7411644f9e110b..6a5e45fbe69169 100644 --- a/Doc/c-api/init.rst +++ b/Doc/c-api/init.rst @@ -2074,6 +2074,77 @@ Python-level trace functions in previous versions. .. versionadded:: 3.12 + +Trace/Profile callback notifications +------------------------------------ + +.. versionadded:: 3.15 + +.. c:type:: PyUnstable_EvalEvent + + An enumeration of events that can trigger a callback registered via + :c:func:`PyUnstable_SetEvalCallback`. The possible values are: + + .. c:macro:: PyUnstable_EVAL_TRACE_SET + + A trace function was set via :func:`sys.settrace`, :c:func:`PyEval_SetTrace`, + or :c:func:`PyEval_SetTraceAllThreads`. + + .. c:macro:: PyUnstable_EVAL_TRACE_CLEAR + + The trace function was cleared. + + .. c:macro:: PyUnstable_EVAL_PROFILE_SET + + A profile function was set via :func:`sys.setprofile`, :c:func:`PyEval_SetProfile`, + or :c:func:`PyEval_SetProfileAllThreads`. + + .. c:macro:: PyUnstable_EVAL_PROFILE_CLEAR + + The profile function was cleared. + + +.. c:type:: int (*PyUnstable_EvalCallback)(PyUnstable_EvalEvent event, void *data) + + The type of the callback function registered using + :c:func:`PyUnstable_SetEvalCallback`. The *event* parameter indicates + which tracing or profiling event occurred. The *data* parameter is the opaque + pointer that was provided when :c:func:`PyUnstable_SetEvalCallback` was called. + + If the callback returns a negative value, the exception is logged using + :c:func:`PyErr_FormatUnraisable`. + + +.. c:function:: int PyUnstable_SetEvalCallback(PyUnstable_EvalCallback callback, void *data) + + Register a callback to be notified when :func:`sys.settrace` or + :func:`sys.setprofile` (or their C equivalents) are called. + + This allows JIT compilers and other tools using :pep:`523` frame evaluation + hooks to efficiently detect tracing or profiling changes without polling. + + The *callback* will be invoked with an event indicating whether tracing or + profiling was set or cleared. The *data* pointer is passed through to the + callback. + + Only one callback can be registered at a time per interpreter. Setting a new + callback replaces any previously registered callback. To clear the callback, + pass ``NULL`` for *callback*. + + Return ``0`` on success. + + +.. c:function:: PyUnstable_EvalCallback PyUnstable_GetEvalCallback(void **data) + + Retrieve the currently registered eval callback and its associated data. + + If *data* is not ``NULL``, the opaque pointer that was passed to + :c:func:`PyUnstable_SetEvalCallback` is stored in ``*data``. + + Return the currently registered callback, or ``NULL`` if no callback + is registered. + + Reference tracing ================= diff --git a/Include/cpython/monitoring.h b/Include/cpython/monitoring.h index 5094c8c23ae32b..7d951656c394af 100644 --- a/Include/cpython/monitoring.h +++ b/Include/cpython/monitoring.h @@ -274,6 +274,20 @@ PyMonitoring_FireStopIterationEvent(PyMonitoringState *state, PyObject *codelike #undef _PYMONITORING_IF_ACTIVE + +/* Callback API for notifications when sys.settrace/sys.setprofile are called. */ +typedef enum { + PyUnstable_EVAL_TRACE_SET = 0, + PyUnstable_EVAL_TRACE_CLEAR = 1, + PyUnstable_EVAL_PROFILE_SET = 2, + PyUnstable_EVAL_PROFILE_CLEAR = 3, +} PyUnstable_EvalEvent; + +typedef int (*PyUnstable_EvalCallback)(PyUnstable_EvalEvent event, void *data); + +PyAPI_FUNC(int) PyUnstable_SetEvalCallback(PyUnstable_EvalCallback callback, void *data); +PyAPI_FUNC(PyUnstable_EvalCallback) PyUnstable_GetEvalCallback(void **data); + #ifdef __cplusplus } #endif diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index 723657e4cef10d..59462ab2d03957 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -1006,6 +1006,11 @@ struct _is { #endif #endif + struct { + PyUnstable_EvalCallback callback; + void *data; + } eval_callback; + /* the initial PyInterpreterState.threads.head */ _PyThreadStateImpl _initial_thread; // _initial_thread should be the last field of PyInterpreterState. diff --git a/Lib/test/test_capi/test_misc.py b/Lib/test/test_capi/test_misc.py index 3997acbdf84695..41d02529d495fd 100644 --- a/Lib/test/test_capi/test_misc.py +++ b/Lib/test/test_capi/test_misc.py @@ -2858,6 +2858,84 @@ def func(): self.do_test(func, names) +class TestEvalCallback(unittest.TestCase): + """Test PyUnstable_SetEvalCallback / PyUnstable_GetEvalCallback API""" + + # Event constants matching PyUnstable_EvalEvent enum values + EVAL_TRACE_SET = 0 + EVAL_TRACE_CLEAR = 1 + EVAL_PROFILE_SET = 2 + EVAL_PROFILE_CLEAR = 3 + + def setUp(self): + self.events = [] + _testcapi.set_eval_callback_record(self.events) + + def tearDown(self): + _testcapi.clear_eval_callback() + sys.settrace(None) + sys.setprofile(None) + + def test_settrace_fires_callback(self): + def dummy_trace(frame, event, arg): + return dummy_trace + sys.settrace(dummy_trace) + self.assertIn(self.EVAL_TRACE_SET, self.events) + + def test_settrace_none_fires_clear(self): + def dummy_trace(frame, event, arg): + return dummy_trace + sys.settrace(dummy_trace) + self.events.clear() + sys.settrace(None) + self.assertIn(self.EVAL_TRACE_CLEAR, self.events) + + def test_setprofile_fires_callback(self): + def dummy_profile(frame, event, arg): + pass + sys.setprofile(dummy_profile) + self.assertIn(self.EVAL_PROFILE_SET, self.events) + + def test_setprofile_none_fires_clear(self): + def dummy_profile(frame, event, arg): + pass + sys.setprofile(dummy_profile) + self.events.clear() + sys.setprofile(None) + self.assertIn(self.EVAL_PROFILE_CLEAR, self.events) + + def test_multiple_set_clear_cycles(self): + def dummy_trace(frame, event, arg): + return dummy_trace + def dummy_profile(frame, event, arg): + pass + + sys.settrace(dummy_trace) + sys.settrace(None) + sys.setprofile(dummy_profile) + sys.setprofile(None) + + self.assertEqual(self.events, [ + self.EVAL_TRACE_SET, + self.EVAL_TRACE_CLEAR, + self.EVAL_PROFILE_SET, + self.EVAL_PROFILE_CLEAR, + ]) + + def test_clear_callback_stops_events(self): + _testcapi.clear_eval_callback() + events_after_clear = [] + _testcapi.set_eval_callback_record(events_after_clear) + _testcapi.clear_eval_callback() + + def dummy_trace(frame, event, arg): + return dummy_trace + sys.settrace(dummy_trace) + sys.settrace(None) + + self.assertEqual(events_after_clear, []) + + @unittest.skipUnless(support.Py_GIL_DISABLED, 'need Py_GIL_DISABLED') class TestPyThreadId(unittest.TestCase): def test_py_thread_id(self): diff --git a/Misc/NEWS.d/next/C_API/2026-02-10-16-07-57.gh-issue-144690.tu_xmD.rst b/Misc/NEWS.d/next/C_API/2026-02-10-16-07-57.gh-issue-144690.tu_xmD.rst new file mode 100644 index 00000000000000..e98a34523af886 --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-02-10-16-07-57.gh-issue-144690.tu_xmD.rst @@ -0,0 +1,5 @@ +Added :c:func:`PyUnstable_SetEvalCallback` and +:c:func:`PyUnstable_GetEvalCallback` to receive notifications when +:func:`sys.settrace` or :func:`sys.setprofile` are called. This allows JIT +compilers and other tools using :pep:`523` frame evaluation hooks to +efficiently detect tracing/profiling changes without polling. diff --git a/Modules/_testcapi/monitoring.c b/Modules/_testcapi/monitoring.c index 3f99836c1ebd83..2050911ba2acfe 100644 --- a/Modules/_testcapi/monitoring.c +++ b/Modules/_testcapi/monitoring.c @@ -1,8 +1,7 @@ #include "parts.h" #include "util.h" -#define Py_BUILD_CORE -#include "internal/pycore_instruments.h" +#include "cpython/monitoring.h" typedef struct { PyObject_HEAD @@ -488,6 +487,43 @@ exit_scope(PyObject *self, PyObject *args) Py_RETURN_NONE; } +static int +test_eval_callback(PyUnstable_EvalEvent event, void *data) +{ + if (data == NULL) { + return 0; + } + PyObject *event_int = PyLong_FromLong((long)event); + if (event_int == NULL) { + return -1; + } + int res = PyList_Append((PyObject *)data, event_int); + Py_DECREF(event_int); + return res; +} + +static PyObject * +set_eval_callback_record(PyObject *self, PyObject *list) +{ + if (!PyList_Check(list)) { + PyErr_SetString(PyExc_TypeError, "argument must be a list"); + return NULL; + } + if (PyUnstable_SetEvalCallback(test_eval_callback, list) < 0) { + return NULL; + } + Py_RETURN_NONE; +} + +static PyObject * +clear_eval_callback(PyObject *self, PyObject *Py_UNUSED(args)) +{ + if (PyUnstable_SetEvalCallback(NULL, NULL) < 0) { + return NULL; + } + Py_RETURN_NONE; +} + static PyMethodDef TestMethods[] = { {"fire_event_py_start", fire_event_py_start, METH_VARARGS}, {"fire_event_py_resume", fire_event_py_resume, METH_VARARGS}, @@ -508,6 +544,8 @@ static PyMethodDef TestMethods[] = { {"fire_event_stop_iteration", fire_event_stop_iteration, METH_VARARGS}, {"monitoring_enter_scope", enter_scope, METH_VARARGS}, {"monitoring_exit_scope", exit_scope, METH_VARARGS}, + {"set_eval_callback_record", set_eval_callback_record, METH_O}, + {"clear_eval_callback", clear_eval_callback, METH_NOARGS}, {NULL}, }; diff --git a/Python/legacy_tracing.c b/Python/legacy_tracing.c index 594d5c5ead5021..6f8d5183a029c1 100644 --- a/Python/legacy_tracing.c +++ b/Python/legacy_tracing.c @@ -7,6 +7,7 @@ #include "pycore_ceval.h" // export _PyEval_SetProfile() #include "pycore_frame.h" // PyFrameObject members #include "pycore_interpframe.h" // _PyFrame_GetCode() +#include "pycore_instruments.h" // PyUnstable_SetEvalCallback #include "opcode.h" #include @@ -521,6 +522,39 @@ set_monitoring_profile_events(PyInterpreterState *interp) return _PyMonitoring_SetEvents(PY_MONITORING_SYS_PROFILE_ID, events); } +int +PyUnstable_SetEvalCallback(PyUnstable_EvalCallback callback, void *data) +{ + PyInterpreterState *interp = _PyInterpreterState_GET(); + interp->eval_callback.callback = callback; + interp->eval_callback.data = data; + return 0; +} + +PyUnstable_EvalCallback +PyUnstable_GetEvalCallback(void **data) +{ + PyInterpreterState *interp = _PyInterpreterState_GET(); + if (data != NULL) { + *data = interp->eval_callback.data; + } + return interp->eval_callback.callback; +} + +static inline void +notify_eval_callback(PyInterpreterState *interp, PyUnstable_EvalEvent event) +{ + if (interp->eval_callback.callback != NULL) { + void *data = interp->eval_callback.data; + if (interp->eval_callback.callback(event, data) < 0) { + PyErr_FormatUnraisable( + "Exception ignored in %s eval callback", + (event == PyUnstable_EVAL_TRACE_SET || event == PyUnstable_EVAL_TRACE_CLEAR) + ? "trace" : "profile"); + } + } +} + int _PyEval_SetProfile(PyThreadState *tstate, Py_tracefunc func, PyObject *arg) { @@ -546,6 +580,10 @@ _PyEval_SetProfile(PyThreadState *tstate, Py_tracefunc func, PyObject *arg) int ret = set_monitoring_profile_events(interp); _PyEval_StartTheWorld(interp); Py_XDECREF(old_profileobj); // needs to be decref'd outside of stop-the-world + + PyUnstable_EvalEvent event = (func != NULL) ? PyUnstable_EVAL_PROFILE_SET : PyUnstable_EVAL_PROFILE_CLEAR; + notify_eval_callback(interp, event); + return ret; } @@ -586,6 +624,10 @@ _PyEval_SetProfileAllThreads(PyInterpreterState *interp, Py_tracefunc func, PyOb int ret = set_monitoring_profile_events(interp); _PyEval_StartTheWorld(interp); Py_XDECREF(old_profileobjs); // needs to be decref'd outside of stop-the-world + + PyUnstable_EvalEvent event = (func != NULL) ? PyUnstable_EVAL_PROFILE_SET : PyUnstable_EVAL_PROFILE_CLEAR; + notify_eval_callback(interp, event); + return ret; } @@ -719,6 +761,10 @@ _PyEval_SetTrace(PyThreadState *tstate, Py_tracefunc func, PyObject *arg) done: _PyEval_StartTheWorld(interp); Py_XDECREF(old_traceobj); // needs to be decref'd outside stop-the-world + + PyUnstable_EvalEvent event = (func != NULL) ? PyUnstable_EVAL_TRACE_SET : PyUnstable_EVAL_TRACE_CLEAR; + notify_eval_callback(interp, event); + return err; } @@ -770,5 +816,9 @@ _PyEval_SetTraceAllThreads(PyInterpreterState *interp, Py_tracefunc func, PyObje int err = set_monitoring_trace_events(interp); _PyEval_StartTheWorld(interp); Py_XDECREF(old_trace_objs); // needs to be decref'd outside of stop-the-world + + PyUnstable_EvalEvent event = (func != NULL) ? PyUnstable_EVAL_TRACE_SET : PyUnstable_EVAL_TRACE_CLEAR; + notify_eval_callback(interp, event); + return err; }