Skip to content

Send the shell reply through the ZMQStream instead of raw on its socket - #1529

Merged
ianthomas23 merged 7 commits into
ipython:mainfrom
BoykoNeov:fix/shell-zmqstream-rearm-after-reply-send
Aug 13, 2026
Merged

ianthomas23 merged 7 commits into
ipython:mainfrom
BoykoNeov:fix/shell-zmqstream-rearm-after-reply-send

Conversation

@BoykoNeov

Copy link
Copy Markdown
Contributor

Summary

On Windows, ipykernel 7 intermittently drops an execute_request on the shell channel: the kernel goes idle (0% CPU) and never replies, and the client times out waiting for execute_reply. In a headless notebook smoke-test we measured this at ~30% of runs; which cell hangs wanders run to run (it is content-innocent), and only a kernel restart recovers. This looks like the same user-visible hang tracked downstream in microsoft/vscode-jupyter#17228.

Forcing WindowsSelectorEventLoopPolicy does not help — the kernel already runs a selector loop.

Root cause

The shell ROUTER socket is dual-use on the shell-channel thread:

  • a ZMQStream reads execute_requests off it (init_kernel builds ZMQStream(self.shell_socket, …), delivered via on_recv), while
  • replies go back over the same socket out-of-band through a raw send_multipart that bypasses the stream, in SubshellManager._send_on_shell_channel:
def _send_on_shell_channel(self, msg) -> None:
    assert current_thread().name == SHELL_CHANNEL_THREAD_NAME
    self._shell_socket.send_multipart(msg)   # raw send on the ZMQStream's own socket

That out-of-band send drains the socket's edge-triggered ZMQ_FD read edge — the documented libzmq corollary that after zmq_send a socket may become readable without producing a new read event. Because the send is not ZMQStream-mediated, the read side is never re-armed, so an execute_request that arrived concurrently strands unread on a registered-but-non-readable fd. The strand is terminal: while a backlog is already pending there is no 0 → nonzero EVENTS transition, so no later arrival re-edges it, and the kernel sits idle.

Minimal raw-pyzmq reproduction of just the send-drains-read-edge step (no Jupyter), on a ROUTER (the shell socket type):

import select, time, zmq
def readable(fd): return bool(select.select([fd], [], [], 0)[0])

ctx = zmq.Context()
a = ctx.socket(zmq.DEALER); a.setsockopt(zmq.IDENTITY, b"A")
b = ctx.socket(zmq.ROUTER)
b.bind("tcp://127.0.0.1:5704"); a.connect("tcp://127.0.0.1:5704"); time.sleep(0.3)
bfd = b.getsockopt(zmq.FD)

a.send(b"hello"); time.sleep(0.2)                       # warmup: learn route 'A', clear setup edges
assert b.recv_multipart() == [b"A", b"hello"]
b.getsockopt(zmq.EVENTS)
assert not readable(bfd) and not (b.getsockopt(zmq.EVENTS) & zmq.POLLIN)

a.send(b"req1"); time.sleep(0.2)                        # a new request arrives, UNREAD
assert readable(bfd)                                    # read edge is set

b.send_multipart([b"A", b"reply"]); time.sleep(0.05)    # OUT-OF-BAND send on the same ROUTER
assert not readable(bfd)                                # <-- the send DRAINED the read edge
assert b.getsockopt(zmq.EVENTS) & zmq.POLLIN            # ...while POLLIN stays set...
assert b.recv_multipart(zmq.NOBLOCK) == [b"A", b"req1"] # ...and the request was still queued

Fix

After each out-of-band reply send, schedule the shell ZMQStream's read handler on the shell-channel loop — the same edge-trap reschedule ZMQStream._update_handler already runs internally:

self._shell_channel_io_loop.add_callback(
    lambda: stream._handle_events(stream.socket, 0)
)

The shell_stream (built in init_kernel) is threaded through ShellChannelThread into SubshellManager so the reply path can reach it; the re-arm is guarded by stream is not None and stream.socket is self._shell_socket. No polling and no new dependency — it moves the existing edge-trap reschedule to the one un-mediated operation that drains the edge.

Validation

Windows, Python 3.13 and 3.14, pyzmq 27.1.0 / libzmq 4.3.5; three arms × 20 real notebook runs, same machine/session:

Arm After the reply send_multipart Wedged
control none 6/20 (30%)
sham identical scheduling overhead, add_callback(lambda: None) (no re-arm) 5/20
fix add_callback(lambda: stream._handle_events(stream.socket, 0)) 0/20

P(0/20 | p=0.30) ≈ 0.70^20 ≈ 8e-4. The sham arm isolates the re-arm itself from the added wake-ups (it stays at the control rate). With the diff applied the threaded reference was live on every send (551 re-arms, 0 None/mismatch). Validated against 7.2.0 / 7.3.0, where _send_on_shell_channel is byte-identical to main.

A public-API alternative

stream.flush(zmq.POLLIN) — a public ZMQStream method ipykernel already calls in kernelbase.py — may be preferable to reaching into _handle_events. flush is a synchronous drain loop whereas _handle_events(socket, 0) is the edge-trap reschedule (related but not identical). I only measured _handle_events; happy to switch to flush if you'd rather stay on the public API.

Notes

🤖 Generated with Claude Code

BoykoNeov added a commit to BoykoNeov/steel-sim that referenced this pull request Jun 14, 2026
The ZMQ_FD edge-drain fix is now an upstream PR (ipython/ipykernel#1529,
filed under BoykoNeov, 3 files +21/-0, validated 0/20 vs 6/20). The doc now
points to the PR and the applicable patch; steel-sim's retry mitigation is
unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@BoykoNeov

Copy link
Copy Markdown
Contributor Author

The failing checks here are pre-existing flakiness on the Windows runners, not this patch — each is on a code path these three files don't touch:

  • build (windows-latest, qt5, 3.10) — tests/inprocess/test_kernel.py::test_pylab timed out while matplotlib built its font cache on a cold runner. That's the in-process kernel (no ZMQ socket, no SubshellManager, no kernelapp), so the shell-channel change can't reach it.
  • build (windows-latest, qt5, pypy-3.11) — RuntimeError: Kernel died before replying to kernel_info, a kernel startup crash that occurs before any execute_request, i.e. before the re-arm in _send_on_shell_channel ever runs. The same pypy-3.11 job passes under qt6 and fails under qt5, and main fails this same job identically (same error; 141 passed / 42 skipped, attributed to test_async_interrupt), so it isn't introduced here.
  • enforce-label — needs a maintainer label.

Everything that actually exercises the change is green: all CPython Windows builds (which run tests/test_subshells.py under the patch), the downstream-project tests, and lint. Happy to rebase or re-run if that's useful.

@practicusai

Copy link
Copy Markdown

@BoykoNeov not sure it is relevant, but the in our case of microsoft/vscode-jupyter#17228 , the OS is Ubuntu. I hope the fix will work for multiple OSes.

@ianthomas23

Copy link
Copy Markdown
Collaborator

I've confirmed the flaky tests passed the second time around.

  • Happy to add a regression test or open a companion tracking issue if you'd prefer.

A regression test is important.

Is there a simpler solution of the _send_on_shell_channel using the shell_stream rather than shell_socket to send_multipart?

BoykoNeov and others added 2 commits June 16, 2026 14:46
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 <[email protected]>
Deterministically reproduces the strand precondition (a request queued on the
dual-use shell ROUTER with its edge-triggered ZMQ_FD read edge already consumed)
then performs the out-of-band reply send through the real
SubshellManager._send_on_shell_channel path and asserts the queued request is
still delivered to on_recv. Behavioural, not implementation-coupled: it passes
whether the reply send re-arms the stream explicitly or is routed through it,
and fails (times out) against a raw-send body. Deterministic (no timing race);
verified on Windows, the edge-trigger behaviour it relies on is general.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@BoykoNeov
BoykoNeov force-pushed the fix/shell-zmqstream-rearm-after-reply-send branch from c53bfe6 to 78782b4 Compare June 16, 2026 11:53
@BoykoNeov

Copy link
Copy Markdown
Contributor Author

Thanks @ianthomas23 — and good the re-run came back green.

Agreed, and I think routing through shell_stream is the better fix. The raw shell_socket.send_multipart is the one op on the dual-use ROUTER that the stream never sees, so it drains the edge-triggered ZMQ_FD read edge and nothing re-arms it. Sending through shell_stream.send_multipart keeps the stream the sole user of the socket — its own _handle_events recvs any concurrently-queued request first, then re-arms POLLIN via _rebuild_io_state. So it kills the root cause instead of re-arming after it, and drops the poke into the private _handle_events. _send_on_shell_channel already runs on the shell-channel thread that owns the stream's loop, so no thread-affinity worries. I've pushed that — the PR is now the stream-send fix plus a regression test.

The test is deterministic rather than statistical: it sets up the strand by hand (queue a request on the ROUTER, consume the ZMQ_FD read edge so it can't re-fire), then does the out-of-band reply send and asserts the queued request still lands in on_recv. It's behavioural, so it passes whether the reply re-arms the stream or routes through it, and times out on a raw-send body. Verified on Windows; the edge-trigger behaviour it leans on is general, so CI should cover the other platforms.

@practicusai — the mechanism is generic libzmq edge-triggering rather than Windows-specific, so this should help the Linux case too.

@BoykoNeov BoykoNeov changed the title Re-arm the shell ZMQStream read after the out-of-band reply send (fix intermittent dropped execute_request on Windows) Send the shell reply through the ZMQStream instead of raw on its socket Jun 16, 2026
@ZupoLlask

Copy link
Copy Markdown

Dear @BoykoNeov,

We're seeing some issues with flaky tests on Windows that seem to be somewhat related with this subject.

Would you give us some help here, please? 🙏

ruff's PT018 flags `assert received and received[-1][-1] == b"req-1"` as a
compound assertion; split it into two so a failure points at the exact part.
No behavior change. Fixes the only red check on this PR (Test Lint / pre-commit).
BoykoNeov added a commit to BoykoNeov/steel-sim that referenced this pull request Jun 21, 2026
…on both PRs

GitHub status re-check 2026-06-21:
- NewUserHa/ipykernel#1 (Fix A) MERGED into patch-2 (self-merge 3638154);
  NewUserHa then reverted dead %asyncio (the #1532 cleanup we'd kept out).
- ipython/ipykernel#1469 now 40/40 green; NewUserHa endorsed + pinged
  ianthomas23 for review. Latest comment is an endorsement, not a question.
- ipython/ipykernel#1529 unchanged: awaiting ianthomas23 re-review, green
  except the known macOS-pypy test_run_concurrently_sequence timeout flake.
Nothing owed from us on either PR; both await maintainer review.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@ianthomas23

Copy link
Copy Markdown
Collaborator

@BoykoNeov This is looking great, thanks.

I think we can go slightly further and not pass in a shell_socket in SubshellManager.__init__ and remove SubshellManager._shell_socket. As far as I can tell the shell_stream is always defined in that constructor; we can assert shell_stream is not None there just in case. I've tried this locally (with the other changes that it causes) and CI passes for me.

@ianthomas23

Copy link
Copy Markdown
Collaborator

@BoykoNeov are you happy to continue with this? If not I will take it over as I'd like to get it merged before the next release.

@BoykoNeov

Copy link
Copy Markdown
Contributor Author

Hi, I am currently on vacation until next week, if you want - take over, otherwise I will see what I can do when I return.
Best regards

The last commit made the reply go through the ZMQStream, but the manager still
kept the socket and picked between the two at send time. So the raw send was
still sitting there as a fallback if the stream was ever unset.

Now shell_socket is gone from SubshellManager.__init__ and _shell_socket is
gone with it, so _send_on_shell_channel is just

    self._shell_stream.send_multipart(msg)

with no branch. There's no socket left in the manager to raw-send on.

ShellChannelThread keeps its shell_socket argument for now, but it was only
holding it to hand to the manager, so it's dead weight - see the PR discussion.
It still gets shell_stream set after construction rather than in __init__,
since the thread is created in init_sockets and the stream doesn't exist until
init_kernel. That's fine - .manager is only ever touched on a running kernel,
so the stream is always there by the time the manager gets built.

Also fixed the docstring while I was in there. It said reply sends are wrapped
in another lock, but that lock is gone - _lock_cache is the only one left, and
sends are already serialised by running in the shell channel thread, which
_send_on_shell_channel asserts.
@BoykoNeov

Copy link
Copy Markdown
Contributor Author

Done - SubshellManager no longer takes or stores the shell socket, so the send is just a plain shell_stream.send_multipart with no branch.

I left ShellChannelThread's shell_socket argument alone, since changing that signature is a bigger call than the one you asked for. But it was only holding the socket to hand to the manager, so nothing reads it now - it and the init_sockets argument can probably both go. Say the word and I'll do it here, or leave it for a follow-up.

Also: there are two asserts, not one. The extra one is in ShellChannelThread.manager - shell_stream is typed ZMQStream | None there (it's set after construction, since the thread exists before init_kernel builds the stream), so mypy needs the narrowing at the SubshellManager call. Happy to make it a RuntimeError instead if you'd rather it survive python -O.

While I was in there I also corrected the class docstring, which said reply sends are wrapped in another lock - that lock is gone, _lock_cache is the only one left, and sends are already serialised by running in the shell channel thread.

@ianthomas23

Copy link
Copy Markdown
Collaborator

Thanks. Please do remove the shell_socket argument to ShellChannelThread.

The thread only held the socket to hand on to SubshellManager, which no
longer takes it: the reply goes out through the shell ZMQStream. Nothing
read the attribute, so both the parameter and the field go.
@BoykoNeov

Copy link
Copy Markdown
Contributor Author

Done — removed, along with the field, since nothing read it.

Silences ruff S110 (try-except-pass) on the CI lint job.

@ianthomas23 ianthomas23 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @BoykoNeov

@thomasjm

Copy link
Copy Markdown

Hi all,

I've confirmed this fixes a problem I've been having. Any chance a new release is planned soon that will include this? Thanks!

kangkangzi2025 added a commit to Rath-Team/OpenRath that referenced this pull request Sep 28, 2026
About 2-4% of code runs never executed: execd sent the execute_request,
the kernel never read it, and execd, which waits for the run with no
deadline, kept the default context locked, so every retry saw "session
is busy" and the call ended as "remained busy after timeout".

The request was stuck inside the kernel. execd opens a new websocket for
every run, and Jupyter Server nudges each new websocket with a
kernel_info_request whose shell reply goes to a channel it has already
closed. ipykernel 7 sends shell replies with a raw send_multipart on the
ROUTER that it reads requests from through a ZMQStream; that send can
consume the socket's wake-up, and a request arriving during it stays
unread until another client connects (ipython/ipykernel#1554). Kernel
debug logs from CI show the stalled request arriving at the kernel only
when a diagnostic client connected to it 20 s later.

Install an IPython startup file when a sandbox opens that re-reads the
shell stream's events after each raw reply. It runs before the kernel
reads its first request and does nothing on kernels without ipykernel
7's SubshellManager. ipython/ipykernel#1529 fixes the kernel upstream
but is not in a release yet; on a kernel that has it the wrapper is
redundant and harmless.

The tests need neither the SDK nor a server, so they live in their own
file and run in the fast test job.
alexanderbosikov added a commit to alexanderbosikov/jupyter-nvim that referenced this pull request Sep 28, 2026
Живое ядро умеет замолчать, не умерев. Watchdog смотрел только на смерть процесса
(os.kill(pid, 0)), поэтому такое ядро навсегда оставалось в starting, а очередь
запусков в Lua копилась без всякого предела: ячейка показывала «в очереди» и
больше не происходило ничего — ни сообщения, ни подсказки. Догадаться до
:JupyterRestart было неоткуда.

Предел не один, а по этапам, потому что этапы §6.2 несут разное. Ответ на shell
ни от чего не зависит — его даёт главный цикл ядра до всякого пользовательского
кода, — и ему хватает десяти секунд. Подписка iopub и проба stdin с повтором и
interrupt честно дольше, им тридцать. Молчание именно на shell и есть форма
ipython/ipykernel#1529, так что ловится оно за десять секунд, а не за минуту.

Числа из замеров, а не из осторожности. 120 стартов обоих kernelspec'ов, включая
по четыре ядра параллельно: ответ на shell — медиана 0.26–0.32 с, максимум 0.43 с,
и параллельный запуск его не сдвинул; iopub — максимум 0.41 с; stdin — p95 0.76 с,
максимум 0.84 с. Отдельно 400 стартов: полная готовность, медиана 0.37 с, худший
случай 4.38 с. Оба спека оказались голым ipykernel_launcher, то есть готовность не
ждёт тяжёлых импортов пользователя — те живут в первой ячейке.

stuck — отдельное состояние от dead намеренно. Процесс жив и держит память, так
что звать его мёртвым — врать, и действия разные: мёртвое поднимают заново, а
замолчавшее ещё может ответить, и тогда _maybe_ready переводит его в ready само,
без рестарта. Поэтому и ложная тревога дешева.

Причина называет вставший этап: у shell, подписки iopub и маршрута stdin разные
места, где смотреть, и это единственное по-настоящему полезное, что можно сказать
в :JupyterLog.

Фиксировать версию ipykernel не к чему: фикс #1529 влит 13.08.2026 и ни в один
релиз по 7.3.0 не попал. Предел всё равно наш — он ограничивает любую причину
молчания, а не эту одну.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
samox73 added a commit to samox73/jotter that referenced this pull request Sep 29, 2026
bpowers added a commit to bpowers/simlin that referenced this pull request Oct 3, 2026
## Why

The JupyterLab journey kept failing after #1074, always the same way: a
notebook's first cell was run and produced no output at all. It failed
once on main and three times across open pull requests. #1074 fixed a
real race in the harness (the kernel session start); this is a second
cause, and it is in the kernel, not the harness.

ipykernel through 7.3 writes shell replies raw on the shell ROUTER
socket that its `ZMQStream` reads. The raw send drains the socket's
edge-triggered read edge, so a request arriving at the same moment is
never dispatched and the kernel sits idle for good
([ipython/ipykernel#1529](ipython/ipykernel#1529),
first released in 7.4.0). JupyterLab sends a burst of shell requests
when it connects a notebook (history, debug, the widget manager's comm),
which makes a notebook's first cell the likely victim: Lab sends the
`execute_request`, the prompt shows `[*]`, and nothing ever comes back.

## What

- The `e2e` extra requires `ipykernel>=7.4` (the lock moves ipykernel
7.2.0 to 7.4.0, with jupyter-client and tornado at 7.4.0's own floors).
- **Running a cell is an action with a checked effect.** `runCell` waits
for a new execution count and otherwise fails at the run, saying which
side never acted (`[*]`: Lab sent it and the kernel has not answered; no
new count: Lab never ran it), with the kernel state and the server log's
last lines. There is no retry: a wedged kernel answers nothing, so
pressing again cannot help.
- The setup waits out a partially written server runtime file instead of
throwing on it (`jupyter_server` writes it in place; this failed the
setup once in 121 runs).
- **The failure artifact was always empty:**
`actions/upload-artifact@v4` skips hidden files, and the journey writes
to `e2e/.output`. The upload includes hidden files, so the trace,
screenshots and server log reach the artifact.

## Evidence

- **A reproduced failure** (four concurrent journeys sharing four CPUs):
the websocket trace shows the `execute_request` sent on the notebook's
own connection and never answered, and a stack dump of that kernel shows
every thread idle in `select()`.
- **A kernel-level reproducer** with no browser (`jupyter_client`, shell
requests pipelined eight deep): ipykernel 7.2.0 wedged in 10 of 10
trials; 7.4.0 in 0 of 10 trials of 3,000 requests each.
- **The journey under the same concurrent load:** 3 failures in 121 runs
before (two kernel wedges, one runtime-file race), 120 of 120 after.
- Unit specs in `kernel-session.spec.ts` cover every arm of the new pure
decisions (every truncation of a runtime file, the prompt forms, each
before-and-after pair of a cell run, both report verdicts).
- The pre-commit hook passed in full.

## What this does not establish

- The failure on main left no artifact (see above), so it is attributed
to this wedge by its shape, not by a trace.
- At a base rate of about 2%, 120 clean runs happen by chance about one
time in eleven; the kernel reproducer carries the claim, not the loop.
- Nothing here has run on GitHub's runners yet; this PR's own check is
the first.
- pysimlin users on ipykernel 7.0 to 7.3 with JupyterLab 4.4 or later
can hit the same wedge in their own notebooks. This changes only the
test extra, not pysimlin's runtime dependencies.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LYR7N9WZwQuFnZFW8JDUgx
samox73 added a commit to samox73/jotter that referenced this pull request Oct 5, 2026
ipykernel 7.4.0 ships ipython/ipykernel#1529. One shell request in flight stays: projects pin older ipykernels, and kernel_info reports IPython's version, not ipykernel's, so a fixed kernel can't be told apart.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants