Skip to content

feat: add Ctrl-] x3 hotkey to power cycle board from serial console#791

Open
evakhoni wants to merge 3 commits into
jumpstarter-dev:mainfrom
evakhoni:feat/console-power-hotkey
Open

feat: add Ctrl-] x3 hotkey to power cycle board from serial console#791
evakhoni wants to merge 3 commits into
jumpstarter-dev:mainfrom
evakhoni:feat/console-power-hotkey

Conversation

@evakhoni

@evakhoni evakhoni commented Jun 14, 2026

Copy link
Copy Markdown
Member

Summary

  • Add Ctrl-] x3 hotkey to the serial console that power cycles the board without exiting the session
  • Auto-discover the power driver from the DUT tree — no manual wiring needed; walks the tree looking for a driver that exposes cycle() or on()/off()
  • Reconnect the console automatically if the serial stream drops after the power cycle (driver-dependent; QEMU closes the serial on reset, most hardware does not)

How it works

The root client is back-referenced onto every driver client after tree construction (object.__setattr__ is used to avoid adding a field to the pydantic schema). PySerialClient uses this to walk the tree and find the first power-capable driver at console start. The discovered callback is passed into Console, which intercepts the Ctrl-] sequence and invokes it. If the serial stream subsequently drops, ConsoleStreamDrop signals start_console to retry the connection.

Test plan

  • With a power driver in the DUT tree: power cycle hint appears, Ctrl-] x3 triggers a power cycle, console survives
  • Without a power driver in the DUT tree: no hint, Ctrl-] x3 has no effect, console works normally
  • Ctrl-B x3 exits cleanly

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds reconnect and power-cycle support to the pyserial interactive console. ConsoleStreamDrop is introduced to signal dropped serial streams. Console gains an optional on_power_cycle async callback triggered by three Ctrl-] keypresses. The CLI discovers a power controller in the client tree, builds the callback, and retries up to 30 times on ConsoleStreamDrop. client_from_channel propagates a root attribute across all client nodes.

Changes

Serial Console Power-Cycle and Reconnect

Layer / File(s) Summary
Root client propagation in client_from_channel
python/packages/jumpstarter/jumpstarter/client/client.py
After building the clients mapping, recursively walks children from root_client and stamps a root attribute on every node via object.__setattr__.
ConsoleStreamDrop exception and Console constructor extension
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py
Adds ConsoleStreamDrop exception, extends Console.__init__ with an optional `on_power_cycle: Callable[[], Awaitable[None]]
Stream-drop detection and Ctrl-] input handling
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py
Converts EndOfStream to ConsoleStreamDrop in __run and __serial_to_stdout; reworks __stdin_to_serial to maintain separate counters for Ctrl-B (exit) and Ctrl-] (power-cycle), invoking on_power_cycle after three Ctrl-] events.
Power controller discovery and retry loop in CLI
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py
Adds _search_power/_find_power_client to locate a power controller in the client tree, _make_power_cycle to build an async off→sleep(2)→on or cycle() callable, and retries Console.run() up to 30 times on ConsoleStreamDrop.
Unit tests for power client discovery
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py
Tests verify _find_power_client returns None with no root client, returns the correct power device when found in root.children, and _make_power_cycle produces an async callable that invokes power operations via portal call.
Integration tests for console key sequences
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py
Adds _start_console PTY helper and three tests: Ctrl-B exits cleanly, Ctrl-] triggers the on_power_cycle callback while the console stays alive, Ctrl-] with no callback does not terminate the console.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • jumpstarter-dev/jumpstarter#408: Directly relates to the async Console in console.py — this PR layers ConsoleStreamDrop, on_power_cycle, and Ctrl-] handling on top of changes introduced there.

Suggested reviewers

  • mangelajo

Poem

🐇 Hop hop, the serial stream fell away,
But the rabbit retries—thirty times a day!
Ctrl-] three times and power shall cycle,
Ctrl-B to escape when things get tidal.
Root clients now know their family tree,
Reconnect, reboot, then hop back with glee! 🔌

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add Ctrl-] x3 hotkey to power cycle board from serial console' directly and clearly describes the main feature addition in the changeset.
Description check ✅ Passed The description provides relevant context about the implementation, explaining how the power-cycle hotkey works, tree discovery mechanism, and reconnection behavior.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@LuuOW LuuOW left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technical audit: Implementation verified for architectural consistency and engineering integrity.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py`:
- Around line 135-142: The `_search_power()` method only checks the children of
the provided client node but skips checking if the client node itself is
power-capable. This causes power-driver discovery to fail when the root node has
power capabilities. Modify the method to first check if the current `client`
parameter has a `cycle` method or both `on` and `off` methods before recursing
into `client.children`, ensuring the root node is included in the power-driver
discovery process.

In
`@python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py`:
- Around line 17-37: The slave_file opened via os.fdopen() in the
_start_console() function is never explicitly closed, causing file descriptor
leaks across multiple test runs. Ensure proper cleanup by either returning
slave_file as part of the function's return tuple so the caller can close it
after the thread completes, or by adding a cleanup mechanism that closes
slave_file after the console.run() thread finishes executing. The key is to
guarantee that the file handle opened for slave_fd is properly closed to prevent
descriptor exhaustion.

In
`@python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py`:
- Around line 47-52: The exception re-raising in the except* ConsoleStreamDrop
and except EndOfStream blocks violates Ruff B904 linting rules by not using
explicit exception chaining. For each exception handler block that raises an
exception (the except* ConsoleStreamDrop block that raises ConsoleStreamDrop()
and the except EndOfStream block that raises ConsoleStreamDrop()), capture the
caught exception as a variable in the except clause (e.g., except*
ConsoleStreamDrop as exc) and use the from keyword when raising to provide
explicit chaining (e.g., raise ConsoleStreamDrop() from exc). Alternatively, use
make lint-fix to automatically apply the necessary fixes to satisfy B904
requirements.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7c042f0b-f525-40fa-9c8d-e51bfe0279df

📥 Commits

Reviewing files that changed from the base of the PR and between 24473f3 and 5118a4a.

📒 Files selected for processing (4)
  • python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py
  • python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console.py
  • python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py
  • python/packages/jumpstarter/jumpstarter/client/client.py

evakhoni and others added 2 commits June 14, 2026 18:00
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
@evakhoni evakhoni force-pushed the feat/console-power-hotkey branch 2 times, most recently from 0242c3e to 67ebd74 Compare June 14, 2026 16:06
- 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 <noreply@anthropic.com>
@evakhoni evakhoni force-pushed the feat/console-power-hotkey branch from 67ebd74 to beac1ee Compare June 14, 2026 18:30

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py (1)

8-33: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Consolidate new driver-package tests into driver_test.py per repository policy.

Both files add comprehensive driver-package test coverage, but not in the required module location.

  • python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py#L8-L33: move/merge the power discovery and power-cycle tests into jumpstarter_driver_pyserial/driver_test.py.
  • python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py#L40-L91: move/merge the Ctrl-B/Ctrl-] integration tests into jumpstarter_driver_pyserial/driver_test.py.

As per coding guidelines, **/jumpstarter-driver-*/jumpstarter_driver_*/*_test.py: Add comprehensive tests in driver_test.py file within the driver package.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py`
around lines 8 - 33, Move the power discovery and power-cycle tests
(test_find_power_client_no_root, test_find_power_client_with_cycle,
test_make_power_cycle_calls_cycle) from
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py
lines 8-33 into
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver_test.py,
and similarly move the Ctrl-B/Ctrl-] integration tests from
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py
lines 40-91 into the same driver_test.py file. Per the coding guidelines,
comprehensive driver-package tests must be consolidated in the driver_test.py
file within the driver package rather than scattered across individual module
test files.

Source: Coding guidelines

🧹 Nitpick comments (2)
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py (1)

24-33: ⚡ Quick win

Add coverage for the _make_power_cycle fallback branch.

test_make_power_cycle_calls_cycle validates only the cycle() path; the off()sleep(2)on() path in python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py is still untested.

💡 Suggested test addition
+def test_make_power_cycle_calls_off_on_when_cycle_missing():
+    called_off = threading.Event()
+    called_on = threading.Event()
+    power = MagicMock(spec=["off", "on"])
+    power.off = MagicMock(side_effect=lambda: called_off.set())
+    power.on = MagicMock(side_effect=lambda: called_on.set())
+
+    with serve(PySerial(url="loop://")) as client:
+        cycle_fn = client._make_power_cycle(power)
+        client.portal.call(cycle_fn)
+
+    assert called_off.is_set()
+    assert called_on.is_set()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py`
around lines 24 - 33, The test_make_power_cycle_calls_cycle test only covers the
cycle() code path in the _make_power_cycle method, but the fallback branch that
uses off() followed by sleep(2) followed by on() remains untested. Add a new
test case that mocks the power object to trigger the fallback path (either by
not providing a cycle method or having it unavailable), then verify that the
fallback sequence of off(), sleep(), and on() method calls is executed properly
when the cycle function is invoked through client.portal.call().
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py (1)

44-45: ⚡ Quick win

Replace fixed sleeps with deterministic readiness signaling.

Line 44, Line 64, and Line 81 use time.sleep(0.1) to assume console readiness; this can cause intermittent CI flakes.

Also applies to: 64-65, 81-83

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py`
around lines 44 - 45, Replace the three instances of time.sleep(0.1) calls with
deterministic readiness signaling instead of relying on fixed delays to assume
console readiness. For each location where time.sleep(0.1) precedes an os.write
operation, implement a proper synchronization mechanism such as using select,
poll, or event-based signaling to confirm the file descriptor is actually ready
for writing before proceeding, eliminating the intermittent CI flakes caused by
timing assumptions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py`:
- Around line 8-33: Move the power discovery and power-cycle tests
(test_find_power_client_no_root, test_find_power_client_with_cycle,
test_make_power_cycle_calls_cycle) from
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py
lines 8-33 into
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver_test.py,
and similarly move the Ctrl-B/Ctrl-] integration tests from
python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py
lines 40-91 into the same driver_test.py file. Per the coding guidelines,
comprehensive driver-package tests must be consolidated in the driver_test.py
file within the driver package rather than scattered across individual module
test files.

---

Nitpick comments:
In
`@python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py`:
- Around line 24-33: The test_make_power_cycle_calls_cycle test only covers the
cycle() code path in the _make_power_cycle method, but the fallback branch that
uses off() followed by sleep(2) followed by on() remains untested. Add a new
test case that mocks the power object to trigger the fallback path (either by
not providing a cycle method or having it unavailable), then verify that the
fallback sequence of off(), sleep(), and on() method calls is executed properly
when the cycle function is invoked through client.portal.call().

In
`@python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py`:
- Around line 44-45: Replace the three instances of time.sleep(0.1) calls with
deterministic readiness signaling instead of relying on fixed delays to assume
console readiness. For each location where time.sleep(0.1) precedes an os.write
operation, implement a proper synchronization mechanism such as using select,
poll, or event-based signaling to confirm the file descriptor is actually ready
for writing before proceeding, eliminating the intermittent CI flakes caused by
timing assumptions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cb30cdcc-eb35-42ef-8a00-b909cd4dc09f

📥 Commits

Reviewing files that changed from the base of the PR and between 67ebd74 and beac1ee.

📒 Files selected for processing (2)
  • python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client_test.py
  • python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/console_test.py


return bytes_read, bytes_sent

def _find_power_client(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should provide a config entry to let admins specify the power device via a "ref". While I think that finding it automatically is cool, and will work out of the box in most cases, imagine environments where you have multiple power controls , and the power controls could not be what you are expecting, or the reset mechanism is different.

Even in some cases the method to be called could be "reset()", i.e. in the esp32 controller.

So I would take a config with "ref" + method in the serial config

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i.e.

export:
   serial:
      type:....
      config:
             ....
      reset_control:
         device:
            ref: "esp32"
         commands:
              - "reset()"
export:
   serial:
      type:....
      config:
             ....
      reset_control:
         device:
            ref: "power"
         commands:
              - "on()"
              - "sleep 2"
              - "off()"

or something like this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah config sounds good. but if unset let it fallback to the auto discovery WDYT? otherwise I guess the user base would be rather small..
+1 on the reset(), i thought I seen it somewhere but forgot it was esp32.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO auto detect continues to be risky, it could pick up the wrong power device if you had multiple doing different things in your setup. We could provide a flag for auto-detection, but explain very clearly what it does, and should be disabled by default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants