Skip to content

Commit f86f638

Browse files
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.
1 parent 993fd8e commit f86f638

5 files changed

Lines changed: 89 additions & 18 deletions

File tree

‎Lib/idlelib/idle_test/test_rpc.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
11
"Test rpc, coverage 20%."
22

33
from idlelib import rpc
4+
import threading
45
import unittest
5-
6+
from unittest import mock
7+
8+
9+
class SocketIOTest(unittest.TestCase):
10+
11+
def test_getresponse_interrupted(self):
12+
# gh-74112: an interrupted wait must release the lock and forget
13+
# the sequence number, so that a late response is discarded.
14+
sockio = rpc.SocketIO(mock.Mock(), debugging=False)
15+
sockio.sockthread = None # Not the current thread.
16+
cvar = sockio.cvars[7] = threading.Condition()
17+
with mock.patch.object(cvar, 'wait', side_effect=KeyboardInterrupt):
18+
with self.assertRaises(KeyboardInterrupt):
19+
sockio._getresponse(7, 0.05)
20+
self.assertNotIn(7, sockio.cvars)
21+
self.assertTrue(cvar.acquire(blocking=False))
22+
cvar.release()
623

724

825
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

143144
def close(self):
144145
sock = self.sock
@@ -314,15 +315,20 @@ def _getresponse(self, myseq, wait):
314315
else:
315316
# wait for notification from socket handling thread
316317
cvar = self.cvars[myseq]
317-
cvar.acquire()
318-
while myseq not in self.responses:
319-
cvar.wait()
320-
response = self.responses[myseq]
321-
self.debug("_getresponse:%s: thread woke up: response: %s" %
322-
(myseq, response))
323-
del self.responses[myseq]
324-
del self.cvars[myseq]
325-
cvar.release()
318+
with cvar:
319+
try:
320+
while myseq not in self.responses:
321+
cvar.wait()
322+
except BaseException:
323+
# Interrupted; a late response will be discarded.
324+
del self.cvars[myseq]
325+
self.responses.pop(myseq, None)
326+
raise
327+
response = self.responses[myseq]
328+
self.debug("_getresponse:%s: thread woke up: response: %s" %
329+
(myseq, response))
330+
del self.responses[myseq]
331+
del self.cvars[myseq]
326332
return response
327333

328334
def newseq(self):
@@ -337,13 +343,14 @@ def putmessage(self, message):
337343
print("Cannot pickle:", repr(message), file=sys.__stderr__)
338344
raise
339345
s = struct.pack("<i", len(s)) + s
340-
while len(s) > 0:
341-
try:
342-
r, w, x = select.select([], [self.sock], [])
343-
n = self.sock.send(s[:BUFSIZE])
344-
except (AttributeError, TypeError):
345-
raise OSError("socket no longer exists")
346-
s = s[n:]
346+
with self.sendlock:
347+
while len(s) > 0:
348+
try:
349+
r, w, x = select.select([], [self.sock], [])
350+
n = self.sock.send(s[:BUFSIZE])
351+
except (AttributeError, TypeError):
352+
raise OSError("socket no longer exists")
353+
s = s[n:]
347354

348355
buff = b''
349356
bufneed = 4

‎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)