post_execute fires during comm_msg handling on a subshell, so output published from the hook is parented to the comm_msg and dropped by frontends

未关闭
#1,564 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
48/100
Issue 类型
缺陷
描述清晰度
基本清楚
活跃度
活跃
技术栈
jupyter, python
领域
api, backend

调研方向

Start with the embedded repro_parent_header.py, especially create_subshell() and the comm_msg path, then compare its ipykernel 7.3.0 output with the 6.31.0 run. Trace where pre_execute/post_execute are fired during subshell comm handling; done means comm handling no longer causes a hook to publish output parented to the comm_msg or drain the cell's pending output.

由索引模型根据 Issue 内容生成。

描述

Summary (AI created)
When comm messages are routed over subshells, ipykernel 7 fires IPython's pre_execute / post_execute events while handling a comm_msg — on the subshell thread, and under the comm_msg's parent header.

Any hook that publishes output from post_execute therefore publishes it with parent_header.msg_id pointing at the comm_msg instead of at the cell's execute_request. Frontends route iopub output to a cell by that id, so the output is silently discarded — no error, no placeholder, nothing.

It is worse than a misroute: the hook has now drained whatever it had queued, so when the cell's own post_execute fires a moment later there is nothing left to display.

This is reachable in a default install — JupyterLab 4.6's commsOverSubshells setting defaults to perCommTarget, so subshells are used whenever the kernel supports them.

Why it matters
I believe this is the cause of matplotlib/ipympl#609 ("figure does not show up in the output area unless plt.show() is explicitly called").

ipympl queues figures during a cell and displays them from post_execute (flush_figures). A figure that is already on screen keeps its comm busy, so the next figure created is the one that vanishes. That accounts for every reported characteristic of #609:

the first plot of a session always works — nothing is messaging a comm yet
it is intermittent, and more likely with slower cells or larger datasets — a longer cell is a wider window for a comm_msg to land mid-execution
it never happens with %matplotlib inline — no comms, so no subshell traffic
matplotlib_inline uses the same post_execute pattern and would be affected anywhere comms are in play.

Reproducer
No matplotlib, ipympl or ipywidgets required. It starts a kernel, registers a post_execute hook that displays a queued payload, opens a comm, creates a subshell, then runs a 3-second cell and sends one comm_msg on the subshell one second in.

Exit code 0 = correct, 1 = bug reproduced.

repro_parent_header.py """ ipykernel 7 fires IPython's pre_execute/post_execute events while handling a comm_msg, on the subshell thread and under the comm_msg's parent header.

Anything that publishes output from a post_execute hook -- ipympl's
flush_figures, matplotlib_inline's -- therefore publishes it with
parent_header.msg_id pointing at the comm_msg rather than at the cell's
execute_request. A frontend routes iopub output to a cell by that id, so the
output is silently dropped. Worse, the hook has now drained its pending queue,
so the cell's own post_execute has nothing left to display.

This is, I believe, the cause of matplotlib/ipympl#609: an ipympl figure that is
already on screen keeps a comm busy, so the next figure is the one that
vanishes -- intermittently, more often with slower cells, and never with
%matplotlib inline (no comms).

No matplotlib / ipympl / ipywidgets required.

python repro_parent_header.py

Exit code 0 = correct behaviour, 1 = bug reproduced.
"""

from future import annotations

import queue
import sys
import time

import ipykernel
from jupyter_client.manager import start_new_kernel

SETUP = r'''
import time, threading
from IPython.display import display
from ipykernel.comm import Comm

T0 = time.time()
EVENTS = []
_pending = []

def _post_execute():
# This is what ipympl's flush_figures does: display whatever the cell queued.
EVENTS.append(("post_execute", round(time.time() - T0, 2), threading.current_thread().name))
while _pending:
display(_pending.pop())

get_ipython().events.register("post_execute", _post_execute)

_comm = Comm(target_name="repro")
_comm.on_msg(lambda msg: None)
print("COMM_ID", _comm.comm_id)
'''

CELL = r'''
_pending.append("PAYLOAD-PUBLISHED-FROM-POST-EXECUTE")
time.sleep(3) # window for a comm_msg to land mid-cell
'''

REPORT = "for e in EVENTS: print(*e)"

def drain(kc, stop_msg_id, log=None):
deadline = time.time() + 30
while time.time() < deadline:
try:
msg = kc.get_iopub_msg(timeout=0.5)
except queue.Empty:
continue
parent = msg.get("parent_header") or {}
item = (msg["msg_type"], parent.get("msg_type"), parent.get("msg_id"), msg["content"])
if log is not None:
log.append(item)
yield item
if (
msg["msg_type"] == "status"
and msg["content"]["execution_state"] == "idle"
and parent.get("msg_id") == stop_msg_id
):
return

def create_subshell(kc) -> str | None:
"""Ask for a subshell, as JupyterLab does for kernel comms. None on ipykernel 6."""
msg = kc.session.msg("create_subshell_request", {})
kc.control_channel.send(msg)
deadline = time.time() + 10
while time.time() < deadline:
try:
reply = kc.get_control_msg(timeout=2)
except queue.Empty:
return None
if reply["parent_header"].get("msg_id") == msg["header"]["msg_id"]:
return reply["content"].get("subshell_id")
return None

def main() -> int:
print(f"ipykernel {ipykernel.version}, python {sys.version.split()[0]}")
km, kc = start_new_kernel(kernel_name="python3")
try:
setup_id = kc.execute(SETUP)
comm_id = None
for mtype, _, _, content in drain(kc, setup_id):
if mtype == "stream" and content["text"].startswith("COMM_ID"):
comm_id = content["text"].split()[1]
assert comm_id, "no comm id"

    subshell_id = create_subshell(kc)
    print(f"subshell: {subshell_id or 'not supported (ipykernel 6)'}")

    cell_id = kc.execute(CELL)
    time.sleep(1.0)  # let the cell get going
    comm_msg = kc.session.msg(
        "comm_msg", {"comm_id": comm_id, "data": {"method": "custom", "content": {}}}
    )
    if subshell_id:
        comm_msg["header"]["subshell_id"] = subshell_id
    kc.shell_channel.send(comm_msg)
    comm_msg_id = comm_msg["header"]["msg_id"]

    trace: list = []
    display_parent = None
    for mtype, ptype, pid, _ in drain(kc, cell_id, log=trace):
        if mtype == "display_data":
            display_parent = (ptype, pid)

    print("\niopub during the cell")
    for mtype, ptype, pid, content in trace:
        who = "CELL" if pid == cell_id else "COMM" if pid == comm_msg_id else "-"
        note = ""
        if mtype == "status":
            note = content["execution_state"]
        elif mtype == "display_data":
            note = str(list(content.get("data", {}).keys()))
        print(f"  {mtype:14s} parent={ptype or '-':22s} [{who}]  {note}")

    print("\nhook invocations (elapsed, thread)")
    report_id = kc.execute(REPORT)
    for mtype, _, _, content in drain(kc, report_id):
        if mtype == "stream":
            for line in content["text"].splitlines():
                print(f"  {line}")

    print(f"\n  execute_request      {cell_id}")
    print(f"  comm_msg             {comm_msg_id}")
    print(f"  display_data parent  {display_parent}\n")

    if display_parent is None:
        print("INCONCLUSIVE: no display_data observed")
        return 2
    if display_parent[1] == cell_id:
        print("OK   display_data is parented to the cell's execute_request")
        return 0
    print("BUG  display_data is parented to the comm_msg, not to the cell.")
    print("     A frontend has no cell for that parent, so the output is dropped.")
    return 1
finally:
    kc.stop_channels()
    km.shutdown_kernel()

if name == "main":
raise SystemExit(main())

ipykernel 7.3.0 — bug ipykernel 7.3.0, python 3.10.11 subshell: 2508bb28-9676-4575-b4a4-791d5104fcc3 iopub during the cell status parent=execute_request [CELL] busy execute_input parent=execute_request [CELL] status parent=comm_msg [COMM] busy display_data parent=comm_msg [COMM] ['text/plain'] <-- orphaned status parent=comm_msg [COMM] idle status parent=execute_request [CELL] idle hook invocations (elapsed, thread) post_execute 0.0 MainThread post_execute 1.08 subshell-2508bb28-9676-4575-b4a4-791d5104fcc3 <-- fires for the comm_msg post_execute 3.08 MainThread <-- cell own; queue already drained BUG display_data is parented to the comm_msg, not to the cell. ipykernel 6.31.0 — correct ipykernel 6.31.0, python 3.10.11 subshell: not supported (ipykernel 6) iopub during the cell status parent=execute_request [CELL] busy execute_input parent=execute_request [CELL] display_data parent=execute_request [CELL] ['text/plain'] status parent=execute_request [CELL] idle hook invocations (elapsed, thread) post_execute 0.0 MainThread post_execute 5.05 MainThread post_execute 5.06 MainThread OK display_data is parented to the cell execute_request Same result on 6.30.1. On 7.3.0 without a subshell the comm_msg is queued until the cell completes and the output is parented correctly — so the concurrency introduced by subshells is the trigger, not comm handling as such.

Question
Is firing pre_execute / post_execute for comm message handling intended?

A comm message is not a cell execution, and hooks registered on those events have no way to tell the two apart. If the events must fire, it would help to either scope them (a distinct event for comm handling) or give handlers a way to detect that they are running outside an execute_request, so libraries like ipympl can defer their flush rather than publishing into a void.

Environment
Windows 11, Python 3.10.11
ipykernel 7.3.0 (bug) vs 6.31.0 / 6.30.1 (correct), all else unchanged
observed in the wild with JupyterLab 4.6.3, ipympl 0.10.0, ipywidgets 8.1.7, matplotlib 3.9.4, commsOverSubshells left at its default perCommTarget

主要语言
Python
星标
734
派生
411
平均合并
1 天 2 小时
30 天内合并 PR
9

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

ipython/ipykernel 的其他 Issue

查看 ipython/ipykernel 的全部 Issue

相似的 Issue

更多 Python Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。