https://github.com/python/cpython/commit/3b276f3f59aba213dce4bd995d4fe66620003e90
commit: 3b276f3f59aba213dce4bd995d4fe66620003e90
branch: main
author: Petr Viktorin <[email protected]>
committer: encukou <[email protected]>
date: 2026-03-02T11:47:32+01:00
summary:

gh-144748: Make PyErr_CheckSignals raise the exception scheduled by 
PyThreadState_SetAsyncExc (GH-145178)


Co-authored-by: Peter Bierma <[email protected]>

files:
A Misc/NEWS.d/next/C_API/2026-02-24-14-46-05.gh-issue-144748.uhnFtE.rst
A Modules/_testlimitedcapi/threadstate.c
M Doc/c-api/exceptions.rst
M Doc/c-api/threads.rst
M Include/internal/pycore_ceval.h
M Lib/test/test_threading.py
M Modules/Setup.stdlib.in
M Modules/_testlimitedcapi.c
M Modules/_testlimitedcapi/parts.h
M Modules/signalmodule.c
M PCbuild/_testlimitedcapi.vcxproj
M PCbuild/_testlimitedcapi.vcxproj.filters
M Python/ceval_gil.c

diff --git a/Doc/c-api/exceptions.rst b/Doc/c-api/exceptions.rst
index 72b013612d77f5..aef191d3a29ac6 100644
--- a/Doc/c-api/exceptions.rst
+++ b/Doc/c-api/exceptions.rst
@@ -699,6 +699,8 @@ Signal Handling
 
    - Executing a pending :ref:`remote debugger <remote-debugging>` script.
 
+   - Raise the exception set by :c:func:`PyThreadState_SetAsyncExc`.
+
    If any handler raises an exception, immediately return ``-1`` with that
    exception set.
    Any remaining interruptions are left to be processed on the next
@@ -714,6 +716,9 @@ Signal Handling
       This function may now execute a remote debugger script, if remote
       debugging is enabled.
 
+   .. versionchanged:: next
+      The exception set by :c:func:`PyThreadState_SetAsyncExc` is now raised.
+
 
 .. c:function:: void PyErr_SetInterrupt()
 
diff --git a/Doc/c-api/threads.rst b/Doc/c-api/threads.rst
index 46e713f4b5f96f..41c7fbda2302cf 100644
--- a/Doc/c-api/threads.rst
+++ b/Doc/c-api/threads.rst
@@ -699,13 +699,25 @@ pointer and a void pointer argument.
 
 .. c:function:: int PyThreadState_SetAsyncExc(unsigned long id, PyObject *exc)
 
-   Asynchronously raise an exception in a thread. The *id* argument is the 
thread
-   id of the target thread; *exc* is the exception object to be raised. This
-   function does not steal any references to *exc*. To prevent naive misuse, 
you
-   must write your own C extension to call this.  Must be called with an 
:term:`attached thread state`.
-   Returns the number of thread states modified; this is normally one, but 
will be
-   zero if the thread id isn't found.  If *exc* is ``NULL``, the pending
-   exception (if any) for the thread is cleared. This raises no exceptions.
+   Schedule an exception to be raised asynchronously in a thread.
+   If the thread has a previously scheduled exception, it is overwritten.
+
+   The *id* argument is the thread id of the target thread, as returned by
+   :c:func:`PyThread_get_thread_ident`.
+   *exc* is the class of the exception to be raised, or ``NULL`` to clear
+   the pending exception (if any).
+
+   Return the number of affected thread states.
+   This is normally ``1`` if *id* is found, even when no change was
+   made (the given *exc* was already pending, or *exc* is ``NULL`` but
+   no exception is pending).
+   If the thread id isn't found, return ``0``.  This raises no exceptions.
+
+   To prevent naive misuse, you must write your own C extension to call this.
+   This function must be called with an :term:`attached thread state`.
+   This function does not steal any references to *exc*.
+   This function does not necessarily interrupt system calls such as
+   :py:func:`~time.sleep`.
 
    .. versionchanged:: 3.7
       The type of the *id* parameter changed from :c:expr:`long` to
@@ -743,7 +755,8 @@ Operating system thread APIs
    :term:`attached thread state`.
 
    .. seealso::
-      :py:func:`threading.get_ident`
+      :py:func:`threading.get_ident` and :py:attr:`threading.Thread.ident`
+      expose this identifier to Python.
 
 
 .. c:function:: PyObject *PyThread_GetInfo(void)
diff --git a/Include/internal/pycore_ceval.h b/Include/internal/pycore_ceval.h
index 1ee1f830827576..f27ec4350bb2c8 100644
--- a/Include/internal/pycore_ceval.h
+++ b/Include/internal/pycore_ceval.h
@@ -286,6 +286,9 @@ PyAPI_FUNC(PyObject *)_Py_MakeCoro(PyFunctionObject *func);
    and asynchronous exception */
 PyAPI_FUNC(int) _Py_HandlePending(PyThreadState *tstate);
 
+/* Raise exception set by PyThreadState_SetAsyncExc, if any */
+PyAPI_FUNC(int) _PyEval_RaiseAsyncExc(PyThreadState *tstate);
+
 extern PyObject * _PyEval_GetFrameLocals(void);
 
 typedef PyObject *(*conversion_func)(PyObject *);
diff --git a/Lib/test/test_threading.py b/Lib/test/test_threading.py
index bdfd03b1e58f62..0ca91ce0d7899d 100644
--- a/Lib/test/test_threading.py
+++ b/Lib/test/test_threading.py
@@ -412,6 +412,53 @@ def run(self):
             t.join()
         # else the thread is still running, and we have no way to kill it
 
+    @cpython_only
+    @unittest.skipUnless(hasattr(signal, "pthread_kill"), "need pthread_kill")
+    @unittest.skipUnless(hasattr(signal, "SIGUSR1"), "need SIGUSR1")
+    def test_PyThreadState_SetAsyncExc_interrupts_sleep(self):
+        _testcapi = import_module("_testlimitedcapi")
+
+        worker_started = threading.Event()
+
+        class InjectedException(Exception):
+            """Custom exception for testing"""
+
+        caught_exception = None
+
+        def catch_exception():
+            nonlocal caught_exception
+            day_as_seconds = 60 * 60 * 24
+            try:
+                worker_started.set()
+                time.sleep(day_as_seconds)
+            except InjectedException as exc:
+                caught_exception = exc
+
+        thread = threading.Thread(target=catch_exception)
+        thread.start()
+        worker_started.wait()
+
+        signal.signal(signal.SIGUSR1, lambda sig, frame: None)
+
+        result = _testcapi.threadstate_set_async_exc(
+            thread.ident, InjectedException)
+        self.assertEqual(result, 1)
+
+        for _ in support.sleeping_retry(support.SHORT_TIMEOUT):
+            if not thread.is_alive():
+                break
+            try:
+                signal.pthread_kill(thread.ident, signal.SIGUSR1)
+            except OSError:
+                # The thread might have terminated between the is_alive check
+                # and the pthread_kill
+                break
+
+        thread.join()
+        signal.signal(signal.SIGUSR1, signal.SIG_DFL)
+
+        self.assertIsInstance(caught_exception, InjectedException)
+
     def test_limbo_cleanup(self):
         # Issue 7481: Failure to start thread should cleanup the limbo map.
         def fail_new_thread(*args, **kwargs):
diff --git 
a/Misc/NEWS.d/next/C_API/2026-02-24-14-46-05.gh-issue-144748.uhnFtE.rst 
b/Misc/NEWS.d/next/C_API/2026-02-24-14-46-05.gh-issue-144748.uhnFtE.rst
new file mode 100644
index 00000000000000..bda7003be94e54
--- /dev/null
+++ b/Misc/NEWS.d/next/C_API/2026-02-24-14-46-05.gh-issue-144748.uhnFtE.rst
@@ -0,0 +1,2 @@
+:c:func:`PyErr_CheckSignals` now raises the exception scheduled by
+:c:func:`PyThreadState_SetAsyncExc`, if any.
diff --git a/Modules/Setup.stdlib.in b/Modules/Setup.stdlib.in
index 1dd0512832adf7..39be41d9d2a426 100644
--- a/Modules/Setup.stdlib.in
+++ b/Modules/Setup.stdlib.in
@@ -176,7 +176,7 @@
 @MODULE__TESTBUFFER_TRUE@_testbuffer _testbuffer.c
 @MODULE__TESTINTERNALCAPI_TRUE@_testinternalcapi _testinternalcapi.c 
_testinternalcapi/test_lock.c _testinternalcapi/pytime.c 
_testinternalcapi/set.c _testinternalcapi/test_critical_sections.c 
_testinternalcapi/complex.c _testinternalcapi/interpreter.c
 @MODULE__TESTCAPI_TRUE@_testcapi _testcapimodule.c _testcapi/vectorcall.c 
_testcapi/heaptype.c _testcapi/abstract.c _testcapi/unicode.c _testcapi/dict.c 
_testcapi/set.c _testcapi/list.c _testcapi/tuple.c _testcapi/getargs.c 
_testcapi/datetime.c _testcapi/docstring.c _testcapi/mem.c _testcapi/watchers.c 
_testcapi/long.c _testcapi/float.c _testcapi/complex.c _testcapi/numbers.c 
_testcapi/structmember.c _testcapi/exceptions.c _testcapi/code.c 
_testcapi/buffer.c _testcapi/pyatomic.c _testcapi/run.c _testcapi/file.c 
_testcapi/codec.c _testcapi/immortal.c _testcapi/gc.c _testcapi/hash.c 
_testcapi/time.c _testcapi/bytes.c _testcapi/object.c _testcapi/modsupport.c 
_testcapi/monitoring.c _testcapi/config.c _testcapi/import.c _testcapi/frame.c 
_testcapi/type.c _testcapi/function.c _testcapi/module.c
-@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c 
_testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c 
_testlimitedcapi/bytes.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c 
_testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c 
_testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c 
_testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c 
_testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/sys.c 
_testlimitedcapi/tuple.c _testlimitedcapi/unicode.c 
_testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c 
_testlimitedcapi/file.c
+@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c 
_testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c 
_testlimitedcapi/bytes.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c 
_testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c 
_testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c 
_testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c 
_testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/sys.c 
_testlimitedcapi/threadstate.c _testlimitedcapi/tuple.c 
_testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c 
_testlimitedcapi/version.c _testlimitedcapi/file.c
 @MODULE__TESTCLINIC_TRUE@_testclinic _testclinic.c
 @MODULE__TESTCLINIC_LIMITED_TRUE@_testclinic_limited _testclinic_limited.c
 
diff --git a/Modules/_testlimitedcapi.c b/Modules/_testlimitedcapi.c
index 4dae99ec92a085..d3eb02d4727347 100644
--- a/Modules/_testlimitedcapi.c
+++ b/Modules/_testlimitedcapi.c
@@ -77,6 +77,9 @@ PyInit__testlimitedcapi(void)
     if (_PyTestLimitedCAPI_Init_Sys(mod) < 0) {
         return NULL;
     }
+    if (_PyTestLimitedCAPI_Init_ThreadState(mod) < 0) {
+        return NULL;
+    }
     if (_PyTestLimitedCAPI_Init_Tuple(mod) < 0) {
         return NULL;
     }
diff --git a/Modules/_testlimitedcapi/parts.h b/Modules/_testlimitedcapi/parts.h
index 60f6f03011a65c..1cbb4f5659af0f 100644
--- a/Modules/_testlimitedcapi/parts.h
+++ b/Modules/_testlimitedcapi/parts.h
@@ -38,6 +38,7 @@ int _PyTestLimitedCAPI_Init_Long(PyObject *module);
 int _PyTestLimitedCAPI_Init_PyOS(PyObject *module);
 int _PyTestLimitedCAPI_Init_Set(PyObject *module);
 int _PyTestLimitedCAPI_Init_Sys(PyObject *module);
+int _PyTestLimitedCAPI_Init_ThreadState(PyObject *module);
 int _PyTestLimitedCAPI_Init_Tuple(PyObject *module);
 int _PyTestLimitedCAPI_Init_Unicode(PyObject *module);
 int _PyTestLimitedCAPI_Init_VectorcallLimited(PyObject *module);
diff --git a/Modules/_testlimitedcapi/threadstate.c 
b/Modules/_testlimitedcapi/threadstate.c
new file mode 100644
index 00000000000000..f2539d97150d69
--- /dev/null
+++ b/Modules/_testlimitedcapi/threadstate.c
@@ -0,0 +1,25 @@
+#include "parts.h"
+#include "util.h"
+
+static PyObject *
+threadstate_set_async_exc(PyObject *module, PyObject *args)
+{
+    unsigned long id;
+    PyObject *exc;
+    if (!PyArg_ParseTuple(args, "kO", &id, &exc)) {
+        return NULL;
+    }
+    int result = PyThreadState_SetAsyncExc(id, exc);
+    return PyLong_FromLong(result);
+}
+
+static PyMethodDef test_methods[] = {
+    {"threadstate_set_async_exc", threadstate_set_async_exc, METH_VARARGS, 
NULL},
+    {NULL},
+};
+
+int
+_PyTestLimitedCAPI_Init_ThreadState(PyObject *m)
+{
+    return PyModule_AddFunctions(m, test_methods);
+}
diff --git a/Modules/signalmodule.c b/Modules/signalmodule.c
index 4d0e224ff757e7..5060e4097d33c9 100644
--- a/Modules/signalmodule.c
+++ b/Modules/signalmodule.c
@@ -1781,20 +1781,28 @@ PyErr_CheckSignals(void)
        Python code to ensure signals are handled. Checking for the GC here
        allows long running native code to clean cycles created using the C-API
        even if it doesn't run the evaluation loop */
-    if (_Py_eval_breaker_bit_is_set(tstate, _PY_GC_SCHEDULED_BIT)) {
+    uintptr_t breaker = _Py_atomic_load_uintptr_relaxed(&tstate->eval_breaker);
+    if (breaker & _PY_GC_SCHEDULED_BIT) {
         _Py_unset_eval_breaker_bit(tstate, _PY_GC_SCHEDULED_BIT);
         _Py_RunGC(tstate);
     }
+    if (breaker & _PY_ASYNC_EXCEPTION_BIT) {
+        if (_PyEval_RaiseAsyncExc(tstate) < 0) {
+            return -1;
+        }
+    }
 
 #if defined(Py_REMOTE_DEBUG) && defined(Py_SUPPORTS_REMOTE_DEBUG)
     _PyRunRemoteDebugger(tstate);
 #endif
 
-    if (!_Py_ThreadCanHandleSignals(tstate->interp)) {
-        return 0;
+    if (_Py_ThreadCanHandleSignals(tstate->interp)) {
+        if (_PyErr_CheckSignalsTstate(tstate) < 0) {
+            return -1;
+        }
     }
 
-    return _PyErr_CheckSignalsTstate(tstate);
+    return 0;
 }
 
 
diff --git a/PCbuild/_testlimitedcapi.vcxproj b/PCbuild/_testlimitedcapi.vcxproj
index 36c41fc9824fda..935467dfcb3283 100644
--- a/PCbuild/_testlimitedcapi.vcxproj
+++ b/PCbuild/_testlimitedcapi.vcxproj
@@ -110,6 +110,7 @@
     <ClCompile Include="..\Modules\_testlimitedcapi\pyos.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\set.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\sys.c" />
+    <ClCompile Include="..\Modules\_testlimitedcapi\threadstate.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\tuple.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\unicode.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\vectorcall_limited.c" />
diff --git a/PCbuild/_testlimitedcapi.vcxproj.filters 
b/PCbuild/_testlimitedcapi.vcxproj.filters
index 62ecb2f70ffa2d..5e0a0f65cfcc3d 100644
--- a/PCbuild/_testlimitedcapi.vcxproj.filters
+++ b/PCbuild/_testlimitedcapi.vcxproj.filters
@@ -26,6 +26,7 @@
     <ClCompile Include="..\Modules\_testlimitedcapi\set.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\sys.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\testcapi_long.h" />
+    <ClCompile Include="..\Modules\_testlimitedcapi\threadstate.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\tuple.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\unicode.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\vectorcall_limited.c" />
diff --git a/Python/ceval_gil.c b/Python/ceval_gil.c
index 88cc66e97f3424..2425bc1b39f0dc 100644
--- a/Python/ceval_gil.c
+++ b/Python/ceval_gil.c
@@ -1423,11 +1423,7 @@ _Py_HandlePending(PyThreadState *tstate)
 
     /* Check for asynchronous exception. */
     if ((breaker & _PY_ASYNC_EXCEPTION_BIT) != 0) {
-        _Py_unset_eval_breaker_bit(tstate, _PY_ASYNC_EXCEPTION_BIT);
-        PyObject *exc = _Py_atomic_exchange_ptr(&tstate->async_exc, NULL);
-        if (exc != NULL) {
-            _PyErr_SetNone(tstate, exc);
-            Py_DECREF(exc);
+        if (_PyEval_RaiseAsyncExc(tstate) < 0) {
             return -1;
         }
     }
@@ -1438,3 +1434,18 @@ _Py_HandlePending(PyThreadState *tstate)
 
     return 0;
 }
+
+int
+_PyEval_RaiseAsyncExc(PyThreadState *tstate)
+{
+    assert(tstate != NULL);
+    assert(tstate == _PyThreadState_GET());
+    _Py_unset_eval_breaker_bit(tstate, _PY_ASYNC_EXCEPTION_BIT);
+    PyObject *exc = _Py_atomic_exchange_ptr(&tstate->async_exc, NULL);
+    if (exc != NULL) {
+        _PyErr_SetNone(tstate, exc);
+        Py_DECREF(exc);
+        return -1;
+    }
+    return 0;
+}

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to