Skip to content

Commit 02c895e

Browse files
BoykoNeovclaude
andcommitted
Run service-thread event loops on a selector loop on Windows
When the kernel runs under a ProactorEventLoop on Windows (enabled by gh-1469 so the main loop can spawn asyncio subprocesses, gh-1468), debugging deadlocks on Python >= 3.12. ProactorEventLoop has no native add_reader, so tornado drives a Proactor loop's zmq sockets via a helper "Tornado selector" thread that does select() then call_soon_threadsafe() to wake the loop. ipykernel exempts its own service threads from the debugger but not tornado's helper. When debugpy suspends every thread at a breakpoint under sys.monitoring (3.12+, interpreter-global), that un-exempt helper freezes mid-wake and the control/debug read path never advances -- the loop sits in the IOCP poll forever. On 3.11 (sys.settrace, per-thread) the helper is not frozen, so it does not reproduce there. The ipykernel service loops -- control, IOPub, the shell channel and subshells -- never need Proactor's subprocess support, so run them on a SelectorEventLoop instead: it implements add_reader natively and needs no helper thread. Only the main/user-code loop stays on Proactor. Off Windows the default loop is already selector-based, so this is a no-op there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6a75d0f commit 02c895e

2 files changed

Lines changed: 28 additions & 3 deletions

File tree

ipykernel/iostream.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@
2222

2323
import zmq
2424
from jupyter_client.session import extract_header
25-
from tornado.ioloop import IOLoop
2625
from zmq.eventloop.zmqstream import ZMQStream
2726

27+
from .thread import make_selector_io_loop
28+
2829
# -----------------------------------------------------------------------------
2930
# Globals
3031
# -----------------------------------------------------------------------------
@@ -67,7 +68,7 @@ def __init__(self, socket, pipe=False, session=False):
6768
self.background_socket = BackgroundSocket(self)
6869
self._master_pid = os.getpid()
6970
self._pipe_flag = pipe
70-
self.io_loop = IOLoop(make_current=False)
71+
self.io_loop = make_selector_io_loop()
7172
if pipe:
7273
self._setup_pipe_in()
7374
self._local = threading.local()

ipykernel/thread.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Base class for threads."""
22

3+
import asyncio
4+
import sys
35
from threading import Thread
46

57
from tornado.ioloop import IOLoop
@@ -8,13 +10,35 @@
810
SHELL_CHANNEL_THREAD_NAME = "Shell channel"
911

1012

13+
def make_selector_io_loop() -> IOLoop:
14+
"""Create a non-current tornado ``IOLoop`` for an ipykernel service thread.
15+
16+
ipykernel runs its service channels -- control, IOPub, the shell channel and
17+
subshells -- on dedicated event loops in background threads. The process-wide
18+
asyncio loop on Windows is a ``ProactorEventLoop`` (so the main user-code loop
19+
can spawn asyncio subprocesses, see #1468/#1469), and Proactor has no native
20+
``add_reader``. Tornado therefore drives a Proactor loop's zmq sockets through
21+
a helper "Tornado selector" thread. When a debugger suspends every thread at a
22+
breakpoint on Python >= 3.12 (``sys.monitoring``), that un-exempt helper thread
23+
freezes mid-wake and deadlocks the control/debug read path (#1469).
24+
25+
These service loops never need Proactor's subprocess support, so we keep them
26+
on a ``SelectorEventLoop``: it implements ``add_reader`` natively and needs no
27+
helper thread. Only the main/user-code loop stays on Proactor. On non-Windows
28+
platforms the default loop is already selector-based, so this is a no-op there.
29+
"""
30+
if sys.platform == "win32":
31+
return IOLoop(make_current=False, asyncio_loop=asyncio.SelectorEventLoop())
32+
return IOLoop(make_current=False)
33+
34+
1135
class BaseThread(Thread):
1236
"""Base class for threads."""
1337

1438
def __init__(self, **kwargs):
1539
"""Initialize the thread."""
1640
super().__init__(**kwargs)
17-
self.io_loop = IOLoop(make_current=False)
41+
self.io_loop = make_selector_io_loop()
1842
self.pydev_do_not_trace = True
1943
self.is_pydev_daemon_thread = True
2044

0 commit comments

Comments
 (0)