Skip to content

Commit 824b67e

Browse files
[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 9232c21) Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
1 parent dd9c3c8 commit 824b67e

5 files changed

Lines changed: 85 additions & 17 deletions

File tree

‎Lib/idlelib/idle_test/test_rpc.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from idlelib import rpc
44
import socket
55
import struct
6+
import threading
67
import unittest
8+
from unittest import mock
79

810

911
class SocketIOTest(unittest.TestCase):
@@ -22,6 +24,18 @@ def test_reconnect_discards_partial_packet(self):
2224
new_peer.sendall(struct.pack('<i', 3) + b'abc')
2325
self.assertEqual(sockio.pollpacket(1), b'abc')
2426

27+
def test_getresponse_interrupted(self):
28+
# gh-74112: an interrupted wait must release the lock and forget
29+
# the sequence number, so that a late response is discarded.
30+
sockio = rpc.SocketIO(mock.Mock(), debugging=False)
31+
sockio.sockthread = None # Not the current thread.
32+
cvar = sockio.cvars[7] = threading.Condition()
33+
with mock.patch.object(cvar, 'wait', side_effect=KeyboardInterrupt):
34+
with self.assertRaises(KeyboardInterrupt):
35+
sockio._getresponse(7, 0.05)
36+
self.assertNotIn(7, sockio.cvars)
37+
self.assertTrue(cvar.acquire(blocking=False))
38+
cvar.release()
2539

2640

2741
class CodePicklerTest(unittest.TestCase):

‎Lib/idlelib/idle_test/test_run.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
from idlelib import run
44
import io
5+
import signal
56
import sys
7+
import threading
8+
import time
9+
from test import support
610
from test.support import captured_output, captured_stderr
711
import unittest
812
from unittest import mock
@@ -522,5 +526,33 @@ def test_exceptions(self):
522526
self.assertTrue(isinstance(e.__context__, ZeroDivisionError))
523527

524528

529+
class InterruptTest(unittest.TestCase):
530+
531+
def setUp(self):
532+
self.ex = run.Executive(mock.Mock(sendlock=threading.Lock()))
533+
self.addCleanup(setattr, run, 'interruptible', run.interruptible)
534+
run.interruptible = True
535+
536+
@unittest.skipIf(signal.getsignal(signal.SIGINT)
537+
in (signal.SIG_DFL, signal.SIG_IGN, None),
538+
'SIGINT is not handled by Python')
539+
def test_interrupt_blocking_call(self):
540+
# gh-74112: interrupt the main thread blocked in time.sleep().
541+
timer = threading.Timer(0.1, self.ex.interrupt_the_server)
542+
self.addCleanup(timer.join)
543+
timer.start()
544+
start = time.monotonic()
545+
with self.assertRaises(KeyboardInterrupt):
546+
time.sleep(support.SHORT_TIMEOUT)
547+
self.assertLess(time.monotonic() - start, support.SHORT_TIMEOUT / 2)
548+
549+
def test_interrupt_ignored(self):
550+
old_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
551+
self.addCleanup(signal.signal, signal.SIGINT, old_handler)
552+
with mock.patch.object(run.thread, 'interrupt_main') as interrupt_main:
553+
self.ex.interrupt_the_server()
554+
interrupt_main.assert_called_once_with()
555+
556+
525557
if __name__ == '__main__':
526558
unittest.main(verbosity=2)

‎Lib/idlelib/rpc.py‎

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ def __init__(self, sock, objtable=None, debugging=None):
139139
self.objtable = objtable
140140
self.responses = {}
141141
self.cvars = {}
142+
self.sendlock = threading.Lock()
142143
# Receive buffer state. A new connection must not inherit a
143144
# partially received packet from the old one (gh-89544).
144145
self.buff = b''
@@ -319,15 +320,20 @@ def _getresponse(self, myseq, wait):
319320
else:
320321
# wait for notification from socket handling thread
321322
cvar = self.cvars[myseq]
322-
cvar.acquire()
323-
while myseq not in self.responses:
324-
cvar.wait()
325-
response = self.responses[myseq]
326-
self.debug("_getresponse:%s: thread woke up: response: %s" %
327-
(myseq, response))
328-
del self.responses[myseq]
329-
del self.cvars[myseq]
330-
cvar.release()
323+
with cvar:
324+
try:
325+
while myseq not in self.responses:
326+
cvar.wait()
327+
except BaseException:
328+
# Interrupted; a late response will be discarded.
329+
del self.cvars[myseq]
330+
self.responses.pop(myseq, None)
331+
raise
332+
response = self.responses[myseq]
333+
self.debug("_getresponse:%s: thread woke up: response: %s" %
334+
(myseq, response))
335+
del self.responses[myseq]
336+
del self.cvars[myseq]
331337
return response
332338

333339
def newseq(self):
@@ -342,13 +348,14 @@ def putmessage(self, message):
342348
print("Cannot pickle:", repr(message), file=sys.__stderr__)
343349
raise
344350
s = struct.pack("<i", len(s)) + s
345-
while len(s) > 0:
346-
try:
347-
r, w, x = select.select([], [self.sock], [])
348-
n = self.sock.send(s[:BUFSIZE])
349-
except (AttributeError, TypeError):
350-
raise OSError("socket no longer exists")
351-
s = s[n:]
351+
with self.sendlock:
352+
while len(s) > 0:
353+
try:
354+
r, w, x = select.select([], [self.sock], [])
355+
n = self.sock.send(s[:BUFSIZE])
356+
except (AttributeError, TypeError):
357+
raise OSError("socket no longer exists")
358+
s = s[n:]
352359

353360
def pollpacket(self, wait):
354361
self._stage0()

‎Lib/idlelib/run.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import io
1010
import linecache
1111
import queue
12+
import signal
1213
import sys
1314
import textwrap
1415
import time
@@ -678,7 +679,19 @@ def runcode(self, code):
678679

679680
def interrupt_the_server(self):
680681
if interruptible:
681-
thread.interrupt_main()
682+
handler = signal.getsignal(signal.SIGINT)
683+
if handler not in (signal.SIG_DFL, signal.SIG_IGN, None):
684+
# A real signal interrupts blocking calls such as
685+
# time.sleep() (gh-74112). The lock prevents interrupting
686+
# the main thread in the middle of sending a message.
687+
with self.rpchandler.sendlock:
688+
if hasattr(signal, 'pthread_kill'):
689+
signal.pthread_kill(threading.main_thread().ident,
690+
signal.SIGINT)
691+
else:
692+
signal.raise_signal(signal.SIGINT)
693+
else:
694+
thread.interrupt_main()
682695

683696
def start_the_debugger(self, gui_adap_oid):
684697
return debugger_r.start_debugger(self.rpchandler, gui_adap_oid)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Ctrl-C in the IDLE Shell now interrupts blocking calls such as
2+
:func:`time.sleep` and :meth:`socket.recv <socket.socket.recv>`.

0 commit comments

Comments
 (0)