Skip to content

Commit 4cab4d7

Browse files
committed
fixes #25
1 parent 25f4cda commit 4cab4d7

12 files changed

Lines changed: 536 additions & 599 deletions

ipymini/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55

66

77
from importlib.metadata import PackageNotFoundError, version
8+
from .unlock import unlock
89
from .kernel import run_kernel
910

1011
try: __version__ = version("ipymini")
1112
except PackageNotFoundError: pass
1213

13-
__all__ = ["run_kernel", "__version__"]
14+
__all__ = ["run_kernel", "unlock", "__version__"]

ipymini/kernel.py

Lines changed: 50 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
from enum import Enum
55
from importlib.metadata import PackageNotFoundError, version
66
from fastcore.basics import nested_idx, store_attr
7-
from microio import ActorCore, CancelScope, CloseScope, ServiceGroup
7+
from microio import ActorCore, CloseScope, ScopeGroup, ServiceGroup, WorkTracker
88
import zmq
99
from jupyter_client.session import Session
1010
from .shell import MiniShell
1111
from .comms import get_comm_manager
12+
from .unlock import _release as _unlock_release
1213
from .debug import DebugFlags, setup_debug, trace_msg
1314
from .zmqthread import AsyncRouterThread, HeartbeatThread, IOPubThread, StdinRouterThread
1415

@@ -128,17 +129,17 @@ def __init__(self, kernel: "MiniKernel", subshell_id:str|None, user_ns: dict,
128129
self.thread = None if not run_in_thread else threading.Thread(target=self._run_loop, daemon=True, name=name)
129130
self.loop = None
130131
self.loop_ready = threading.Event()
131-
self.actor = ActorCore(self._handle_actor_item)
132+
self.actor = ActorCore(self._handle_actor_item, concurrent=True)
132133
self.aborting = False
133134
self.abort_handle = None
134135
self._shell = None
135136
self.shell_ready = threading.Event()
136137
self.parent_header_var = contextvars.ContextVar("parent_header", default=None)
137138
self.parent_idents_var = contextvars.ContextVar("parent_idents", default=None)
138-
self.executing = threading.Event()
139-
self.exec_scope = None
139+
self.exec_tracker = WorkTracker()
140+
self.executing = self.exec_tracker.busy
141+
self.exec_scopes = ScopeGroup()
140142
self.sync_executing = threading.Event()
141-
self.exec_state = ExecState.IDLE
142143
self.last_exec_state = None
143144
self.state_lock = threading.Lock()
144145
self.shell_handlers = dict(kernel_info_request=self._handle_kernel_info, connect_request=self._handle_connect,
@@ -157,7 +158,7 @@ def _init_shell(self):
157158
if self._shell is not None: return
158159
self._shell = MiniShell(request_input=self.request_input, debug_event_callback=self.send_debug_event,
159160
zmq_context=self.kernel.context, user_ns=self.user_ns, use_singleton=self.use_singleton,
160-
async_cancel_scope=self._async_cancel_scope, sync_execution_context=self._sync_execution_context)
161+
exec_scopes=self.exec_scopes, sync_execution_context=self._sync_execution_context)
161162
self._shell.ipy.kernel = self.kernel
162163
self._shell.set_stream_sender(self._send_stream)
163164
self._shell.set_display_sender(self._send_display_event)
@@ -177,45 +178,46 @@ def stop(self, interrupt: bool = False):
177178

178179
def join(self, timeout:float|None=None)->bool: return _join_or_log(self.thread, timeout=timeout)
179180

180-
def submit(self, msg: dict, idents: list[bytes]|None)->bool: return bool(self.actor.submit((msg, idents)))
181-
182-
def _set_exec_state(self, state: ExecState):
183-
with self.state_lock: self.exec_state = state
181+
def submit(self, msg: dict, idents: list[bytes]|None)->bool:
182+
"Queue executes behind the cell baton; run other messages on the loop directly."
183+
if nested_idx(msg, "header", "msg_type") == "execute_request": return bool(self.actor.submit((msg, idents)))
184+
return self._submit_direct(msg, idents)
185+
186+
def _submit_direct(self, msg: dict, idents: list[bytes]|None)->bool:
187+
"Handle a non-execute message promptly, without queueing behind busy cells."
188+
if self.scope.closed: return False
189+
loop = self.loop
190+
if loop is None: return bool(self.actor.submit((msg, idents))) # loop not started yet; deliver via mailbox
191+
item = (msg, idents)
192+
def _run(): loop.create_task(self._handle_actor_item(item, lambda: None))
193+
try: loop.call_soon_threadsafe(_run)
194+
except RuntimeError: return False
195+
return True
184196

185197
def _set_last_exec_state(self, state: ExecState):
186198
with self.state_lock: self.last_exec_state = state
187199

188-
def _get_exec_state(self)->ExecState:
189-
with self.state_lock: return self.exec_state
190-
191200
def interrupt(self)->bool:
192-
"Raise KeyboardInterrupt in subshell thread if executing."
193-
if self._get_exec_state() != ExecState.RUNNING: return False
194-
self._set_exec_state(ExecState.CANCELLING)
195-
if self.cancel_async_execution(): return True
201+
"Cancel running async executions, or raise KeyboardInterrupt in a sync one."
202+
if not self.executing.is_set(): return False
203+
already_cancelling = self.exec_scopes.cancelling
204+
if self.exec_scopes.cancel("interrupt", latch=True): return True
205+
if already_cancelling: return True # still cancelling from a previous interrupt; don't double-inject
196206
if not self.sync_executing.is_set(): return True
197207
if self.thread is None: return False
198208
thread_id = self.thread.ident
199209
if thread_id is None: return False
200210
return _raise_async_exception(thread_id, KeyboardInterrupt)
201211

202212
def cancel_async_execution(self, *, wake: bool = False)->bool:
203-
"Cancel the current async cell execution, if there is one."
204-
scope = self.exec_scope
205-
if scope is None: return False
206-
return scope.cancelled or scope.cancel("interrupt", wake=wake)
207-
208-
def _async_cancel_scope(self):
209-
scope = self.exec_scope
210-
if scope is None: self.exec_scope = scope = CancelScope()
211-
if self._get_exec_state() == ExecState.CANCELLING: scope.cancel("interrupt")
212-
return scope
213+
"Cancel all active async cell executions, if any."
214+
return self.exec_scopes.cancel("interrupt", wake=wake, latch=True)
213215

214216
@contextmanager
215217
def _sync_execution_context(self):
216218
self.sync_executing.set()
217219
try:
218-
if self._get_exec_state() == ExecState.CANCELLING: raise KeyboardInterrupt
220+
if self.exec_scopes.cancelling: raise KeyboardInterrupt
219221
yield
220222
finally: self.sync_executing.clear()
221223

@@ -285,7 +287,7 @@ async def _main(self):
285287
dbg(f"SUBSHELL started id={self.subshell_id}")
286288
await self.actor.run(bind=False)
287289

288-
async def _handle_actor_item(self, item):
290+
async def _handle_actor_item(self, item, release):
289291
msg, idents = item
290292
if msg is subshell_abort_clear:
291293
self._stop_aborting()
@@ -294,7 +296,7 @@ async def _handle_actor_item(self, item):
294296
msg_type = nested_idx(msg, "header", "msg_type") or "?"
295297
msg_id = (nested_idx(msg, "header", "msg_id") or "?")[:8]
296298
dbg(f"EXEC {msg_type} id={msg_id}")
297-
try: await self._handle_message(msg, idents)
299+
try: await self._handle_message(msg, idents, release)
298300
except Exception as exc: self._handle_internal_error(msg, idents, exc)
299301
dbg(f"DONE {msg_type} id={msg_id}")
300302

@@ -317,7 +319,7 @@ def _send_error_reply(self, msg_type:str, error:dict, msg: dict, idents: list[by
317319
self.send_reply(_reply_type(msg_type), reply, msg, idents)
318320
if msg_type == "execute_request": self.kernel.send_status("idle", msg)
319321

320-
async def _handle_message(self, msg: dict, idents: list[bytes]|None):
322+
async def _handle_message(self, msg: dict, idents: list[bytes]|None, release):
321323
msg_type = msg["header"]["msg_type"]
322324
msg_id = msg["header"].get("msg_id", "?")[:8]
323325
dbg(f"HANDLE_MSG {msg_type} id={msg_id}")
@@ -334,7 +336,7 @@ async def _handle_message(self, msg: dict, idents: list[bytes]|None):
334336
return
335337
if msg_type == "execute_request":
336338
dbg(f"DISPATCH_EXEC id={msg_id}")
337-
await self._handle_execute(msg, idents)
339+
await self._handle_execute(msg, idents, release)
338340
return
339341
self._dispatch_shell_non_execute(msg, idents)
340342
finally:
@@ -399,7 +401,17 @@ def _handle_connect(self, msg: dict, idents: list[bytes]|None):
399401
stdin_port=self.kernel.connection.stdin_port, control_port=self.kernel.connection.control_port, hb_port=self.kernel.connection.hb_port)
400402
self.send_reply("connect_reply", content, msg, idents)
401403

402-
async def _handle_execute(self, msg: dict, idents: list[bytes]|None):
404+
def _safe_release(self, release):
405+
"Wrap an actor release callback so it is safe to call from any thread."
406+
loop = asyncio.get_running_loop()
407+
def _do():
408+
try: running = asyncio.get_running_loop()
409+
except RuntimeError: running = None
410+
if running is loop: release()
411+
else: loop.call_soon_threadsafe(release)
412+
return _do
413+
414+
async def _handle_execute(self, msg: dict, idents: list[bytes]|None, release):
403415
msg_id = (nested_idx(msg, "header", "msg_id") or "?")[:8]
404416
content = msg.get("content", {})
405417
code = content.get("code", "")
@@ -410,8 +422,9 @@ async def _handle_execute(self, msg: dict, idents: list[bytes]|None):
410422
allow_stdin = bool(content.get("allow_stdin", False))
411423

412424
dbg(f"HANDLE_EXEC id={msg_id} code={code[:30]!r}...")
413-
self._set_exec_state(ExecState.RUNNING)
414-
self.executing.set()
425+
_unlock_release.set(self._safe_release(release))
426+
self.exec_scopes.clear() # a new execute ends any previous cancelling window
427+
self.exec_tracker.add()
415428
terminal_state = ExecState.COMPLETED
416429
iopub = self.kernel.iopub
417430
sent_reply = sent_error = False
@@ -432,12 +445,11 @@ async def _handle_execute(self, msg: dict, idents: list[bytes]|None):
432445
result = await self.shell.execute(code, silent=silent, store_history=store_history,
433446
user_expressions=user_expressions, allow_stdin=allow_stdin)
434447
finally:
435-
self.exec_scope = None
436448
if timeout_handle: timeout_handle.cancel()
437449
dbg(f"BRIDGE_DONE id={msg_id}")
438450

439451
error = result.get("error")
440-
if error and error.get("ename") == "CancelledError" and self._get_exec_state() == ExecState.CANCELLING:
452+
if error and error.get("ename") == "CancelledError" and self.exec_scopes.cancelling:
441453
error = dict(error) | dict(ename="KeyboardInterrupt", evalue="")
442454
exec_count = result.get("execution_count")
443455

@@ -470,8 +482,7 @@ async def _handle_execute(self, msg: dict, idents: list[bytes]|None):
470482
finally:
471483
self.kernel.send_status("idle", msg)
472484
self._set_last_exec_state(terminal_state)
473-
self.executing.clear()
474-
self._set_exec_state(ExecState.IDLE)
485+
self.exec_tracker.done()
475486

476487
def _shell_handler(self, msg: dict, idents: list[bytes]|None):
477488
msg_type = nested_idx(msg, "header", "msg_type") or None
@@ -714,7 +725,6 @@ def handle_sigint(self, signum, frame):
714725
for subshell in children: subshell.interrupt()
715726
parent = self.subshells.parent
716727
if not parent.executing.is_set(): return
717-
parent._set_exec_state(ExecState.CANCELLING)
718728
if parent.cancel_async_execution(wake=True): return
719729
if not parent.sync_executing.is_set(): return
720730
raise KeyboardInterrupt

ipymini/shell/shell.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from IPython.core.interactiveshell import InteractiveShell
1515
from IPython.core.shellapp import InteractiveShellApp
1616

17+
from microio import ScopeGroup
18+
1719
from .comms import comm_context
1820
from ipymini.debug import Debugger, debug_cell_filename
1921
from ipymini.term import IPythonCapture
@@ -78,7 +80,7 @@ def _init_ipython_app(shell):
7880

7981
class MiniShell:
8082
def __init__(self, request_input: Callable[[str, bool], str], debug_event_callback: Callable[[dict], None] | None = None,
81-
zmq_context: zmq.Context | None = None, *, user_ns: dict | None = None, use_singleton: bool = True, async_cancel_scope=None,
83+
zmq_context: zmq.Context | None = None, *, user_ns: dict | None = None, use_singleton: bool = True, exec_scopes=None,
8284
sync_execution_context=None):
8385
"Initialize IPython shell, IO capture, and debugger hooks."
8486
from IPython.core import page
@@ -94,7 +96,7 @@ def _code_name(raw_code: str, transformed_code: str, number: int) -> str: return
9496
self.ipy.compile.get_code_name = _code_name
9597
self.request_input = request_input
9698
self.capture = IPythonCapture(self.ipy, request_input=request_input)
97-
self.async_cancel_scope = async_cancel_scope or (lambda: None)
99+
self.exec_scopes = exec_scopes or ScopeGroup()
98100
self.sync_execution_context = sync_execution_context or nullcontext
99101

100102
self.ipy.set_hook("show_in_pager", page.as_hook(self._show_in_pager), 99)
@@ -153,11 +155,10 @@ async def _run_cell(self, code: str, silent: bool, store_history: bool):
153155
coro = shell.run_cell_async(code, store_history=store_history, silent=silent,
154156
transformed_cell=transformed, preprocessing_exc_tuple=exc_tuple)
155157
_dbg("_run_cell: awaiting async task")
156-
scope = self.async_cancel_scope()
157158
try:
158159
try:
159-
with (scope if scope is not None else nullcontext()): res = await coro
160-
if scope is not None and scope.cancelled_caught: raise KeyboardInterrupt
160+
with self.exec_scopes.scope() as scope: res = await coro
161+
if scope.cancelled_caught: raise KeyboardInterrupt
161162
except asyncio.CancelledError as exc: raise KeyboardInterrupt() from exc
162163
finally:
163164
shell.events.trigger("post_execute")

ipymini/term/display.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import contextvars
2+
13
from IPython.core.displayhook import DisplayHook
24
from IPython.core.displaypub import DisplayPublisher
35

@@ -30,12 +32,27 @@ def clear_output(self, wait: bool = False):
3032

3133

3234
class MiniDisplayHook(DisplayHook):
33-
def __init__(self, shell=None):
34-
"DisplayHook that captures last result metadata."
35-
super().__init__(shell=shell)
36-
self.last = None
37-
self.last_metadata = None
38-
self.last_execution_count = None
35+
"DisplayHook that captures last result metadata, isolated per execution context."
36+
37+
# ContextVars so concurrent (unlocked) executions cannot read or clobber each other's result
38+
_last = contextvars.ContextVar("ipymini.dh_last", default=None)
39+
_last_metadata = contextvars.ContextVar("ipymini.dh_last_metadata", default=None)
40+
_last_execution_count = contextvars.ContextVar("ipymini.dh_last_execution_count", default=None)
41+
42+
@property
43+
def last(self): return self._last.get()
44+
@last.setter
45+
def last(self, v): self._last.set(v)
46+
47+
@property
48+
def last_metadata(self): return self._last_metadata.get()
49+
@last_metadata.setter
50+
def last_metadata(self, v): self._last_metadata.set(v)
51+
52+
@property
53+
def last_execution_count(self): return self._last_execution_count.get()
54+
@last_execution_count.setter
55+
def last_execution_count(self, v): self._last_execution_count.set(v)
3956

4057
def write_output_prompt(self): self.last_execution_count = self.prompt_count
4158

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ dependencies = [
1414
"ipython>=8.18",
1515
"jupyter_client>=8.6",
1616
"jupyter_core>=5.5",
17-
"microio>=0.1",
17+
"microio>=0.1.1",
1818
"pyzmq>=25.1",
1919
]
2020

tests/kernel/test_asyncio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_asyncio_features() -> None:
2121
assert reply["content"]["status"] == "ok"
2222
pred = lambda m: parent_id(m) == msg_id and m.get("msg_type") == "stream" and "ok" in m.get("content", {}).get("text", "")
2323
wait_for_msg(kc.get_iopub_msg, pred, timeout=default_timeout, err="expected stdout from create_task")
24-
kc.iopub_drain(msg_id)
24+
# NB: no iopub_drain here - wait_for_msg already consumed this request's idle, so a drain would block until timeout
2525

2626
reply = kc.dap.initialize(**debug_init_args)
2727
assert reply.get("success"), f"initialize: {reply}"

tests/kernel/test_display.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,21 @@
66
def test_display_data_and_payloads(tmp_path):
77
samples = [("from IPython.display import HTML, display; display(HTML('<b>test</b>'))", "text/html"),
88
("from IPython.display import Math, display; display(Math('\\\\frac{1}{2}'))", "text/latex")]
9-
with start_kernel() as (_, kc):
9+
10+
root = Path(__file__).resolve().parents[1]
11+
ipdir = tmp_path / "ipdir"
12+
profile = ipdir / "profile_default"
13+
profile.mkdir(parents=True)
14+
config_path = profile / "ipython_kernel_config.py"
15+
config_path.write_text("c = get_config()\nc.InteractiveShell.display_page = False\n", encoding="utf-8")
16+
17+
extra_path = os.environ.get("PYTHONPATH", "")
18+
paths = [str(root)]
19+
if extra_path: paths.append(extra_path)
20+
extra_env = dict(IPYTHONDIR=str(ipdir), PYTHONPATH=os.pathsep.join(paths))
21+
22+
with KernelHarness(extra_env=extra_env) as h:
23+
kc = h.kc
1024
for code, mime in samples:
1125
msg_id = kc.execute(code, store_history=False)
1226
reply = kc.shell_reply(msg_id)
@@ -25,28 +39,15 @@ def test_display_data_and_payloads(tmp_path):
2539
assert len(next_inputs) == 1
2640
kc.iopub_drain(msg_id)
2741

28-
root = Path(__file__).resolve().parents[1]
29-
ipdir = tmp_path / "ipdir"
30-
profile = ipdir / "profile_default"
31-
profile.mkdir(parents=True)
32-
config_path = profile / "ipython_kernel_config.py"
33-
config_path.write_text("c = get_config()\nc.InteractiveShell.display_page = False\n", encoding="utf-8")
34-
35-
extra_path = os.environ.get("PYTHONPATH", "")
36-
paths = [str(root)]
37-
if extra_path: paths.append(extra_path)
38-
extra_env = dict(IPYTHONDIR=str(ipdir), PYTHONPATH=os.pathsep.join(paths))
39-
40-
with KernelHarness(extra_env=extra_env) as h:
41-
msg_id = h.kc.execute("print?")
42-
reply = h.kc.shell_reply(msg_id)
42+
msg_id = kc.execute("print?")
43+
reply = kc.shell_reply(msg_id)
4344
assert reply["content"]["status"] == "ok"
4445
payloads = reply["content"]["payload"]
4546
assert len(payloads) == 1
4647
assert payloads[0]["source"] == "page"
4748
mimebundle = payloads[0]["data"]
4849
assert "text/plain" in mimebundle
49-
h.kc.iopub_drain(msg_id)
50+
kc.iopub_drain(msg_id)
5051

5152
root = Path(__file__).resolve().parents[1]
5253
ipdir = tmp_path / "ipdir_display"

0 commit comments

Comments
 (0)