Skip to content

Commit db5ce47

Browse files
committed
Add fiit_console a custom Jupyter kernel client
This patch add the new command 'fiit_console' to replace the Jupyter Console command used in this project to connect a shell to a remote Jupyter ipykernel hosted in the fiit runtime. This command subclass and modify the internals of the `ZMQTerminalIPythonApp` class of the jupyter/jupyter_console project and provides a number of useful features in the context of this project. - At startup, if the kernel is busy, the shell waits infinitely for the prompt to resume and displays the remote output (stdout/stderr streams). - Echo potential long running command (`%emu_start`, `%step`, `%cont`) run by another kernel client. - Optional full echo of commands run by another kernel client, via the environment variable `FULL_ECHO=true`. - Stop the shell prompt when echo a command run by another kernel client in order to reflect the busy status of the remote kernel. - Fix the long output latency of other kernel client output when Jupyter Console is configured with `include_other_output` to true, via a custom asynchronous polling of the iopub channel. - Provide a consistent output for commands run by another client, rather than the bugged output provided by the default implementation. - Provide a way to launch a shell by retrieving remote kernel information from `plugin_backend` without specify a backend port, if backend uses its default port. - Provide two new commands for launching the custom Jupiter client: * via config: `fiit_console --existing [jupyter_json_kernel_config]` * via backend: `fiit console --help` - It seems, but it is not certain, that this implementation solves the invalid signature problem encountered with the default Jupyter Console. Previously, the `jupyter console` command from the Jupyter project was used as frontend to provide a terminal connected to a remote Jupyter ipykernel hosted in the fiit runtime. The shell implementation has several issues and behaviours which are not convenient in the context of this project. When the shell client start, if the remote kernel is in 'busy' state because it is executing a cell, the shell hang 60 seconds and quit, in addition if the remote kernel stream output such as stdout/stderr the shell display nothing. This behavior is very annoying, for example if a remote kernel is busy because a process is emulating and debugging a firmware executing a long run with a forward of the serial output to stdout, the shell client displays nothing and can potentially quit before the debugger resumes the shell prompt at a specific breakpoint. When echoing commands of another kernel client, via the `include_other_output` option, the output is inconsistent which is a bug in the current implementation, see the old pending pull request jupyter/jupyter_console#274. Furthermore, there is a long latency in the output of the other commands caused by an inefficient pooling of the iopub channel. When the state of the remote kernel is 'busy', the shell client prompt remains active. This is the default implementation of the Jupyter console. If the `include_other_output` option is enable, the remote output is displayed and nested with the shell prompt which is a bit messy and makes the prompt unusable if the remote output is very verbose. It is preferable to block the prompt and display the cell output run by another kernel client for a subset of commands. Such as the `%emu_start` `%step` and `%cont` commands, which are potentially performing a long run in the remote kernel and provide important information when the prompt is resumed, such ad the new current code location in the emulated code, which is useful for all other kernel client. When several shell clients are connected to the remote kernel and after several commands, all shells crash with the exception message 'Invalid Signature'.
1 parent f760742 commit db5ce47

File tree

5 files changed

+309
-69
lines changed

5 files changed

+309
-69
lines changed

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@
6868
},
6969
entry_points={
7070
'console_scripts': [
71-
'fiit=fiit.scripts.fiit:main'
71+
'fiit=fiit.scripts.fiit:main',
72+
'fiit_console=fiit.frontend.fiit_console:fiit_console'
7273
]
7374
},
7475
zip_safe=False,

src/fiit/frontend/fiit_console.py

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
################################################################################
2+
#
3+
# Copyright 2022-2025 Vincent Dary
4+
#
5+
# This file is part of fiit.
6+
#
7+
# fiit is free software: you can redistribute it and/or modify it under the
8+
# terms of the GNU General Public License as published by the Free Software
9+
# Foundation, either version 3 of the License, or (at your option) any later
10+
# version.
11+
#
12+
# fiit is distributed in the hope that it will be useful, but WITHOUT ANY
13+
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14+
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License along with
17+
# fiit. If not, see <https://www.gnu.org/licenses/>.
18+
#
19+
################################################################################
20+
import uuid
21+
import dataclasses
22+
import tempfile
23+
import signal
24+
import sys
25+
import os
26+
from typing import Optional
27+
import asyncio
28+
29+
import zmq
30+
31+
from prompt_toolkit.application import get_app_or_none
32+
33+
from jupyter_console.ptshell import ZMQTerminalInteractiveShell, ask_yes_no
34+
from jupyter_console.app import ZMQTerminalIPythonApp
35+
from jupyter_client.consoleapp import JupyterConsoleApp
36+
37+
from fiit.plugins.backend import BACKEND_REQ_GET_BACKEND_DATA, BackendRequest
38+
39+
40+
class RemoteKernelSync(Exception):
41+
pass
42+
43+
44+
class SynchronizedZmqTerminal(ZMQTerminalInteractiveShell):
45+
"""
46+
Extend the ZMQTerminalInteractiveShell
47+
"""
48+
include_other_output = True
49+
50+
ECHO_FILTER = ['%emu_start', '%es', '%step', '%s', '%cont', '%c']
51+
52+
def __init__(self, **kwargs):
53+
super().__init__(**kwargs)
54+
55+
# cache
56+
self._iopub_msg_cache: Optional[dict] = None
57+
58+
# Warning: asyncio.Event must be initialized in the event loop
59+
self._is_running_cell: Optional[asyncio.Event] = None
60+
self._interact_lock: Optional[asyncio.Event] = None
61+
self._is_interact_loop_stopped: Optional[asyncio.Event] = None
62+
63+
self._full_echo = os.getenv('FULL_ECHO', False)
64+
self._other_is_running_cell_with_echo = False
65+
self._other_is_running_cell = False
66+
67+
def init_kernel_info(self):
68+
""" Subclassed to print stdout/stderr stream if kernel is busy. """
69+
self.client.hb_channel.unpause()
70+
msg_id = self.client.kernel_info()
71+
iopub_socket = self.client.iopub_channel.socket
72+
shell_socket = self.client.shell_channel.socket
73+
socket_poller = zmq.Poller()
74+
socket_poller.register(iopub_socket, zmq.POLLIN)
75+
socket_poller.register(shell_socket, zmq.POLLIN)
76+
77+
while True:
78+
socks = dict(socket_poller.poll())
79+
80+
if socks.get(shell_socket) == zmq.POLLIN:
81+
reply = self.client.get_shell_msg()
82+
if reply['parent_header'].get('msg_id') == msg_id:
83+
self.kernel_info = reply['content']
84+
return
85+
86+
elif socks.get(iopub_socket) == zmq.POLLIN:
87+
msg = self.client.iopub_channel.get_msg()
88+
if msg['header']['msg_type'] == 'stream':
89+
if msg['content']['name'] == "stdout":
90+
print(msg['content']['text'], end='', flush=True)
91+
elif msg['content']['name'] == 'stderr':
92+
print(msg['content']['text'], end='', flush=True)
93+
94+
def _init_events(self):
95+
self._is_running_cell = asyncio.Event()
96+
self._is_interact_loop_stopped = asyncio.Event()
97+
self._interact_lock = asyncio.Event()
98+
self._is_running_cell.clear()
99+
self._is_interact_loop_stopped.clear()
100+
self._interact_lock.clear()
101+
102+
@property
103+
def interact_loop_is_locked(self) -> bool:
104+
return self._is_interact_loop_stopped.is_set()
105+
106+
@property
107+
def cell_is_running(self) -> bool:
108+
return self._is_running_cell.is_set()
109+
110+
async def lock_interact_loop(self):
111+
if not self.interact_loop_is_locked and not self.cell_is_running:
112+
app = get_app_or_none()
113+
if not app.is_done and app.is_running:
114+
self._interact_lock.clear()
115+
app.exit(exception=RemoteKernelSync('kernel sync'))
116+
await self._is_interact_loop_stopped.wait()
117+
118+
def unlock_interact_loop(self):
119+
if self.interact_loop_is_locked:
120+
self._interact_lock.set()
121+
122+
async def interact(self, loop=None, display_banner=None):
123+
""" Override to allow prompt freezing via `RemoteKernelSync`. """
124+
while self.keep_running:
125+
print('\n', end='')
126+
127+
try:
128+
code = await self.prompt_for_code()
129+
except EOFError:
130+
if (not self.confirm_exit
131+
or ask_yes_no('Do you really want to exit ([y]/n)?',
132+
'y', 'n')):
133+
self.ask_exit()
134+
except RemoteKernelSync:
135+
# Can fix ghost side effects of asynchronous prompt not yet exited
136+
# await asyncio.sleep(0.1)
137+
self._is_interact_loop_stopped.set()
138+
await self._interact_lock.wait()
139+
self._is_interact_loop_stopped.clear()
140+
141+
else:
142+
if code:
143+
self._is_running_cell.set()
144+
self.run_cell(code, store_history=True)
145+
self._is_running_cell.clear()
146+
147+
async def handle_external_iopub(self, loop=None):
148+
"""
149+
Override to fix inefficient and slow manual polling in parent method,
150+
and allow post jupyter message render with asynchronous capability in
151+
same event loop (for exemple for asynchronous event sync).
152+
"""
153+
self._init_events()
154+
poller = zmq.asyncio.Poller()
155+
poller.register(self.client.iopub_channel.socket, zmq.POLLIN)
156+
157+
while self.keep_running:
158+
events = dict(await poller.poll(0.5))
159+
160+
if self.client.iopub_channel.socket in events:
161+
self.handle_iopub()
162+
await self._post_jupyter_message_render(self._iopub_msg_cache)
163+
164+
async def _post_jupyter_message_render(self, msg: dict) -> None:
165+
msg = self._iopub_msg_cache
166+
msg_type = msg['header']['msg_type']
167+
168+
if (msg_type == 'execute_input'
169+
and self._other_is_running_cell_with_echo):
170+
await self.lock_interact_loop()
171+
content = self._iopub_msg_cache['content']
172+
ec = content.get('execution_count',
173+
self.execution_count - 1)
174+
175+
if self._pending_clearoutput:
176+
print("\r", end="")
177+
sys.stdout.flush()
178+
sys.stdout.flush()
179+
self._pending_clearoutput = False
180+
181+
sys.stdout.write(f'Remote In [{ec}]: {content["code"]}\n')
182+
sys.stdout.flush()
183+
184+
elif not self._other_is_running_cell_with_echo:
185+
self.unlock_interact_loop()
186+
187+
def _include_output(self, msg: dict) -> bool:
188+
self._set_terminal_states(msg)
189+
msg_type = msg['header']['msg_type']
190+
191+
if self._other_is_running_cell_with_echo and msg_type == 'execute_input':
192+
return False # input render from handle_iopub() is bugged for other
193+
elif self._other_is_running_cell and not self._other_is_running_cell_with_echo:
194+
return False # no render for other cell running without echo
195+
196+
return super().include_output(msg)
197+
198+
def _msg_cache_wrapper(self, msg: dict) -> bool:
199+
ret = self._include_output(msg)
200+
self._iopub_msg_cache = msg
201+
return ret
202+
203+
def include_output(self, msg: dict) -> bool:
204+
"""
205+
`Include_output()` is the best place to capture iopub message since this
206+
method is called just after read message on the channel iopub channel
207+
in `handle_iopub()`.
208+
"""
209+
return self._msg_cache_wrapper(msg)
210+
211+
def _set_terminal_states(self, msg: dict) -> None:
212+
"""
213+
Warning:
214+
This methods set the states only for this terminal layer before
215+
`handle_iopub()` set states, so `ZMQTerminalInteractiveShell` states
216+
are the past states (t-1), minus the `execution_count` counter which is
217+
synchronized before this method call.
218+
"""
219+
msg_type = msg['header']['msg_type']
220+
from_here = self.from_here(msg)
221+
222+
if (self.include_other_output
223+
and not from_here
224+
and self._execution_state == 'busy'
225+
and msg_type == 'execute_input'
226+
and (self._full_echo or msg['content']['code'] in self.ECHO_FILTER)):
227+
self._other_is_running_cell_with_echo = True
228+
elif (self.include_other_output
229+
and not from_here
230+
and self._execution_state == 'busy'
231+
and msg_type == 'status'
232+
and msg['content']['execution_state'] == 'idle'
233+
and (self._full_echo or self._other_is_running_cell_with_echo)):
234+
self._other_is_running_cell_with_echo = False
235+
236+
if (self.include_other_output
237+
and not from_here
238+
and self._execution_state == 'busy'
239+
and msg_type == 'execute_input'):
240+
self._other_is_running_cell = True
241+
elif (self.include_other_output
242+
and not from_here
243+
and self._execution_state == 'busy'
244+
and msg_type == 'status'
245+
and msg['content']['execution_state'] == 'idle'):
246+
self._other_is_running_cell = False
247+
248+
249+
class SynchronizedTerminalApp(ZMQTerminalIPythonApp):
250+
classes = [SynchronizedZmqTerminal] + JupyterConsoleApp.classes
251+
252+
def init_shell(self):
253+
JupyterConsoleApp.initialize(self)
254+
# relay sigint to kernel
255+
signal.signal(signal.SIGINT, self.handle_sigint)
256+
self.shell = SynchronizedZmqTerminal.instance(
257+
parent=self,
258+
manager=self.kernel_manager,
259+
client=self.kernel_client,
260+
confirm_exit=self.confirm_exit,
261+
)
262+
self.shell.own_kernel = not self.existing
263+
264+
265+
fiit_console = SynchronizedTerminalApp.launch_instance
266+
267+
268+
def fiit_console_from_backend(backend_ip: str, backend_port: str) -> None:
269+
zmq_context = zmq.Context()
270+
sock = zmq_context.socket(zmq.REQ)
271+
sock.connect(f'tcp://{backend_ip}:{backend_port}')
272+
req = BackendRequest(method=BACKEND_REQ_GET_BACKEND_DATA, id=uuid.uuid1().hex)
273+
sock.send_json(dataclasses.asdict(req))
274+
res = sock.recv_json()
275+
sock.close()
276+
277+
if res.get('error') is not None:
278+
print(f'error: {res["error"]["message"]}')
279+
sys.exit(1)
280+
281+
f = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
282+
f.write(res['result']['jupyter_client_json_config'])
283+
f.close()
284+
print(f'[i] Jupyter console configuration file dropped to "{f.name}".')
285+
SynchronizedTerminalApp.launch_instance(['--existing', f.name])

src/fiit/frontend/jupyter/jupyter_console.py

Lines changed: 0 additions & 51 deletions
This file was deleted.

src/fiit/plugins/backend.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class EventQueueInfo:
4949
@dataclasses.dataclass
5050
class BackendData:
5151
event_queue_info: EventQueueInfo = dataclasses.field(default_factory=EventQueueInfo)
52-
jupiter_client_json_config: Optional[str] = None
52+
jupyter_client_json_config: Optional[str] = None
5353

5454

5555
class ThreadSafeSingleton(type):
@@ -360,6 +360,8 @@ def run_backend_request_loop(self) -> None:
360360
# Backend Plugin
361361
################################################################################
362362

363+
BACKEND_REQUEST_DEFAULT_PORT = 2560
364+
363365

364366
class PluginBackend(FiitPlugin):
365367
NAME = 'plugin_backend'
@@ -372,7 +374,8 @@ class PluginBackend(FiitPlugin):
372374
'schema': {
373375
'allow_remote_connection': {'type': 'boolean', 'default': True,
374376
'required': False},
375-
'request_port': {'type': 'integer', 'required': True},
377+
'request_port': {'type': 'integer', 'required': False,
378+
'default': BACKEND_REQUEST_DEFAULT_PORT},
376379
'event_pub_port': {'type': 'integer', 'required': True},
377380
}
378381
}
@@ -390,7 +393,7 @@ def plugin_load(
390393
with BackendDataContext() as backend_data:
391394
if emulator_shell := plugins_context.get(ctx_conf.SHELL.name):
392395
if emulator_shell._remote_ipykernel:
393-
backend_data.jupiter_client_json_config = \
396+
backend_data.jupyter_client_json_config = \
394397
emulator_shell.get_remote_ipkernel_client_config()
395398

396399
backend.run_backend_request_loop()

0 commit comments

Comments
 (0)