conkernelclient

Concurrent-safe Jupyter KernelClient

Background

Jupyter’s KernelClient is designed around a simple request-reply pattern: you send one message on the shell channel, wait for its reply, then send the next. This works fine for a single-threaded notebook, but falls apart when you need concurrent execution. For instance, running multiple cells in parallel, or letting an LLM tool loop fire off code while a long-running computation is still in flight. The underlying ZMQ socket isn’t safe to share across tasks, and there’s no built-in mechanism to route replies back to the correct caller when multiple requests are outstanding.

conkernelclient solves this with ConKernelClient, a drop-in replacement for AsyncKernelClient that makes concurrent calls safe. It patches Session.send to synchronise with the ZMQ I/O thread (preventing a race where two sends interleave), and runs one _pump task per channel that routes every inbound message by parent message ID. execute sends fire-and-forget, reply awaits one execute_reply, and run collects every message one execute causes, so multiple coroutines can await their replies independently without interfering with each other.

Installation

Install from pypi

$ pip install conkernelclient

How to use

from conkernelclient import *

The main entry point is ConKernelManager, a drop-in replacement for AsyncKernelManager that creates ConKernelClient instances. Start a kernel and connect a client in the usual way:

import asyncio
from jupyter_client.session import Session
km = ConKernelManager(session=Session(key=b'x'))
await km.start_kernel()
kc = await km.client().start_channels()
await kc.is_alive()
True

Once connected, reply() runs code and awaits the shell reply. execute() is the fire-and-forget form, and exec_outs() collects just the outputs:

r = await kc.reply('2+1', timeout=1)
r['content']['status']

The key feature is safe concurrent execution. Multiple reply() calls can be outstanding simultaneously, each resolved by its request’s message ID:

from fastcore.test import test_eq
a = kc.reply('x=2')
b = kc.reply('y=3')
r = await asyncio.wait_for(asyncio.gather(a, b), timeout=2)
test_eq(len(r), 2)
r[0]['parent_header']['msg_id']

Both replies arrive independently, each routed to the correct caller. Without ConKernelClient, the second execute would either block waiting for the first to finish, or the replies would get crossed.

As usual, we clean up when we’re done:

if await km.is_alive():
    kc.stop_channels()
    await km.shutdown_kernel()