From a08dbf7b390ffdc019dc8ede997d9559cfb1ad1c Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 24 Jul 2025 17:41:30 -0400 Subject: [PATCH 01/14] Allow subinterpreters to handle signals. --- Include/cpython/pylifecycle.h | 3 +++ Include/internal/pycore_interp.h | 3 +++ Include/internal/pycore_pystate.h | 7 ++++--- Python/interpconfig.c | 2 ++ Python/pylifecycle.c | 9 +++++++-- 5 files changed, 19 insertions(+), 5 deletions(-) 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..a52ea40d97833f 100644 --- a/Include/internal/pycore_pystate.h +++ b/Include/internal/pycore_pystate.h @@ -11,7 +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 // before calling most Python APIs. If the GIL is enabled, then "attached" @@ -78,11 +78,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/Python/interpconfig.c b/Python/interpconfig.c index 1add8a81425b9a..f5c9f4fb728739 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]; diff --git a/Python/pylifecycle.c b/Python/pylifecycle.c index e22a9cc1c75050..ad89996cd0927c 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; From bbb2e372a95f83bc477c26e68e164b3fb0cdcc22 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 24 Jul 2025 17:44:09 -0400 Subject: [PATCH 02/14] Add a documentation entry. --- Doc/c-api/init.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Doc/c-api/init.rst b/Doc/c-api/init.rst index e3ad4f4cdc52cc..76c7ed6c9100f8 100644 --- a/Doc/c-api/init.rst +++ b/Doc/c-api/init.rst @@ -1580,6 +1580,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) From 61eb4db2b281f387ccd03bb2bac6feb1794e4665 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 11:39:50 -0400 Subject: [PATCH 03/14] Add a test case. Also fix the existing test cases that were broken. --- Lib/test/test_interpreters/test_api.py | 35 ++++++++++++++++++++++++++ Python/interpconfig.c | 1 + 2 files changed, 36 insertions(+) diff --git a/Lib/test/test_interpreters/test_api.py b/Lib/test/test_interpreters/test_api.py index a34b20beaca7a3..b3046a0106225a 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() @@ -2360,6 +2364,37 @@ def test_set___main___attrs(self): ) self.assertEqual(rc, 0) + def test_interpreter_handles_signals(self): + import subprocess + import sys + import signal + + interp_source = """if True: + import sys + import time + + sys.stdout.write('x') + sys.stdout.flush() + 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() + self.assertEqual(stdout, b"") + self.assertIn(b"KeyboardInterrupt", stderr) + if __name__ == '__main__': # Test needs to be a package, so we can do relative imports. diff --git a/Python/interpconfig.c b/Python/interpconfig.c index f5c9f4fb728739..56c3e4d8ad9f54 100644 --- a/Python/interpconfig.c +++ b/Python/interpconfig.c @@ -262,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 From 3d15931f6e12953ef5fa9279f923acc9d3c3c30f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 11:40:45 -0400 Subject: [PATCH 04/14] Simplify the test. --- Lib/test/test_interpreters/test_api.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Lib/test/test_interpreters/test_api.py b/Lib/test/test_interpreters/test_api.py index b3046a0106225a..22f182eb2bcc54 100644 --- a/Lib/test/test_interpreters/test_api.py +++ b/Lib/test/test_interpreters/test_api.py @@ -2370,11 +2370,9 @@ def test_interpreter_handles_signals(self): import signal interp_source = """if True: - import sys import time - sys.stdout.write('x') - sys.stdout.flush() + print('x', end='', flush=True) time.sleep(10) print("should never happen", flush=True) """ From d3536383a48f4994990d073ebb17ee6852f443f5 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 11:51:46 -0400 Subject: [PATCH 05/14] Add a test for legacy subinterpreters. --- Lib/test/test_interpreters/test_api.py | 38 +++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_interpreters/test_api.py b/Lib/test/test_interpreters/test_api.py index 22f182eb2bcc54..11ca9ef64e7370 100644 --- a/Lib/test/test_interpreters/test_api.py +++ b/Lib/test/test_interpreters/test_api.py @@ -2364,6 +2364,7 @@ def test_set___main___attrs(self): ) self.assertEqual(rc, 0) + @support.requires_subprocess() def test_interpreter_handles_signals(self): import subprocess import sys @@ -2389,10 +2390,45 @@ def test_interpreter_handles_signals(self): stderr=subprocess.PIPE, close_fds=True) self.assertEqual(proc.stdout.read(1), b'x') proc.send_signal(signal.SIGINT) - stdout, stderr = proc.communicate() + stdout, stderr = proc.communicate(timeout=5) self.assertEqual(stdout, b"") self.assertIn(b"KeyboardInterrupt", stderr) + @support.requires_subprocess() + 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) + if __name__ == '__main__': # Test needs to be a package, so we can do relative imports. From 455cfdad42b28c446e4e99cc660493cbc5cef43f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 11:57:00 -0400 Subject: [PATCH 06/14] Fix stray newline change. --- Include/internal/pycore_pystate.h | 1 + 1 file changed, 1 insertion(+) diff --git a/Include/internal/pycore_pystate.h b/Include/internal/pycore_pystate.h index a52ea40d97833f..522f3ffdfb3e84 100644 --- a/Include/internal/pycore_pystate.h +++ b/Include/internal/pycore_pystate.h @@ -13,6 +13,7 @@ extern "C" { #include "pycore_tstate.h" #include "pycore_interp.h" // _PyInterpreterState_HasFeature + // Values for PyThreadState.state. A thread must be in the "attached" state // before calling most Python APIs. If the GIL is enabled, then "attached" // implies that the thread holds the GIL and "detached" implies that the From 6d6969e366acc11ee80c205f2476fabaf82cc10d Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 11:57:39 -0400 Subject: [PATCH 07/14] Adjust comment. --- Include/internal/pycore_pystate.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Include/internal/pycore_pystate.h b/Include/internal/pycore_pystate.h index 522f3ffdfb3e84..8a8c181df05710 100644 --- a/Include/internal/pycore_pystate.h +++ b/Include/internal/pycore_pystate.h @@ -11,7 +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 +#include "pycore_interp.h" // _PyInterpreterState_HasFeature() // Values for PyThreadState.state. A thread must be in the "attached" state From 3bc3dadc442e617014cfb6e513b8716c912a6a15 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 12:07:53 -0400 Subject: [PATCH 08/14] Add whatsnew entry. --- Doc/whatsnew/3.15.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index e8e2c1ed6047bf..a55d4d86bd3aa9 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -487,6 +487,11 @@ New features a string. See the documentation for caveats. (Contributed by Petr Viktorin in :gh:`131510`) +* 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 ---------------------- From 6c175d3a0258c0ec9d916bf3ffb3d0a5d493b86e Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 12:21:46 -0400 Subject: [PATCH 09/14] Add blurb. --- .../next/C_API/2025-07-28-12-21-42.gh-issue-137173.wvMm3C.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Misc/NEWS.d/next/C_API/2025-07-28-12-21-42.gh-issue-137173.wvMm3C.rst 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. From fc967a26dff6da36232b84790dcdefafd3a5cbe5 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 12:45:20 -0400 Subject: [PATCH 10/14] Fix test_capi. --- Lib/test/test_capi/test_getargs.py | 1 + Lib/test/test_capi/test_misc.py | 47 ++++++++++++++++++------------ Lib/test/test_threading.py | 1 + 3 files changed, 31 insertions(+), 18 deletions(-) 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_threading.py b/Lib/test/test_threading.py index 00a3037c3e1e01..153cdc65c28750 100644 --- a/Lib/test/test_threading.py +++ b/Lib/test/test_threading.py @@ -1798,6 +1798,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, ) """) From de562044db9d61d15386cd89acf7805e1652698b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 12:46:14 -0400 Subject: [PATCH 11/14] Fix test_embed. --- Lib/test/test_embed.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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( From 7b84abdb081b4be4a966c0b981dfdcb4ef5361a3 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 12:48:13 -0400 Subject: [PATCH 12/14] Fix test_import. --- Lib/test/test_import/__init__.py | 2 ++ 1 file changed, 2 insertions(+) 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): From 00c8ccb25c67c15ce7070364d162edb14e310154 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 28 Jul 2025 13:41:40 -0400 Subject: [PATCH 13/14] Skip test on Windows. --- Lib/test/test_interpreters/test_api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_interpreters/test_api.py b/Lib/test/test_interpreters/test_api.py index 11ca9ef64e7370..d6d119cd023faf 100644 --- a/Lib/test/test_interpreters/test_api.py +++ b/Lib/test/test_interpreters/test_api.py @@ -2365,6 +2365,7 @@ def test_set___main___attrs(self): self.assertEqual(rc, 0) @support.requires_subprocess() + @unittest.skipIf(os.name == 'nt', 'SIGINT not supported on windows') def test_interpreter_handles_signals(self): import subprocess import sys @@ -2395,6 +2396,7 @@ def test_interpreter_handles_signals(self): 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 From ff3bd3f40db5bc4e5656a5a5a841c771c23ac638 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 31 Jul 2025 10:51:21 -0400 Subject: [PATCH 14/14] Some very basic support for the `signal` module. Catching signals while another interpreter has registered `signal` module handlers still does not work, because we don't have a good way to make the callbacks per-interpreter. We'd need something like PEP 788's weak reference API to safely keep references to interpreters without worrying about concurrent deallocation during signal handling. --- Doc/library/signal.rst | 11 ++++++----- Lib/test/test_interpreters/test_api.py | 21 +++++++++++++++++++++ Modules/signalmodule.c | 7 ++++--- 3 files changed, 31 insertions(+), 8 deletions(-) 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/Lib/test/test_interpreters/test_api.py b/Lib/test/test_interpreters/test_api.py index d6d119cd023faf..41be1e23d8cd7b 100644 --- a/Lib/test/test_interpreters/test_api.py +++ b/Lib/test/test_interpreters/test_api.py @@ -2364,6 +2364,8 @@ 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): @@ -2431,6 +2433,25 @@ def test_legacy_interpreter_does_not_handle_signals(self): 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. diff --git a/Modules/signalmodule.c b/Modules/signalmodule.c index 54bcd3270ef31a..b1082345f58cf7 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) { @@ -507,7 +508,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) { @@ -751,7 +752,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; } @@ -1661,7 +1662,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; }