when cell contains ipywidget , execute cell will sometimes blocks
package version
nbformat 5.10.4
nbclient 0.10.0
here is the code which can reproduce the bug
import nbformat
from nbclient import NotebookClient
def execute_ipynb(ipynb_file):
with open(ipynb_file, 'r', encoding='utf-8') as f:
nb_node = nbformat.read(f, as_version=4)
client = NotebookClient(nb_node)
try:
with client.setup_kernel():
info_msg = client.wait_for_reply(client.kc.kernel_info())
for index, cell in enumerate(nb_node.cells):
client.execute_cell(cell, index)
except Exception as e:
print("An error occurred during notebook execution:{}", e)
ipynb_file = 'test.ipynb'
execute_ipynb(ipynb_file)
content of test.ipynb
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "655816ee-ac2e-44d0-8b1f-4d2686ef0942",
"metadata": {},
"outputs": [],
"source": [
"import ipywidgets as widgets\n",
"import matplotlib.pyplot as plt\n",
"import numpy as np\n",
"import IPython\n",
"\n",
"%matplotlib widget\n",
"\n",
"sr = 44100\n",
"duration = 1.0\n",
"\n",
"with plt.ioff():\n",
" fig = plt.figure(figsize=(14, 7))\n",
"\n",
"@widgets.interact(\n",
" freq=widgets.FloatLogSlider(base=np.e, min=np.log(20), max=np.log(20000), step=0.001, value=440),\n",
" autoplay=False,\n",
")\n",
"def audio(freq: float, autoplay: bool):\n",
" print(f\"Frequency: {freq}\")\n",
" \n",
" length = int(sr * duration)\n",
" t = np.linspace(0, duration, length)\n",
" y = 0.1 * np.sin(2 * np.pi * freq * t)\n",
"\n",
" fig.clear() \n",
" ax = fig.add_subplot(1, 1, 1)\n",
" ax.plot(y)\n",
"\n",
" fig.tight_layout()\n",
" IPython.display.display(\n",
" fig.canvas,\n",
" IPython.display.Audio(y, rate=sr, autoplay=autoplay, normalize=False),\n",
" )\n",
"import time\n",
"# time.sleep(0.1)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.16"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
execute above python code will sometimes blocks
Reason: If the kernel does not pass in the timeout parameter when calling the poll api, and sends a send message to the shell channel at the same time, it will always be blocked.
i have raise an merge request to fix the issue
here is my fix merge request https://github.com/jupyter/nbclient/pull/311
Looks like I hit this problem while debugging https://github.com/voila-dashboards/voila/issues/1522. I don't understand why this isn't working, but after adding a ton of logging both for nbclient and jupyter_client packages, I see that in my case AsyncKernelClient is used, that one uses AsyncZMQSocketChannel for the shell_channel:
https://github.com/jupyter/jupyter_client/blob/1f36fbf4d0e2e067129552d9e69212f7bf7c0624/jupyter_client/asynchronous/client.py#L54
I have modified this method https://github.com/jupyter/jupyter_client/blob/1f36fbf4d0e2e067129552d9e69212f7bf7c0624/jupyter_client/channels.py#L302C1-L313C24 to look like this:
async def get_msg( # type:ignore[override]
self, timeout: t.Optional[float] = None
) -> t.Dict[str, t.Any]:
"""Gets a message if there is one that is ready."""
assert self.socket is not None
timeout_ms = None if timeout is None else int(timeout * 1000) # seconds to ms
ready = await self.socket.poll(timeout_ms)
if ready:
res = await self._recv()
print(f"Got result from call #{timeout}, {res=}")
return res
else:
raise Empty
notice that I am printing timeout in the log, but since this is an argument coming in - it can be used as an id to make sure I can differentiate between calls and track them across the board, e.g. see global_counter_value_get below.
Another piece of the puzzle is from nbclient, I have modified this method https://github.com/jupyter/nbclient/blob/2650b217c4c9965110ceb4de193092d7eb36c2bb/nbclient/client.py#L761-L806
like this:
import threading
global_counter = 10000
counter_lock = threading.Lock()
def global_counter_value_get():
global global_counter
with counter_lock:
global_counter += 1
return global_counter
async def _async_poll_for_reply(
##################### not changed above
while True:
try:
if error_on_timeout_execute_reply:
msg = error_on_timeout_execute_reply # type:ignore[unreachable]
msg["parent_header"] = {"msg_id": msg_id}
else:
id_value = global_counter_value_get()
print(f"About to call into get_msg #{id_value}")
msg = await ensure_async(self.kc.shell_channel.get_msg(timeout=id_value ))
print(f"Got response for #{id_value}; {msg=}")
if msg["parent_header"].get("msg_id") == msg_id:
####################### not changed below
And after making the changes above I can see that jupyter_client seems to return a value just fine, e.g. the log message right above the return is printed out; but that response never arrives to _async_poll_for_reply, e.g. it's like the evenloop is blocked and await never works.
I have switchinged from using AsyncMappingKernelManager to a blocking one and it solved the problem, which is one more reason to suspect that there is something off with the event loop here. This is the config I am using now: --NotebookApp.kernel_manager_class=jupyter_server.services.kernels.kernelmanager.MappingKernelManager