https://github.com/python/cpython/commit/9232c21a1c1eb6a12b96acebbef6c86a698228e0
commit: 9232c21a1c1eb6a12b96acebbef6c86a698228e0
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-09-23T21:29:36+03:00
summary:

gh-74112: Make Ctrl-C in the IDLE Shell interrupt blocking calls (GH-157662)

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.

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 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 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]

Reply via email to