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
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 48/100
Research direction
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.
Written by the indexing model from the issue text.
Description
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())
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
- Dominant language
- Python
- Stars
- 734
- Forks
- 411
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 9
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from ipython/ipykernel
-
Difficulty 1/5 Under an hour Newbie friendliness 72/100
-
Difficulty 4/5 3-5 days Newbie friendliness 68/100
-
ipython/ipykernel#1550 · 1 comment · 1 reaction · 1 assignee ·
-
Difficulty 3/5 1-2 days Newbie friendliness 66/100
-
Difficulty 4/5 3-5 days Newbie friendliness 35/100
All issues in ipython/ipykernel
Similar issues
-
Add: hunch Open
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
AbdelStark/awesome-typesafe#104 ·
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
DiamondLightSource/dodal#2211 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
openml/openml-python#1749 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
sipyourdrink-ltd/bernstein#6191 ·