Skip to content

Commit c742270

Browse files
BoykoNeovclaudepre-commit-ci[bot]
authored
Send the shell reply through the ZMQStream instead of raw on its socket (#1529)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 214b83a commit c742270

4 files changed

Lines changed: 193 additions & 13 deletions

File tree

ipykernel/kernelapp.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -412,11 +412,7 @@ def init_control(self, context):
412412
self.control_socket.router_handover = 1
413413

414414
self.control_thread = ControlThread(daemon=True)
415-
self.shell_channel_thread = ShellChannelThread(
416-
context,
417-
self.shell_socket,
418-
daemon=True,
419-
)
415+
self.shell_channel_thread = ShellChannelThread(context, daemon=True)
420416

421417
def init_iopub(self, context):
422418
"""Initialize the iopub channel."""
@@ -608,6 +604,9 @@ def init_kernel(self):
608604
"""Create the Kernel object itself"""
609605
if self.shell_channel_thread:
610606
shell_stream = ZMQStream(self.shell_socket, self.shell_channel_thread.io_loop)
607+
# Hand the stream to the shell-channel thread so SubshellManager can send the
608+
# out-of-band reply through the stream rather than raw on the socket (the wedge fix).
609+
self.shell_channel_thread.shell_stream = shell_stream
611610
else:
612611
shell_stream = ZMQStream(self.shell_socket)
613612
control_stream = ZMQStream(self.control_socket, self.control_thread.io_loop)

ipykernel/shellchannel.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any
88

99
import zmq
10+
from zmq.eventloop.zmqstream import ZMQStream
1011

1112
from .subshell_manager import SubshellManager
1213
from .thread import SHELL_CHANNEL_THREAD_NAME, BaseThread
@@ -21,14 +22,15 @@ class ShellChannelThread(BaseThread):
2122
def __init__(
2223
self,
2324
context: zmq.Context[Any],
24-
shell_socket: zmq.Socket[Any],
2525
**kwargs,
2626
):
2727
"""Initialize the thread."""
2828
super().__init__(name=SHELL_CHANNEL_THREAD_NAME, **kwargs)
2929
self._manager: SubshellManager | None = None
3030
self._zmq_context = context # Avoid use of self._context
31-
self._shell_socket = shell_socket
31+
# Set by kernelapp.init_kernel after it builds the shell ZMQStream, since this
32+
# thread is created before the stream exists.
33+
self.shell_stream: ZMQStream | None = None
3234
# Record the parent thread - the thread that started the app (usually the main thread)
3335
self.parent_thread = current_thread()
3436

@@ -39,10 +41,12 @@ def manager(self) -> SubshellManager:
3941
# Lazy initialisation.
4042
if self._manager is None:
4143
assert current_thread() == self.parent_thread
44+
# Also narrows the type for the manager, which takes a non-optional stream.
45+
assert self.shell_stream is not None
4246
self._manager = SubshellManager(
4347
self._zmq_context,
4448
self.io_loop,
45-
self._shell_socket,
49+
self.shell_stream,
4650
)
4751
return self._manager
4852

ipykernel/subshell_manager.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import zmq
1313
from tornado.ioloop import IOLoop
14+
from zmq.eventloop.zmqstream import ZMQStream
1415

1516
from .socket_pair import SocketPair
1617
from .subshell import SubshellThread
@@ -29,8 +30,8 @@ class SubshellManager:
2930
Reading of cache information can be performed by other threads, so all reads are
3031
protected by a lock so that they are atomic.
3132
32-
Sending reply messages via the shell_socket is wrapped by another lock to protect
33-
against multiple subshells attempting to send at the same time.
33+
Reply messages are sent on the shell channel through `shell_stream`, which is the
34+
only user of the shell socket; all such sends occur in the shell channel thread.
3435
3536
.. versionadded:: 7
3637
"""
@@ -39,14 +40,17 @@ def __init__(
3940
self,
4041
context: zmq.Context[t.Any],
4142
shell_channel_io_loop: IOLoop,
42-
shell_socket: zmq.Socket[t.Any],
43+
shell_stream: ZMQStream,
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
49-
self._shell_socket = shell_socket
50+
# ZMQStream reading the shell socket. The manager deliberately holds no reference
51+
# to that socket: sends must go through the stream, never raw on the socket.
52+
assert shell_stream is not None
53+
self._shell_stream = shell_stream
5054
self._cache: dict[str, SubshellThread] = {}
5155
self._lock_cache = Lock() # Sync lock across threads when accessing cache.
5256

@@ -225,7 +229,15 @@ def _process_control_request(
225229

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

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

tests/test_subshell_wedge.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Regression test for the dual-use shell ROUTER wedge (gh-1529).
2+
3+
ipykernel 7's shell ROUTER is read by a ``ZMQStream`` on the shell-channel thread while
4+
replies are sent back over the *same* socket out-of-band by
5+
``SubshellManager._send_on_shell_channel``. A raw ``send_multipart`` on that socket drains
6+
its edge-triggered ``ZMQ_FD`` read edge; because the stream never sees the send it is never
7+
re-armed, so a request that arrived concurrently can strand unread on a registered-but-
8+
non-readable fd. The kernel then goes idle and never replies -- an intermittent dropped
9+
``execute_request``, most visible on Windows but a generic libzmq edge-trigger behaviour.
10+
11+
This test reproduces the strand *precondition* deterministically -- a request queued on the
12+
ROUTER whose read edge has already been consumed, with the stream not yet having delivered
13+
it -- then performs the out-of-band reply send through the real code path and asserts the
14+
queued request is still delivered to ``on_recv``.
15+
16+
It is deliberately *behavioural*: it checks delivery, not how the fix is implemented, so it
17+
holds whether the reply send re-arms the stream explicitly or is routed through the stream.
18+
Without the fix the queued request is never delivered and the test fails (times out). The
19+
strand precondition is created with documented raw-``zmq`` operations rather than a timing
20+
race, so the test is deterministic. It relies on the libzmq ``ZMQ_FD`` edge-trigger
21+
behaviour, which is documented as general (not Windows-specific); it has been verified here
22+
on Windows, and CI confirms the other platforms.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import asyncio
28+
import contextlib
29+
import threading
30+
import time
31+
32+
import zmq
33+
from tornado.ioloop import IOLoop
34+
from zmq.eventloop.zmqstream import ZMQStream
35+
36+
from ipykernel.subshell_manager import SubshellManager
37+
from ipykernel.thread import SHELL_CHANNEL_THREAD_NAME
38+
39+
TIMEOUT = 10.0
40+
41+
42+
def _run_on_loop(loop, func):
43+
"""Run ``func()`` on the loop thread, block until it finishes, return/raise its result."""
44+
box: dict = {}
45+
done = threading.Event()
46+
47+
def runner():
48+
try:
49+
box["result"] = func()
50+
except BaseException as exc:
51+
box["error"] = exc
52+
finally:
53+
done.set()
54+
55+
loop.add_callback(runner)
56+
if not done.wait(TIMEOUT):
57+
msg = "callback did not complete on the shell-channel loop"
58+
raise TimeoutError(msg)
59+
if "error" in box:
60+
raise box["error"]
61+
return box.get("result")
62+
63+
64+
def test_concurrent_request_not_stranded_by_reply_send():
65+
context = zmq.Context()
66+
67+
# Shell ROUTER, read by a ZMQStream on the shell-channel loop -- exactly like the kernel.
68+
shell_socket = context.socket(zmq.ROUTER)
69+
port = shell_socket.bind_to_random_port("tcp://127.0.0.1")
70+
71+
client = context.socket(zmq.DEALER)
72+
client.setsockopt(zmq.IDENTITY, b"client-1")
73+
client.connect(f"tcp://127.0.0.1:{port}")
74+
75+
# An IOLoop in a thread named like the kernel's shell-channel thread: the
76+
# _send_on_shell_channel assert requires this exact thread name.
77+
loop_box: dict = {}
78+
loop_ready = threading.Event()
79+
80+
def run_loop():
81+
asyncio.set_event_loop(asyncio.new_event_loop())
82+
loop = IOLoop.current()
83+
loop_box["loop"] = loop
84+
loop.add_callback(loop_ready.set)
85+
loop.start()
86+
87+
thread = threading.Thread(target=run_loop, name=SHELL_CHANNEL_THREAD_NAME, daemon=True)
88+
thread.start()
89+
assert loop_ready.wait(TIMEOUT), "shell-channel loop did not start"
90+
loop = loop_box["loop"]
91+
92+
received: list[list[bytes]] = []
93+
got_message = threading.Event()
94+
stream = manager = None
95+
96+
try:
97+
# Build the shell stream and manager on the loop thread (add_handler must run there).
98+
def setup():
99+
_stream = ZMQStream(shell_socket, loop)
100+
101+
def on_recv(frames):
102+
received.append(frames)
103+
got_message.set()
104+
105+
_stream.on_recv(on_recv, copy=True)
106+
_manager = SubshellManager(context, loop, _stream)
107+
return _stream, _manager
108+
109+
stream, manager = _run_on_loop(loop, setup)
110+
111+
# Warmup: teach the ROUTER the client's route and let the stream drain to idle.
112+
client.send_multipart([b"warmup"])
113+
assert got_message.wait(TIMEOUT), "warmup request never delivered"
114+
routing_id = received[0][0]
115+
assert routing_id == b"client-1"
116+
117+
received.clear()
118+
got_message.clear()
119+
120+
def strand_then_reply():
121+
# Runs on the loop thread, so the stream's fd handler cannot interleave while
122+
# this callback executes -- that is what makes the strand deterministic.
123+
client.send_multipart([b"req-1"])
124+
125+
# Wait until the request is actually queued on the ROUTER. Reading EVENTS here
126+
# also consumes the edge-triggered read edge (libzmq ZMQ_FD corollary), so by
127+
# the time we exit this loop the request is queued and unread while the fd is
128+
# no longer readable -- the coalesced-edge strand precondition.
129+
deadline = time.monotonic() + TIMEOUT
130+
while not (shell_socket.events & zmq.POLLIN):
131+
if time.monotonic() > deadline:
132+
msg = "request never queued on the ROUTER"
133+
raise TimeoutError(msg)
134+
135+
assert not got_message.is_set(), "request delivered before the reply send"
136+
137+
# Out-of-band reply send through the real code path. With the fix this re-arms /
138+
# routes through the stream so the queued request is serviced; without it the
139+
# request stays stranded on the registered-but-non-readable fd.
140+
manager._send_on_shell_channel([routing_id, b"reply"])
141+
142+
_run_on_loop(loop, strand_then_reply)
143+
144+
assert got_message.wait(TIMEOUT), (
145+
"the concurrently-queued request was stranded by the out-of-band reply send "
146+
"and never delivered to on_recv -- the shell-channel wedge has regressed"
147+
)
148+
assert received
149+
assert received[-1][-1] == b"req-1"
150+
finally:
151+
152+
def teardown():
153+
if manager is not None:
154+
with contextlib.suppress(Exception):
155+
manager.close()
156+
if stream is not None:
157+
stream.close()
158+
159+
with contextlib.suppress(Exception):
160+
_run_on_loop(loop, teardown)
161+
loop.add_callback(loop.stop)
162+
thread.join(timeout=TIMEOUT)
163+
client.close()
164+
shell_socket.close()
165+
context.term()

0 commit comments

Comments
 (0)