Skip to content

Commit b3a9169

Browse files
committed
Fixed a number of async issues and enhanced process cleanup
1 parent 9c941f9 commit b3a9169

File tree

4 files changed

+140
-39
lines changed

4 files changed

+140
-39
lines changed

nbclient/client.py

Lines changed: 96 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# For python 3.5 compatibility we import asynccontextmanager from async_generator instead of
66
# contextlib, and we `await yield_()` instead of just `yield`
77
from async_generator import asynccontextmanager, async_generator, yield_
8+
from contextlib import contextmanager
89

910
from time import monotonic
1011
from queue import Empty
@@ -15,8 +16,14 @@
1516

1617
from nbformat.v4 import output_from_msg
1718

18-
from .exceptions import CellTimeoutError, DeadKernelError, CellExecutionComplete, CellExecutionError
19-
from .util import run_sync
19+
from .exceptions import (
20+
CellControlSignal,
21+
CellTimeoutError,
22+
DeadKernelError,
23+
CellExecutionComplete,
24+
CellExecutionError
25+
)
26+
from .util import run_sync, await_or_block
2027

2128

2229
def timestamp():
@@ -324,7 +331,28 @@ def start_kernel_manager(self):
324331
self.km.client_class = 'jupyter_client.asynchronous.AsyncKernelClient'
325332
return self.km
326333

327-
async def start_new_kernel_client(self, **kwargs):
334+
async def _async_cleanup_kernel(self):
335+
try:
336+
# Send a polite shutdown request
337+
await await_or_block(self.kc.shutdown)
338+
try:
339+
# Queue the manager to kill the process, sometimes the built-in and above
340+
# shutdowns have not been successful or called yet, so give a direct kill
341+
# call here and recover gracefully if it's already dead.
342+
await await_or_block(self.km.shutdown_kernel, now=True)
343+
except RuntimeError as e:
344+
# The error isn't specialized, so we have to check the message
345+
if 'No kernel is running!' not in str(e):
346+
raise
347+
finally:
348+
# Remove any state left over even if we failed to stop the kernel
349+
await await_or_block(self.km.cleanup)
350+
await await_or_block(self.kc.stop_channels)
351+
self.kc = None
352+
353+
_cleanup_kernel = run_sync(_async_cleanup_kernel)
354+
355+
async def async_start_new_kernel_client(self, **kwargs):
328356
"""Creates a new kernel client.
329357
330358
Parameters
@@ -346,22 +374,44 @@ async def start_new_kernel_client(self, **kwargs):
346374
if self.km.ipykernel and self.ipython_hist_file:
347375
self.extra_arguments += ['--HistoryManager.hist_file={}'.format(self.ipython_hist_file)]
348376

349-
await self.km.start_kernel(extra_arguments=self.extra_arguments, **kwargs)
377+
await await_or_block(self.km.start_kernel, extra_arguments=self.extra_arguments, **kwargs)
350378

351379
self.kc = self.km.client()
352-
self.kc.start_channels()
380+
await await_or_block(self.kc.start_channels)
353381
try:
354-
await self.kc.wait_for_ready(timeout=self.startup_timeout)
382+
await await_or_block(self.kc.wait_for_ready, timeout=self.startup_timeout)
355383
except RuntimeError:
356-
self.kc.stop_channels()
357-
await self.km.shutdown_kernel()
384+
await self._async_cleanup_kernel()
358385
raise
359386
self.kc.allow_stdin = False
360387
return self.kc
361388

389+
start_new_kernel_client = run_sync(async_start_new_kernel_client)
390+
391+
@contextmanager
392+
def setup_kernel(self, **kwargs):
393+
"""
394+
Context manager for setting up the kernel to execute a notebook.
395+
396+
The assigns the Kernel Manager (`self.km`) if missing and Kernel Client(`self.kc`).
397+
398+
When control returns from the yield it stops the client's zmq channels, and shuts
399+
down the kernel.
400+
"""
401+
# Can't use run_until_complete on an asynccontextmanager function :(
402+
if self.km is None:
403+
self.start_kernel_manager()
404+
405+
if not self.km.has_kernel:
406+
self.start_new_kernel_client(**kwargs)
407+
try:
408+
yield
409+
finally:
410+
self._cleanup_kernel()
411+
362412
@asynccontextmanager
363413
@async_generator # needed for python 3.5 compatibility
364-
async def setup_kernel(self, **kwargs):
414+
async def async_setup_kernel(self, **kwargs):
365415
"""
366416
Context manager for setting up the kernel to execute a notebook.
367417
@@ -374,12 +424,11 @@ async def setup_kernel(self, **kwargs):
374424
self.start_kernel_manager()
375425

376426
if not self.km.has_kernel:
377-
await self.start_new_kernel_client(**kwargs)
427+
await self.async_start_new_kernel_client(**kwargs)
378428
try:
379429
await yield_(None) # would just yield in python >3.5
380430
finally:
381-
self.kc.stop_channels()
382-
self.kc = None
431+
await self._async_cleanup_kernel()
383432

384433
async def async_execute(self, **kwargs):
385434
"""
@@ -392,15 +441,16 @@ async def async_execute(self, **kwargs):
392441
"""
393442
self.reset_execution_trackers()
394443

395-
async with self.setup_kernel(**kwargs):
444+
async with self.async_setup_kernel(**kwargs):
396445
self.log.info("Executing notebook with kernel: %s" % self.kernel_name)
397446
for index, cell in enumerate(self.nb.cells):
398447
# Ignore `'execution_count' in content` as it's always 1
399448
# when store_history is False
400449
await self.async_execute_cell(
401450
cell, index, execution_count=self.code_cells_executed + 1
402451
)
403-
info_msg = await self._wait_for_reply(self.kc.kernel_info())
452+
msg_id = await await_or_block(self.kc.kernel_info)
453+
info_msg = await self.async_wait_for_reply(msg_id)
404454
self.nb.metadata['language_info'] = info_msg['content']['language_info']
405455
self.set_widgets_metadata()
406456

@@ -450,12 +500,12 @@ def _update_display_id(self, display_id, msg):
450500
outputs[output_idx]['data'] = out['data']
451501
outputs[output_idx]['metadata'] = out['metadata']
452502

453-
async def _poll_for_reply(self, msg_id, cell, timeout, task_poll_output_msg):
503+
async def _async_poll_for_reply(self, msg_id, cell, timeout, task_poll_output_msg):
454504
if timeout is not None:
455505
deadline = monotonic() + timeout
456506
while True:
457507
try:
458-
msg = await self.kc.shell_channel.get_msg(timeout=timeout)
508+
msg = await await_or_block(self.kc.shell_channel.get_msg, timeout=timeout)
459509
if msg['parent_header'].get('msg_id') == msg_id:
460510
if self.record_timing:
461511
cell['metadata']['execution']['shell.execute_reply'] = timestamp()
@@ -474,12 +524,12 @@ async def _poll_for_reply(self, msg_id, cell, timeout, task_poll_output_msg):
474524
timeout = max(0, deadline - monotonic())
475525
except Empty:
476526
# received no message, check if kernel is still alive
477-
await self._check_alive()
478-
await self._handle_timeout(timeout, cell)
527+
await self._async_check_alive()
528+
await self._async_handle_timeout(timeout, cell)
479529

480-
async def _poll_output_msg(self, parent_msg_id, cell, cell_index):
530+
async def _async_poll_output_msg(self, parent_msg_id, cell, cell_index):
481531
while True:
482-
msg = await self.kc.iopub_channel.get_msg(timeout=None)
532+
msg = await await_or_block(self.kc.iopub_channel.get_msg, timeout=None)
483533
if msg['parent_header'].get('msg_id') == parent_msg_id:
484534
try:
485535
# Will raise CellExecutionComplete when completed
@@ -498,39 +548,42 @@ def _get_timeout(self, cell):
498548

499549
return timeout
500550

501-
async def _handle_timeout(self, timeout, cell=None):
551+
async def _async_handle_timeout(self, timeout, cell=None):
502552
self.log.error("Timeout waiting for execute reply (%is)." % timeout)
503553
if self.interrupt_on_timeout:
504554
self.log.error("Interrupting kernel")
505-
await self.km.interrupt_kernel()
555+
await await_or_block(self.km.interrupt_kernel)
506556
else:
507557
raise CellTimeoutError.error_from_timeout_and_cell(
508558
"Cell execution timed out", timeout, cell
509559
)
510560

511-
async def _check_alive(self):
512-
if not await self.kc.is_alive():
561+
async def _async_check_alive(self):
562+
if not await await_or_block(self.kc.is_alive):
513563
self.log.error("Kernel died while waiting for execute reply.")
514564
raise DeadKernelError("Kernel died")
515565

516-
async def _wait_for_reply(self, msg_id, cell=None):
566+
async def async_wait_for_reply(self, msg_id, cell=None):
517567
# wait for finish, with timeout
518568
timeout = self._get_timeout(cell)
519569
cummulative_time = 0
520-
self.shell_timeout_interval = 5
521570
while True:
522571
try:
523-
msg = await self.kc.shell_channel.get_msg(timeout=self.shell_timeout_interval)
572+
msg = await await_or_block(self.kc.shell_channel.get_msg, timeout=self.shell_timeout_interval)
524573
except Empty:
525-
await self._check_alive()
574+
await self._async_check_alive()
526575
cummulative_time += self.shell_timeout_interval
527576
if timeout and cummulative_time > timeout:
528-
await self._handle_timeout(timeout, cell)
577+
await self._async_async_handle_timeout(timeout, cell)
529578
break
530579
else:
531580
if msg['parent_header'].get('msg_id') == msg_id:
532581
return msg
533582

583+
wait_for_reply = run_sync(async_wait_for_reply)
584+
# Backwards compatability naming for papermill
585+
_wait_for_reply = wait_for_reply
586+
534587
def _timeout_with_deadline(self, timeout, deadline):
535588
if deadline is not None and deadline - monotonic() < timeout:
536589
timeout = deadline - monotonic()
@@ -596,7 +649,7 @@ async def async_execute_cell(self, cell, cell_index, execution_count=None, store
596649
cell['metadata']['execution'] = {}
597650

598651
self.log.debug("Executing cell:\n%s", cell.source)
599-
parent_msg_id = self.kc.execute(
652+
parent_msg_id = await await_or_block(self.kc.execute,
600653
cell.source, store_history=store_history, stop_on_error=not self.allow_errors
601654
)
602655
# We launched a code cell to execute
@@ -607,11 +660,20 @@ async def async_execute_cell(self, cell, cell_index, execution_count=None, store
607660
self.clear_before_next_output = False
608661

609662
task_poll_output_msg = asyncio.ensure_future(
610-
self._poll_output_msg(parent_msg_id, cell, cell_index)
611-
)
612-
exec_reply = await self._poll_for_reply(
613-
parent_msg_id, cell, exec_timeout, task_poll_output_msg
663+
self._async_poll_output_msg(parent_msg_id, cell, cell_index)
614664
)
665+
try:
666+
exec_reply = await self._async_poll_for_reply(
667+
parent_msg_id, cell, exec_timeout, task_poll_output_msg
668+
)
669+
except Exception as e:
670+
# Best effort to cancel request if it hasn't been resolved
671+
try:
672+
# Check if the task_poll_output is doing the raising for us
673+
if not isinstance(e, CellControlSignal):
674+
task_poll_output_msg.cancel()
675+
finally:
676+
raise
615677

616678
if execution_count:
617679
cell['execution_count'] = execution_count

nbclient/exceptions.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
class CellTimeoutError(TimeoutError):
1+
class CellControlSignal(Exception):
2+
"""
3+
A custom exception used to indicate that the exception is used for cell
4+
control actions (not the best model, but it's needed to cover existing
5+
behavior without major refactors).
6+
"""
7+
pass
8+
9+
10+
class CellTimeoutError(TimeoutError, CellControlSignal):
211
"""
312
A custom exception to capture when a cell has timed out during execution.
413
"""
@@ -21,7 +30,7 @@ class DeadKernelError(RuntimeError):
2130
pass
2231

2332

24-
class CellExecutionComplete(Exception):
33+
class CellExecutionComplete(CellControlSignal):
2534
"""
2635
Used as a control signal for cell execution across execute_cell and
2736
process_message function calls. Raised when all execution requests
@@ -32,7 +41,7 @@ class CellExecutionComplete(Exception):
3241
pass
3342

3443

35-
class CellExecutionError(Exception):
44+
class CellExecutionError(CellControlSignal):
3645
"""
3746
Custom exception to propagate exceptions that are raised during
3847
notebook execution to the caller. This is mostly useful when

nbclient/tests/test_client.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
IPY_MAJOR = IPython.version_info[0]
3737

3838

39+
class AsyncMock(Mock):
40+
pass
41+
42+
3943
def make_async(mock_value):
4044
async def _():
4145
return mock_value
@@ -116,7 +120,7 @@ def prepare_cell_mocks(*messages, reply_msg=None):
116120
def shell_channel_message_mock():
117121
# Return the message generator for
118122
# self.kc.shell_channel.get_msg => {'parent_header': {'msg_id': parent_id}}
119-
return MagicMock(
123+
return AsyncMock(
120124
return_value=make_async(NBClientTestsBase.merge_dicts(
121125
{
122126
'parent_header': {'msg_id': parent_id},
@@ -129,7 +133,7 @@ def shell_channel_message_mock():
129133
def iopub_messages_mock():
130134
# Return the message generator for
131135
# self.kc.iopub_channel.get_msg => messages[i]
132-
return Mock(
136+
return AsyncMock(
133137
side_effect=[
134138
# Default the parent_header so mocks don't need to include this
135139
make_async(
@@ -386,6 +390,16 @@ def get_time_from_str(s):
386390
assert status_idle - cell_end < delta
387391

388392

393+
def test_synchronous_setup_kernel():
394+
nb = nbformat.v4.new_notebook()
395+
executor = NotebookClient(nb)
396+
with executor.setup_kernel():
397+
# Prove it initalized client
398+
assert executor.kc is not None
399+
# Prove it removed the client (and hopefully cleaned up)
400+
assert executor.kc is None
401+
402+
389403
class TestExecute(NBClientTestsBase):
390404
"""Contains test functions for execute.py"""
391405

nbclient/util.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import asyncio
77

8+
from typing import Coroutine
9+
810

911
def run_sync(coro):
1012
"""Runs a coroutine and blocks until it has executed.
@@ -45,3 +47,17 @@ def wrapped(self, *args, **kwargs):
4547
return result
4648
wrapped.__doc__ = coro.__doc__
4749
return wrapped
50+
51+
52+
async def await_or_block(func, *args, **kwargs):
53+
"""Awaits the function if it's an asynchronous function. Otherwise block
54+
on execution.
55+
"""
56+
if asyncio.iscoroutinefunction(func):
57+
return await func(*args, **kwargs)
58+
else:
59+
result = func(*args, **kwargs)
60+
# Mocks mask that the function is a coroutine :/
61+
if isinstance(result, Coroutine):
62+
return await result
63+
return result

0 commit comments

Comments
 (0)