Skip to content

Add Renode emulator driver for embedded target simulation#533

Open
vtz wants to merge 5 commits intojumpstarter-dev:mainfrom
vtz:feat/renode-driver
Open

Add Renode emulator driver for embedded target simulation#533
vtz wants to merge 5 commits intojumpstarter-dev:mainfrom
vtz:feat/renode-driver

Conversation

@vtz
Copy link
Copy Markdown
Contributor

@vtz vtz commented Apr 9, 2026

Summary

  • Introduce jumpstarter-driver-renode, a composite driver that enables Renode-based virtual hardware targets in Jumpstarter
  • Users can define any Renode-supported platform (STM32, S32K, Nucleo H753ZI, etc.) via exporter YAML configuration without modifying driver code
  • Includes RenodeMonitor (async telnet client), RenodePower (process lifecycle), RenodeFlasher (firmware loading via sysbus LoadELF/LoadBinary), and RenodeClient (composite client with CLI extension)
  • Adds ADR-0001 documenting architectural decisions: control interface (telnet monitor), UART exposure (PTY terminals), configuration model (managed mode with extra_commands), and firmware loading strategy

Design Decisions

Key architectural decisions are documented in python/docs/source/contributing/adr/0001-renode-integration.md:

Decision Choice Rationale
Control Interface Telnet Monitor Lowest-common-denominator, mirrors QEMU's QMP pattern, uses anyio.connect_tcp
UART Exposure PTY Terminals Consistent with QEMU driver's PySerial integration
Configuration Model Managed Mode Driver constructs monitor commands from YAML params; extra_commands for customization
Firmware Loading Deferred to Flash Aligns with QEMU's flash() then on() semantic

Files Changed

  • python/packages/jumpstarter-driver-renode/ — Full driver package (monitor, driver, client, tests, examples)
  • python/docs/source/contributing/adr/ — ADR index and ADR-0001 for Renode integration
  • python/docs/source/reference/package-apis/drivers/renode.md — Docs symlink to driver README

Test Plan

  • Unit tests for RenodeMonitor (connect, execute, close, timeout, error handling)
  • Unit tests for RenodePower (on/off, firmware loading, UART configuration)
  • Unit tests for RenodeFlasher (flash, format detection, not-running error)
  • Unit tests for Renode config validation (missing platform, defaults, UART config)
  • Client and CLI tests (property access, monitor command forwarding)
  • E2E test with serve() (requires Renode installed, marked with skipif)
  • CI: Add Renode installation step to python-tests.yaml (follow-up)

Made with Cursor

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 9, 2026

Deploy Preview for jumpstarter-docs failed. Why did it fail? →

Name Link
🔨 Latest commit 51a01cf
🔍 Latest deploy log https://app.netlify.com/projects/jumpstarter-docs/deploys/69dc9b6696025700081bf905

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

📝 Walkthrough

Walkthrough

Adds a new Renode driver package to Jumpstarter: async Renode telnet monitor client, RenodePower and RenodeFlasher drivers, composite Renode driver and RPC client/CLI, tests, package metadata, examples, README, and an ADR documenting design decisions and risks.

Changes

Cohort / File(s) Summary
ADR & Docs
python/docs/source/contributing/adr/0001-renode-integration.md, python/docs/source/contributing/adr/index.md, python/docs/source/reference/package-apis/drivers/renode.md
New ADR describing Renode integration scope, design decisions (telnet monitor, PTY console, YAML-managed config, flash/on semantics), ADR index, and a reference doc linking to the package README.
Package metadata & examples
python/packages/jumpstarter-driver-renode/pyproject.toml, python/packages/jumpstarter-driver-renode/README.md, python/packages/jumpstarter-driver-renode/examples/exporter.yaml, python/packages/jumpstarter-driver-renode/.gitignore
New package pyproject, README, example exporter YAML, and .gitignore; registers entry points for Renode drivers and dev/test config.
Monitor client
python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/monitor.py
Async Renode telnet monitor client with connect/retry, prompt detection, execute(command)->response, disconnect, and RenodeMonitorError.
Driver implementation
python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py
Composite Renode driver plus RenodePower (process lifecycle, monitor wiring, PTY UART) and RenodeFlasher (temp firmware storage, load command handling, hot-load when running); child driver wiring and teardown logic.
Client & CLI
python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/client.py
RenodeClient exposing platform, uart, machine_name, monitor_cmd() and a monitor CLI subcommand.
Tests
python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py
Comprehensive pytest suite covering monitor connect/execute/disconnect, power on/off/close flows, flasher behavior (hot-load, override load_command), config wiring, PTY/temp lifecycles, client/CLI behavior, plus an E2E placeholder.

Sequence Diagrams

sequenceDiagram
    participant CLI
    participant RenodeDriver as Renode Driver
    participant Power as RenodePower
    participant Monitor as RenodeMonitor
    participant RenodeProc as Renode Process
    participant PTY as PTY Terminal

    CLI->>RenodeDriver: on()
    RenodeDriver->>Power: on()
    Power->>Power: pick free port
    Power->>RenodeProc: spawn renode --monitor-port=<port>
    Power->>Monitor: connect(host=localhost, port)
    Monitor->>RenodeProc: TCP connection
    Monitor->>Monitor: read until prompt
    Monitor-->>Power: connected

    Power->>Monitor: execute("machine LoadPlatform ...")
    Monitor->>RenodeProc: send command + newline
    Monitor->>Monitor: read until prompt
    Monitor-->>Power: response

    Power->>PTY: create PTY for UART
    Power->>Monitor: execute("connector ...")
    Monitor-->>Power: connector ok

    alt firmware configured
        Power->>Monitor: execute("sysbus LoadELF <path>")
        Monitor-->>Power: load ok
    end

    Power->>Monitor: execute("start")
    Monitor-->>Power: running
    Power-->>RenodeDriver: on() complete
    RenodeDriver-->>CLI: Ready
Loading
sequenceDiagram
    participant CLI
    participant RenodeDriver as Renode Driver
    participant Flasher as RenodeFlasher
    participant Monitor as RenodeMonitor
    participant RenodeProc as Renode Process

    CLI->>RenodeDriver: flash(firmware_resource)
    RenodeDriver->>Flasher: flash(resource)
    Flasher->>Flasher: write resource -> temp file
    Flasher->>Flasher: record firmware path & load_command

    alt simulation running
        Flasher->>Monitor: execute("sysbus LoadELF <path>")
        Monitor->>RenodeProc: perform load
        Monitor-->>Flasher: success
        Flasher->>Monitor: execute("machine Reset")
        Monitor-->>Flasher: reset ok
    end

    Flasher-->>RenodeDriver: flash() complete
    RenodeDriver-->>CLI: Firmware stored/loaded
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

backport release-0.7

Suggested reviewers

  • kirkbrauer
  • mangelajo

Poem

🐰 I dug a tiny temp-file den,
Telnet hops and PTY pen,
Firmware naps in a soft binary bed,
Reset, start — the emu hums ahead,
Hooray, Renode springs to life again!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add Renode emulator driver for embedded target simulation' clearly and specifically summarizes the main change: introducing a new Renode driver package for embedded target simulation.
Description check ✅ Passed The description is directly related to the changeset, providing a summary of the driver components, design decisions documented in ADR-0001, files changed, and test plan.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ 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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/client.py (1)

22-31: Accept the remainder of the CLI line as the monitor command.

Most Renode monitor commands contain spaces, so this only works naturally when the whole command is shell-quoted. Using a variadic Click argument makes ... monitor sysbus LoadELF @foo.elf`` behave as expected.

Proposed fix
     def cli(self):
         base = super().cli()

         `@base.command`(name="monitor")
-        `@click.argument`("command")
-        def monitor_command(command):
+        `@click.argument`("command", nargs=-1)
+        def monitor_command(command: tuple[str, ...]):
             """Send a command to the Renode monitor."""
-            result = self.monitor_cmd(command)
+            result = self.monitor_cmd(" ".join(command))
             if result.strip():
                 click.echo(result.strip())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/client.py`
around lines 22 - 31, The CLI monitor handler currently defines a single-word
argument and should accept the remainder of the CLI line; update the Click
argument in cli() for the nested monitor_command (the `@base.command` handler) to
use a variadic argument (click.argument("command", nargs=-1)) and then join the
tuple into a single string before calling monitor_cmd (e.g., command_str = "
".join(command) and pass command_str to self.monitor_cmd) so multi-word Renode
monitor commands work without shell-quoting.
python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py (1)

298-310: Consider renaming test for clarity.

The test name test_power_close_calls_off is misleading since close() doesn't actually call off() — it directly terminates the process without sending the quit command or disconnecting the monitor. Consider renaming to test_power_close_terminates_process to accurately reflect the behavior being tested.

Suggested rename
     `@pytest.mark.anyio`
-    async def test_power_close_calls_off(self):
-        """close() terminates the process."""
+    async def test_power_close_terminates_process(self):
+        """close() terminates the process directly without monitor cleanup."""
         driver = _make_driver()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py`
around lines 298 - 310, Rename the test function to reflect actual behavior:
change the test named test_power_close_calls_off to
test_power_close_terminates_process and update its reference in the file (the
async test function that calls RenodePower.close and asserts
mock_process.terminate was called and power._process is None) so the name
accurately describes that close() terminates the process rather than calling
off().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py`:
- Around line 99-127: The block that starts the Renode process with Popen and
then performs monitor setup (Popen -> self._process, RenodeMonitor,
self._monitor.connect, and subsequent self._monitor.execute calls) must be
wrapped in a try/except so any exception during monitor connection or commands
will teardown the spawned subprocess to avoid leaking it and leaving
self._process set. Modify the code so after creating self._process you use try:
... to run the RenodeMonitor connect and all execute calls (including load and
start), and in except Exception as e: ensure you cleanly stop the subprocess
(call self._process.terminate()/kill(), wait() and close pipes), set
self._process = None, then re-raise the exception; reference the symbols Popen,
self._process, RenodeMonitor, self._monitor.connect, self._monitor.execute and
ensure cleanup mirrors what off() would do.
- Around line 98-99: The Popen invocation in driver.py is piping
stdin/stdout/stderr (self._process = Popen(..., stdin=PIPE, stdout=PIPE,
stderr=PIPE)) which are never read and can cause the Renode process to block;
change those three arguments to use subprocess.DEVNULL instead so the child
won't deadlock (import DEVNULL if needed) — update the Popen call where
self._process is created and ensure any related code expecting piped streams is
removed or adjusted.
- Around line 144-148: The shutdown path is blocking because it calls the
synchronous Popen.wait(timeout=5) (used alongside self._process.terminate() and
except TimeoutExpired -> self._process.kill()) inside an async context (off());
change this to a non-blocking approach by running the blocking wait/killing
logic off the event loop (e.g., wrap the wait/kill sequence in a thread via
asyncio.to_thread or AnyIO's thread-runner) or replace it with an async poll
loop using self._process.poll() plus async sleep and then call kill if still
running; update the code around self._process.terminate(), self._process.wait(),
TimeoutExpired, and self._process.kill() accordingly so off() never blocks the
event loop.

In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/monitor.py`:
- Around line 43-52: The execute method currently returns raw Renode responses;
update execute (in jumpstarter_driver_renode.monitor.Monitor.execute) to inspect
the text returned by _read_until_prompt and, if it indicates a command failure
(e.g., contains "ERROR", "Error", "Failed", or matches Renode's error prefix),
raise RenodeMonitorError with the full response instead of returning it; keep
logging the request/response but throw RenodeMonitorError to stop initialization
(adjust tests like test_monitor_execute_error_response and callers such as
RenodePower.on which rely on execute's behavior to handle failures).

---

Nitpick comments:
In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/client.py`:
- Around line 22-31: The CLI monitor handler currently defines a single-word
argument and should accept the remainder of the CLI line; update the Click
argument in cli() for the nested monitor_command (the `@base.command` handler) to
use a variadic argument (click.argument("command", nargs=-1)) and then join the
tuple into a single string before calling monitor_cmd (e.g., command_str = "
".join(command) and pass command_str to self.monitor_cmd) so multi-word Renode
monitor commands work without shell-quoting.

In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py`:
- Around line 298-310: Rename the test function to reflect actual behavior:
change the test named test_power_close_calls_off to
test_power_close_terminates_process and update its reference in the file (the
async test function that calls RenodePower.close and asserts
mock_process.terminate was called and power._process is None) so the name
accurately describes that close() terminates the process rather than calling
off().
🪄 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: d349f643-86a8-49ef-8781-30c12c5cb166

📥 Commits

Reviewing files that changed from the base of the PR and between 8896671 and afc136f.

📒 Files selected for processing (12)
  • python/docs/source/contributing/adr/0001-renode-integration.md
  • python/docs/source/contributing/adr/index.md
  • python/docs/source/reference/package-apis/drivers/renode.md
  • python/packages/jumpstarter-driver-renode/.gitignore
  • python/packages/jumpstarter-driver-renode/README.md
  • python/packages/jumpstarter-driver-renode/examples/exporter.yaml
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/__init__.py
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/client.py
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/monitor.py
  • python/packages/jumpstarter-driver-renode/pyproject.toml

ambient-code bot pushed a commit that referenced this pull request Apr 9, 2026
- Add structured Design Decisions (DD-N) section to JEP template following
  the ADR pattern used in the project (e.g., ADR-0001 from PR #533)
- Add Consequences section (positive/negative/risks) to JEP template
- Mark all template sections as mandatory, optional, or conditional
- Document the relationship between JEPs and ADRs in JEP-0000
- Add SpecKit and ADR references to Prior Art in JEP-0000
- Add agent instructions and document conventions to jeps/README.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
vtz added 2 commits April 11, 2026 13:32
Introduce jumpstarter-driver-renode, a composite driver that enables
Renode-based virtual hardware targets in Jumpstarter. Users can define
any Renode-supported platform (STM32, S32K, Nucleo H753ZI, etc.) via
exporter YAML configuration without modifying driver code.

Key components:
- RenodeMonitor: async telnet client for the Renode monitor interface
- RenodePower: manages Renode process lifecycle and simulation control
- RenodeFlasher: firmware loading via sysbus LoadELF / LoadBinary
- RenodeClient: composite client with CLI extension for monitor commands

Includes ADR-0001 documenting architectural decisions (control interface,
UART exposure, configuration model, firmware loading strategy).

Made-with: Cursor
- Replace PIPE with DEVNULL in Popen to prevent deadlocks (pipes
  were never read)
- Wrap monitor setup in try-except to teardown subprocess on failure,
  preventing process leaks
- Use anyio.to_thread.run_sync for blocking wait() in off() to avoid
  blocking the event loop
- Raise RenodeMonitorError on error responses instead of silently
  returning error text
- Accept multi-word monitor commands in CLI via nargs=-1
- Rename test_power_close_calls_off to test_power_close_terminates_process
- Add docstrings across all public APIs

Made-with: Cursor
@vtz vtz force-pushed the feat/renode-driver branch from fd842df to 742c714 Compare April 11, 2026 17:33
Restructure the Renode integration ADR to follow the JEP template
format: metadata table, Abstract, Motivation, Rejected Alternatives,
Prior Art, Implementation History, and References sections. The DD-N
design decisions and Consequences sections were already aligned.

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py (2)

40-72: Simplify the running-state check in flash().

Line 62 uses hasattr(self.parent.children["power"], "_process"), but _process is always defined as a dataclass field with default=None. This check always returns True, making it misleading. The effective guard is if monitor is not None on line 64.

Consider replacing with a more explicit check:

Proposed fix
-        if hasattr(self.parent.children["power"], "_process"):
-            monitor = self.parent.children["power"]._monitor
-            if monitor is not None:
+        power = self.parent.children["power"]
+        if power._process is not None and power._monitor is not None:
+            monitor = power._monitor
+            if True:  # already checked above

Or more simply:

-        if hasattr(self.parent.children["power"], "_process"):
-            monitor = self.parent.children["power"]._monitor
-            if monitor is not None:
+        monitor = self.parent.children["power"]._monitor
+        if monitor is not None:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py`
around lines 40 - 72, The hasattr(self.parent.children["power"], "_process")
check in RenodeFlasher.flash is misleading because _process is always present;
remove that hasattr guard and instead explicitly ensure the power child and its
monitor exist before hot-loading: fetch the power child (e.g. power =
self.parent.children.get("power")), check power is not None and power._monitor
is not None, then assign monitor = power._monitor and run monitor.execute(...)
as currently written; update the RenodeFlasher.flash method to use these
explicit checks (referencing RenodeFlasher.flash, self.parent.children["power"],
_monitor and _process to locate the code).

166-177: Monitor socket not explicitly closed in synchronous close().

The close() method sets self._monitor = None without closing the underlying TCP socket. While the socket will likely be broken when Renode terminates, it's not explicitly cleaned up.

Since close() must be synchronous (per the learning, it's called during driver teardown outside an async context) and disconnect() is async, a potential workaround is to directly close the socket if available:

Possible enhancement
     def close(self):
         """Synchronous cleanup for use during driver teardown."""
         if self._process is not None:
             if self._monitor is not None:
+                # Best-effort sync close of the socket
+                if self._monitor._stream is not None:
+                    try:
+                        self._monitor._stream._transport.close()
+                    except Exception:
+                        pass
                 self._monitor = None

This is a minor improvement — the socket will be cleaned up when Renode terminates anyway, but explicit cleanup is cleaner.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py`
around lines 166 - 177, The close() method currently nulled out self._monitor
without closing the underlying socket; modify close() to synchronously close the
monitor if present (e.g., call self._monitor.close() or the monitor's
appropriate close/shutdown method) inside a try/except to swallow/LOG any
exceptions, then set self._monitor = None, and proceed to terminate the process
as before; reference the close() method and the self._monitor attribute (and
keep disconnect() untouched since it is async).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py`:
- Around line 318-398: The async iterator mocks in TestRenodeFlasher tests
(test_flash_stores_firmware_path, test_flash_while_running_sends_load_and_reset,
test_flash_custom_load_command) use AsyncMock(return_value=iter(...)) which
doesn't implement async iteration; update each patched flasher.resource mock
(the mock_res used in those with patch.object(flasher, "resource")) to implement
async iteration by assigning mock_res.__aiter__ = lambda self: self and
mock_res.__anext__ = AsyncMock(side_effect=[<chunks...>, StopAsyncIteration()])
so the async for chunk in res: loops in RenodeFlasher.flash() iterate correctly
and yield the desired byte chunks.

---

Nitpick comments:
In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py`:
- Around line 40-72: The hasattr(self.parent.children["power"], "_process")
check in RenodeFlasher.flash is misleading because _process is always present;
remove that hasattr guard and instead explicitly ensure the power child and its
monitor exist before hot-loading: fetch the power child (e.g. power =
self.parent.children.get("power")), check power is not None and power._monitor
is not None, then assign monitor = power._monitor and run monitor.execute(...)
as currently written; update the RenodeFlasher.flash method to use these
explicit checks (referencing RenodeFlasher.flash, self.parent.children["power"],
_monitor and _process to locate the code).
- Around line 166-177: The close() method currently nulled out self._monitor
without closing the underlying socket; modify close() to synchronously close the
monitor if present (e.g., call self._monitor.close() or the monitor's
appropriate close/shutdown method) inside a try/except to swallow/LOG any
exceptions, then set self._monitor = None, and proceed to terminate the process
as before; reference the close() method and the self._monitor attribute (and
keep disconnect() untouched since it is async).
🪄 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: 381d75b0-27c9-41e5-9df3-3869a5a95ad9

📥 Commits

Reviewing files that changed from the base of the PR and between afc136f and f7e8718.

⛔ Files ignored due to path filters (1)
  • python/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • python/docs/source/contributing/adr/0001-renode-integration.md
  • python/docs/source/contributing/adr/index.md
  • python/docs/source/reference/package-apis/drivers/renode.md
  • python/packages/jumpstarter-driver-renode/.gitignore
  • python/packages/jumpstarter-driver-renode/README.md
  • python/packages/jumpstarter-driver-renode/examples/exporter.yaml
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/__init__.py
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/client.py
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/monitor.py
  • python/packages/jumpstarter-driver-renode/pyproject.toml
✅ Files skipped from review due to trivial changes (7)
  • python/docs/source/reference/package-apis/drivers/renode.md
  • python/packages/jumpstarter-driver-renode/.gitignore
  • python/docs/source/contributing/adr/index.md
  • python/packages/jumpstarter-driver-renode/examples/exporter.yaml
  • python/packages/jumpstarter-driver-renode/pyproject.toml
  • python/packages/jumpstarter-driver-renode/README.md
  • python/docs/source/contributing/adr/0001-renode-integration.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/client.py

@vtz
Copy link
Copy Markdown
Contributor Author

vtz commented Apr 11, 2026

@kirkbrauer ADR-0001 has been restructured to align with the JEP template format from #423 (metadata table, Abstract, Motivation, Rejected Alternatives, Prior Art, Implementation History, References, and license footer). The DD-N design decisions and Consequences sections were already in the right shape.

Replace incorrect AsyncMock(return_value=iter([...])) with idiomatic
__aiter__/__anext__ pattern. The old pattern silently yielded nothing
(with RuntimeWarning), the new one properly iterates the chunks.

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py (1)

241-248: Strengthen idempotency tests with observable assertions.

Both tests currently pass on “no exception” only. Add explicit assertions that no startup/shutdown side effects were triggered (or assert expected warning logs), so regressions are caught deterministically.

🧪 Suggested direction
 `@pytest.mark.anyio`
 async def test_power_on_idempotent(self):
     """Second on() call logs warning and does nothing."""
     driver = _make_driver()
     power: RenodePower = driver.children["power"]
     power._process = MagicMock()

-    await power.on()
+    with patch("jumpstarter_driver_renode.driver._find_renode") as mock_find_renode:
+        await power.on()
+    mock_find_renode.assert_not_called()

 `@pytest.mark.anyio`
 async def test_power_off_idempotent(self):
     """Second off() call logs warning and does nothing."""
     driver = _make_driver()
     power: RenodePower = driver.children["power"]
     power._process = None

-    await power.off()
+    await power.off()  # no-op path
+    assert power._process is None

As per coding guidelines: python/packages/jumpstarter-driver-*/jumpstarter_driver_*/driver_test.py: “Each driver package must include comprehensive tests in driver_test.py.”

Also applies to: 290-297

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py`
around lines 241 - 248, The idempotency tests (e.g., test_power_on_idempotent
and the corresponding power.off test around lines 290-297) currently only await
calls; enhance them to assert observable behavior: verify that
RenodePower._process (accessed via power._process) was not called when calling
power.on() or power.off() a second time, and assert that the expected warning
log was emitted (mock the logger or use caplog) to ensure the function logged a
warning rather than performing startup/shutdown side effects; update both tests
to include these assertions so regressions are caught deterministically.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py`:
- Around line 445-449: The test test_renode_pty_path should use
pathlib.Path-based assertions to mirror the driver implementation: replace the
string suffix check and manual substring check with a single Path
equality/assertion that Path(driver._pty) == Path(driver._tmp_dir.name) / "pty"
(or assert Path(driver._pty).resolve() == (Path(driver._tmp_dir.name) /
"pty").resolve()) so the test uses the same Path construction as the driver
(referencing driver._pty and driver._tmp_dir).

---

Nitpick comments:
In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py`:
- Around line 241-248: The idempotency tests (e.g., test_power_on_idempotent and
the corresponding power.off test around lines 290-297) currently only await
calls; enhance them to assert observable behavior: verify that
RenodePower._process (accessed via power._process) was not called when calling
power.on() or power.off() a second time, and assert that the expected warning
log was emitted (mock the logger or use caplog) to ensure the function logged a
warning rather than performing startup/shutdown side effects; update both tests
to include these assertions so regressions are caught deterministically.
🪄 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: 91eeec4c-c940-43e5-87c4-52dfc245331c

📥 Commits

Reviewing files that changed from the base of the PR and between f7e8718 and e19be08.

📒 Files selected for processing (1)
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py

Mirror the driver's Path construction instead of string suffix matching.

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py (2)

266-267: Use exact call-count assertion for quit.

assert_called_with("quit") won’t fail if quit is sent multiple times.

🔧 Tighten assertion
-        mock_monitor.execute.assert_called_with("quit")
+        mock_monitor.execute.assert_called_once_with("quit")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py`
around lines 266 - 267, Replace the loose assertion on the monitor's quit call
with an exact one: change the check using
mock_monitor.execute.assert_called_with("quit") to assert it was called exactly
once (e.g., mock_monitor.execute.assert_called_once_with("quit")) so the test
fails if "quit" was sent multiple times; keep the existing
mock_monitor.disconnect.assert_called_once() as-is.

241-248: Strengthen idempotency tests with side-effect assertions.

These tests currently only verify “no exception,” so regressions that accidentally re-run startup/shutdown paths would still pass.

🔧 Proposed test hardening
     `@pytest.mark.anyio`
     async def test_power_on_idempotent(self):
         """Second on() call logs warning and does nothing."""
         driver = _make_driver()
         power: RenodePower = driver.children["power"]
         power._process = MagicMock()

-        await power.on()
+        with patch("jumpstarter_driver_renode.driver.Popen") as mock_popen, patch(
+            "jumpstarter_driver_renode.driver.RenodeMonitor"
+        ) as mock_monitor_cls:
+            await power.on()
+
+        mock_popen.assert_not_called()
+        mock_monitor_cls.assert_not_called()
@@
     `@pytest.mark.anyio`
     async def test_power_off_idempotent(self):
         """Second off() call logs warning and does nothing."""
         driver = _make_driver()
         power: RenodePower = driver.children["power"]
         power._process = None
+        power._monitor = AsyncMock(spec=RenodeMonitor)

         await power.off()
+        power._monitor.execute.assert_not_called()
+        power._monitor.disconnect.assert_not_called()

As per coding guidelines, python/packages/jumpstarter-driver-*/jumpstarter_driver_*/driver_test.py: Each driver package must include comprehensive tests in driver_test.py.

Also applies to: 290-297

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py`
around lines 241 - 248, The idempotency tests (e.g., test_power_on_idempotent)
only await power.on() but don't assert that startup side-effects weren't
executed; update the test to assert that the mocked process and startup methods
were not called and that a warning was logged instead: after creating driver via
_make_driver(), use the existing MagicMock on power._process and assert its
start/execute/send-like methods were not invoked and that the logger (or warning
call) on RenodePower was called once; apply the same pattern to the
corresponding power.off idempotent test (the similar block around lines 290-297)
so both on() and off() tests verify no side-effects and a warning was emitted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py`:
- Around line 266-267: Replace the loose assertion on the monitor's quit call
with an exact one: change the check using
mock_monitor.execute.assert_called_with("quit") to assert it was called exactly
once (e.g., mock_monitor.execute.assert_called_once_with("quit")) so the test
fails if "quit" was sent multiple times; keep the existing
mock_monitor.disconnect.assert_called_once() as-is.
- Around line 241-248: The idempotency tests (e.g., test_power_on_idempotent)
only await power.on() but don't assert that startup side-effects weren't
executed; update the test to assert that the mocked process and startup methods
were not called and that a warning was logged instead: after creating driver via
_make_driver(), use the existing MagicMock on power._process and assert its
start/execute/send-like methods were not invoked and that the logger (or warning
call) on RenodePower was called once; apply the same pattern to the
corresponding power.off idempotent test (the similar block around lines 290-297)
so both on() and off() tests verify no side-effects and a warning was emitted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5e2ecf2f-4b37-4a32-befa-52ee533ea3e7

📥 Commits

Reviewing files that changed from the base of the PR and between e19be08 and 51a01cf.

📒 Files selected for processing (1)
  • python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py

@ambient-code
Copy link
Copy Markdown
Contributor

ambient-code bot commented Apr 13, 2026

ADR-0001 → JEP Conversion: Renode Integration

I took a pass at converting the Renode integration ADR (python/docs/source/contributing/adr/0001-renode-integration.md from PR #533) into the JEP template format defined in this PR. Here's what the conversion looks like and what it reveals about the ADR↔JEP relationship.

Where to put it

Per the JEP process defined here, the file should live at:

docs/internal/jeps/JEP-0010-renode-integration.md

(JEP-0010 is the first non-reserved number, since 0000-0009 are reserved for process/meta-JEPs.)

If the original ADR is also kept, it should move to docs/internal/adr/ per the directory structure this PR establishes.

The converted JEP

Click to expand full JEP-0010 (click to expand)

JEP-0010: Renode Emulator Driver for MCU Target Simulation

Field Value
JEP 0010
Title Renode Emulator Driver for MCU Target Simulation
Author(s) @vtz (Vinicius Zein)
Status Implemented
Type Standards Track
Created 2026-04-06
Updated 2026-04-11
Discussion PR #533

Abstract (mandatory)

This JEP proposes integrating the Renode emulation
framework into Jumpstarter as a new driver package
(jumpstarter-driver-renode). The driver enables microcontroller-class
virtual targets running bare-metal firmware or RTOS on Cortex-M and
RISC-V MCUs, complementing the existing QEMU driver which targets
Linux-capable SoCs. Users define new Renode targets through YAML
exporter configuration alone, without modifying driver code.

Motivation (mandatory)

Jumpstarter provides a driver-based framework for interacting with
devices under test, both physical hardware and virtual systems. The
existing QEMU driver enables Linux-class virtual targets (aarch64,
x86_64) using full-system emulation with virtio devices and cloud-init
provisioning.

There is growing demand for microcontroller-class virtual targets
running bare-metal firmware or RTOS (Zephyr, FreeRTOS, ThreadX) on
Cortex-M and RISC-V MCUs. Renode by Antmicro is an open-source
emulation framework designed specifically for this domain, with
extensive peripheral models for STM32, NXP S32K, Nordic, SiFive, and
other MCU platforms.

The high-level alternative of extending the QEMU driver for MCU targets
was considered. QEMU's MCU support (e.g., qemu-system-arm -M stm32vldiscovery) is limited in peripheral modeling and does not match
Renode's breadth for embedded platforms. The QEMU driver remains the
right choice for Linux-capable SoCs while Renode fills the MCU gap.

User Stories (optional)

  • As a firmware developer, I want to run my STM32 or RISC-V
    firmware in a CI pipeline without physical hardware, so that I
    can validate embedded builds on every commit without depending on
    device availability.

  • As a HiL test author, I want to interact with a Renode
    virtual target using the same pexpect/console API I use for QEMU
    and physical boards, so that I can write target-agnostic test
    scripts.

  • As a platform integrator, I want to define new MCU targets
    by editing an exporter YAML file, so that I don't need to write
    driver code for each new board or SoC variant.

Constraints

  • The driver must follow Jumpstarter's established composite driver
    pattern (as demonstrated by jumpstarter-driver-qemu)
  • Users must be able to define new Renode targets through configuration
    alone, without modifying driver code
  • The solution should minimize external dependencies and runtime
    requirements
  • The UART/console interface must be compatible with Jumpstarter's
    existing PySerial and pexpect tooling
  • The async framework must be anyio (the project's standard)

Reference Targets

The initial targets for validation are:

  • STM32F407 Discovery (Cortex-M4F) -- opensomeip FreeRTOS/ThreadX
    ports, Renode built-in platform
  • NXP S32K388 (Cortex-M7) -- opensomeip Zephyr port, custom
    platform description
  • Nucleo H753ZI (Cortex-M7) -- openbsw-zephyr, Renode built-in
    stm32h743.repl

Proposal (mandatory)

The jumpstarter-driver-renode package adds a composite driver that
manages a Renode process and exposes three child drivers: power
control, firmware flashing, and serial console. From a user's
perspective, interacting with a Renode virtual target is identical to
interacting with a QEMU virtual target or a physical board.

Exporter Configuration

A target is defined entirely in YAML. Here is an STM32F407 example:

apiVersion: jumpstarter.dev/v1alpha1
kind: ExporterConfig
metadata:
  name: renode-stm32f407
export:
  ecu:
    type: jumpstarter_driver_renode.driver.Renode
    config:
      platform: "platforms/boards/stm32f4_discovery-kit.repl"
      uart: "sysbus.usart2"

For targets that require register pokes or other setup before firmware
runs, use extra_commands:

config:
  platform: "/path/to/s32k388_renode.repl"
  uart: "sysbus.uart0"
  extra_commands:
    - "sysbus WriteDoubleWord 0x40090030 0x0301"

Configuration Parameters

Parameter Type Default Description
platform str (required) Path to .repl file or Renode built-in name
uart str sysbus.uart0 UART peripheral path in Renode object model
machine_name str machine-0 Renode machine name
monitor_port int 0 (auto) Telnet monitor port (0 = find a free port)
extra_commands list[str] [] Monitor commands run after UART setup

Python API

From test code or a leased client session:

with serve(Renode(
    platform="platforms/boards/stm32f4_discovery-kit.repl",
    uart="sysbus.usart2",
)) as target:
    target.flasher.flash("/path/to/firmware.elf")
    target.power.on()
    # target.console is a standard PySerial/pexpect interface
    target.power.off()

The RenodeClient extends CompositeClient with:

  • platform, uart, machine_name properties
  • monitor_cmd(command) for sending arbitrary Renode monitor commands
  • A monitor CLI subcommand (jmp ... monitor "help")

Power Lifecycle

  1. flash(source) -- streams firmware to a temp file. If the
    simulation is already running, sends sysbus LoadELF + machine Reset (hot-load). Otherwise stores the path for the next on().
  2. on() -- starts the Renode process, connects the telnet
    monitor, loads the platform description, creates a PTY terminal,
    wires the UART, runs extra_commands, loads firmware (if set), and
    sends start.
  3. off() -- sends quit to the monitor, disconnects, terminates
    the process (with a kill fallback after 5 s timeout).

CLI

The driver extends the composite CLI with a monitor subcommand:

jmp ... monitor "mach create"
jmp ... monitor "help"

Multi-word commands are joined automatically.

API / Protocol Changes (if applicable)

This JEP introduces a new driver package. No existing gRPC .proto
definitions, operator CRDs, or driver interfaces are modified. The new
driver implements the existing PowerInterface, FlasherInterface,
and reuses PySerial as a child driver -- all established interfaces.

Hardware Considerations (if applicable)

Although Renode is a virtual target, several hardware-boundary
concerns apply:

  • PTY requirement: the driver uses emulation CreateUartPtyTerminal to create a pseudo-terminal, which requires
    Linux or macOS. No Windows support.
  • Privileged access: not required. Renode runs as a normal user
    process. No /dev/kvm or device access needed.
  • Timing: Renode simulates instruction-level timing but the
    monitor telnet interface adds host-network latency. The monitor
    client uses anyio.fail_after for timeouts (default 10 s for
    connection).
  • Degraded state: if the Renode process crashes or exits
    unexpectedly, off() handles cleanup via terminate() +
    wait() + kill(). The on() method wraps the entire setup
    sequence in a try/except that calls off() on any failure.

Design Decisions (mandatory for Standards Track)

DD-1: Control Interface -- Telnet Monitor

Alternatives considered:

  1. Telnet monitor -- Renode's built-in TCP monitor interface.
    Simple socket connection, send text commands, read responses.
    Lightweight, no extra runtime needed.
  2. pyrenode3 -- Python.NET bridge to Renode's C# internals. More
    powerful but requires .NET runtime or Mono, heavy dependency, less
    stable API surface.

Decision: Telnet monitor.

Rationale: It is the lowest-common-denominator interface that works
with any Renode installation. It mirrors the QEMU driver's pattern
where Popen starts the emulator process and a side-channel protocol
(QMP for QEMU, telnet monitor for Renode) provides programmatic
control. The monitor client uses anyio.connect_tcp with
anyio.fail_after for timeouts, consistent with TcpNetwork and
grpc.py in the project. No telnetlib, telnetlib3, or
asynctelnet is introduced since these are not used anywhere in the
project.

DD-2: UART Exposure -- PTY Terminal

Alternatives considered:

  1. PTY (emulation CreateUartPtyTerminal) -- Creates a
    pseudo-terminal file on the host. Reuses the existing PySerial
    child driver exactly as QEMU does. Linux/macOS only.
  2. Socket (emulation CreateServerSocketTerminal) -- Exposes UART
    as a TCP socket. Cross-platform. Maps to TcpNetwork driver. Has
    telnet IAC negotiation bytes to handle.

Decision: PTY as the primary interface.

Rationale: Consistency with the QEMU driver, which uses -serial pty and wires a PySerial child driver to the discovered PTY path.
This reuses the same serial/pexpect/console tooling without any
adaptation. Socket terminal support can be added later as a fallback
for platforms without PTY support.

DD-3: Configuration Model -- Managed Mode

Alternatives considered:

  1. Managed mode -- The driver constructs all Renode monitor
    commands from YAML config parameters (platform, uart, firmware
    path). The driver handles platform loading, UART wiring, and
    firmware loading programmatically.
  2. Script mode -- User provides a complete .resc script. The
    driver runs it but still manages UART terminal setup.

Decision: Managed mode as primary, with an extra_commands list
for target-specific customization.

Rationale: Managed mode gives the driver full control over the UART
terminal setup (which must use PTY for Jumpstarter integration, not the
CreateFileBackend or showAnalyzer used in typical .resc scripts).
The extra_commands list covers target-specific needs like register
pokes (e.g., sysbus WriteDoubleWord 0x40090030 0x0301 for S32K388
PL011 UART enablement) and Ethernet switch setup. The opensomeip .resc
files are CI-oriented and their setup maps directly to managed-mode
config parameters.

DD-4: Firmware Loading -- Deferred to Flash

Alternatives considered:

  1. flash() stores the firmware path, on() loads it into the
    simulation and starts
  2. on() starts the simulation, flash() loads firmware and resets

Decision: Option 1 -- flash() stores the path, on() loads and
starts.

Rationale: This matches the QEMU driver's semantic where you flash
a disk image first, then power on. It also allows re-flashing between
power cycles without restarting the Renode process. The RenodeFlasher
additionally supports hot-loading: if the simulation is already running,
flash() sends the sysbus LoadELF command and resets the machine.

Design Details (mandatory for Standards Track)

Architecture

The driver follows Jumpstarter's composite driver pattern:

Renode (composite root)
├── RenodePower      (PowerInterface)  -- manages Renode process + monitor
├── RenodeFlasher    (FlasherInterface) -- streams firmware, hot-loads
└── PySerial         (child)           -- wired to PTY created by Renode

The Renode dataclass holds configuration and shared state (temp
directory, firmware path, PTY path). RenodePower and RenodeFlasher
hold a parent reference back to Renode for access to shared state.

Monitor Client (RenodeMonitor)

The RenodeMonitor class manages the TCP connection to Renode's telnet
monitor:

  • Connection: anyio.connect_tcp with retry loop (0.5 s sleep
    between attempts, 10 s total timeout via anyio.fail_after)
  • Protocol: line-oriented text. Commands are sent as
    command\n. Responses are read until a prompt line matching
    (monitor) or (machine-name) is detected.
  • Error detection: responses starting with known error markers
    (Could not find, Error, Invalid, Failed, Unknown) raise
    RenodeMonitorError.
  • Buffering: a byte buffer accumulates receive(4096) calls and
    is scanned for prompt lines, handling partial reads correctly.

Process Lifecycle

  1. on() starts Renode via Popen with --disable-xwt --plain --port N and stdin/stdout/stderr set to DEVNULL.
  2. The monitor connects over TCP and sends the setup command sequence.
  3. off() sends quit, disconnects the monitor, then terminates the
    process with a 5 s grace period before kill().
  4. close() provides synchronous cleanup for driver teardown.

Data Flow

User test code
    │
    ▼
RenodeClient (gRPC) ──► Renode driver (server side)
    │                        │
    │                        ├── RenodePower.on()
    │                        │       │
    │                        │       ├── Popen("renode --disable-xwt ...")
    │                        │       ├── RenodeMonitor.connect(TCP)
    │                        │       ├── monitor: mach create, LoadPlatform,
    │                        │       │           CreateUartPtyTerminal, ...
    │                        │       └── monitor: start
    │                        │
    │                        ├── RenodeFlasher.flash()
    │                        │       └── stream firmware → temp file
    │                        │           (or hot-load via monitor if running)
    │                        │
    │                        └── PySerial ──► PTY file ◄── Renode UART

Error Handling

  • on() wraps the entire setup in try/except; any failure triggers
    off() to clean up the process and monitor.
  • off() catches exceptions from monitor.execute("quit") silently
    (the process may already be dead).
  • RenodeMonitor.disconnect() catches exceptions from aclose()
    (the socket may already be closed).
  • Popen termination uses wait(5) with a TimeoutExpired fallback
    to kill().

Concurrency

  • All async operations use anyio (no asyncio directly).
  • Popen.wait() is called via anyio.to_thread.run_sync() to avoid
    blocking the event loop.
  • The monitor is single-threaded (one command at a time); no
    concurrent monitor access is expected within a single driver
    instance.

Test Plan (mandatory for Standards Track)

Unit Tests

The test suite (driver_test.py) covers the following without
requiring Renode installed:

  • RenodeMonitor (5 tests): connect retry logic, command
    execution, error response detection, not-connected guard,
    disconnect idempotency.
  • RenodePower (7 tests): on() command sequence verification,
    extra_commands ordering, firmware-less power-on, idempotent on/off,
    clean vs timeout shutdown, close().
  • RenodeFlasher (4 tests): firmware path storage, hot-load while
    running, custom load_command, dump() not-implemented guard.
  • Configuration (7 tests): default values, children wiring,
    custom config, PTY path, temp directory lifecycle, property getters.
  • Client (5 tests): property round-trip through serve(),
    children accessibility, monitor_cmd error when not running, CLI
    rendering, monitor subcommand help.

All hardware-dependent behavior is mocked via unittest.mock.

End-to-End Tests

One E2E test (test_driver_renode_e2e) performs a full power on/off
cycle using serve(). It is:

  • Skipped if renode is not in PATH
  • Marked xfail on macOS GitHub Actions (known flakiness)

Hardware-in-the-Loop Tests

Not applicable -- Renode is a software emulator. The reference targets
(STM32F407, S32K388, Nucleo H753ZI) serve as validation that the
driver can load real platform descriptions and firmware from the
opensomeip project.

Manual Verification

Reviewers can verify by:

  1. Installing Renode and the driver package
  2. Running the E2E test: pytest driver_test.py -k e2e
  3. Using an exporter YAML with a known-good firmware (e.g., STM32F407
    Zephyr hello_world) and checking console output via pexpect

Backward Compatibility (mandatory for Standards Track)

This is a new driver package with no existing users. No backward
compatibility concerns:

  • No existing gRPC .proto definitions are modified
  • No existing driver interfaces are changed
  • No existing operator CRDs are affected
  • The package can be installed independently via
    pip install jumpstarter-driver-renode

Consequences (mandatory)

Positive

  • Single jumpstarter-driver-renode package supports any Renode target
    through YAML configuration alone
  • No .NET runtime or Mono dependency required
  • Consistent user experience with the QEMU driver (same composite
    pattern, same console/pexpect workflow)
  • extra_commands provides an escape hatch for target-specific
    customization without code changes

Negative

  • PTY-only UART exposure limits to Linux/macOS (acceptable since Renode
    itself primarily targets these platforms)
  • The telnet monitor protocol is text-based and less structured than
    QMP's JSON -- error detection requires string matching
  • Full .resc script support is deferred; users with complex Renode
    setups must express their configuration as managed-mode parameters
    plus extra_commands

Risks

  • Renode's monitor protocol has no formal specification; prompt
    detection and error handling rely on observed behavior
  • Renode's PTY terminal support on macOS may have edge cases not
    covered in testing

Rejected Alternatives (mandatory)

Beyond the alternatives listed in each Design Decision above, the
high-level alternative of not integrating Renode and instead
extending the QEMU driver for MCU targets was considered. QEMU's MCU
support (e.g., qemu-system-arm -M stm32vldiscovery) is limited in
peripheral modeling and does not match Renode's breadth for embedded
platforms. The QEMU driver remains the right choice for Linux-capable
SoCs while Renode fills the MCU gap.

Prior Art (optional)

  • jumpstarter-driver-qemu -- The existing Jumpstarter QEMU driver
    established the composite driver pattern, Popen-based process
    management, and side-channel control protocol (QMP) that this JEP
    follows.
  • Renode documentation -- Renode docs
    for monitor commands, platform descriptions, and UART terminal types.
  • opensomeip -- github.com/vtz/opensomeip
    provides the reference Renode targets (STM32F407, S32K388) used for
    validation.

Future Possibilities (optional)

  • Socket terminal fallback: add emulation CreateServerSocketTerminal support for platforms without PTY (or
    for remote Renode instances).
  • Full .resc script mode: allow users to provide a complete
    Renode script, with the driver only injecting PTY terminal setup.
  • Multi-machine simulation: support multiple Renode machines in a
    single process for network/bus simulation between virtual ECUs.
  • Renode integration testing in CI: add Renode to the project's
    CI infrastructure for automated E2E validation with reference
    firmware.

Implementation History

  • 2026-04-06: JEP proposed
  • 2026-04-09: Initial implementation merged (PR #533)
  • 2026-04-11: Address review feedback (DEVNULL, try-except cleanup,
    async wait, RenodeMonitorError, multi-word CLI, docstrings)

References


This JEP is licensed under the
Apache License, Version 2.0,
consistent with the Jumpstarter project.

What was added (gaps filled)

The ADR's existing content (Abstract, Motivation, DD-1 through DD-4, Consequences, Rejected Alternatives, Prior Art, References) mapped directly to JEP sections with no changes. The following sections were new content that the ADR didn't express:

New JEP Section What it adds
User Stories Three concrete user personas (firmware dev, test author, platform integrator)
Proposal Teaching-oriented description of the driver: exporter YAML examples, config parameter table, Python API usage, power lifecycle walkthrough, CLI commands
API / Protocol Changes Explicit statement that no existing interfaces are modified
Hardware Considerations PTY requirement, no privileged access needed, timing/timeout behavior, crash cleanup
Design Details Architecture diagram (composite tree), monitor client internals, process lifecycle, data flow diagram, error handling strategy, concurrency model
Test Plan Itemized inventory of all 28 unit tests by category, E2E test description, skip/xfail conditions, manual verification steps
Backward Compatibility Explicit "new package, no concerns" statement
Future Possibilities Socket fallback, .resc script mode, multi-machine simulation, CI integration

What the ADR expresses that the JEP template doesn't naturally accommodate

Two sections from the ADR have no direct JEP template equivalent and are preserved with <!-- NOTE --> comments:

  1. Constraints — a scannable checklist of hard requirements (must use composite pattern, must use anyio, config-only targets, etc.). In the JEP template these get scattered across Motivation and Design Decisions, losing their cohesion as a verification checklist.

  2. Reference Targets — specific MCU boards used as scope/acceptance criteria (STM32F407, S32K388, Nucleo H753ZI). These serve as an implicit scope boundary ("we're building for these") that doesn't fit neatly into Test Plan (which asks "how do you verify?") or Motivation (which asks "why?").

Observation on scope

Per JEP-0000's own criteria, new drivers that follow the existing scaffold and don't modify framework interfaces do not require a JEP. The Renode driver follows the composite driver pattern exactly and introduces no new interfaces. This makes it a good ADR candidate but arguably not a JEP candidate — which is actually a validation that JEP-0000 draws the line in the right place.

That said, having this conversion exercise is useful to see how the two formats relate and whether the JEP template's Constraints and Reference Targets gaps should be addressed.

@mangelajo
Copy link
Copy Markdown
Member

@vtz can we convert the ADR into a JEP which we are trying to push at the community level?, there is no community decission to use ADRs, and that may get confusing to keep both in repo.

reason="Renode not installed",
)
@pytest.mark.xfail(
platform.system() == "Darwin" and os.getenv("GITHUB_ACTIONS") == "true",
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.

why?, I think this is just copying from other places.



@pytest.mark.skipif(
shutil.which("renode") is None,
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.

can we modify the .github workflows for python testing to install renode so this is rested in CI?

Comment on lines +45 to +66
async def flash(self, source, load_command: str | None = None):
"""Flash firmware to the simulated MCU.

If the simulation is not yet running, stores the firmware for
loading during power-on. If already running, loads the firmware
and resets the machine.
"""
firmware_path = self.parent._tmp_dir.name + "/firmware"
async with await FileWriteStream.from_path(firmware_path) as stream:
async with self.resource(source) as res:
async for chunk in res:
await stream.send(chunk)

cmd = load_command or "sysbus LoadELF"
self.parent._firmware_path = firmware_path
self.parent._load_command = cmd

if hasattr(self.parent.children["power"], "_process"):
monitor = self.parent.children["power"]._monitor
if monitor is not None:
await monitor.execute(f'{cmd} @"{firmware_path}"')
await monitor.execute("machine Reset")
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.

[HIGH] The load_command parameter in flash() is client-supplied and interpolated directly into a monitor command without validation. A remote client can inject arbitrary monitor commands by passing something like load_command='logFile @"/tmp/exfil"\nsysbus LoadELF'. The injected value is also persisted in self.parent._load_command and replayed during subsequent power.on() calls.

Suggested fix: validate load_command against an allowlist of known Renode load commands (e.g., sysbus LoadELF, sysbus LoadBinary, sysbus LoadSymbolsFrom) and raise ValueError if it doesn't match.

AI-generated, human reviewed

Comment on lines +240 to +245
async def monitor_cmd(self, command: str) -> str:
"""Send an arbitrary command to the Renode monitor."""
power: RenodePower = self.children["power"]
if power._monitor is None:
raise RuntimeError("Renode is not running")
return await power._monitor.execute(command)
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.

[HIGH] monitor_cmd forwards arbitrary strings to the Renode telnet monitor without any filtering. The Renode monitor supports commands that interact with the host filesystem (logFile, include, CreateFileTerminal, LoadPlatformDescription), so any authenticated Jumpstarter client can use these to read or write files on the host. For comparison, the existing QEMU driver does not expose an unrestricted QMP passthrough.

Suggested fix: implement an allowlist of permitted monitor commands, or gate this export behind an explicit allow_raw_monitor: true configuration flag that defaults to false.

AI-generated, human reviewed

Comment on lines +30 to +41
async def connect(self, host: str, port: int, timeout: float = 10) -> None:
"""Connect to the Renode monitor, retrying until the prompt appears."""
with fail_after(timeout):
while True:
try:
self._stream = await connect_tcp(host, port)
self._buffer = b""
await self._read_until_prompt()
logger.info("connected to Renode monitor at %s:%d", host, port)
return
except OSError:
await sleep(0.5)
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.

[HIGH] TCP socket streams leak on retry iterations in connect(). When connect_tcp succeeds but _read_until_prompt() subsequently fails with an OSError, the except clause catches it and the loop retries. On the next iteration, self._stream = await connect_tcp(...) overwrites the reference to the previous stream without closing it. This is a realistic scenario during Renode startup when the telnet port accepts connections before the monitor is ready.

Suggested fix: close the existing stream before retrying:

except OSError:
    if self._stream is not None:
        await self._stream.aclose()
        self._stream = None
    await sleep(0.5)

AI-generated, human reviewed

Comment on lines +45 to +62
async def execute(self, command: str) -> str:
"""Send a command and return the response text (excluding the prompt).

Raises RenodeMonitorError if the response indicates a command failure.
"""
if self._stream is None:
raise RuntimeError("not connected to Renode monitor")

logger.debug("monitor> %s", command)
await self._stream.send(f"{command}\n".encode())
response = await self._read_until_prompt()
logger.debug("monitor< %s", response.strip())

stripped = response.strip()
if stripped and any(stripped.startswith(m) for m in self._ERROR_MARKERS):
raise RenodeMonitorError(stripped)

return response
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.

[HIGH] execute() has no timeout and can block an async task indefinitely. It calls _read_until_prompt() which runs an unbounded while True loop. If Renode stops sending data but keeps the TCP connection open (process hang, simulation deadlock), self._stream.receive(4096) blocks forever. Unlike connect() which wraps in fail_after(timeout), execute() has no cancellation scope.

Suggested fix: wrap the read in a configurable timeout:

async def execute(self, command: str, timeout: float = 30) -> str:
    ...
    with fail_after(timeout):
        response = await self._read_until_prompt()
    ...

AI-generated, human reviewed

Comment on lines +11 to +18
dependencies = [
"jumpstarter",
"jumpstarter-driver-composite",
"jumpstarter-driver-network",
"jumpstarter-driver-opendal",
"jumpstarter-driver-power",
"jumpstarter-driver-pyserial",
]
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.

[HIGH] Missing workspace source entry in the top-level python/pyproject.toml. Every driver package in the workspace has an explicit entry in [tool.uv.sources] (e.g., jumpstarter-driver-qemu = { workspace = true }), but the new jumpstarter-driver-renode package has no such entry. Without it, other workspace packages listing jumpstarter-driver-renode as a dependency won't resolve to the local workspace copy during development, causing build failures since the package doesn't exist on PyPI yet.

Suggested fix: add jumpstarter-driver-renode = { workspace = true } to [tool.uv.sources] in python/pyproject.toml, alphabetically after jumpstarter-driver-qemu.

AI-generated, human reviewed

Comment on lines +140 to +159
async def off(self) -> None:
"""Stop simulation, disconnect monitor, and terminate the Renode process."""
if self._process is None:
self.logger.warning("already powered off, ignoring request")
return

if self._monitor is not None:
try:
await self._monitor.execute("quit")
except Exception:
pass
await self._monitor.disconnect()
self._monitor = None

self._process.terminate()
try:
await to_thread.run_sync(self._process.wait, 5)
except TimeoutExpired:
self._process.kill()
self._process = None
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.

[LOW] off() leaves inconsistent state if terminate() raises on an already-exited process. ProcessLookupError propagates without executing self._process = None, leaving the driver in a state where subsequent off() calls fail and on() short-circuits.

Suggested fix: wrap the terminate/wait/kill sequence in try/finally to always set self._process = None.

AI-generated, human reviewed

Comment on lines +24 to +27
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
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.

[LOW] TOCTOU race in _find_free_port(): the ephemeral port is discovered by binding and immediately releasing a socket. A local process could race to bind the same port before Renode does. The QEMU driver avoids this by using Unix domain sockets.

Suggested fix: switch to a Unix domain socket for the monitor connection if Renode supports it, or use --port 0 if Renode supports reporting its actual bound port.

AI-generated, human reviewed

Comment on lines +85 to +137
async def on(self) -> None:
"""Start Renode, connect monitor, configure platform, and begin simulation."""
if self._process is not None:
self.logger.warning("already powered on, ignoring request")
return

renode_bin = _find_renode()
port = self.parent.monitor_port or _find_free_port()
self.parent._active_monitor_port = port

cmdline = [
renode_bin,
"--disable-xwt",
"--plain",
"--port",
str(port),
]

self.logger.info("starting Renode: %s", " ".join(cmdline))
self._process = Popen(cmdline, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL)

self._monitor = RenodeMonitor()
try:
await self._monitor.connect("127.0.0.1", port)

machine = self.parent.machine_name
await self._monitor.execute(f'mach create "{machine}"')
await self._monitor.execute(
f'machine LoadPlatformDescription @"{self.parent.platform}"'
)

pty_path = self.parent._pty
await self._monitor.execute(
f'emulation CreateUartPtyTerminal "term" "{pty_path}"'
)
await self._monitor.execute(
f"connector Connect {self.parent.uart} term"
)

for cmd in self.parent.extra_commands:
await self._monitor.execute(cmd)

if self.parent._firmware_path:
load_cmd = self.parent._load_command or "sysbus LoadELF"
await self._monitor.execute(
f'{load_cmd} @"{self.parent._firmware_path}"'
)

await self._monitor.execute("start")
self.logger.info("Renode simulation started")
except Exception:
await self.off()
raise
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.

[LOW] RenodePower.on() handles multiple responsibilities in ~53 lines: idempotency check, binary location, port allocation, command-line construction, process spawn, monitor connection, machine creation, platform loading, UART setup, extra commands, firmware loading, simulation start, and error cleanup.

Suggested fix: extract the monitor setup sequence (lines 110-133) into a private method like _configure_simulation() to improve readability.

AI-generated, human reviewed

Comment on lines +23 to +25
# ---------------------------------------------------------------------------
# 5.1 RenodeMonitor unit tests
# ---------------------------------------------------------------------------
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.

[LOW] Six decorative separator comments reference internal plan task numbers (5.1-5.6) and restate what the immediately-following class name already says (e.g., # 5.1 RenodeMonitor unit tests followed by class TestRenodeMonitor:). These add noise without information.

Suggested fix: remove all six separator blocks. The class and function names already convey the grouping.

AI-generated, human reviewed

@@ -0,0 +1,562 @@
from __future__ import annotations

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.

[LOW] Several minor paths lack test coverage: (1) _find_renode() error path when binary not on PATH; (2) RenodePower.read() raising NotImplementedError; (3) Renode.monitor_cmd() success path; (4) close() timeout-kill branch; (5) E2E test only exercises start/stop, not flash or console interaction.

Suggested fix: add targeted tests for each: (1) mock shutil.which returning None; (2) parallel to test_dump_not_implemented; (3) mock monitor and verify forwarding; (4) mock wait raising TimeoutExpired; (5) extend E2E with flash and monitor_cmd when Renode is available.

AI-generated, human reviewed

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