From 02c895edd7bb1cc12f0ce3ac52103ec00841b2a6 Mon Sep 17 00:00:00 2001 From: BoykoNeov Date: Thu, 18 Jun 2026 15:07:03 +0300 Subject: [PATCH] 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 --- ipykernel/iostream.py | 5 +++-- ipykernel/thread.py | 26 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/ipykernel/iostream.py b/ipykernel/iostream.py index 2c1094f35..a5b5b6c0b 100644 --- a/ipykernel/iostream.py +++ b/ipykernel/iostream.py @@ -22,9 +22,10 @@ import zmq from jupyter_client.session import extract_header -from tornado.ioloop import IOLoop from zmq.eventloop.zmqstream import ZMQStream +from .thread import make_selector_io_loop + # ----------------------------------------------------------------------------- # Globals # ----------------------------------------------------------------------------- @@ -67,7 +68,7 @@ def __init__(self, socket, pipe=False, session=False): self.background_socket = BackgroundSocket(self) self._master_pid = os.getpid() self._pipe_flag = pipe - self.io_loop = IOLoop(make_current=False) + self.io_loop = make_selector_io_loop() if pipe: self._setup_pipe_in() self._local = threading.local() diff --git a/ipykernel/thread.py b/ipykernel/thread.py index 3e1d4f07a..bb6764984 100644 --- a/ipykernel/thread.py +++ b/ipykernel/thread.py @@ -1,5 +1,7 @@ """Base class for threads.""" +import asyncio +import sys from threading import Thread from tornado.ioloop import IOLoop @@ -8,13 +10,35 @@ SHELL_CHANNEL_THREAD_NAME = "Shell channel" +def make_selector_io_loop() -> IOLoop: + """Create a non-current tornado ``IOLoop`` for an ipykernel service thread. + + ipykernel runs its service channels -- control, IOPub, the shell channel and + subshells -- on dedicated event loops in background threads. The process-wide + asyncio loop on Windows is a ``ProactorEventLoop`` (so the main user-code loop + can spawn asyncio subprocesses, see #1468/#1469), and Proactor has no native + ``add_reader``. Tornado therefore drives a Proactor loop's zmq sockets through + a helper "Tornado selector" thread. When a debugger suspends every thread at a + breakpoint on Python >= 3.12 (``sys.monitoring``), that un-exempt helper thread + freezes mid-wake and deadlocks the control/debug read path (#1469). + + These service loops never need Proactor's subprocess support, so we keep them + on a ``SelectorEventLoop``: it implements ``add_reader`` natively and needs no + helper thread. Only the main/user-code loop stays on Proactor. On non-Windows + platforms the default loop is already selector-based, so this is a no-op there. + """ + if sys.platform == "win32": + return IOLoop(make_current=False, asyncio_loop=asyncio.SelectorEventLoop()) + return IOLoop(make_current=False) + + class BaseThread(Thread): """Base class for threads.""" def __init__(self, **kwargs): """Initialize the thread.""" super().__init__(**kwargs) - self.io_loop = IOLoop(make_current=False) + self.io_loop = make_selector_io_loop() self.pydev_do_not_trace = True self.is_pydev_daemon_thread = True