https://github.com/python/cpython/commit/824b67e4b4f20aec056a6f23462d1815178ed531 commit: 824b67e4b4f20aec056a6f23462d1815178ed531 branch: 3.14 author: Miss Islington (bot) <[email protected]> committer: serhiy-storchaka <[email protected]> date: 2026-09-23T18:55:56Z summary:
[3.14] gh-74112: Make Ctrl-C in the IDLE Shell interrupt blocking calls (GH-157662) (GH-158016) 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. (cherry picked from commit 9232c21a1c1eb6a12b96acebbef6c86a698228e0) Co-authored-by: Serhiy Storchaka <[email protected]> files: A Misc/NEWS.d/next/IDLE/2026-09-17-12-00-00.gh-issue-74112.sigint.rst M Lib/idlelib/idle_test/test_rpc.py M Lib/idlelib/idle_test/test_run.py M Lib/idlelib/rpc.py M Lib/idlelib/run.py diff --git a/Lib/idlelib/idle_test/test_rpc.py b/Lib/idlelib/idle_test/test_rpc.py index 15c3ed14b8f6f3..d929165e2e5ec9 100644 --- a/Lib/idlelib/idle_test/test_rpc.py +++ b/Lib/idlelib/idle_test/test_rpc.py @@ -3,7 +3,9 @@ from idlelib import rpc import socket import struct +import threading import unittest +from unittest import mock class SocketIOTest(unittest.TestCase): @@ -22,6 +24,18 @@ def test_reconnect_discards_partial_packet(self): new_peer.sendall(struct.pack('<i', 3) + b'abc') self.assertEqual(sockio.pollpacket(1), b'abc') + 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 2ec43d59c60682..9947ad498965c2 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 9af9863d74e0b6..a07dbccd651eeb 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() # Receive buffer state. A new connection must not inherit a # partially received packet from the old one (gh-89544). self.buff = b'' @@ -319,15 +320,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): @@ -342,13 +348,14 @@ def putmessage(self, message): print("Cannot pickle:", repr(message), file=sys.__stderr__) raise s = struct.pack("<i", len(s)) + s - 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:] + 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:] def pollpacket(self, wait): self._stage0() 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 <socket.socket.recv>`. _______________________________________________ Python-checkins mailing list -- [email protected] To unsubscribe send an email to [email protected] https://mail.python.org/mailman3//lists/python-checkins.python.org Member address: [email protected]
