diff --git a/Lib/multiprocessing/forkserver.py b/Lib/multiprocessing/forkserver.py index b204a3ad0f1f01e..a93ff4f0be58033 100644 --- a/Lib/multiprocessing/forkserver.py +++ b/Lib/multiprocessing/forkserver.py @@ -320,8 +320,14 @@ def sigchld_handler(*_unused): # protect the process from ^C signal.SIGINT: signal.SIG_IGN, } - old_handlers = {sig: signal.signal(sig, val) - for (sig, val) in handlers.items()} + old_handlers = {} + for sig, val in handlers.items(): + old_handler = signal.signal(sig, val) + if old_handler is None: + # gh-159418: a handler installed outside of Python (e.g. from C) + # cannot be restored from Python, so use the default action. + old_handler = signal.SIG_DFL + old_handlers[sig] = old_handler # calling os.write() in the Python signal handler is racy signal.set_wakeup_fd(sig_w) diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index 40abeda816963b8..31c997497048264 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -7610,6 +7610,44 @@ def test_forkserver_preload_imports_sys_path(self): q.close() self.assertIn(self._mod_name, child_imported_modules) + @staticmethod + def send_child_sigint_state(conn, mod_name): + conn.send((mod_name in sys.modules, signal.getsignal(signal.SIGINT))) + + def test_forkserver_preload_foreign_sigint_handler(self): + # gh-159418: signal.signal() returns None when the handler was already + # installed outside Python before the interpreter started, for example + # by a library loaded with LD_PRELOAD. Simulate that in the forkserver + # with a preloaded module. + if self._ctx.get_start_method() != "forkserver": + self.skipTest("forkserver specific test.") + mod_name = "foreign_sigint_test_mod" + mod_path = os.path.join(self._temp_dir, f"{mod_name}.py") + with open(mod_path, "w", encoding="utf-8") as mod: + mod.write(textwrap.dedent("""\ + import signal + _signal = signal.signal + def _fake_signal(signum, handler): + old_handler = _signal(signum, handler) + return None if signum == signal.SIGINT else old_handler + signal.signal = _fake_signal + """)) + forkserver = multiprocessing.forkserver._forkserver + self.addCleanup(self._ctx.set_forkserver_preload, + forkserver._preload_modules) + self.addCleanup(forkserver._stop) # Don't reuse the patched server. + forkserver._stop() # Must be fresh. + self._ctx.set_forkserver_preload([mod_name]) + r, w = self._ctx.Pipe(duplex=False) + self.addCleanup(r.close) + proc = self._ctx.Process( + target=self.send_child_sigint_state, args=(w, mod_name)) + proc.start() + w.close() + proc.join() + self.assertEqual(proc.exitcode, 0) + self.assertEqual(r.recv(), (True, signal.SIG_DFL)) + @staticmethod def enq_sys_path_and_import(queue, mod_name): queue.put(sys.path) diff --git a/Misc/NEWS.d/next/Library/2026-10-15-18-54-03.gh-issue-159418.-iY5r_.rst b/Misc/NEWS.d/next/Library/2026-10-15-18-54-03.gh-issue-159418.-iY5r_.rst new file mode 100644 index 000000000000000..97a5b0583f89f9b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-15-18-54-03.gh-issue-159418.-iY5r_.rst @@ -0,0 +1,4 @@ +Fix :mod:`multiprocessing` ``forkserver`` child processes exiting with a +:exc:`TypeError` when :data:`~signal.SIGINT` had a handler installed outside +of Python, for example by a native library loaded with ``LD_PRELOAD``. Such +a signal is now reset to :data:`~signal.SIG_DFL` in the child.