From da5c286cee04aa44e7b0359024a828b0f694b21c Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 23 Feb 2026 18:04:58 +0100 Subject: [PATCH 1/6] Expose threadstate_set_async_exc in _testlimitedcapi --- Modules/Setup.stdlib.in | 2 +- Modules/_testlimitedcapi.c | 3 +++ Modules/_testlimitedcapi/parts.h | 1 + Modules/_testlimitedcapi/threadstate.c | 25 ++++++++++++++++++++++++ PCbuild/_testlimitedcapi.vcxproj | 1 + PCbuild/_testlimitedcapi.vcxproj.filters | 1 + 6 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 Modules/_testlimitedcapi/threadstate.c 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/PCbuild/_testlimitedcapi.vcxproj b/PCbuild/_testlimitedcapi.vcxproj index 36c41fc9824fda..935467dfcb3283 100644 --- a/PCbuild/_testlimitedcapi.vcxproj +++ b/PCbuild/_testlimitedcapi.vcxproj @@ -110,6 +110,7 @@ + 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 @@ + From 2f6139fca5fc1796e9ed03d36f955f55ab2385fd Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 23 Feb 2026 18:17:49 +0100 Subject: [PATCH 2/6] Add _PyEval_RaiseAsyncExc; call it from PyErr_CheckSignals --- Include/internal/pycore_ceval.h | 3 +++ Modules/signalmodule.c | 16 ++++++++++++---- Python/ceval_gil.c | 19 ++++++++++++++----- 3 files changed, 29 insertions(+), 9 deletions(-) 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/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/Python/ceval_gil.c b/Python/ceval_gil.c index 88cc66e97f3424..c7d8e510566d0b 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,16 @@ _Py_HandlePending(PyThreadState *tstate) return 0; } + +int +_PyEval_RaiseAsyncExc(PyThreadState *tstate) +{ + _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; +} From b47b9e892e2c264f39577834948c7609498987c2 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Tue, 24 Feb 2026 14:26:00 +0100 Subject: [PATCH 3/6] Test & docs --- Doc/c-api/exceptions.rst | 5 +++ Doc/c-api/threads.rst | 26 ++++++++---- Lib/test/test_threading.py | 42 +++++++++++++++++++ ...-02-24-14-46-05.gh-issue-144748.uhnFtE.rst | 2 + 4 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/C_API/2026-02-24-14-46-05.gh-issue-144748.uhnFtE.rst 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 ` 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..3e9361c2333bd4 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 diff --git a/Lib/test/test_threading.py b/Lib/test/test_threading.py index bdfd03b1e58f62..6aa8ee79153d97 100644 --- a/Lib/test/test_threading.py +++ b/Lib/test/test_threading.py @@ -412,6 +412,48 @@ 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): + signal.pthread_kill(thread.ident, signal.SIGUSR1) + if not thread.is_alive(): + 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. From 9c23743bc19b62d49282a5da1c209ba34e8502be Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 25 Feb 2026 13:45:32 +0100 Subject: [PATCH 4/6] Python/ceval_gil.c: Add asserts Co-authored-by: Peter Bierma --- Python/ceval_gil.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Python/ceval_gil.c b/Python/ceval_gil.c index c7d8e510566d0b..2425bc1b39f0dc 100644 --- a/Python/ceval_gil.c +++ b/Python/ceval_gil.c @@ -1438,6 +1438,8 @@ _Py_HandlePending(PyThreadState *tstate) 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) { From 99040b673006d5a97bed8955e0b2a0927d8f1928 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 25 Feb 2026 13:49:21 +0100 Subject: [PATCH 5/6] Expand PyThread_get_thread_ident's seealso --- Doc/c-api/threads.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Doc/c-api/threads.rst b/Doc/c-api/threads.rst index 3e9361c2333bd4..41c7fbda2302cf 100644 --- a/Doc/c-api/threads.rst +++ b/Doc/c-api/threads.rst @@ -755,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) From ac8cf872282020d762c4c2b9f9a326a1f5e20d1b Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 25 Feb 2026 14:16:46 +0100 Subject: [PATCH 6/6] Make the test more robust --- Lib/test/test_threading.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_threading.py b/Lib/test/test_threading.py index 6aa8ee79153d97..0ca91ce0d7899d 100644 --- a/Lib/test/test_threading.py +++ b/Lib/test/test_threading.py @@ -445,9 +445,14 @@ def catch_exception(): self.assertEqual(result, 1) for _ in support.sleeping_retry(support.SHORT_TIMEOUT): - signal.pthread_kill(thread.ident, signal.SIGUSR1) 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)