Shell reply sent raw on the ROUTER can drop the ZMQStream wake-up; a request arriving in that window is not read until the next connection

オープン
#1,554 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
68/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
活発
技術スタック
python

調査の方向性

SubshellManager._send_on_shell_channel と shell-channel ZMQStream のセットアップから始め、次に zmq_lost_wakeup_det.py を --close-a 付きと --fix 付きで実行して、lost wake-up を再現・検証します。完了条件は、raw ROUTER の応答中に到着したリクエストが、後続の接続を必要とせずに応答され、既存の shell-channel の動作が維持されることです。

索引モデルが issue の本文から書いたものです。

説明

Repo: ipykernel · Versions: ipykernel 7.2.0, pyzmq 27.1.0, libzmq 4.3.5, jupyter_server 2.21.0, macOS arm64 (also seen on Linux CI runners)

Summary

Since ipykernel 7, shell replies are written to the ROUTER socket with a raw
send_multipart from the shell channel thread
(SubshellManager._send_on_shell_channel), while shell requests are read
through a ZMQStream on that same socket. A raw send runs libzmq's
process_commands, which consumes the ROUTER's edge-triggered wake-up. pyzmq
re-reads ZMQ_EVENTS only after its own stream operations, so a request that
landed on the ROUTER while that reply was being sent is never noticed: it sits
in the socket until some later command on the socket wakes the stream (in
practice, the next client connection).

ipykernel 6 sent replies through the stream itself and did not have this window.

Why it shows up as "first message on a fresh connection hangs"

jupyter_server nudges every new channels websocket with a kernel_info_request
on a transient shell channel and closes that channel as soon as the control
channel answers. The kernel's shell reply to the nudge is therefore always sent
to a peer that has already gone (the ROUTER drops it silently), and a reply that
went nowhere produces no follow-up command. A client whose first real request
arrived during that send waits until another connection is opened. Symptoms:

  • a cell that never runs (kernel shows busy/idle never toggling),
  • a JupyterLab connection that hangs on first use and comes alive the moment a
    second tab connects to the same kernel.

Deterministic reproducer

zmq_lost_wakeup_det.py (below) rebuilds the shell-channel-thread shape —
one IOLoop, a ZMQStream on the ROUTER, a second ZMQStream on an inproc PAIR whose
callback writes the reply to the ROUTER raw — and forces the interleaving:
client B's request lands while A's reply callback is running.

$ python zmq_lost_wakeup_det.py --close-a --trials=3       # A closes like the nudge does
trial 0: B LOST (stuck in the ROUTER)
trial 1: B LOST (stuck in the ROUTER)
trial 2: B LOST (stuck in the ROUTER)
fix=False settle=0.2 trials=3 lost=3

$ python zmq_lost_wakeup_det.py --close-a --fix --trials=3  # re-read ZMQ_EVENTS after the raw send
trial 0: B answered
trial 1: B answered
trial 2: B answered
fix=True settle=0.2 trials=3 lost=0

Without --close-a the reply to A is delivered, A's peer eventually produces
another command, and B is rescued — which is why the bug looks intermittent in
the wild and always clears on the next connection.

(--settle must exceed libzmq's max_command_delay, ~1 ms on x86 TSC and
~125 ms on Apple Silicon; the default 0.2 s covers both.)

Suggested fix

After the raw send in SubshellManager._send_on_shell_channel, re-check the
shell stream's events, e.g. shell_stream._rebuild_io_state() — the same thing
pyzmq does after its own sends — or send the reply through the stream. We
currently carry the former as a wrapper installed before app.initialize()
and it removes the first-message hang in our CI.

I am happy to turn this into a PR if the maintainers prefer one approach over
the other.

zmq_lost_wakeup_det.py
"""Deterministic reproducer: ZMQStream wake-up lost to a raw send on the same socket.

Structure of ipykernel 7's shell channel thread: one IOLoop, a ZMQStream on the
ROUTER (requests in), and a second ZMQStream on an inproc PAIR (replies from the
main thread) whose callback writes the reply to the ROUTER with a RAW
``send_multipart``.

Interleaving forced here: while the PAIR callback is running (i.e. inside one
loop iteration, after the selector already took its readiness snapshot), client
B's request arrives at the ROUTER. libzmq queues an activate-read command and
signals the ROUTER's wake-up fd. The raw send then runs libzmq's
process_commands, which consumes that signal. Nothing re-reads ZMQ_EVENTS on the
ROUTER stream afterwards, so the loop never wakes for B: B's request sits in the
ROUTER until some later command on that socket wakes the stream again.

usage: python zmq_lost_wakeup_det.py [--fix] [--settle=SECONDS]
  --settle  time to wait inside the callback before the raw send, must exceed
            libzmq's max_command_delay (~1 ms on x86 TSC, ~125 ms on Apple Silicon)
"""
import select, sys, threading, time
import zmq
from tornado.ioloop import IOLoop
from zmq.eventloop.zmqstream import ZMQStream

FIX = "--fix" in sys.argv
SETTLE = float(next((a.split("=")[1] for a in sys.argv if a.startswith("--settle=")), 0.2))
CLOSE_A = "--close-a" in sys.argv
TRIALS = int(next((a.split("=")[1] for a in sys.argv if a.startswith("--trials=")), 5))

ctx = zmq.Context()
router = ctx.socket(zmq.ROUTER)
port = router.bind_to_random_port("tcp://127.0.0.1")
to_main_a, to_main_b = ctx.socket(zmq.PAIR), ctx.socket(zmq.PAIR)
to_main_a.bind("inproc://to-main"); to_main_b.connect("inproc://to-main")
from_main_a, from_main_b = ctx.socket(zmq.PAIR), ctx.socket(zmq.PAIR)
from_main_a.bind("inproc://from-main"); from_main_b.connect("inproc://from-main")

loop = IOLoop(make_current=False)
router_stream = ZMQStream(router, loop)
from_main_stream = ZMQStream(from_main_a, loop)

b_go = threading.Event()
b_sent = threading.Event()

def on_request(frames):
    to_main_a.send_multipart(frames)

def on_reply(frames):
    if frames[-1].startswith(b"A"):
        # A's reply is about to go out on the raw socket. Let B's request land first,
        # inside this same loop iteration, then wait out libzmq's send-side throttle.
        b_go.set(); b_sent.wait(); time.sleep(SETTLE)
        fd = router.fd
        print(f"    fd readable before raw send: {bool(select.select([fd], [], [], 0)[0])}")
    router.send_multipart(frames)          # raw send, as ipykernel 7 does
    if frames[-1].startswith(b"A"):
        print(f"    fd readable after raw send:  {bool(select.select([fd], [], [], 0)[0])}")
    if FIX:
        router_stream._rebuild_io_state()  # re-read ZMQ_EVENTS after touching the socket raw

router_stream.on_recv(on_request)
from_main_stream.on_recv(on_reply)
threading.Thread(target=loop.start, daemon=True, name="shell-channel").start()

def main_thread():
    while True:
        from_main_b.send_multipart(to_main_b.recv_multipart())
threading.Thread(target=main_thread, daemon=True, name="main-shell").start()

def dealer():
    s = ctx.socket(zmq.DEALER); s.linger = 0; s.connect(f"tcp://127.0.0.1:{port}"); return s

lost = 0
for trial in range(TRIALS):
    b_go.clear(); b_sent.clear()
    a, b = dealer(), dealer()
    time.sleep(0.05)                                   # both DEALERs connected
    a.send_multipart([b"", b"A-%d" % trial])
    if CLOSE_A:
        time.sleep(0.02); a.close()   # like jupyter_server's nudge: the transient channel is gone before the kernel replies
    b_go.wait()
    b.send_multipart([b"", b"B-%d" % trial]); time.sleep(0.02); b_sent.set()
    if not CLOSE_A:
        assert a.poll(2000), "A itself never answered"
        a.recv_multipart()
    if b.poll(1000):
        b.recv_multipart(); print(f"trial {trial}: B answered")
    else:
        lost += 1; print(f"trial {trial}: B LOST (stuck in the ROUTER)")
        c = dealer(); c.send_multipart([b"", b"C-%d" % trial])   # any new command wakes the stream
        print(f"    after a third client's request: B answered={bool(b.poll(1000))} C answered={bool(c.poll(1000))}")
        c.close()
    if not CLOSE_A: a.close()
    b.close()
print(f"fix={FIX} settle={SETTLE} trials={TRIALS} lost={lost}")
主要言語
Python
スター
734
フォーク
411
平均マージ
1日 2時間
マージ済み PR(30日)
9

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

ipython/ipykernel のほかの issue

ipython/ipykernel の issue をすべて見る

似ている issue

Python の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。