Repository navigation
Send the shell reply through the ZMQStream instead of raw on its socket - #1529
ianthomas23 merged 7 commits into
Conversation
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]>
|
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:
Everything that actually exercises the change is green: all CPython Windows builds (which run |
|
@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. |
|
I've confirmed the flaky tests passed the second time around.
A regression test is important. Is there a simpler solution of the |
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]>
c53bfe6 to
78782b4
Compare
for more information, see https://pre-commit.ci
|
Thanks @ianthomas23 — and good the re-run came back green. Agreed, and I think routing through The test is deterministic rather than statistical: it sets up the strand by hand (queue a request on the ROUTER, consume the @practicusai — the mechanism is generic libzmq edge-triggering rather than Windows-specific, so this should help the Linux case too. |
|
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).
…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]>
|
@BoykoNeov This is looking great, thanks. I think we can go slightly further and not pass in a |
|
@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. |
|
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. |
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.
|
Done - I left Also: there are two asserts, not one. The extra one is in While I was in there I also corrected the class docstring, which said reply sends are wrapped in another lock - that lock is gone, |
|
Thanks. Please do remove the |
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.
|
Done — removed, along with the field, since nothing read it. |
Silences ruff S110 (try-except-pass) on the CI lint job.
|
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! |
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.
Живое ядро умеет замолчать, не умерев. 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]>
## 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
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]>
Summary
On Windows, ipykernel 7 intermittently drops an
execute_requeston the shell channel: the kernel goes idle (0% CPU) and never replies, and the client times out waiting forexecute_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
WindowsSelectorEventLoopPolicydoes not help — the kernel already runs a selector loop.Root cause
The shell ROUTER socket is dual-use on the shell-channel thread:
ZMQStreamreadsexecute_requests off it (init_kernelbuildsZMQStream(self.shell_socket, …), delivered viaon_recv), whilesend_multipartthat bypasses the stream, inSubshellManager._send_on_shell_channel:That out-of-band send drains the socket's edge-triggered
ZMQ_FDread edge — the documented libzmq corollary that afterzmq_senda socket may become readable without producing a new read event. Because the send is notZMQStream-mediated, the read side is never re-armed, so anexecute_requestthat arrived concurrently strands unread on a registered-but-non-readable fd. The strand is terminal: while a backlog is already pending there is no0 → nonzeroEVENTStransition, 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):
Fix
After each out-of-band reply send, schedule the shell
ZMQStream's read handler on the shell-channel loop — the same edge-trap rescheduleZMQStream._update_handleralready runs internally:The
shell_stream(built ininit_kernel) is threaded throughShellChannelThreadintoSubshellManagerso the reply path can reach it; the re-arm is guarded bystream 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:
send_multipartadd_callback(lambda: None)(no re-arm)add_callback(lambda: stream._handle_events(stream.socket, 0))P(0/20 | p=0.30) ≈ 0.70^20 ≈ 8e-4. Theshamarm 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_channelis byte-identical tomain.A public-API alternative
stream.flush(zmq.POLLIN)— a publicZMQStreammethod ipykernel already calls inkernelbase.py— may be preferable to reaching into_handle_events.flushis 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 toflushif you'd rather stay on the public API.Notes
main:_send_on_shell_channelis a baresend_multipart.ZMQ_FDedge-trigger bug class), getsockopt(zmq.EVENTS) drains signaler, can cause zmq.asyncio recv to miss wakeups zeromq/pyzmq#2173 (same signaler-drain family, different layer).CellTimeoutErrorsignature — works today and is orthogonal to this fix.🤖 Generated with Claude Code