diff --git a/Doc/c-api/init.rst b/Doc/c-api/init.rst index 199b64387266bf..4ffbd109d88a90 100644 --- a/Doc/c-api/init.rst +++ b/Doc/c-api/init.rst @@ -1585,6 +1585,15 @@ function. You can create and destroy them using the following functions: If this is :c:macro:`PyInterpreterConfig_OWN_GIL` then :c:member:`PyInterpreterConfig.use_main_obmalloc` must be ``0``. + .. c:member:: int can_handle_signals + + If this is ``0``, then the interpreter will ignore incoming signals when + it is running on the main thread. These signals will instead be handled + by the next interpreter in the main thread that is capable of handling + signals. + + .. versionadded:: next + .. c:function:: PyStatus Py_NewInterpreterFromConfig(PyThreadState **tstate_p, const PyInterpreterConfig *config) diff --git a/Doc/library/signal.rst b/Doc/library/signal.rst index b0307d3dea1170..f111a10a7495ec 100644 --- a/Doc/library/signal.rst +++ b/Doc/library/signal.rst @@ -61,12 +61,13 @@ This has consequences: Signals and threads ^^^^^^^^^^^^^^^^^^^ -Python signal handlers are always executed in the main Python thread of the main interpreter, +Python signal handlers are always executed in the main Python thread of +intepreters that support signal handling (:c:member:`PyInterpreterConfig.can_handle_signals`), even if the signal was received in another thread. This means that signals can't be used as a means of inter-thread communication. You can use the synchronization primitives from the :mod:`threading` module instead. -Besides, only the main thread of the main interpreter is allowed to set a new signal handler. +Besides, only the main thread is allowed to set a new signal handler. Module contents @@ -421,7 +422,7 @@ The :mod:`signal` module defines the following functions: same process as the caller. The target thread can be executing any code (Python or not). However, if the target thread is executing the Python interpreter, the Python signal handlers will be :ref:`executed by the main - thread of the main interpreter `. Therefore, the only point of sending a + thread of a supporting interpreter `. Therefore, the only point of sending a signal to a particular Python thread would be to force a running system call to fail with :exc:`InterruptedError`. @@ -523,7 +524,7 @@ The :mod:`signal` module defines the following functions: any bytes from *fd* before calling poll or select again. When threads are enabled, this function can only be called - from :ref:`the main thread of the main interpreter `; + from :ref:`the main thread of a supporting interpreter `; attempting to call it from other threads will cause a :exc:`ValueError` exception to be raised. @@ -578,7 +579,7 @@ The :mod:`signal` module defines the following functions: above). (See the Unix man page :manpage:`signal(2)` for further information.) When threads are enabled, this function can only be called - from :ref:`the main thread of the main interpreter `; + from :ref:`the main thread of a supporting interpreter `; attempting to call it from other threads will cause a :exc:`ValueError` exception to be raised. diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index dc16c944886bd0..62c839536dc6ef 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -722,7 +722,7 @@ New features * Add API for checking an extension module's ABI compatibility: :c:data:`Py_mod_abi`, :c:func:`PyABIInfo_Check`, :c:macro:`PyABIInfo_VAR` and :c:data:`Py_mod_abi`. - (Contributed by Petr Viktorin in :gh:`137210`.) + (Contributed by Petr Viktorin in :gh:`137210`) * Implement :pep:`782`, the :ref:`PyBytesWriter API `. Add functions: @@ -742,6 +742,11 @@ New features (Contributed by Victor Stinner in :gh:`129813`.) +* Subinterpreters now have the option to handle signals through the + :c:member:`PyInterpreterConfig.can_handle_signals` setting. Only + subinterpreters running in the main thread will be able to handle + signals. + Porting to Python 3.15 ---------------------- diff --git a/Include/cpython/pylifecycle.h b/Include/cpython/pylifecycle.h index 86ce6e6f79824a..bd827f2f39af42 100644 --- a/Include/cpython/pylifecycle.h +++ b/Include/cpython/pylifecycle.h @@ -47,6 +47,7 @@ typedef struct { int allow_daemon_threads; int check_multi_interp_extensions; int gil; + int can_handle_signals; } PyInterpreterConfig; #define _PyInterpreterConfig_INIT \ @@ -58,6 +59,7 @@ typedef struct { .allow_daemon_threads = 0, \ .check_multi_interp_extensions = 1, \ .gil = PyInterpreterConfig_OWN_GIL, \ + .can_handle_signals = 1, \ } // gh-117649: The free-threaded build does not currently support single-phase @@ -78,6 +80,7 @@ typedef struct { .allow_daemon_threads = 1, \ .check_multi_interp_extensions = _PyInterpreterConfig_LEGACY_CHECK_MULTI_INTERP_EXTENSIONS, \ .gil = PyInterpreterConfig_SHARED_GIL, \ + .can_handle_signals = 0, \ } PyAPI_FUNC(PyStatus) Py_NewInterpreterFromConfig( diff --git a/Include/internal/pycore_interp.h b/Include/internal/pycore_interp.h index 5b1bb202191b51..ab54806825438b 100644 --- a/Include/internal/pycore_interp.h +++ b/Include/internal/pycore_interp.h @@ -93,6 +93,9 @@ might not be allowed in the current interpreter (i.e. os.fork() would fail). /* Set if os.exec*() is allowed. */ #define Py_RTFLAGS_EXEC (1UL << 16) +/* Set if signal handling is allowed. */ +#define Py_RTFLAGS_CAN_HANDLE_SIGNALS (1UL << 17) + extern int _PyInterpreterState_HasFeature(PyInterpreterState *interp, unsigned long feature); diff --git a/Include/internal/pycore_pystate.h b/Include/internal/pycore_pystate.h index ea3dfbd2eef9c1..8a8c181df05710 100644 --- a/Include/internal/pycore_pystate.h +++ b/Include/internal/pycore_pystate.h @@ -11,6 +11,7 @@ extern "C" { #include "pycore_pythonrun.h" // _PyOS_STACK_MARGIN_SHIFT #include "pycore_typedefs.h" // _PyRuntimeState #include "pycore_tstate.h" +#include "pycore_interp.h" // _PyInterpreterState_HasFeature() // Values for PyThreadState.state. A thread must be in the "attached" state @@ -78,11 +79,12 @@ extern void _PyInterpreterState_ReinitRunningMain(PyThreadState *); extern const PyConfig* _Py_GetMainConfig(void); -/* Only handle signals on the main thread of the main interpreter. */ +/* Only handle signals on the main thread of an interpreter that supports it. */ static inline int _Py_ThreadCanHandleSignals(PyInterpreterState *interp) { - return (_Py_IsMainThread() && _Py_IsMainInterpreter(interp)); + return (_Py_IsMainThread() + && _PyInterpreterState_HasFeature(interp, Py_RTFLAGS_CAN_HANDLE_SIGNALS)); } diff --git a/Lib/test/test_capi/test_getargs.py b/Lib/test/test_capi/test_getargs.py index 0b2473bac2be11..1044fd0e4fc79b 100644 --- a/Lib/test/test_capi/test_getargs.py +++ b/Lib/test/test_capi/test_getargs.py @@ -1445,6 +1445,7 @@ def test_gh_119213(self): use_main_obmalloc=False, gil=2, check_multi_interp_extensions=True, + can_handle_signals=True, ) rc = support.run_in_subinterp_with_config(script, **config) assert rc == 0 diff --git a/Lib/test/test_capi/test_misc.py b/Lib/test/test_capi/test_misc.py index ef950f5df04ad3..ab0e7072a9bccd 100644 --- a/Lib/test/test_capi/test_misc.py +++ b/Lib/test/test_capi/test_misc.py @@ -1730,8 +1730,9 @@ def test_configured_settings(self): DAEMON_THREADS = 1<<11 FORK = 1<<15 EXEC = 1<<16 + SIGNALS = 1<<17 ALL_FLAGS = (OBMALLOC | FORK | EXEC | THREADS | DAEMON_THREADS - | EXTENSIONS); + | EXTENSIONS | SIGNALS); features = [ 'obmalloc', @@ -1741,23 +1742,25 @@ def test_configured_settings(self): 'daemon_threads', 'extensions', 'own_gil', + 'signals', ] kwlist = [f'allow_{n}' for n in features] kwlist[0] = 'use_main_obmalloc' - kwlist[-2] = 'check_multi_interp_extensions' - kwlist[-1] = 'own_gil' + kwlist[-3] = 'check_multi_interp_extensions' + kwlist[-2] = 'own_gil' + kwlist[-1] = 'can_handle_signals' expected_to_work = { - (True, True, True, True, True, True, True): + (True, True, True, True, True, True, True, True): (ALL_FLAGS, True), - (True, False, False, False, False, False, False): + (True, False, False, False, False, False, False, False): (OBMALLOC, False), - (False, False, False, True, False, True, False): + (False, False, False, True, False, True, False, False): (THREADS | EXTENSIONS, False), } expected_to_fail = { - (False, False, False, False, False, False, False), + (False, False, False, False, False, False, False, False), } # gh-117649: The free-threaded build does not currently allow @@ -1824,7 +1827,8 @@ def test_overridden_setting_extensions_subinterp_check(self): DAEMON_THREADS = 1<<11 FORK = 1<<15 EXEC = 1<<16 - BASE_FLAGS = OBMALLOC | FORK | EXEC | THREADS | DAEMON_THREADS + SIGNALS = 1<<17 + BASE_FLAGS = OBMALLOC | FORK | EXEC | THREADS | DAEMON_THREADS | SIGNALS base_kwargs = { 'use_main_obmalloc': True, 'allow_fork': True, @@ -1832,6 +1836,7 @@ def test_overridden_setting_extensions_subinterp_check(self): 'allow_threads': True, 'allow_daemon_threads': True, 'own_gil': False, + 'can_handle_signals': True } def check(enabled, override): @@ -1961,6 +1966,7 @@ class InterpreterConfigTests(unittest.TestCase): allow_threads=True, allow_daemon_threads=False, check_multi_interp_extensions=True, + can_handle_signals=True, gil='own', ), 'legacy': types.SimpleNamespace( @@ -1970,6 +1976,7 @@ class InterpreterConfigTests(unittest.TestCase): allow_threads=True, allow_daemon_threads=True, check_multi_interp_extensions=bool(Py_GIL_DISABLED), + can_handle_signals=False, gil='shared', ), 'empty': types.SimpleNamespace( @@ -1979,6 +1986,7 @@ class InterpreterConfigTests(unittest.TestCase): allow_threads=False, allow_daemon_threads=False, check_multi_interp_extensions=False, + can_handle_signals=False, gil='default', ), } @@ -1991,16 +1999,18 @@ def iter_all_configs(self): for allow_threads in (True, False): for allow_daemon in (True, False): for checkext in (True, False): - for gil in ('shared', 'own', 'default'): - yield types.SimpleNamespace( - use_main_obmalloc=use_main_obmalloc, - allow_fork=allow_fork, - allow_exec=allow_exec, - allow_threads=allow_threads, - allow_daemon_threads=allow_daemon, - check_multi_interp_extensions=checkext, - gil=gil, - ) + for handle_signals in (True, False): + for gil in ('shared', 'own', 'default'): + yield types.SimpleNamespace( + use_main_obmalloc=use_main_obmalloc, + allow_fork=allow_fork, + allow_exec=allow_exec, + allow_threads=allow_threads, + allow_daemon_threads=allow_daemon, + check_multi_interp_extensions=checkext, + can_handle_signals=handle_signals, + gil=gil, + ) def assert_ns_equal(self, ns1, ns2, msg=None): # This is mostly copied from TestCase.assertDictEqual. @@ -2175,6 +2185,7 @@ def new_interp(config): with self.subTest('main'): expected = _interpreters.new_config('legacy') expected.gil = 'own' + expected.can_handle_signals = True if Py_GIL_DISABLED: expected.check_multi_interp_extensions = False interpid, *_ = _interpreters.get_main() diff --git a/Lib/test/test_embed.py b/Lib/test/test_embed.py index 22dfdb6bb6f138..f1990b2dfe6f9b 100644 --- a/Lib/test/test_embed.py +++ b/Lib/test/test_embed.py @@ -1827,10 +1827,11 @@ def test_init_main_interpreter_settings(self): DAEMON_THREADS = 1<<11 FORK = 1<<15 EXEC = 1<<16 + SIGNALS = 1<<17 expected = { # All optional features should be enabled. 'feature_flags': - OBMALLOC | FORK | EXEC | THREADS | DAEMON_THREADS, + OBMALLOC | FORK | EXEC | THREADS | DAEMON_THREADS | SIGNALS, 'own_gil': True, } out, err = self.run_embedded_interpreter( diff --git a/Lib/test/test_import/__init__.py b/Lib/test/test_import/__init__.py index abbd5f1ed5f12f..18c4fee3b890db 100644 --- a/Lib/test/test_import/__init__.py +++ b/Lib/test/test_import/__init__.py @@ -2194,9 +2194,11 @@ class SubinterpImportTests(unittest.TestCase): ISOLATED = dict( use_main_obmalloc=False, gil=2, + can_handle_signals=True, ) NOT_ISOLATED = {k: not v for k, v in ISOLATED.items()} NOT_ISOLATED['gil'] = 1 + NOT_ISOLATED['can_handle_signals'] = False @unittest.skipUnless(hasattr(os, "pipe"), "requires os.pipe()") def pipe(self): diff --git a/Lib/test/test_interpreters/test_api.py b/Lib/test/test_interpreters/test_api.py index 289e607ad3fad3..f935f181a6fa82 100644 --- a/Lib/test/test_interpreters/test_api.py +++ b/Lib/test/test_interpreters/test_api.py @@ -1855,6 +1855,7 @@ def test_new_config(self): allow_threads=True, allow_daemon_threads=False, check_multi_interp_extensions=True, + can_handle_signals=True, gil='own', ), 'legacy': types.SimpleNamespace( @@ -1864,6 +1865,7 @@ def test_new_config(self): allow_threads=True, allow_daemon_threads=True, check_multi_interp_extensions=bool(Py_GIL_DISABLED), + can_handle_signals=False, gil='shared', ), 'empty': types.SimpleNamespace( @@ -1873,6 +1875,7 @@ def test_new_config(self): allow_threads=False, allow_daemon_threads=False, check_multi_interp_extensions=False, + can_handle_signals=False, gil='default', ), } @@ -2134,6 +2137,7 @@ def test_get_config(self): with self.subTest('main'): expected = _interpreters.new_config('legacy') expected.gil = 'own' + expected.can_handle_signals = True if Py_GIL_DISABLED: expected.check_multi_interp_extensions = False interpid, *_ = _interpreters.get_main() @@ -2371,6 +2375,94 @@ def test_set___main___attrs(self): self.assertEqual(rc, 0) +class SignalTests(TestBase): + @support.requires_subprocess() + @unittest.skipIf(os.name == 'nt', 'SIGINT not supported on windows') + def test_interpreter_handles_signals(self): + import subprocess + import sys + import signal + + interp_source = """if True: + import time + + print('x', end='', flush=True) + time.sleep(10) + print("should never happen", flush=True) + """ + + source = f"""if True: + from concurrent import interpreters + + interp = interpreters.create() + interp.exec('''{interp_source}''') + """ + + proc = subprocess.Popen([sys.executable, '-c', source], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, close_fds=True) + self.assertEqual(proc.stdout.read(1), b'x') + proc.send_signal(signal.SIGINT) + stdout, stderr = proc.communicate(timeout=5) + self.assertEqual(stdout, b"") + self.assertIn(b"KeyboardInterrupt", stderr) + + @support.requires_subprocess() + @unittest.skipIf(os.name == 'nt', 'SIGINT not supported on windows') + def test_legacy_interpreter_does_not_handle_signals(self): + import subprocess + import sys + import signal + + interp_source = """if True: + import time + + print('x', end='', flush=True) + time.sleep(1) + print('inquisition', end='', flush=True) + """ + + source = f"""if True: + import _interpreters + + config = _interpreters.new_config("legacy") + interp = _interpreters.create(config) + res = _interpreters.run_string(interp, '''{interp_source}''') + assert res is None + """ + + proc = subprocess.Popen([sys.executable, '-c', source], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, close_fds=True) + self.assertEqual(proc.stdout.read(1), b'x') + proc.send_signal(signal.SIGINT) + with self.assertRaises(subprocess.TimeoutExpired): + stdout, stderr = proc.communicate(timeout=0.5) + stdout, stderr = proc.communicate() + self.assertEqual(stdout, b"inquisition") + self.assertIn(b"KeyboardInterrupt", stderr) + self.assertNotIn(b"AssertionError", stderr) + + @unittest.skipIf(os.name == 'nt', 'SIGUSR1 not supported') + def test_signal_module_in_subinterpreters(self): + read, write = self.pipe() + interp = interpreters.create() + interp.exec(f"""if True: + import signal + import os + + def sig(signum, stack): + signame = signal.Signals(signum).name + assert signame == "SIGUSR1" + os.write({write}, b'x') + + signal.signal(signal.SIGUSR1, sig) + signal.raise_signal(signal.SIGUSR1) + """) + self.assertEqual(os.read(read, 1), b'x') + + + if __name__ == '__main__': # Test needs to be a package, so we can do relative imports. unittest.main() diff --git a/Lib/test/test_threading.py b/Lib/test/test_threading.py index 774059c21d7246..9290dcb2e82407 100644 --- a/Lib/test/test_threading.py +++ b/Lib/test/test_threading.py @@ -1825,6 +1825,7 @@ def func(): allow_threads={allowed}, allow_daemon_threads={daemon_allowed}, check_multi_interp_extensions={check_multi_interp_extensions}, + can_handle_signals=True, own_gil=False, ) """) diff --git a/Misc/NEWS.d/next/C_API/2025-07-28-12-21-42.gh-issue-137173.wvMm3C.rst b/Misc/NEWS.d/next/C_API/2025-07-28-12-21-42.gh-issue-137173.wvMm3C.rst new file mode 100644 index 00000000000000..e880301d2e8dbc --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2025-07-28-12-21-42.gh-issue-137173.wvMm3C.rst @@ -0,0 +1,4 @@ +Add :c:member:`PyInterpreterConfig.can_handle_signals` to allow +subinterpreters to handle signals when they are running the main thread. +This also allows interpreters created by :mod:`concurrent.interpreters` to +handle signals. diff --git a/Modules/signalmodule.c b/Modules/signalmodule.c index 3c79ef1429087a..c27e56ef18ca9f 100644 --- a/Modules/signalmodule.c +++ b/Modules/signalmodule.c @@ -303,6 +303,7 @@ trip_signal(int sig_num) int fd = wakeup.fd; if (fd != INVALID_FD) { PyInterpreterState *interp = _PyInterpreterState_Main(); + assert(interp != NULL); unsigned char byte = (unsigned char)sig_num; #ifdef MS_WINDOWS if (wakeup.use_send) { @@ -508,7 +509,7 @@ signal_signal_impl(PyObject *module, int signalnum, PyObject *handler) if (!_Py_ThreadCanHandleSignals(tstate->interp)) { _PyErr_SetString(tstate, PyExc_ValueError, "signal only works in main thread " - "of the main interpreter"); + "of interpreters that support it"); return NULL; } if (signalnum < 1 || signalnum >= Py_NSIG) { @@ -753,7 +754,7 @@ signal_set_wakeup_fd_impl(PyObject *module, PyObject *fdobj, if (!_Py_ThreadCanHandleSignals(tstate->interp)) { _PyErr_SetString(tstate, PyExc_ValueError, "set_wakeup_fd only works in main thread " - "of the main interpreter"); + "of supporting interpreters"); return NULL; } @@ -1670,7 +1671,7 @@ signal_module_exec(PyObject *m) #endif PyThreadState *tstate = _PyThreadState_GET(); - if (_Py_IsMainInterpreter(tstate->interp)) { + if (_Py_ThreadCanHandleSignals(tstate->interp)) { if (signal_get_set_handlers(state, d) < 0) { return -1; } diff --git a/Python/interpconfig.c b/Python/interpconfig.c index 1add8a81425b9a..56c3e4d8ad9f54 100644 --- a/Python/interpconfig.c +++ b/Python/interpconfig.c @@ -87,6 +87,7 @@ _PyInterpreterConfig_AsDict(PyInterpreterConfig *config) ADD_BOOL(allow_threads); ADD_BOOL(allow_daemon_threads); ADD_BOOL(check_multi_interp_extensions); + ADD_BOOL(can_handle_signals); ADD_STR(gil, gil_flag_to_str(config->gil)); @@ -182,6 +183,7 @@ interp_config_from_dict(PyObject *origdict, PyInterpreterConfig *config, COPY_BOOL(allow_threads); COPY_BOOL(allow_daemon_threads); COPY_BOOL(check_multi_interp_extensions); + COPY_BOOL(can_handle_signals); // PyInterpreterConfig.gil char buf[20]; @@ -260,6 +262,7 @@ _PyInterpreterConfig_InitFromState(PyInterpreterConfig *config, .allow_threads = FLAG(THREADS), .allow_daemon_threads = FLAG(DAEMON_THREADS), .check_multi_interp_extensions = FLAG(MULTI_INTERP_EXTENSIONS), + .can_handle_signals = FLAG(CAN_HANDLE_SIGNALS), #undef FLAG .gil = interp->ceval.own_gil ? PyInterpreterConfig_OWN_GIL diff --git a/Python/pylifecycle.c b/Python/pylifecycle.c index 51a777077d8255..9840957675f365 100644 --- a/Python/pylifecycle.c +++ b/Python/pylifecycle.c @@ -566,6 +566,10 @@ init_interp_settings(PyInterpreterState *interp, interp->feature_flags |= Py_RTFLAGS_MULTI_INTERP_EXTENSIONS; } + if (config->can_handle_signals) { + interp->feature_flags |= Py_RTFLAGS_CAN_HANDLE_SIGNALS; + } + switch (config->gil) { case PyInterpreterConfig_DEFAULT_GIL: break; case PyInterpreterConfig_SHARED_GIL: break; @@ -636,10 +640,11 @@ pycore_create_interpreter(_PyRuntimeState *runtime, } PyInterpreterConfig config = _PyInterpreterConfig_LEGACY_INIT; - // The main interpreter always has its own GIL and supports single-phase - // init extensions. + // The main interpreter always has its own GIL, supports single-phase + // init extensions, and can handle signals. config.gil = PyInterpreterConfig_OWN_GIL; config.check_multi_interp_extensions = 0; + config.can_handle_signals = 1; status = init_interp_settings(interp, &config); if (_PyStatus_EXCEPTION(status)) { return status;