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]