Skip to content

gh-102381: don't call watcher callback with dead object #102382

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Mar 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions Doc/c-api/code.rst
Original file line number Diff line number Diff line change
Expand Up @@ -172,16 +172,27 @@ bound into a function.
before the destruction of *co* takes place, so the prior state of *co*
can be inspected.

If *event* is ``PY_CODE_EVENT_DESTROY``, taking a reference in the callback
to the about-to-be-destroyed code object will resurrect it and prevent it
from being freed at this time. When the resurrected object is destroyed
later, any watcher callbacks active at that time will be called again.

Users of this API should not rely on internal runtime implementation
details. Such details may include, but are not limited to, the exact
order and timing of creation and destruction of code objects. While
changes in these details may result in differences observable by watchers
(including whether a callback is invoked or not), it does not change
the semantics of the Python code being executed.

If the callback returns with an exception set, it must return ``-1``; this
exception will be printed as an unraisable exception using
:c:func:`PyErr_WriteUnraisable`. Otherwise it should return ``0``.
If the callback sets an exception, it must return ``-1``; this exception will
be printed as an unraisable exception using :c:func:`PyErr_WriteUnraisable`.
Otherwise it should return ``0``.

There may already be a pending exception set on entry to the callback. In
this case, the callback should return ``0`` with the same exception still
set. This means the callback may not call any other API that can set an
exception unless it saves and clears the exception state first, and restores
it before returning.

.. versionadded:: 3.12

Expand Down
21 changes: 17 additions & 4 deletions Doc/c-api/dict.rst
Original file line number Diff line number Diff line change
Expand Up @@ -298,13 +298,26 @@ Dictionary Objects
dictionary.

The callback may inspect but must not modify *dict*; doing so could have
unpredictable effects, including infinite recursion.
unpredictable effects, including infinite recursion. Do not trigger Python
code execution in the callback, as it could modify the dict as a side effect.

If *event* is ``PyDict_EVENT_DEALLOCATED``, taking a new reference in the
callback to the about-to-be-destroyed dictionary will resurrect it and
prevent it from being freed at this time. When the resurrected object is
destroyed later, any watcher callbacks active at that time will be called
again.

Callbacks occur before the notified modification to *dict* takes place, so
the prior state of *dict* can be inspected.

If the callback returns with an exception set, it must return ``-1``; this
exception will be printed as an unraisable exception using
:c:func:`PyErr_WriteUnraisable`. Otherwise it should return ``0``.
If the callback sets an exception, it must return ``-1``; this exception will
be printed as an unraisable exception using :c:func:`PyErr_WriteUnraisable`.
Otherwise it should return ``0``.

There may already be a pending exception set on entry to the callback. In
this case, the callback should return ``0`` with the same exception still
set. This means the callback may not call any other API that can set an
exception unless it saves and clears the exception state first, and restores
it before returning.

.. versionadded:: 3.12
17 changes: 14 additions & 3 deletions Doc/c-api/function.rst
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,19 @@ There are a few functions specific to Python functions.
runtime behavior depending on optimization decisions, it does not change
the semantics of the Python code being executed.

If the callback returns with an exception set, it must return ``-1``; this
exception will be printed as an unraisable exception using
:c:func:`PyErr_WriteUnraisable`. Otherwise it should return ``0``.
If *event* is ``PyFunction_EVENT_DESTROY``, Taking a reference in the
callback to the about-to-be-destroyed function will resurrect it, preventing
it from being freed at this time. When the resurrected object is destroyed
later, any watcher callbacks active at that time will be called again.

If the callback sets an exception, it must return ``-1``; this exception will
be printed as an unraisable exception using :c:func:`PyErr_WriteUnraisable`.
Otherwise it should return ``0``.

There may already be a pending exception set on entry to the callback. In
this case, the callback should return ``0`` with the same exception still
set. This means the callback may not call any other API that can set an
exception unless it saves and clears the exception state first, and restores
it before returning.

.. versionadded:: 3.12
13 changes: 9 additions & 4 deletions Include/cpython/code.h
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,14 @@ PyAPI_FUNC(int) PyCode_Addr2Line(PyCodeObject *, int);

PyAPI_FUNC(int) PyCode_Addr2Location(PyCodeObject *, int, int *, int *, int *, int *);

typedef enum PyCodeEvent {
PY_CODE_EVENT_CREATE,
PY_CODE_EVENT_DESTROY
#define PY_FOREACH_CODE_EVENT(V) \
V(CREATE) \
V(DESTROY)

typedef enum {
#define PY_DEF_EVENT(op) PY_CODE_EVENT_##op,
PY_FOREACH_CODE_EVENT(PY_DEF_EVENT)
#undef PY_DEF_EVENT
} PyCodeEvent;


Expand All @@ -236,7 +241,7 @@ typedef enum PyCodeEvent {
* The callback is invoked with a borrowed reference to co, after it is
* created and before it is destroyed.
*
* If the callback returns with an exception set, it must return -1. Otherwise
* If the callback sets an exception, it must return -1. Otherwise
* it should return 0.
*/
typedef int (*PyCode_WatchCallback)(
Expand Down
21 changes: 13 additions & 8 deletions Include/cpython/dictobject.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ typedef struct {

/* Dictionary version: globally unique, value change each time
the dictionary is modified */
#ifdef Py_BUILD_CORE
#ifdef Py_BUILD_CORE
uint64_t ma_version_tag;
#else
Py_DEPRECATED(3.12) uint64_t ma_version_tag;
#endif
#endif

PyDictKeysObject *ma_keys;

Expand Down Expand Up @@ -90,13 +90,18 @@ PyAPI_FUNC(PyObject *) _PyDictView_Intersect(PyObject* self, PyObject *other);

/* Dictionary watchers */

#define PY_FOREACH_DICT_EVENT(V) \
V(ADDED) \
V(MODIFIED) \
V(DELETED) \
V(CLONED) \
V(CLEARED) \
V(DEALLOCATED)

typedef enum {
PyDict_EVENT_ADDED,
PyDict_EVENT_MODIFIED,
PyDict_EVENT_DELETED,
PyDict_EVENT_CLONED,
PyDict_EVENT_CLEARED,
PyDict_EVENT_DEALLOCATED,
#define PY_DEF_EVENT(EVENT) PyDict_EVENT_##EVENT,
PY_FOREACH_DICT_EVENT(PY_DEF_EVENT)
#undef PY_DEF_EVENT
} PyDict_WatchEvent;

// Callback to be invoked when a watched dict is cleared, dealloced, or modified.
Expand Down
16 changes: 8 additions & 8 deletions Include/cpython/funcobject.h
Original file line number Diff line number Diff line change
Expand Up @@ -131,17 +131,17 @@ PyAPI_DATA(PyTypeObject) PyStaticMethod_Type;
PyAPI_FUNC(PyObject *) PyClassMethod_New(PyObject *);
PyAPI_FUNC(PyObject *) PyStaticMethod_New(PyObject *);

#define FOREACH_FUNC_EVENT(V) \
V(CREATE) \
V(DESTROY) \
V(MODIFY_CODE) \
V(MODIFY_DEFAULTS) \
#define PY_FOREACH_FUNC_EVENT(V) \
V(CREATE) \
V(DESTROY) \
V(MODIFY_CODE) \
V(MODIFY_DEFAULTS) \
V(MODIFY_KWDEFAULTS)

typedef enum {
#define DEF_EVENT(EVENT) PyFunction_EVENT_##EVENT,
FOREACH_FUNC_EVENT(DEF_EVENT)
#undef DEF_EVENT
#define PY_DEF_EVENT(EVENT) PyFunction_EVENT_##EVENT,
PY_FOREACH_FUNC_EVENT(PY_DEF_EVENT)
#undef PY_DEF_EVENT
} PyFunction_WatchEvent;

/*
Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_dict.h
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ _PyDict_NotifyEvent(PyDict_WatchEvent event,
PyObject *key,
PyObject *value)
{
assert(Py_REFCNT((PyObject*)mp) > 0);
int watcher_bits = mp->ma_version_tag & DICT_VERSION_MASK;
if (watcher_bits) {
_PyDict_SendEvent(watcher_bits, event, mp, key, value);
Expand Down
52 changes: 50 additions & 2 deletions Lib/test/test_capi/test_watchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,21 @@ def test_error(self):
self.watch(wid, d)
with catch_unraisable_exception() as cm:
d["foo"] = "bar"
self.assertIs(cm.unraisable.object, d)
self.assertIn(
"PyDict_EVENT_ADDED watcher callback for <dict at",
cm.unraisable.object
)
self.assertEqual(str(cm.unraisable.exc_value), "boom!")
self.assert_events([])

def test_dealloc_error(self):
d = {}
with self.watcher(kind=self.ERROR) as wid:
self.watch(wid, d)
with catch_unraisable_exception() as cm:
del d
self.assertEqual(str(cm.unraisable.exc_value), "boom!")

def test_two_watchers(self):
d1 = {}
d2 = {}
Expand Down Expand Up @@ -389,6 +400,25 @@ def test_code_object_events_dispatched(self):
del co4
self.assert_event_counts(0, 0, 0, 0)

def test_error(self):
with self.code_watcher(2):
with catch_unraisable_exception() as cm:
co = _testcapi.code_newempty("test_watchers", "dummy0", 0)

self.assertEqual(
cm.unraisable.object,
f"PY_CODE_EVENT_CREATE watcher callback for {co!r}"
)
self.assertEqual(str(cm.unraisable.exc_value), "boom!")

def test_dealloc_error(self):
co = _testcapi.code_newempty("test_watchers", "dummy0", 0)
with self.code_watcher(2):
with catch_unraisable_exception() as cm:
del co

self.assertEqual(str(cm.unraisable.exc_value), "boom!")

def test_clear_out_of_range_watcher_id(self):
with self.assertRaisesRegex(ValueError, r"Invalid code watcher ID -1"):
_testcapi.clear_code_watcher(-1)
Expand Down Expand Up @@ -479,7 +509,25 @@ def watcher(*args):
def myfunc():
pass

self.assertIs(cm.unraisable.object, myfunc)
self.assertEqual(
cm.unraisable.object,
f"PyFunction_EVENT_CREATE watcher callback for {myfunc!r}"
)

def test_dealloc_watcher_raises_error(self):
class MyError(Exception):
pass

def watcher(*args):
raise MyError("testing 123")

def myfunc():
pass

with self.add_watcher(watcher):
with catch_unraisable_exception() as cm:
del myfunc

self.assertIsInstance(cm.unraisable.exc_value, MyError)

def test_clear_out_of_range_watcher_id(self):
Expand Down
13 changes: 12 additions & 1 deletion Modules/_testcapi/watchers.c
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,13 @@ noop_code_event_handler(PyCodeEvent event, PyCodeObject *co)
return 0;
}

static int
error_code_event_handler(PyCodeEvent event, PyCodeObject *co)
{
PyErr_SetString(PyExc_RuntimeError, "boom!");
return -1;
}

static PyObject *
add_code_watcher(PyObject *self, PyObject *which_watcher)
{
Expand All @@ -333,7 +340,11 @@ add_code_watcher(PyObject *self, PyObject *which_watcher)
num_code_object_created_events[1] = 0;
num_code_object_destroyed_events[1] = 0;
}
else if (which_l == 2) {
watcher_id = PyCode_AddWatcher(error_code_event_handler);
}
else {
PyErr_Format(PyExc_ValueError, "invalid watcher %d", which_l);
return NULL;
}
if (watcher_id < 0) {
Expand Down Expand Up @@ -672,7 +683,7 @@ _PyTestCapi_Init_Watchers(PyObject *mod)
PyFunction_EVENT_##event)) { \
return -1; \
}
FOREACH_FUNC_EVENT(ADD_EVENT);
PY_FOREACH_FUNC_EVENT(ADD_EVENT);
#undef ADD_EVENT

return 0;
Expand Down
38 changes: 37 additions & 1 deletion Objects/codeobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,24 @@
#include "pycore_tuple.h" // _PyTuple_ITEMS()
#include "clinic/codeobject.c.h"

static PyObject* code_repr(PyCodeObject *co);

static const char *
code_event_name(PyCodeEvent event) {
switch (event) {
#define CASE(op) \
case PY_CODE_EVENT_##op: \
return "PY_CODE_EVENT_" #op;
PY_FOREACH_CODE_EVENT(CASE)
#undef CASE
}
Py_UNREACHABLE();
}

static void
notify_code_watchers(PyCodeEvent event, PyCodeObject *co)
{
assert(Py_REFCNT(co) > 0);
PyInterpreterState *interp = _PyInterpreterState_GET();
assert(interp->_initialized);
uint8_t bits = interp->active_code_watchers;
Expand All @@ -25,7 +40,21 @@ notify_code_watchers(PyCodeEvent event, PyCodeObject *co)
// callback must be non-null if the watcher bit is set
assert(cb != NULL);
if (cb(event, co) < 0) {
PyErr_WriteUnraisable((PyObject *) co);
// Don't risk resurrecting the object if an unraisablehook keeps
// a reference; pass a string as context.
PyObject *context = NULL;
PyObject *repr = code_repr(co);
if (repr) {
context = PyUnicode_FromFormat(
"%s watcher callback for %U",
code_event_name(event), repr);
Py_DECREF(repr);
}
if (context == NULL) {
context = Py_NewRef(Py_None);
}
PyErr_WriteUnraisable(context);
Py_DECREF(context);
}
}
i++;
Expand Down Expand Up @@ -1667,7 +1696,14 @@ code_new_impl(PyTypeObject *type, int argcount, int posonlyargcount,
static void
code_dealloc(PyCodeObject *co)
{
assert(Py_REFCNT(co) == 0);
Py_SET_REFCNT(co, 1);
notify_code_watchers(PY_CODE_EVENT_DESTROY, co);
if (Py_REFCNT(co) > 1) {
Py_SET_REFCNT(co, Py_REFCNT(co) - 1);
return;
}
Py_SET_REFCNT(co, 0);

if (co->co_extra != NULL) {
PyInterpreterState *interp = _PyInterpreterState_GET();
Expand Down
Loading