From f7ae21c200c499c10f424d8443f687d5f2861f02 Mon Sep 17 00:00:00 2001 From: Evgeni Vakhonin Date: Mon, 8 Jun 2026 22:49:10 +0300 Subject: [PATCH 1/3] feat: add Ctrl-] x3 hotkey to power cycle board from serial console - Add on_power_cycle callback to Console; Ctrl-] x3 triggers it - Add root back-reference to DriverClient, stamped during tree construction - Auto-discover power client from DUT siblings in PySerialClient - Prefer cycle() when available, fall back to off()+on() Co-Authored-By: Claude Sonnet 4.6 --- .../jumpstarter_driver_pyserial/client.py | 32 +++++++++++++++++-- .../jumpstarter_driver_pyserial/console.py | 14 +++++++- .../jumpstarter/jumpstarter/client/base.py | 1 + .../jumpstarter/jumpstarter/client/client.py | 10 ++++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py index af7481f0f..1bd858031 100644 --- a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py @@ -3,7 +3,7 @@ from typing import Optional import click -from anyio import BrokenResourceError, EndOfStream, create_task_group, open_file +from anyio import BrokenResourceError, EndOfStream, create_task_group, open_file, sleep, to_thread from anyio.streams.file import FileReadStream from jumpstarter_driver_network.adapters import PexpectAdapter from pexpect.fdpexpect import fdspawn @@ -125,6 +125,30 @@ async def _stdin_to_serial(self, stream) -> tuple[int, int]: return bytes_read, bytes_sent + def _find_power_client(self): + if self.root is None: + return None + return self._search_power(self.root) + + def _search_power(self, client): + for child in client.children.values(): + if hasattr(child, "cycle") or (hasattr(child, "on") and hasattr(child, "off")): + return child + result = self._search_power(child) + if result is not None: + return result + return None + + def _make_power_cycle(self, power_client): + async def _cycle(): + if hasattr(power_client, "cycle"): + await to_thread.run_sync(power_client.cycle) + else: + await to_thread.run_sync(power_client.off) + await sleep(2) + await to_thread.run_sync(power_client.on) + return _cycle + def cli(self): # noqa: C901 @driver_click_group(self) def base(): @@ -134,8 +158,12 @@ def base(): @base.command() def start_console(): """Start serial port console""" + power_client = self._find_power_client() + on_power_cycle = self._make_power_cycle(power_client) if power_client is not None else None click.echo("\nStarting serial port console ... exit with CTRL+B x 3 times\n") - console = Console(serial_client=self) + if on_power_cycle is not None: + click.echo("Power cycle: CTRL+] x 3 times\n") + console = Console(serial_client=self, on_power_cycle=on_power_cycle) console.run() @base.command() diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py index b315b2f55..47caac769 100644 --- a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py @@ -1,6 +1,7 @@ import sys import termios import tty +from collections.abc import Awaitable, Callable from contextlib import contextmanager from anyio import create_task_group @@ -14,8 +15,9 @@ class ConsoleExit(Exception): class Console: - def __init__(self, serial_client: DriverClient): + def __init__(self, serial_client: DriverClient, on_power_cycle: Callable[[], Awaitable[None]] | None = None): self.serial_client = serial_client + self.on_power_cycle = on_power_cycle def run(self): with self.setraw(): @@ -49,14 +51,24 @@ async def __serial_to_stdout(self, stream): async def __stdin_to_serial(self, stream): stdin = FileReadStream(sys.stdin.buffer) ctrl_b_count = 0 + ctrl_bracket_count = 0 # Ctrl-] x3 triggers power cycle while True: data = await stdin.receive(max_bytes=1) if not data: continue if data == b"\x02": # Ctrl-B ctrl_b_count += 1 + ctrl_bracket_count = 0 if ctrl_b_count == 3: raise ConsoleExit + elif data == b"\x1d": # Ctrl-] + ctrl_bracket_count += 1 + ctrl_b_count = 0 + if ctrl_bracket_count == 3 and self.on_power_cycle is not None: + await self.on_power_cycle() + ctrl_bracket_count = 0 + continue else: ctrl_b_count = 0 + ctrl_bracket_count = 0 await stream.send(data) diff --git a/python/packages/jumpstarter/jumpstarter/client/base.py b/python/packages/jumpstarter/jumpstarter/client/base.py index 8d6df5db3..8490277ee 100644 --- a/python/packages/jumpstarter/jumpstarter/client/base.py +++ b/python/packages/jumpstarter/jumpstarter/client/base.py @@ -30,6 +30,7 @@ class DriverClient(AsyncDriverClient): """ children: dict[str, DriverClient] = field(default_factory=dict) + root: DriverClient | None = field(default=None) portal: BlockingPortal stack: ExitStack diff --git a/python/packages/jumpstarter/jumpstarter/client/client.py b/python/packages/jumpstarter/jumpstarter/client/client.py index 980f2da39..98cf9c117 100644 --- a/python/packages/jumpstarter/jumpstarter/client/client.py +++ b/python/packages/jumpstarter/jumpstarter/client/client.py @@ -134,4 +134,14 @@ async def client_from_channel( clients[index] = client + root_client = next(reversed(clients.values())) + + def _iter_all(client): + yield client + for child in client.children.values(): + yield from _iter_all(child) + + for c in _iter_all(root_client): + c.root = root_client + return clients.popitem(last=True)[1] From c720171f508244bb69ea6f11b4c0481e04c75bc7 Mon Sep 17 00:00:00 2001 From: Evgeni Vakhonin Date: Thu, 11 Jun 2026 22:51:32 +0300 Subject: [PATCH 2/3] fix: reconnect console on serial stream drop after power cycle - Add ConsoleStreamDrop exception to signal a dropped serial stream - Add retry loop in start_console to reconnect on stream drop - Use object.__setattr__ to stamp root back-reference without polluting pydantic schema - Remove root field from DriverClient; it is now a dynamic attribute Co-Authored-By: Claude Sonnet 4.6 --- .../jumpstarter_driver_pyserial/client.py | 22 ++++++++--- .../jumpstarter_driver_pyserial/console.py | 37 +++++++++++++------ .../jumpstarter/jumpstarter/client/base.py | 1 - .../jumpstarter/jumpstarter/client/client.py | 3 +- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py index 1bd858031..eb2c30d54 100644 --- a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py @@ -1,4 +1,5 @@ import sys +import time from contextlib import contextmanager from typing import Optional @@ -8,7 +9,7 @@ from jumpstarter_driver_network.adapters import PexpectAdapter from pexpect.fdpexpect import fdspawn -from .console import Console +from .console import Console, ConsoleStreamDrop from jumpstarter.client import DriverClient from jumpstarter.client.decorators import driver_click_group @@ -126,9 +127,10 @@ async def _stdin_to_serial(self, stream) -> tuple[int, int]: return bytes_read, bytes_sent def _find_power_client(self): - if self.root is None: + root = getattr(self, 'root', None) + if root is None: return None - return self._search_power(self.root) + return self._search_power(root) def _search_power(self, client): for child in client.children.values(): @@ -163,8 +165,18 @@ def start_console(): click.echo("\nStarting serial port console ... exit with CTRL+B x 3 times\n") if on_power_cycle is not None: click.echo("Power cycle: CTRL+] x 3 times\n") - console = Console(serial_client=self, on_power_cycle=on_power_cycle) - console.run() + retries = 0 + while retries < 30: + console = Console(serial_client=self, on_power_cycle=on_power_cycle) + try: + console.run() + break + except ConsoleStreamDrop: + click.echo("\r\nSerial connection lost, reconnecting...\n", err=True) + retries += 1 + time.sleep(1) + else: + click.echo("\nSerial connection lost (reconnect attempts exhausted).\n", err=True) @base.command() @click.option( diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py index 47caac769..f27e2de12 100644 --- a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from contextlib import contextmanager -from anyio import create_task_group +from anyio import EndOfStream, create_task_group from anyio.streams.file import FileReadStream, FileWriteStream from jumpstarter.client import DriverClient @@ -14,6 +14,11 @@ class ConsoleExit(Exception): pass +class ConsoleStreamDrop(Exception): + """Serial stream dropped; caller may reconnect.""" + pass + + class Console: def __init__(self, serial_client: DriverClient, on_power_cycle: Callable[[], Awaitable[None]] | None = None): self.serial_client = serial_client @@ -33,20 +38,28 @@ def setraw(self): termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, original) async def __run(self): - async with self.serial_client.stream_async(method="connect") as stream: - try: - async with create_task_group() as tg: - tg.start_soon(self.__serial_to_stdout, stream) - tg.start_soon(self.__stdin_to_serial, stream) - except* ConsoleExit: - pass + try: + async with self.serial_client.stream_async(method="connect") as stream: + try: + async with create_task_group() as tg: + tg.start_soon(self.__serial_to_stdout, stream) + tg.start_soon(self.__stdin_to_serial, stream) + except* ConsoleExit: + pass + except* ConsoleStreamDrop: + raise ConsoleStreamDrop() from None + except EndOfStream: + raise ConsoleStreamDrop() from None async def __serial_to_stdout(self, stream): stdout = FileWriteStream(sys.stdout.buffer) - while True: - data = await stream.receive() - await stdout.send(data) - sys.stdout.flush() + try: + while True: + data = await stream.receive() + await stdout.send(data) + sys.stdout.flush() + except EndOfStream: + raise ConsoleStreamDrop() from None async def __stdin_to_serial(self, stream): stdin = FileReadStream(sys.stdin.buffer) diff --git a/python/packages/jumpstarter/jumpstarter/client/base.py b/python/packages/jumpstarter/jumpstarter/client/base.py index 8490277ee..8d6df5db3 100644 --- a/python/packages/jumpstarter/jumpstarter/client/base.py +++ b/python/packages/jumpstarter/jumpstarter/client/base.py @@ -30,7 +30,6 @@ class DriverClient(AsyncDriverClient): """ children: dict[str, DriverClient] = field(default_factory=dict) - root: DriverClient | None = field(default=None) portal: BlockingPortal stack: ExitStack diff --git a/python/packages/jumpstarter/jumpstarter/client/client.py b/python/packages/jumpstarter/jumpstarter/client/client.py index 98cf9c117..8cb212ed8 100644 --- a/python/packages/jumpstarter/jumpstarter/client/client.py +++ b/python/packages/jumpstarter/jumpstarter/client/client.py @@ -97,7 +97,6 @@ async def client_from_channel( stub = MultipathExporterStub([channel]) response = await stub.GetReport(empty_pb2.Empty()) - for index, report in enumerate(response.reports): topo[index] = [] @@ -142,6 +141,6 @@ def _iter_all(client): yield from _iter_all(child) for c in _iter_all(root_client): - c.root = root_client + object.__setattr__(c, 'root', root_client) return clients.popitem(last=True)[1] From beac1ee8b30ddc17b5b71c171d5d3f4e051f5152 Mon Sep 17 00:00:00 2001 From: Evgeni Vakhonin Date: Fri, 12 Jun 2026 00:10:23 +0300 Subject: [PATCH 3/3] test: add console hotkey tests using PTY-simulated stdin - Ctrl-B x3 exits cleanly - Ctrl-] x3 triggers on_power_cycle and console stays alive - Ctrl-] x3 without a power client does nothing Co-Authored-By: Claude Sonnet 4.6 --- .../client_test.py | 32 +++++++ .../console_test.py | 91 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py create mode 100644 python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py new file mode 100644 index 000000000..dfefb4df9 --- /dev/null +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py @@ -0,0 +1,32 @@ +import threading +from unittest.mock import MagicMock + +from .driver import PySerial +from jumpstarter.common.utils import serve + + +def test_find_power_client_no_root(): + with serve(PySerial(url="loop://")) as client: + assert client._find_power_client() is None + + +def test_find_power_client_with_cycle(): + power = MagicMock(spec=["cycle", "children"]) + power.children = {} + root = MagicMock(spec=["children"]) + root.children = {"power": power} + + with serve(PySerial(url="loop://")) as client: + object.__setattr__(client, "root", root) + assert client._find_power_client() is power + + +def test_make_power_cycle_calls_cycle(): + called = threading.Event() + power = MagicMock() + power.cycle = MagicMock(side_effect=lambda: called.set()) + + with serve(PySerial(url="loop://")) as client: + cycle_fn = client._make_power_cycle(power) + client.portal.call(cycle_fn) + assert called.is_set() diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py new file mode 100644 index 000000000..4e8f691d4 --- /dev/null +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py @@ -0,0 +1,91 @@ +import os +import threading +import time +from unittest.mock import MagicMock, patch + +from .console import Console +from .driver import PySerial +from jumpstarter.common.utils import serve + + +def _start_console(client, on_power_cycle=None): + """Run Console.run() in a thread with a PTY substituted for stdin. + + Returns (master_fd, thread, result_dict). Write keypresses to master_fd; + the result dict gets an 'exc' key if the console thread raises. + """ + master_fd, slave_fd = os.openpty() + slave_file = os.fdopen(slave_fd, "rb", buffering=0) + + mock_stdin = MagicMock() + mock_stdin.fileno.return_value = slave_fd + mock_stdin.buffer = slave_file + + result = {} + + def _run(): + with patch("sys.stdin", mock_stdin): + console = Console(serial_client=client, on_power_cycle=on_power_cycle) + try: + console.run() + except Exception as e: + result["exc"] = e + slave_file.close() + + t = threading.Thread(target=_run, daemon=True) + t.start() + return master_fd, t, result + + +def test_ctrl_b_exits(): + with serve(PySerial(url="loop://")) as client: + master_fd, t, result = _start_console(client) + try: + time.sleep(0.1) + os.write(master_fd, b"a") + os.write(master_fd, b"\x02\x02\x02") + t.join(timeout=5) + finally: + os.close(master_fd) + + assert not t.is_alive(), "console did not exit after Ctrl-B x3" + assert "exc" not in result + + +def test_ctrl_bracket_triggers_power_cycle(): + power_cycled = threading.Event() + + async def on_power_cycle(): + power_cycled.set() + + with serve(PySerial(url="loop://")) as client: + master_fd, t, result = _start_console(client, on_power_cycle=on_power_cycle) + try: + time.sleep(0.1) + os.write(master_fd, b"\x1d\x1d\x1d") + assert power_cycled.wait(timeout=5), "power cycle was not triggered" + assert t.is_alive(), "console exited after power cycle" + os.write(master_fd, b"\x02\x02\x02") + t.join(timeout=5) + finally: + os.close(master_fd) + + assert not t.is_alive() + assert "exc" not in result + + +def test_ctrl_bracket_without_power_client(): + with serve(PySerial(url="loop://")) as client: + master_fd, t, result = _start_console(client, on_power_cycle=None) + try: + time.sleep(0.1) + os.write(master_fd, b"\x1d\x1d\x1d") + time.sleep(0.1) + assert t.is_alive(), "console exited unexpectedly on Ctrl-] without power client" + os.write(master_fd, b"\x02\x02\x02") + t.join(timeout=5) + finally: + os.close(master_fd) + + assert not t.is_alive() + assert "exc" not in result