Skip to content

Commit 742c714

Browse files
committed
Address CodeRabbit review feedback
- 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
1 parent 2aaaee3 commit 742c714

5 files changed

Lines changed: 155 additions & 32 deletions

File tree

python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,36 @@
33

44

55
class RenodeClient(CompositeClient):
6+
"""Client for interacting with a Renode composite driver."""
7+
68
@property
79
def platform(self) -> str:
10+
"""The Renode platform description path."""
811
return self.call("get_platform")
912

1013
@property
1114
def uart(self) -> str:
15+
"""The UART peripheral path in the Renode object model."""
1216
return self.call("get_uart")
1317

1418
@property
1519
def machine_name(self) -> str:
20+
"""The Renode machine name."""
1621
return self.call("get_machine_name")
1722

1823
def monitor_cmd(self, command: str) -> str:
1924
"""Send an arbitrary command to the Renode monitor."""
2025
return self.call("monitor_cmd", command)
2126

2227
def cli(self):
28+
"""Extend the composite CLI with a ``monitor`` subcommand."""
2329
base = super().cli()
2430

2531
@base.command(name="monitor")
26-
@click.argument("command")
32+
@click.argument("command", nargs=-1, required=True)
2733
def monitor_command(command):
2834
"""Send a command to the Renode monitor."""
29-
result = self.monitor_cmd(command)
35+
result = self.monitor_cmd(" ".join(command))
3036
if result.strip():
3137
click.echo(result.strip())
3238

python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
from collections.abc import AsyncGenerator
77
from dataclasses import dataclass, field
88
from pathlib import Path
9-
from subprocess import PIPE, Popen, TimeoutExpired
9+
from subprocess import DEVNULL, Popen, TimeoutExpired
1010
from tempfile import TemporaryDirectory
1111

12+
from anyio import to_thread
1213
from anyio.streams.file import FileWriteStream
1314
from jumpstarter_driver_opendal.driver import FlasherInterface
1415
from jumpstarter_driver_power.driver import PowerInterface, PowerReading
@@ -67,18 +68,22 @@ async def flash(self, source, load_command: str | None = None):
6768

6869
@export
6970
async def dump(self, target, partition: str | None = None):
71+
"""Not supported for Renode targets."""
7072
raise NotImplementedError("dump is not supported for Renode targets")
7173

7274

7375
@dataclass(kw_only=True)
7476
class RenodePower(PowerInterface, Driver):
77+
"""Power controller that manages the Renode process lifecycle."""
78+
7579
parent: Renode
7680

7781
_process: Popen | None = field(init=False, default=None, repr=False)
7882
_monitor: RenodeMonitor | None = field(init=False, default=None, repr=False)
7983

8084
@export
8185
async def on(self) -> None:
86+
"""Start Renode, connect monitor, configure platform, and begin simulation."""
8287
if self._process is not None:
8388
self.logger.warning("already powered on, ignoring request")
8489
return
@@ -96,39 +101,44 @@ async def on(self) -> None:
96101
]
97102

98103
self.logger.info("starting Renode: %s", " ".join(cmdline))
99-
self._process = Popen(cmdline, stdin=PIPE, stdout=PIPE, stderr=PIPE)
104+
self._process = Popen(cmdline, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL)
100105

101106
self._monitor = RenodeMonitor()
102-
await self._monitor.connect("127.0.0.1", port)
103-
104-
machine = self.parent.machine_name
105-
await self._monitor.execute(f'mach create "{machine}"')
106-
await self._monitor.execute(
107-
f'machine LoadPlatformDescription @"{self.parent.platform}"'
108-
)
109-
110-
pty_path = self.parent._pty
111-
await self._monitor.execute(
112-
f'emulation CreateUartPtyTerminal "term" "{pty_path}"'
113-
)
114-
await self._monitor.execute(
115-
f"connector Connect {self.parent.uart} term"
116-
)
107+
try:
108+
await self._monitor.connect("127.0.0.1", port)
117109

118-
for cmd in self.parent.extra_commands:
119-
await self._monitor.execute(cmd)
110+
machine = self.parent.machine_name
111+
await self._monitor.execute(f'mach create "{machine}"')
112+
await self._monitor.execute(
113+
f'machine LoadPlatformDescription @"{self.parent.platform}"'
114+
)
120115

121-
if self.parent._firmware_path:
122-
load_cmd = self.parent._load_command or "sysbus LoadELF"
116+
pty_path = self.parent._pty
117+
await self._monitor.execute(
118+
f'emulation CreateUartPtyTerminal "term" "{pty_path}"'
119+
)
123120
await self._monitor.execute(
124-
f'{load_cmd} @"{self.parent._firmware_path}"'
121+
f"connector Connect {self.parent.uart} term"
125122
)
126123

127-
await self._monitor.execute("start")
128-
self.logger.info("Renode simulation started")
124+
for cmd in self.parent.extra_commands:
125+
await self._monitor.execute(cmd)
126+
127+
if self.parent._firmware_path:
128+
load_cmd = self.parent._load_command or "sysbus LoadELF"
129+
await self._monitor.execute(
130+
f'{load_cmd} @"{self.parent._firmware_path}"'
131+
)
132+
133+
await self._monitor.execute("start")
134+
self.logger.info("Renode simulation started")
135+
except Exception:
136+
await self.off()
137+
raise
129138

130139
@export
131140
async def off(self) -> None:
141+
"""Stop simulation, disconnect monitor, and terminate the Renode process."""
132142
if self._process is None:
133143
self.logger.warning("already powered off, ignoring request")
134144
return
@@ -143,16 +153,18 @@ async def off(self) -> None:
143153

144154
self._process.terminate()
145155
try:
146-
self._process.wait(timeout=5)
156+
await to_thread.run_sync(self._process.wait, 5)
147157
except TimeoutExpired:
148158
self._process.kill()
149159
self._process = None
150160

151161
@export
152162
async def read(self) -> AsyncGenerator[PowerReading, None]:
163+
"""Not supported — Renode does not provide power readings."""
153164
raise NotImplementedError
154165

155166
def close(self):
167+
"""Synchronous cleanup for use during driver teardown."""
156168
if self._process is not None:
157169
if self._monitor is not None:
158170
self._monitor = None
@@ -194,6 +206,7 @@ class Renode(Driver):
194206

195207
@classmethod
196208
def client(cls) -> str:
209+
"""Return the fully-qualified client class name."""
197210
return "jumpstarter_driver_renode.client.RenodeClient"
198211

199212
def __post_init__(self):
@@ -210,14 +223,17 @@ def _pty(self) -> str:
210223

211224
@export
212225
def get_platform(self) -> str:
226+
"""Return the Renode platform description path."""
213227
return self.platform
214228

215229
@export
216230
def get_uart(self) -> str:
231+
"""Return the UART peripheral path in the Renode object model."""
217232
return self.uart
218233

219234
@export
220235
def get_machine_name(self) -> str:
236+
"""Return the Renode machine name."""
221237
return self.machine_name
222238

223239
@export

python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pytest
1111

1212
from jumpstarter_driver_renode.driver import Renode, RenodeFlasher, RenodePower
13-
from jumpstarter_driver_renode.monitor import RenodeMonitor
13+
from jumpstarter_driver_renode.monitor import RenodeMonitor, RenodeMonitorError
1414

1515
from jumpstarter.common.utils import serve
1616

@@ -73,7 +73,7 @@ async def test_monitor_execute_command(self):
7373

7474
@pytest.mark.anyio
7575
async def test_monitor_execute_error_response(self):
76-
"""Monitor returns error text from Renode without raising."""
76+
"""Monitor raises RenodeMonitorError on error responses."""
7777
monitor = RenodeMonitor()
7878
stream = AsyncMock()
7979
stream.receive = AsyncMock(
@@ -82,8 +82,8 @@ async def test_monitor_execute_error_response(self):
8282
monitor._stream = stream
8383
monitor._buffer = b""
8484

85-
result = await monitor.execute("bad command")
86-
assert "Could not find peripheral" in result
85+
with pytest.raises(RenodeMonitorError, match="Could not find peripheral"):
86+
await monitor.execute("bad command")
8787

8888
@pytest.mark.anyio
8989
async def test_monitor_execute_not_connected(self):

python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/monitor.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,25 @@ async def connect(self, host: str, port: int, timeout: float = 10) -> None:
4040
except OSError:
4141
await sleep(0.5)
4242

43+
_ERROR_MARKERS = ("Could not find", "Error", "Invalid", "Failed", "Unknown")
44+
4345
async def execute(self, command: str) -> str:
44-
"""Send a command and return the response text (excluding the prompt)."""
46+
"""Send a command and return the response text (excluding the prompt).
47+
48+
Raises RenodeMonitorError if the response indicates a command failure.
49+
"""
4550
if self._stream is None:
4651
raise RuntimeError("not connected to Renode monitor")
4752

4853
logger.debug("monitor> %s", command)
4954
await self._stream.send(f"{command}\n".encode())
5055
response = await self._read_until_prompt()
5156
logger.debug("monitor< %s", response.strip())
57+
58+
stripped = response.strip()
59+
if stripped and any(stripped.startswith(m) for m in self._ERROR_MARKERS):
60+
raise RenodeMonitorError(stripped)
61+
5262
return response
5363

5464
async def disconnect(self) -> None:

python/uv.lock

Lines changed: 91 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)