From 2475ce49bb4ffd5d443b5265b2a3aa49da76f866 Mon Sep 17 00:00:00 2001
From: Itamar Ostricher <itamarost@gmail.com>
Date: Tue, 22 Nov 2022 17:19:25 -0800
Subject: [PATCH 1/5] gh-91054: Add API to allow extensions to set callback
 function on creation and destruction of PyCodeObject

Co-authored-by: Ye11ow-Flash <janshah@cs.stonybrook.edu>
---
 Include/cpython/code.h           | 35 +++++++++++++++++++
 Include/internal/pycore_code.h   |  2 ++
 Include/internal/pycore_interp.h |  1 +
 Objects/codeobject.c             | 59 ++++++++++++++++++++++++++++++++
 Python/pystate.c                 |  4 +++
 5 files changed, 101 insertions(+)

diff --git a/Include/cpython/code.h b/Include/cpython/code.h
index fd57e0035bc09a..f11d099e0379ef 100644
--- a/Include/cpython/code.h
+++ b/Include/cpython/code.h
@@ -181,6 +181,41 @@ 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
+} PyCodeEvent;
+
+
+/*
+ * A callback that is invoked for different events in a code object's lifecycle.
+ *
+ * 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
+ * it should return 0.
+ */
+typedef int (*PyCode_WatchCallback)(
+  PyCodeEvent event,
+  PyCodeObject* co);
+
+/*
+ * Register a per-interpreter callback that will be invoked for code object
+ * lifecycle events.
+ *
+ * Returns a handle that may be passed to PyCode_ClearWatcher on success,
+ * or -1 and sets an error if no more handles are available.
+ */
+PyAPI_FUNC(int) PyCode_AddWatcher(PyCode_WatchCallback callback);
+
+/*
+ * Clear the watcher associated with the watcher_id handle.
+ *
+ * Returns 0 on success or -1 if no watcher exists for the provided id.
+ */
+PyAPI_FUNC(int) PyCode_ClearWatcher(int watcher_id);
+
 /* for internal use only */
 struct _opaque {
     int computed_line;
diff --git a/Include/internal/pycore_code.h b/Include/internal/pycore_code.h
index 80c1bfb6c9afa2..357fc85a95cf15 100644
--- a/Include/internal/pycore_code.h
+++ b/Include/internal/pycore_code.h
@@ -4,6 +4,8 @@
 extern "C" {
 #endif
 
+#define CODE_MAX_WATCHERS 8
+
 /* PEP 659
  * Specialization and quickening structs and helper functions
  */
diff --git a/Include/internal/pycore_interp.h b/Include/internal/pycore_interp.h
index 532b28499080f2..f9d10313b62f23 100644
--- a/Include/internal/pycore_interp.h
+++ b/Include/internal/pycore_interp.h
@@ -191,6 +191,7 @@ struct _is {
 
     PyObject *audit_hooks;
     PyType_WatchCallback type_watchers[TYPE_MAX_WATCHERS];
+    PyCode_WatchCallback code_watchers[CODE_MAX_WATCHERS];
 
     struct _Py_unicode_state unicode;
     struct _Py_float_state float_state;
diff --git a/Objects/codeobject.c b/Objects/codeobject.c
index fc1db72977aa01..e4bbd6d59ba092 100644
--- a/Objects/codeobject.c
+++ b/Objects/codeobject.c
@@ -12,6 +12,62 @@
 #include "clinic/codeobject.c.h"
 
 
+static void
+notify_code_watchers(PyCodeEvent event, PyCodeObject *co)
+{
+    PyInterpreterState *interp = _PyInterpreterState_GET();
+    assert(interp->_initialized);
+    for (int i = 0; i < CODE_MAX_WATCHERS; i++) {
+        PyCode_WatchCallback cb = interp->code_watchers[i];
+        if ((cb != NULL) && (cb(event, co) < 0)) {
+            PyErr_WriteUnraisable((PyObject *) co);
+        }
+    }
+}
+
+int
+PyCode_AddWatcher(PyCode_WatchCallback callback)
+{
+    PyInterpreterState *interp = _PyInterpreterState_GET();
+    assert(interp->_initialized);
+
+    for (int i = 0; i < CODE_MAX_WATCHERS; i++) {
+        if (!interp->code_watchers[i]) {
+            interp->code_watchers[i] = callback;
+            return i;
+        }
+    }
+
+    PyErr_SetString(PyExc_RuntimeError, "no more code watcher IDs available");
+    return -1;
+}
+
+static inline int
+validate_watcher_id(PyInterpreterState *interp, int watcher_id)
+{
+    if (watcher_id < 0 || watcher_id >= CODE_MAX_WATCHERS) {
+        PyErr_Format(PyExc_ValueError, "Invalid code watcher ID %d", watcher_id);
+        return -1;
+    }
+    if (!interp->code_watchers[watcher_id]) {
+        PyErr_Format(PyExc_ValueError, "No code watcher set for ID %d", watcher_id);
+        return -1;
+    }
+    return 0;
+}
+
+int
+PyCode_ClearWatcher(int watcher_id)
+{
+    PyInterpreterState *interp = _PyInterpreterState_GET();
+    assert(interp->_initialized);
+    if (validate_watcher_id(interp, watcher_id) < 0) {
+        return -1;
+    }
+    interp->code_watchers[watcher_id] = NULL;
+    return 0;
+}
+
 /******************
  * generic helpers
  ******************/
@@ -355,6 +411,7 @@ init_code(PyCodeObject *co, struct _PyCodeConstructor *con)
     }
     co->_co_firsttraceable = entry_point;
     _PyCode_Quicken(co);
+    notify_code_watchers(PY_CODE_EVENT_CREATE, co);
 }
 
 static int
@@ -1615,6 +1672,8 @@ code_new_impl(PyTypeObject *type, int argcount, int posonlyargcount,
 static void
 code_dealloc(PyCodeObject *co)
 {
+    notify_code_watchers(PY_CODE_EVENT_DESTROY, co);
+
     if (co->co_extra != NULL) {
         PyInterpreterState *interp = _PyInterpreterState_GET();
         _PyCodeObjectExtra *co_extra = co->co_extra;
diff --git a/Python/pystate.c b/Python/pystate.c
index 19fd9a6ae4497b..ce31e337376079 100644
--- a/Python/pystate.c
+++ b/Python/pystate.c
@@ -466,6 +466,10 @@ interpreter_clear(PyInterpreterState *interp, PyThreadState *tstate)
     }
     interp->active_func_watchers = 0;
 
+    for (int i=0; i < CODE_MAX_WATCHERS; i++) {
+        interp->code_watchers[i] = NULL;
+    }
+
     // XXX Once we have one allocator per interpreter (i.e.
     // per-interpreter GC) we must ensure that all of the interpreter's
     // objects have been cleaned up at the point.

From 21db76b610416ff269d846b3c3f85a6f4d364fce Mon Sep 17 00:00:00 2001
From: Itamar Ostricher <itamarost@gmail.com>
Date: Sat, 26 Nov 2022 14:34:44 -0800
Subject: [PATCH 2/5] Add tests for code object watcher

Co-authored-by: Ye11ow-Flash <janshah@cs.stonybrook.edu>
---
 Lib/test/test_capi/test_watchers.py |  68 +++++++++++++++
 Modules/_testcapi/watchers.c        | 131 ++++++++++++++++++++++++++++
 2 files changed, 199 insertions(+)

diff --git a/Lib/test/test_capi/test_watchers.py b/Lib/test/test_capi/test_watchers.py
index 5e4f42a86006bd..ebe7d2783189a3 100644
--- a/Lib/test/test_capi/test_watchers.py
+++ b/Lib/test/test_capi/test_watchers.py
@@ -336,6 +336,74 @@ def test_no_more_ids_available(self):
                 self.add_watcher()
 
 
+class TestCodeObjectWatchers(unittest.TestCase):
+    @contextmanager
+    def code_watcher(self, which_watcher):
+        wid = _testcapi.add_code_watcher(which_watcher)
+        try:
+            yield wid
+        finally:
+            _testcapi.clear_code_watcher(wid)
+
+    def assert_event_counts(self, exp_created_0, exp_destroyed_0,
+                            exp_created_1, exp_destroyed_1):
+        self.assertEqual(
+            exp_created_0, _testcapi.get_code_watcher_num_created_events(0))
+        self.assertEqual(
+            exp_destroyed_0, _testcapi.get_code_watcher_num_destroyed_events(0))
+        self.assertEqual(
+            exp_created_1, _testcapi.get_code_watcher_num_created_events(1))
+        self.assertEqual(
+            exp_destroyed_1, _testcapi.get_code_watcher_num_destroyed_events(1))
+
+    def test_code_object_events_dispatched(self):
+        # verify that all counts are zero before any watchers are registered
+        self.assert_event_counts(0, 0, 0, 0)
+
+        # verify that all counts remain zero when a code object is
+        # created and destroyed with no watchers registered
+        co1 = _testcapi.code_newempty("test_watchers", "dummy1", 0)
+        self.assert_event_counts(0, 0, 0, 0)
+        del co1
+        self.assert_event_counts(0, 0, 0, 0)
+
+        # verify counts are as expected when first watcher is registered
+        with self.code_watcher(0):
+            self.assert_event_counts(0, 0, 0, 0)
+            co2 = _testcapi.code_newempty("test_watchers", "dummy2", 0)
+            self.assert_event_counts(1, 0, 0, 0)
+            del co2
+            self.assert_event_counts(1, 1, 0, 0)
+
+            # again with second watcher registered
+            with self.code_watcher(1):
+                self.assert_event_counts(1, 1, 0, 0)
+                co3 = _testcapi.code_newempty("test_watchers", "dummy3", 0)
+                self.assert_event_counts(2, 1, 1, 0)
+                del co3
+                self.assert_event_counts(2, 2, 1, 1)
+
+        # verify counts remain as they were after both watchers are cleared
+        co4 = _testcapi.code_newempty("test_watchers", "dummy4", 0)
+        self.assert_event_counts(2, 2, 1, 1)
+        del co4
+        self.assert_event_counts(2, 2, 1, 1)
+
+    def test_clear_out_of_range_watcher_id(self):
+        with self.assertRaisesRegex(ValueError, r"Invalid code watcher ID -1"):
+            _testcapi.clear_code_watcher(-1)
+        with self.assertRaisesRegex(ValueError, r"Invalid code watcher ID 8"):
+            _testcapi.clear_code_watcher(8)  # CODE_MAX_WATCHERS = 8
+
+    def test_clear_unassigned_watcher_id(self):
+        with self.assertRaisesRegex(ValueError, r"No code watcher set for ID 1"):
+            _testcapi.clear_code_watcher(1)
+
+    def test_allocate_too_many_watchers(self):
+        with self.assertRaisesRegex(RuntimeError, r"no more code watcher IDs available"):
+            _testcapi.allocate_too_many_code_watchers()
+
+
 class TestFuncWatchers(unittest.TestCase):
     @contextmanager
     def add_watcher(self, func):
diff --git a/Modules/_testcapi/watchers.c b/Modules/_testcapi/watchers.c
index 608cd780d12a26..f0e51fd462e70e 100644
--- a/Modules/_testcapi/watchers.c
+++ b/Modules/_testcapi/watchers.c
@@ -2,6 +2,7 @@
 
 #define Py_BUILD_CORE
 #include "pycore_function.h"  // FUNC_MAX_WATCHERS
+#include "pycore_code.h"  // CODE_MAX_WATCHERS
 
 // Test dict watching
 static PyObject *g_dict_watch_events;
@@ -277,6 +278,126 @@ unwatch_type(PyObject *self, PyObject *args)
     Py_RETURN_NONE;
 }
 
+
+// Test code object watching
+
+#define NUM_CODE_WATCHERS 2
+static int num_code_object_created_events[NUM_CODE_WATCHERS] = {0, 0};
+static int num_code_object_destroyed_events[NUM_CODE_WATCHERS] = {0, 0};
+
+static int
+handle_code_object_event(int which_watcher, PyCodeEvent event, PyCodeObject *co) {
+    if (event == PY_CODE_EVENT_CREATE) {
+        num_code_object_created_events[which_watcher]++;
+    }
+    else if (event == PY_CODE_EVENT_DESTROY) {
+        num_code_object_destroyed_events[which_watcher]++;
+    }
+    else {
+        return -1;
+    }
+    return 0;
+}
+
+static int
+first_code_object_callback(PyCodeEvent event, PyCodeObject *co)
+{
+    return handle_code_object_event(0, event, co);
+}
+
+static int
+second_code_object_callback(PyCodeEvent event, PyCodeObject *co)
+{
+    return handle_code_object_event(1, event, co);
+}
+
+static int
+noop_code_event_handler(PyCodeEvent event, PyCodeObject *co)
+{
+    return 0;
+}
+
+static PyObject *
+add_code_watcher(PyObject *self, PyObject *which_watcher)
+{
+    int watcher_id;
+    assert(PyLong_Check(which_watcher));
+    long which_l = PyLong_AsLong(which_watcher);
+    if (which_l == 0) {
+        watcher_id = PyCode_AddWatcher(first_code_object_callback);
+    }
+    else if (which_l == 1) {
+        watcher_id = PyCode_AddWatcher(second_code_object_callback);
+    }
+    else {
+        return NULL;
+    }
+    if (watcher_id < 0) {
+        return NULL;
+    }
+    return PyLong_FromLong(watcher_id);
+}
+
+static PyObject *
+clear_code_watcher(PyObject *self, PyObject *watcher_id)
+{
+    assert(PyLong_Check(watcher_id));
+    long watcher_id_l = PyLong_AsLong(watcher_id);
+    if (PyCode_ClearWatcher(watcher_id_l) < 0) {
+        return NULL;
+    }
+    Py_RETURN_NONE;
+}
+
+static PyObject *
+get_code_watcher_num_created_events(PyObject *self, PyObject *watcher_id)
+{
+    assert(PyLong_Check(watcher_id));
+    long watcher_id_l = PyLong_AsLong(watcher_id);
+    assert(watcher_id_l >= 0 && watcher_id_l < NUM_CODE_WATCHERS);
+    return PyLong_FromLong(num_code_object_created_events[watcher_id_l]);
+}
+
+static PyObject *
+get_code_watcher_num_destroyed_events(PyObject *self, PyObject *watcher_id)
+{
+    assert(PyLong_Check(watcher_id));
+    long watcher_id_l = PyLong_AsLong(watcher_id);
+    assert(watcher_id_l >= 0 && watcher_id_l < NUM_CODE_WATCHERS);
+    return PyLong_FromLong(num_code_object_destroyed_events[watcher_id_l]);
+}
+
+static PyObject *
+allocate_too_many_code_watchers(PyObject *self, PyObject *args)
+{
+    int watcher_ids[CODE_MAX_WATCHERS + 1];
+    int num_watchers = 0;
+    for (unsigned long i = 0; i < sizeof(watcher_ids) / sizeof(int); i++) {
+        int watcher_id = PyCode_AddWatcher(noop_code_event_handler);
+        if (watcher_id == -1) {
+            break;
+        }
+        watcher_ids[i] = watcher_id;
+        num_watchers++;
+    }
+    PyObject *type, *value, *traceback;
+    PyErr_Fetch(&type, &value, &traceback);
+    for (int i = 0; i < num_watchers; i++) {
+        if (PyCode_ClearWatcher(watcher_ids[i]) < 0) {
+            PyErr_WriteUnraisable(Py_None);
+            break;
+        }
+    }
+    if (type) {
+        PyErr_Restore(type, value, traceback);
+        return NULL;
+    }
+    else if (PyErr_Occurred()) {
+        return NULL;
+    }
+    Py_RETURN_NONE;
+}
+
 // Test function watchers
 
 #define NUM_FUNC_WATCHERS 2
@@ -509,6 +630,16 @@ static PyMethodDef test_methods[] = {
     {"unwatch_type",             unwatch_type,            METH_VARARGS, NULL},
     {"get_type_modified_events", get_type_modified_events, METH_NOARGS, NULL},
 
+    // Code object watchers.
+    {"add_code_watcher",         add_code_watcher,        METH_O,       NULL},
+    {"clear_code_watcher",       clear_code_watcher,      METH_O,       NULL},
+    {"get_code_watcher_num_created_events",
+     get_code_watcher_num_created_events,                 METH_O,       NULL},
+    {"get_code_watcher_num_destroyed_events",
+     get_code_watcher_num_destroyed_events,               METH_O,       NULL},
+    {"allocate_too_many_code_watchers",
+     (PyCFunction) allocate_too_many_code_watchers,       METH_NOARGS,  NULL},
+
     // Function watchers.
     {"add_func_watcher",         add_func_watcher,        METH_O,       NULL},
     {"clear_func_watcher",       clear_func_watcher,      METH_O,       NULL},

From 7b87796fa44ef853dab5456474aba59703e01314 Mon Sep 17 00:00:00 2001
From: Itamar Ostricher <itamarost@gmail.com>
Date: Sun, 27 Nov 2022 13:47:52 -0800
Subject: [PATCH 3/5] Add docs and news for code watchers

---
 Doc/c-api/code.rst                            | 48 +++++++++++++++++++
 Doc/whatsnew/3.12.rst                         |  4 ++
 Misc/ACKS                                     |  1 +
 ...2-11-27-13-50-13.gh-issue-91054.oox_kW.rst |  3 ++
 4 files changed, 56 insertions(+)
 create mode 100644 Misc/NEWS.d/next/Core and Builtins/2022-11-27-13-50-13.gh-issue-91054.oox_kW.rst

diff --git a/Doc/c-api/code.rst b/Doc/c-api/code.rst
index 9054e7ee3181a5..a6eb86f1a0b514 100644
--- a/Doc/c-api/code.rst
+++ b/Doc/c-api/code.rst
@@ -115,3 +115,51 @@ bound into a function.
    the free variables. On error, ``NULL`` is returned and an exception is raised.
 
    .. versionadded:: 3.11
+
+.. c:function:: int PyCode_AddWatcher(PyCode_WatchCallback callback)
+
+   Register *callback* as a code object watcher for the current interpreter.
+   Return an ID which may be passed to :c:func:`PyCode_ClearWatcher`.
+   In case of error (e.g. no more watcher IDs available),
+   return ``-1`` and set an exception.
+
+   .. versionadded:: 3.12
+
+.. c:function:: int PyCode_ClearWatcher(int watcher_id)
+
+   Clear watcher identified by *watcher_id* previously returned from
+   :c:func:`PyCode_AddWatcher` for the current interpreter.
+   Return ``0`` on success, or ``-1`` and set an exception on error
+   (e.g. if the given *watcher_id* was never registered.)
+
+   .. versionadded:: 3.12
+
+.. c:type:: PyCodeEvent
+
+   Enumeration of possible code object watcher events:
+   - ``PY_CODE_EVENT_CREATE``
+   - ``PY_CODE_EVENT_DESTROY``
+
+   .. versionadded:: 3.12
+
+.. c:type:: int (*PyCode_WatchCallback)(PyCodeEvent event, PyCodeObject* co)
+
+   Type of a code object watcher callback function.
+
+   If *event* is ``PY_CODE_EVENT_CREATE``, then the callback is invoked
+   after `co` has been fully initialized. Otherwise, the callback is invoked
+   before the destruction of *co* takes place, so the prior state of *co*
+   can be inspected.
+
+   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``.
+
+   .. versionadded:: 3.12
diff --git a/Doc/whatsnew/3.12.rst b/Doc/whatsnew/3.12.rst
index dff4de621b4c49..f6360c17cbc2b0 100644
--- a/Doc/whatsnew/3.12.rst
+++ b/Doc/whatsnew/3.12.rst
@@ -764,6 +764,10 @@ New Features
   callbacks to receive notification on changes to a type.
   (Contributed by Carl Meyer in :gh:`91051`.)
 
+* Added :c:func:`PyCode_AddWatcher` and :c:func:`PyCode_ClearWatcher`
+  APIs to register callbacks to receive notification on creation and
+  destruction of code objects.
+  (Contributed by Itamar Ostricher in :gh:`91054`.)
 
 * Add :c:func:`PyFrame_GetVar` and :c:func:`PyFrame_GetVarString` functions to
   get a frame variable by its name.
diff --git a/Misc/ACKS b/Misc/ACKS
index 5d97067b85d3d4..55c5d099cd8e94 100644
--- a/Misc/ACKS
+++ b/Misc/ACKS
@@ -1320,6 +1320,7 @@ Michele Orrù
 Tomáš Orsava
 Oleg Oshmyan
 Denis Osipov
+Itamar Ostricher
 Denis S. Otkidach
 Peter Otten
 Michael Otteneder
diff --git a/Misc/NEWS.d/next/Core and Builtins/2022-11-27-13-50-13.gh-issue-91054.oox_kW.rst b/Misc/NEWS.d/next/Core and Builtins/2022-11-27-13-50-13.gh-issue-91054.oox_kW.rst
new file mode 100644
index 00000000000000..c46459c15b9e65
--- /dev/null
+++ b/Misc/NEWS.d/next/Core and Builtins/2022-11-27-13-50-13.gh-issue-91054.oox_kW.rst	
@@ -0,0 +1,3 @@
+Add :c:func:`PyCode_AddWatcher` and :c:func:`PyCode_ClearWatcher` APIs to
+register callbacks to receive notification on creation and destruction of
+code objects.

From ea8563924b980aae4efcbf8752fee3ac06befc57 Mon Sep 17 00:00:00 2001
From: Itamar Ostricher <itamarost@gmail.com>
Date: Mon, 28 Nov 2022 20:30:56 -0800
Subject: [PATCH 4/5] Add Jaineel Shah (@Ye11ow-Flash) to ACKS list

---
 Misc/ACKS | 1 +
 1 file changed, 1 insertion(+)

diff --git a/Misc/ACKS b/Misc/ACKS
index 55c5d099cd8e94..d50cb3c2d1ee4f 100644
--- a/Misc/ACKS
+++ b/Misc/ACKS
@@ -1628,6 +1628,7 @@ Silas Sewell
 Ian Seyer
 Dmitry Shachnev
 Anish Shah
+Jaineel Shah
 Daniel Shahaf
 Hui Shang
 Geoff Shannon

From c1892be0eca08e972b803d65c03003f19398f5c9 Mon Sep 17 00:00:00 2001
From: Itamar Ostricher <itamarost@gmail.com>
Date: Wed, 30 Nov 2022 11:50:13 -0800
Subject: [PATCH 5/5] Add a bit vector to optimize watcher dispatch

A bit is set in the bit vector iff there is a watcher set at the
corresponding offset in the watcher array. Only notify watchers
if at least one bit is set.
---
 Include/internal/pycore_interp.h |  2 ++
 Objects/codeobject.c             | 14 +++++++++-----
 Python/pystate.c                 |  1 +
 3 files changed, 12 insertions(+), 5 deletions(-)

diff --git a/Include/internal/pycore_interp.h b/Include/internal/pycore_interp.h
index f9d10313b62f23..c9597cfa7a4d10 100644
--- a/Include/internal/pycore_interp.h
+++ b/Include/internal/pycore_interp.h
@@ -192,6 +192,8 @@ struct _is {
     PyObject *audit_hooks;
     PyType_WatchCallback type_watchers[TYPE_MAX_WATCHERS];
     PyCode_WatchCallback code_watchers[CODE_MAX_WATCHERS];
+    // One bit is set for each non-NULL entry in code_watchers
+    uint8_t active_code_watchers;
 
     struct _Py_unicode_state unicode;
     struct _Py_float_state float_state;
diff --git a/Objects/codeobject.c b/Objects/codeobject.c
index a6776a5e7985f9..0c197d767b0a23 100644
--- a/Objects/codeobject.c
+++ b/Objects/codeobject.c
@@ -16,11 +16,13 @@ static void
 notify_code_watchers(PyCodeEvent event, PyCodeObject *co)
 {
     PyInterpreterState *interp = _PyInterpreterState_GET();
-    assert(interp->_initialized);
-    for (int i = 0; i < CODE_MAX_WATCHERS; i++) {
-        PyCode_WatchCallback cb = interp->code_watchers[i];
-        if ((cb != NULL) && (cb(event, co) < 0)) {
-            PyErr_WriteUnraisable((PyObject *) co);
+    if (interp->active_code_watchers) {
+        assert(interp->_initialized);
+        for (int i = 0; i < CODE_MAX_WATCHERS; i++) {
+            PyCode_WatchCallback cb = interp->code_watchers[i];
+            if ((cb != NULL) && (cb(event, co) < 0)) {
+                PyErr_WriteUnraisable((PyObject *) co);
+            }
         }
     }
 }
@@ -34,6 +36,7 @@ PyCode_AddWatcher(PyCode_WatchCallback callback)
     for (int i = 0; i < CODE_MAX_WATCHERS; i++) {
         if (!interp->code_watchers[i]) {
             interp->code_watchers[i] = callback;
+            interp->active_code_watchers |= (1 << i);
             return i;
         }
     }
@@ -65,6 +68,7 @@ PyCode_ClearWatcher(int watcher_id)
         return -1;
     }
     interp->code_watchers[watcher_id] = NULL;
+    interp->active_code_watchers &= ~(1 << watcher_id);
     return 0;
 }
 
diff --git a/Python/pystate.c b/Python/pystate.c
index ce31e337376079..793ba917c41f2c 100644
--- a/Python/pystate.c
+++ b/Python/pystate.c
@@ -469,6 +469,7 @@ interpreter_clear(PyInterpreterState *interp, PyThreadState *tstate)
     for (int i=0; i < CODE_MAX_WATCHERS; i++) {
         interp->code_watchers[i] = NULL;
     }
+    interp->active_code_watchers = 0;
 
     // XXX Once we have one allocator per interpreter (i.e.
     // per-interpreter GC) we must ensure that all of the interpreter's