From f86f638e15bafdaf3ace6a882b6d8181b4858b34 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Thu, 17 Sep 2026 11:20:00 +0300 Subject: [PATCH] gh-74112: Make Ctrl-C in the IDLE Shell interrupt blocking calls Send a real SIGINT to the main thread of the user process instead of calling _thread.interrupt_main(), which only sets a flag checked between bytecodes. The signal is sent while holding a new lock which protects sending a message, so that the main thread is not interrupted in the middle of a message. An interrupted wait for a response now releases its lock, so that the socket thread does not deadlock. --- Lib/idlelib/idle_test/test_rpc.py | 19 ++++++++- Lib/idlelib/idle_test/test_run.py | 32 +++++++++++++++ Lib/idlelib/rpc.py | 39 +++++++++++-------- Lib/idlelib/run.py | 15 ++++++- ...6-09-17-12-00-00.gh-issue-74112.sigint.rst | 2 + 5 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2026-09-17-12-00-00.gh-issue-74112.sigint.rst diff --git a/Lib/idlelib/idle_test/test_rpc.py b/Lib/idlelib/idle_test/test_rpc.py index 81eff398c72f45..fbb42ec551ffba 100644 --- a/Lib/idlelib/idle_test/test_rpc.py +++ b/Lib/idlelib/idle_test/test_rpc.py @@ -1,8 +1,25 @@ "Test rpc, coverage 20%." from idlelib import rpc +import threading import unittest - +from unittest import mock + + +class SocketIOTest(unittest.TestCase): + + def test_getresponse_interrupted(self): + # gh-74112: an interrupted wait must release the lock and forget + # the sequence number, so that a late response is discarded. + sockio = rpc.SocketIO(mock.Mock(), debugging=False) + sockio.sockthread = None # Not the current thread. + cvar = sockio.cvars[7] = threading.Condition() + with mock.patch.object(cvar, 'wait', side_effect=KeyboardInterrupt): + with self.assertRaises(KeyboardInterrupt): + sockio._getresponse(7, 0.05) + self.assertNotIn(7, sockio.cvars) + self.assertTrue(cvar.acquire(blocking=False)) + cvar.release() class CodePicklerTest(unittest.TestCase): diff --git a/Lib/idlelib/idle_test/test_run.py b/Lib/idlelib/idle_test/test_run.py index f35c3168a446fe..19adb325ec06f1 100644 --- a/Lib/idlelib/idle_test/test_run.py +++ b/Lib/idlelib/idle_test/test_run.py @@ -2,7 +2,11 @@ from idlelib import run import io +import signal import sys +import threading +import time +from test import support from test.support import captured_output, captured_stderr import unittest from unittest import mock @@ -522,5 +526,33 @@ def test_exceptions(self): self.assertTrue(isinstance(e.__context__, ZeroDivisionError)) +class InterruptTest(unittest.TestCase): + + def setUp(self): + self.ex = run.Executive(mock.Mock(sendlock=threading.Lock())) + self.addCleanup(setattr, run, 'interruptible', run.interruptible) + run.interruptible = True + + @unittest.skipIf(signal.getsignal(signal.SIGINT) + in (signal.SIG_DFL, signal.SIG_IGN, None), + 'SIGINT is not handled by Python') + def test_interrupt_blocking_call(self): + # gh-74112: interrupt the main thread blocked in time.sleep(). + timer = threading.Timer(0.1, self.ex.interrupt_the_server) + self.addCleanup(timer.join) + timer.start() + start = time.monotonic() + with self.assertRaises(KeyboardInterrupt): + time.sleep(support.SHORT_TIMEOUT) + self.assertLess(time.monotonic() - start, support.SHORT_TIMEOUT / 2) + + def test_interrupt_ignored(self): + old_handler = signal.signal(signal.SIGINT, signal.SIG_IGN) + self.addCleanup(signal.signal, signal.SIGINT, old_handler) + with mock.patch.object(run.thread, 'interrupt_main') as interrupt_main: + self.ex.interrupt_the_server() + interrupt_main.assert_called_once_with() + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Lib/idlelib/rpc.py b/Lib/idlelib/rpc.py index 3f0b2230dd185d..d74c29f0f6c8a3 100644 --- a/Lib/idlelib/rpc.py +++ b/Lib/idlelib/rpc.py @@ -139,6 +139,7 @@ def __init__(self, sock, objtable=None, debugging=None): self.objtable = objtable self.responses = {} self.cvars = {} + self.sendlock = threading.Lock() def close(self): sock = self.sock @@ -314,15 +315,20 @@ def _getresponse(self, myseq, wait): else: # wait for notification from socket handling thread cvar = self.cvars[myseq] - cvar.acquire() - while myseq not in self.responses: - cvar.wait() - response = self.responses[myseq] - self.debug("_getresponse:%s: thread woke up: response: %s" % - (myseq, response)) - del self.responses[myseq] - del self.cvars[myseq] - cvar.release() + with cvar: + try: + while myseq not in self.responses: + cvar.wait() + except BaseException: + # Interrupted; a late response will be discarded. + del self.cvars[myseq] + self.responses.pop(myseq, None) + raise + response = self.responses[myseq] + self.debug("_getresponse:%s: thread woke up: response: %s" % + (myseq, response)) + del self.responses[myseq] + del self.cvars[myseq] return response def newseq(self): @@ -337,13 +343,14 @@ def putmessage(self, message): print("Cannot pickle:", repr(message), file=sys.__stderr__) raise s = struct.pack(" 0: - try: - r, w, x = select.select([], [self.sock], []) - n = self.sock.send(s[:BUFSIZE]) - except (AttributeError, TypeError): - raise OSError("socket no longer exists") - s = s[n:] + with self.sendlock: + while len(s) > 0: + try: + r, w, x = select.select([], [self.sock], []) + n = self.sock.send(s[:BUFSIZE]) + except (AttributeError, TypeError): + raise OSError("socket no longer exists") + s = s[n:] buff = b'' bufneed = 4 diff --git a/Lib/idlelib/run.py b/Lib/idlelib/run.py index 2725043b4ed925..c69060620f5f9f 100644 --- a/Lib/idlelib/run.py +++ b/Lib/idlelib/run.py @@ -9,6 +9,7 @@ import io import linecache import queue +import signal import sys import textwrap import time @@ -678,7 +679,19 @@ def runcode(self, code): def interrupt_the_server(self): if interruptible: - thread.interrupt_main() + handler = signal.getsignal(signal.SIGINT) + if handler not in (signal.SIG_DFL, signal.SIG_IGN, None): + # A real signal interrupts blocking calls such as + # time.sleep() (gh-74112). The lock prevents interrupting + # the main thread in the middle of sending a message. + with self.rpchandler.sendlock: + if hasattr(signal, 'pthread_kill'): + signal.pthread_kill(threading.main_thread().ident, + signal.SIGINT) + else: + signal.raise_signal(signal.SIGINT) + else: + thread.interrupt_main() def start_the_debugger(self, gui_adap_oid): return debugger_r.start_debugger(self.rpchandler, gui_adap_oid) diff --git a/Misc/NEWS.d/next/IDLE/2026-09-17-12-00-00.gh-issue-74112.sigint.rst b/Misc/NEWS.d/next/IDLE/2026-09-17-12-00-00.gh-issue-74112.sigint.rst new file mode 100644 index 00000000000000..18a230e75913b5 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-09-17-12-00-00.gh-issue-74112.sigint.rst @@ -0,0 +1,2 @@ +Ctrl-C in the IDLE Shell now interrupts blocking calls such as +:func:`time.sleep` and :meth:`socket.recv `.