Skip to content

Commit 0273cec

Browse files
Allow async cell to process Comm messages (#1565)
1 parent 0f613fc commit 0273cec

4 files changed

Lines changed: 166 additions & 14 deletions

File tree

‎ipykernel/comm/comm.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Distributed under the terms of the Modified BSD License.
55

66
import uuid
7+
from threading import Lock
78
from typing import Optional
89
from warnings import warn
910

@@ -15,12 +16,32 @@
1516
from ipykernel.kernelbase import Kernel
1617

1718

19+
def _request_id(data):
20+
if isinstance(data, dict):
21+
content = data.get("content")
22+
if isinstance(content, dict) and isinstance(content.get("id"), str):
23+
return content["id"]
24+
return None
25+
26+
1827
# this is the class that will be created if we do comm.create_comm
1928
class BaseComm(comm.base_comm.BaseComm):
2029
"""The base class for comms."""
2130

2231
kernel: Optional["Kernel"] = None
2332

33+
def __init__(self, *args, **kwargs):
34+
self._reply_subshell_lock = Lock()
35+
self._reply_subshell_ids = {}
36+
super().__init__(*args, **kwargs)
37+
38+
def _reply_subshell_for(self, data, default):
39+
request_id = _request_id(data)
40+
with self._reply_subshell_lock:
41+
if request_id is not None and request_id in self._reply_subshell_ids:
42+
return self._reply_subshell_ids.pop(request_id)
43+
return getattr(self, "_reply_subshell_id", default)
44+
2445
def publish_msg(self, msg_type, data=None, metadata=None, buffers=None, **keys):
2546
"""Helper for sending a comm message on IOPub"""
2647
if not Kernel.initialized():
@@ -34,12 +55,22 @@ def publish_msg(self, msg_type, data=None, metadata=None, buffers=None, **keys):
3455
self.kernel = Kernel.instance()
3556

3657
assert self.kernel.session is not None
58+
parent = self.kernel.get_parent()
59+
if parent.get("header"):
60+
# A comm can be used from a different subshell than the one that
61+
# created it. Route the frontend reply to the loop that sent it.
62+
subshell_id = parent["header"].get("subshell_id")
63+
request_id = _request_id(data)
64+
with self._reply_subshell_lock:
65+
self._reply_subshell_id = subshell_id
66+
if request_id is not None:
67+
self._reply_subshell_ids[request_id] = subshell_id
3768
self.kernel.session.send(
3869
self.kernel.iopub_socket,
3970
msg_type,
4071
content,
4172
metadata=json_clean(metadata),
42-
parent=self.kernel.get_parent(),
73+
parent=parent,
4374
ident=self.topic,
4475
buffers=buffers,
4576
)

‎ipykernel/kernelbase.py‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,9 @@ def should_handle(self, stream, msg, idents):
419419
"""
420420
return True
421421

422-
async def dispatch_shell(self, msg, /, subshell_id: str | None = None):
422+
async def dispatch_shell(
423+
self, msg, /, subshell_id: str | None = None, *, concurrent: bool = False
424+
):
423425
"""dispatch shell requests"""
424426
if len(msg) == 1 and msg[0].buffer == b"stop aborting":
425427
# Dummy "stop aborting" message to stop aborting execute requests on this subshell.
@@ -450,10 +452,12 @@ async def dispatch_shell(self, msg, /, subshell_id: str | None = None):
450452

451453
# Set the parent message for side effects.
452454
self.set_parent(idents, msg, channel="shell")
453-
self._publish_status("busy", "shell")
455+
if not concurrent:
456+
self._publish_status("busy", "shell")
454457

455458
msg_type = msg["header"]["msg_type"]
456-
assert msg["header"].get("subshell_id") == subshell_id
459+
if msg_type not in {"comm_msg", "comm_close"}:
460+
assert msg["header"].get("subshell_id") == subshell_id
457461

458462
if self._supports_kernel_subshells:
459463
stream = self.shell_channel_thread.manager.get_subshell_to_shell_channel_socket(
@@ -483,7 +487,8 @@ async def dispatch_shell(self, msg, /, subshell_id: str | None = None):
483487
if inspect.isawaitable(should_handle):
484488
should_handle = await should_handle
485489
if not should_handle:
486-
self._publish_status_and_flush("idle", "shell", stream)
490+
if not concurrent:
491+
self._publish_status_and_flush("idle", "shell", stream)
487492
self.log.debug("Not handling %s:%s", msg_type, msg["header"].get("msg_id"))
488493
return
489494

@@ -492,10 +497,11 @@ async def dispatch_shell(self, msg, /, subshell_id: str | None = None):
492497
self.log.warning("Unknown message type: %r", msg_type)
493498
else:
494499
self.log.debug("%s: %s", msg_type, msg)
495-
try:
496-
self.pre_handler_hook()
497-
except Exception:
498-
self.log.debug("Unable to signal in pre_handler_hook:", exc_info=True)
500+
if not concurrent:
501+
try:
502+
self.pre_handler_hook()
503+
except Exception:
504+
self.log.debug("Unable to signal in pre_handler_hook:", exc_info=True)
499505
try:
500506
result = handler(stream, idents, msg)
501507
if inspect.isawaitable(result):
@@ -506,16 +512,18 @@ async def dispatch_shell(self, msg, /, subshell_id: str | None = None):
506512
# Ctrl-c shouldn't crash the kernel here.
507513
self.log.error("KeyboardInterrupt caught in kernel.")
508514
finally:
509-
try:
510-
self.post_handler_hook()
511-
except Exception:
512-
self.log.debug("Unable to signal in post_handler_hook:", exc_info=True)
515+
if not concurrent:
516+
try:
517+
self.post_handler_hook()
518+
except Exception:
519+
self.log.debug("Unable to signal in post_handler_hook:", exc_info=True)
513520

514521
if sys.stdout is not None:
515522
sys.stdout.flush()
516523
if sys.stderr is not None:
517524
sys.stderr.flush()
518-
self._publish_status_and_flush("idle", "shell", stream)
525+
if not concurrent:
526+
self._publish_status_and_flush("idle", "shell", stream)
519527

520528
def pre_handler_hook(self):
521529
"""Hook to execute before calling message handler"""
@@ -600,6 +608,16 @@ async def shell_channel_thread_main(self, msg):
600608
msg3 = self.session.deserialize(msg2, content=False, copy=False)
601609
subshell_id = msg3["header"].get("subshell_id")
602610

611+
if msg3["header"]["msg_type"] in {"comm_msg", "comm_close"} and hasattr(
612+
self, "comm_manager"
613+
):
614+
content = self.session.unpack(msg3["content"])
615+
comm = self.comm_manager.get_comm(content.get("comm_id"))
616+
if comm is not None:
617+
route = getattr(comm, "_reply_subshell_for", None)
618+
if route is not None:
619+
subshell_id = route(content.get("data"), subshell_id)
620+
603621
# Find inproc pair socket to use to send message to correct subshell.
604622
subshell_manager = self.shell_channel_thread.manager
605623
try:
@@ -635,6 +653,26 @@ async def shell_main(self, subshell_id: str | None, msg):
635653
# async cells at the same time which would be a nice feature to have but is an API
636654
# change.
637655
assert asyncio_lock is not None
656+
if asyncio_lock.locked() and self.session is not None:
657+
try:
658+
_, frames = self.session.feed_identities(msg, copy=False)
659+
header = self.session.deserialize(frames, content=False, copy=False)["header"]
660+
except Exception:
661+
header = {}
662+
if header.get("msg_type") in {"comm_open", "comm_msg", "comm_close"}:
663+
# A running async cell may be waiting for a widget reply on this
664+
# channel. Dispatch comms without waiting for the cell's lock.
665+
shell_parent = self.get_parent("shell")
666+
shell_ident = self._get_shell_context_var(self._shell_parent_ident)
667+
try:
668+
comm_task = asyncio.create_task(
669+
self.dispatch_shell(msg, subshell_id=subshell_id, concurrent=True),
670+
context=copy_context(),
671+
)
672+
await comm_task
673+
finally:
674+
self.set_parent(shell_ident, shell_parent, channel="shell")
675+
return
638676
async with asyncio_lock:
639677
await self.dispatch_shell(msg, subshell_id=subshell_id)
640678

‎tests/test_kernel.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,35 @@ def test_simple_print():
5858
_check_master(kc, expected=True)
5959

6060

61+
def test_async_cell_waiting_for_comm_reply():
62+
with new_kernel() as kc:
63+
msg_id = kc.execute(
64+
"""import asyncio, comm
65+
reply = asyncio.get_running_loop().create_future()
66+
widget = comm.create_comm(target_name='comm-reply-test')
67+
widget.on_msg(lambda msg: reply.set_result(msg['content']['data']['value']))
68+
result = await asyncio.wait_for(reply, 5)
69+
assert result == 42
70+
"""
71+
)
72+
while True:
73+
msg = kc.get_iopub_msg(timeout=10)
74+
if msg["msg_type"] == "error" and msg["parent_header"].get("msg_id") == msg_id:
75+
raise AssertionError("\n".join(msg["content"]["traceback"]))
76+
if msg["msg_type"] == "comm_open" and msg["parent_header"].get("msg_id") == msg_id:
77+
comm_id = msg["content"]["comm_id"]
78+
break
79+
80+
next_msg_id = kc.execute("assert result == 42")
81+
kc.shell_channel.send(
82+
kc.session.msg("comm_msg", {"comm_id": comm_id, "data": {"value": 42}})
83+
)
84+
reply_msg = get_reply(kc, msg_id, timeout=10)
85+
assert reply_msg["content"]["status"] == "ok", reply_msg["content"]
86+
next_reply = get_reply(kc, next_msg_id, timeout=10)
87+
assert next_reply["content"]["status"] == "ok", next_reply["content"]
88+
89+
6190
@pytest.mark.parametrize(
6291
("code", "expect_error_status"),
6392
[

‎tests/test_subshells.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,60 @@ def test_thread_ids():
133133
delete_subshell_helper(kc, subshell_id)
134134

135135

136+
def test_comm_reply_follows_requesting_subshell():
137+
with new_kernel() as kc:
138+
subshell_id = create_subshell_helper(kc)["subshell_id"]
139+
comm_id = execute_request_subshell_id(
140+
kc,
141+
"import comm; widget = comm.create_comm(target_name='comm-thread-test'); print(widget.comm_id)",
142+
subshell_id,
143+
)
144+
145+
msg = execute_request(
146+
kc,
147+
"""import asyncio, threading
148+
reply = asyncio.get_running_loop().create_future()
149+
request_thread = threading.get_ident()
150+
def on_reply(message):
151+
global callback_thread
152+
callback_thread = threading.get_ident()
153+
reply.set_result(message['content']['data']['content']['value'])
154+
widget.on_msg(on_reply)
155+
widget.send({'method': 'custom', 'content': {'id': 'request-1', 'operation': 'get'}})
156+
assert await asyncio.wait_for(reply, 2) == 42
157+
assert callback_thread == request_thread
158+
""",
159+
None,
160+
)
161+
while True:
162+
outgoing = kc.get_iopub_msg(timeout=10)
163+
if (
164+
outgoing["msg_type"] == "error"
165+
and outgoing["parent_header"].get("msg_id") == msg["header"]["msg_id"]
166+
):
167+
raise AssertionError("\n".join(outgoing["content"]["traceback"]))
168+
if (
169+
outgoing["msg_type"] == "comm_msg"
170+
and outgoing["parent_header"].get("msg_id") == msg["header"]["msg_id"]
171+
):
172+
break
173+
assert outgoing["content"]["comm_id"] == comm_id
174+
175+
response = kc.session.msg(
176+
"comm_msg",
177+
{
178+
"comm_id": comm_id,
179+
"data": {"method": "custom", "content": {"id": "request-1", "value": 42}},
180+
},
181+
)
182+
response["header"]["subshell_id"] = subshell_id
183+
kc.shell_channel.send(response)
184+
reply_msg = get_reply(kc, msg["header"]["msg_id"], timeout=5)
185+
assert reply_msg["content"]["status"] == "ok", reply_msg["content"]
186+
wait_for_idle(kc, msg["header"]["msg_id"])
187+
delete_subshell_helper(kc, subshell_id)
188+
189+
136190
@pytest.mark.parametrize("are_subshells", [(False, True), (True, False), (True, True)])
137191
@pytest.mark.parametrize("overlap", [True, False])
138192
def test_run_concurrently_sequence(are_subshells, overlap, request):

0 commit comments

Comments
 (0)