Skip to content

Commit 2784c12

Browse files
BoykoNeovclaude
andcommitted
Send the shell reply through the ZMQStream instead of raw on its socket
On Windows, ipykernel 7 intermittently drops an execute_request on the shell channel: the kernel goes idle and never replies, and the client times out waiting for execute_reply (historically ~30% of headless notebook runs in our measurements; which cell hangs wanders run to run). Root cause: the shell ROUTER socket is dual-use on the shell-channel thread. A ZMQStream reads execute_requests off it, while replies are sent back over the SAME socket out-of-band via a raw send_multipart in SubshellManager._send_on_shell_channel. That out-of-band send drains the socket's edge-triggered ZMQ_FD read edge (a documented libzmq corollary: after zmq_send the socket may become readable without a new edge). Because the send is not ZMQStream-mediated, the stream is never re-armed and a request that arrived concurrently strands unread on a registered-but- non-readable fd. The strand is terminal: no later arrival re-edges it. Fix: send the reply through shell_stream.send_multipart rather than raw on shell_socket. This keeps the ZMQStream the sole user of the socket: the send is serviced by the stream's own _handle_events, which recvs any concurrently-queued request first and then re-arms POLLIN via _rebuild_io_state, so the request cannot strand. It removes the root cause rather than re-arming after it, and needs no reach into the private _handle_events. The shell_stream (built in kernelapp.init_kernel) is threaded through ShellChannelThread into SubshellManager so the reply path can reach it, and falls back to the raw socket if no stream was threaded in. Verified by the regression test added next, which reproduces the strand precondition deterministically and asserts the queued request is still delivered once the reply is routed through the stream. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 821f6c0 commit 2784c12

3 files changed

Lines changed: 27 additions & 1 deletion

File tree

ipykernel/kernelapp.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,9 @@ def init_kernel(self):
608608
"""Create the Kernel object itself"""
609609
if self.shell_channel_thread:
610610
shell_stream = ZMQStream(self.shell_socket, self.shell_channel_thread.io_loop)
611+
# Hand the stream to the shell-channel thread so SubshellManager can send the
612+
# out-of-band reply through the stream rather than raw on the socket (the wedge fix).
613+
self.shell_channel_thread.shell_stream = shell_stream
611614
else:
612615
shell_stream = ZMQStream(self.shell_socket)
613616
control_stream = ZMQStream(self.control_socket, self.control_thread.io_loop)

ipykernel/shellchannel.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ def __init__(
2929
self._manager: SubshellManager | None = None
3030
self._zmq_context = context # Avoid use of self._context
3131
self._shell_socket = shell_socket
32+
# Set by kernelapp.init_kernel after it builds the shell ZMQStream (this thread
33+
# is created before the stream). Threaded into SubshellManager so the out-of-band
34+
# reply is sent through the stream rather than raw on the socket (the wedge fix).
35+
self.shell_stream = None
3236
# Record the parent thread - the thread that started the app (usually the main thread)
3337
self.parent_thread = current_thread()
3438

@@ -43,6 +47,7 @@ def manager(self) -> SubshellManager:
4347
self._zmq_context,
4448
self.io_loop,
4549
self._shell_socket,
50+
self.shell_stream,
4651
)
4752
return self._manager
4853

ipykernel/subshell_manager.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,19 @@ def __init__(
4040
context: zmq.Context[t.Any],
4141
shell_channel_io_loop: IOLoop,
4242
shell_socket: zmq.Socket[t.Any],
43+
shell_stream=None,
4344
):
4445
"""Initialize the subshell manager."""
4546
self._parent_thread = current_thread()
4647

4748
self._context: zmq.Context[t.Any] = context
4849
self._shell_channel_io_loop = shell_channel_io_loop
4950
self._shell_socket = shell_socket
51+
# ZMQStream that reads `shell_socket`. The out-of-band reply below is sent through
52+
# this stream rather than raw on the socket: a raw send would drain the socket's
53+
# edge-triggered ZMQ_FD read edge without the stream re-arming, stranding a
54+
# concurrently-arrived request unread (the wedge).
55+
self._shell_stream = shell_stream
5056
self._cache: dict[str, SubshellThread] = {}
5157
self._lock_cache = Lock() # Sync lock across threads when accessing cache.
5258

@@ -225,7 +231,19 @@ def _process_control_request(
225231

226232
def _send_on_shell_channel(self, msg) -> None:
227233
assert current_thread().name == SHELL_CHANNEL_THREAD_NAME
228-
self._shell_socket.send_multipart(msg)
234+
# Send the reply through the shell ZMQStream rather than raw on its socket. A raw
235+
# send_multipart on the dual-use shell ROUTER drains its edge-triggered ZMQ_FD read
236+
# edge; because the stream never sees that send, it is never re-armed, so a request
237+
# that arrived concurrently can strand unread on a registered-but-non-readable fd
238+
# (the wedge). Routing the send through the stream keeps the stream the sole user of
239+
# the socket: the send is serviced by the stream's own _handle_events, which recvs
240+
# any pending request first and then re-arms POLLIN via _rebuild_io_state, so the
241+
# request cannot strand. Fall back to the raw socket if no stream was threaded in.
242+
stream = self._shell_stream
243+
if stream is not None and stream.socket is self._shell_socket:
244+
stream.send_multipart(msg)
245+
else:
246+
self._shell_socket.send_multipart(msg)
229247

230248
def _stop_subshell(self, subshell_thread: SubshellThread) -> None:
231249
"""Stop a subshell thread and close all of its resources."""

0 commit comments

Comments
 (0)