Skip to content

Commit fa597d9

Browse files
authored
Merge pull request #772 from blink1073/fix-shutdown-behavior
2 parents 5759512 + 523ba88 commit fa597d9

File tree

9 files changed

+73
-56
lines changed

9 files changed

+73
-56
lines changed

.github/workflows/main.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ jobs:
7171
- name: Run the tests
7272
if: ${{ !startsWith(matrix.python-version, 'pypy') && !startsWith(matrix.os, 'windows') }}
7373
run: |
74-
args="-vv --cov jupyter_client --cov-branch --cov-report term-missing:skip-covered --cov-fail-under 70"
75-
python -m pytest $args || python -m pytest $args --lf
74+
args="-vv --cov jupyter_client --cov-branch --cov-report term-missing:skip-covered"
75+
python -m pytest $args --cov-fail-under 70 || python -m pytest $args --lf
7676
- name: Run the tests on pypy and windows
7777
if: ${{ startsWith(matrix.python-version, 'pypy') || startsWith(matrix.os, 'windows') }}
7878
run: |
@@ -150,3 +150,5 @@ jobs:
150150
steps:
151151
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
152152
- uses: jupyterlab/maintainer-tools/.github/actions/test-sdist@v1
153+
with:
154+
test_command: pytest --vv || pytest -vv --lf

jupyter_client/channels.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def run(self) -> None:
109109
loop = asyncio.new_event_loop()
110110
asyncio.set_event_loop(loop)
111111
loop.run_until_complete(self._async_run())
112+
loop.close()
112113

113114
async def _async_run(self) -> None:
114115
"""The thread's main activity. Call start() instead."""

jupyter_client/client.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ class KernelClient(ConnectionFileMixin):
9393
# The PyZMQ Context to use for communication with the kernel.
9494
context = Instance(zmq.asyncio.Context)
9595

96-
_created_context: Bool = Bool(False)
96+
_created_context = Bool(False)
9797

9898
def _context_default(self) -> zmq.asyncio.Context:
9999
self._created_context = True
@@ -116,6 +116,23 @@ def _context_default(self) -> zmq.asyncio.Context:
116116
# flag for whether execute requests should be allowed to call raw_input:
117117
allow_stdin: bool = True
118118

119+
def __del__(self):
120+
"""Handle garbage collection. Destroy context if applicable."""
121+
if self._created_context and self.context and not self.context.closed:
122+
if self.channels_running:
123+
if self.log:
124+
self.log.warning("Could not destroy zmq context for %s", self)
125+
else:
126+
if self.log:
127+
self.log.debug("Destroying zmq context for %s", self)
128+
self.context.destroy()
129+
try:
130+
super_del = super().__del__
131+
except AttributeError:
132+
pass
133+
else:
134+
super_del()
135+
119136
# --------------------------------------------------------------------------
120137
# Channel proxy methods
121138
# --------------------------------------------------------------------------
@@ -286,9 +303,6 @@ def start_channels(
286303
:meth:`start_kernel`. If the channels have been stopped and you
287304
call this, :class:`RuntimeError` will be raised.
288305
"""
289-
# Create the context if needed.
290-
if not self._created_context:
291-
self.context = self._context_default()
292306
if iopub:
293307
self.iopub_channel.start()
294308
if shell:
@@ -318,9 +332,6 @@ def stop_channels(self) -> None:
318332
self.hb_channel.stop()
319333
if self.control_channel.is_alive():
320334
self.control_channel.stop()
321-
if self._created_context:
322-
self._created_context = False
323-
self.context.destroy()
324335

325336
@property
326337
def channels_running(self) -> bool:

jupyter_client/multikernelmanager.py

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,10 @@ def create_kernel_manager(*args: Any, **kwargs: Any) -> KernelManager:
9595
help="Share a single zmq.Context to talk to all my kernels",
9696
).tag(config=True)
9797

98-
_created_context = Bool(False)
99-
10098
context = Instance("zmq.Context")
10199

100+
_created_context = Bool(False)
101+
102102
_pending_kernels = Dict()
103103

104104
@property
@@ -111,7 +111,12 @@ def _context_default(self) -> zmq.Context:
111111
self._created_context = True
112112
return zmq.Context()
113113

114+
connection_dir = Unicode("")
115+
116+
_kernels = Dict()
117+
114118
def __del__(self):
119+
"""Handle garbage collection. Destroy context if applicable."""
115120
if self._created_context and self.context and not self.context.closed:
116121
if self.log:
117122
self.log.debug("Destroying zmq context for %s", self)
@@ -123,10 +128,6 @@ def __del__(self):
123128
else:
124129
super_del()
125130

126-
connection_dir = Unicode("")
127-
128-
_kernels = Dict()
129-
130131
def list_kernel_ids(self) -> t.List[str]:
131132
"""Return a list of the kernel ids of the active kernels."""
132133
# Create a copy so we can iterate over kernels in operations
@@ -171,17 +172,19 @@ async def _add_kernel_when_ready(
171172
try:
172173
await kernel_awaitable
173174
self._kernels[kernel_id] = km
174-
finally:
175175
self._pending_kernels.pop(kernel_id, None)
176+
except Exception as e:
177+
self.log.exception(e)
176178

177179
async def _remove_kernel_when_ready(
178180
self, kernel_id: str, kernel_awaitable: t.Awaitable
179181
) -> None:
180182
try:
181183
await kernel_awaitable
182184
self.remove_kernel(kernel_id)
183-
finally:
184185
self._pending_kernels.pop(kernel_id, None)
186+
except Exception as e:
187+
self.log.exception(e)
185188

186189
def _using_pending_kernels(self):
187190
"""Returns a boolean; a clearer method for determining if
@@ -207,15 +210,15 @@ async def _async_start_kernel(self, kernel_name: t.Optional[str] = None, **kwarg
207210
kwargs['kernel_id'] = kernel_id # Make kernel_id available to manager and provisioner
208211

209212
starter = ensure_async(km.start_kernel(**kwargs))
210-
fut = asyncio.ensure_future(self._add_kernel_when_ready(kernel_id, km, starter))
211-
self._pending_kernels[kernel_id] = fut
213+
task = asyncio.create_task(self._add_kernel_when_ready(kernel_id, km, starter))
214+
self._pending_kernels[kernel_id] = task
212215
# Handling a Pending Kernel
213216
if self._using_pending_kernels():
214217
# If using pending kernels, do not block
215218
# on the kernel start.
216219
self._kernels[kernel_id] = km
217220
else:
218-
await fut
221+
await task
219222
# raise an exception if one occurred during kernel startup.
220223
if km.ready.exception():
221224
raise km.ready.exception() # type: ignore
@@ -224,22 +227,6 @@ async def _async_start_kernel(self, kernel_name: t.Optional[str] = None, **kwarg
224227

225228
start_kernel = run_sync(_async_start_kernel)
226229

227-
async def _shutdown_kernel_when_ready(
228-
self,
229-
kernel_id: str,
230-
now: t.Optional[bool] = False,
231-
restart: t.Optional[bool] = False,
232-
) -> None:
233-
"""Wait for a pending kernel to be ready
234-
before shutting the kernel down.
235-
"""
236-
# Only do this if using pending kernels
237-
if self._using_pending_kernels():
238-
kernel = self._kernels[kernel_id]
239-
await kernel.ready
240-
# Once out of a pending state, we can call shutdown.
241-
await ensure_async(self.shutdown_kernel(kernel_id, now=now, restart=restart))
242-
243230
async def _async_shutdown_kernel(
244231
self,
245232
kernel_id: str,
@@ -258,22 +245,21 @@ async def _async_shutdown_kernel(
258245
Will the kernel be restarted?
259246
"""
260247
self.log.info("Kernel shutdown: %s" % kernel_id)
261-
# If we're using pending kernels, block shutdown when a kernel is pending.
262-
if self._using_pending_kernels() and kernel_id in self._pending_kernels:
263-
raise RuntimeError("Kernel is in a pending state. Cannot shutdown.")
264248
# If the kernel is still starting, wait for it to be ready.
265-
elif kernel_id in self._pending_kernels:
266-
kernel = self._pending_kernels[kernel_id]
249+
if kernel_id in self._pending_kernels:
250+
task = self._pending_kernels[kernel_id]
267251
try:
268-
await kernel
252+
await task
269253
km = self.get_kernel(kernel_id)
270254
await t.cast(asyncio.Future, km.ready)
255+
except asyncio.CancelledError:
256+
pass
271257
except Exception:
272258
self.remove_kernel(kernel_id)
273259
return
274260
km = self.get_kernel(kernel_id)
275261
# If a pending kernel raised an exception, remove it.
276-
if km.ready.exception():
262+
if not km.ready.cancelled() and km.ready.exception():
277263
self.remove_kernel(kernel_id)
278264
return
279265
stopper = ensure_async(km.shutdown_kernel(now, restart))
@@ -320,13 +306,19 @@ async def _async_shutdown_all(self, now: bool = False) -> None:
320306
"""Shutdown all kernels."""
321307
kids = self.list_kernel_ids()
322308
kids += list(self._pending_kernels)
323-
futs = [ensure_async(self._shutdown_kernel_when_ready(kid, now=now)) for kid in set(kids)]
309+
kms = list(self._kernels.values())
310+
futs = [ensure_async(self.shutdown_kernel(kid, now=now)) for kid in set(kids)]
324311
await asyncio.gather(*futs)
325-
# When using "shutdown all", all pending kernels
326-
# should be awaited before exiting this method.
312+
# If using pending kernels, the kernels will not have been fully shut down.
327313
if self._using_pending_kernels():
328-
for km in self._kernels.values():
329-
await km.ready
314+
for km in kms:
315+
try:
316+
await km.ready
317+
except asyncio.CancelledError:
318+
self._pending_kernels[km.kernel_id].cancel()
319+
except Exception:
320+
# Will have been logged in _add_kernel_when_ready
321+
pass
330322

331323
shutdown_all = run_sync(_async_shutdown_all)
332324

jupyter_client/tests/test_kernelmanager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,15 @@ async def test_async_signal_kernel_subprocesses(self, name, install, expected):
194194
class TestKernelManager:
195195
def test_lifecycle(self, km):
196196
km.start_kernel(stdout=PIPE, stderr=PIPE)
197+
kc = km.client()
197198
assert km.is_alive()
198199
is_done = km.ready.done()
199200
assert is_done
200201
km.restart_kernel(now=True)
201202
assert km.is_alive()
202203
km.interrupt_kernel()
203204
assert isinstance(km, KernelManager)
205+
kc.stop_channels()
204206
km.shutdown_kernel(now=True)
205207
assert km.context.closed
206208

jupyter_client/tests/test_multikernelmanager.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,11 @@ def _run_lifecycle(km, test_kid=None):
8888
assert kid in km.list_kernel_ids()
8989
km.interrupt_kernel(kid)
9090
k = km.get_kernel(kid)
91+
kc = k.client()
9192
assert isinstance(k, KernelManager)
9293
km.shutdown_kernel(kid, now=True)
9394
assert kid not in km, f"{kid} not in {km}"
95+
kc.stop_channels()
9496

9597
def _run_cinfo(self, km, transport, ip):
9698
kid = km.start_kernel(stdout=PIPE, stderr=PIPE)
@@ -158,8 +160,10 @@ def test_start_sequence_ipc_kernels(self):
158160

159161
def tcp_lifecycle_with_loop(self):
160162
# Ensure each thread has an event loop
161-
asyncio.set_event_loop(asyncio.new_event_loop())
163+
loop = asyncio.new_event_loop()
164+
asyncio.set_event_loop(loop)
162165
self.test_tcp_lifecycle()
166+
loop.close()
163167

164168
def test_start_parallel_thread_kernels(self):
165169
self.test_tcp_lifecycle()
@@ -415,10 +419,6 @@ async def test_use_pending_kernels_early_shutdown(self):
415419
kernel = km.get_kernel(kid)
416420
assert not kernel.ready.done()
417421
# Try shutting down while the kernel is pending
418-
with pytest.raises(RuntimeError):
419-
await ensure_future(km.shutdown_kernel(kid, now=True))
420-
await kernel.ready
421-
# Shutdown once the kernel is ready
422422
await ensure_future(km.shutdown_kernel(kid, now=True))
423423
# Wait for the kernel to shutdown
424424
await kernel.ready
@@ -476,6 +476,7 @@ def tcp_lifecycle_with_loop(self):
476476
loop = asyncio.new_event_loop()
477477
asyncio.set_event_loop(loop)
478478
loop.run_until_complete(self.raw_tcp_lifecycle())
479+
loop.close()
479480

480481
# static so picklable for multiprocessing on Windows
481482
@classmethod
@@ -491,6 +492,7 @@ def raw_tcp_lifecycle_sync(cls, test_kid=None):
491492
loop = asyncio.new_event_loop()
492493
asyncio.set_event_loop(loop)
493494
loop.run_until_complete(cls.raw_tcp_lifecycle(test_kid=test_kid))
495+
loop.close()
494496

495497
@gen_test
496498
async def test_start_parallel_thread_kernels(self):

jupyter_client/threaded.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@ def stop(self) -> None:
243243
self.close()
244244
self.ioloop = None
245245

246+
def __del__(self):
247+
self.close()
248+
246249
def close(self) -> None:
247250
if self.ioloop is not None:
248251
try:

jupyter_client/utils.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@ def wrapped(*args, **kwargs):
1313
try:
1414
loop = asyncio.get_running_loop()
1515
except RuntimeError:
16-
loop = asyncio.new_event_loop()
17-
asyncio.set_event_loop(loop)
16+
# Workaround for bugs.python.org/issue39529.
17+
try:
18+
loop = asyncio.get_event_loop_policy().get_event_loop()
19+
except RuntimeError:
20+
loop = asyncio.new_event_loop()
21+
asyncio.set_event_loop(loop)
1822
import nest_asyncio # type: ignore
1923

2024
nest_asyncio.apply(loop)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ filterwarnings= [
4141
# Fail on warnings
4242
"error",
4343

44-
# Workarounds for https://github.com/pytest-dev/pytest-asyncio/issues/77
44+
# We need to handle properly closing loops as part of https://github.com/jupyter/jupyter_client/issues/755.
4545
"ignore:unclosed <socket.socket:ResourceWarning",
4646
"ignore:unclosed event loop:ResourceWarning",
4747

0 commit comments

Comments
 (0)