ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
remote_gdb_remote.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Supervise exactly one remote J-Link GDB server process."""
4
5from __future__ import annotations
6
7import ctypes
8import os
9import re
10import select
11import shutil
12import signal
13import stat
14import subprocess
15import sys
16import tempfile
17import time
18from collections.abc import Callable
19from dataclasses import dataclass
20from pathlib import Path
21from typing import NoReturn
22
23PORT_MIN = 1024
24PORT_MAX = 65535
25IDENTIFIER_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}")
26LISTEN_STATE = "0A"
27START_TIMEOUT_SECONDS = 10.0
28STOP_TIMEOUT_SECONDS = 5.0
29POLL_SECONDS = 0.05
30PR_SET_PDEATHSIG = 1
31PROC_FIELDS_MIN = 10
32REMOTE_ARG_COUNT = 4
33_STOP_REQUESTED = [False]
34
35
36class RemoteError(RuntimeError):
37 """The remote server could not be started or supervised safely."""
38
39
40def _fail(message: str) -> NoReturn:
41 raise RemoteError(message)
42
43
44@dataclass(frozen=True)
45class SupervisorHooks:
46 """Injectable boundaries keep the selftest offline and non-signalling."""
47
48 spawn: Callable[[list[str]], object]
49 port_busy: Callable[[int], bool]
50 listener_owned: Callable[[int, int], bool]
51 channel_open: Callable[[], bool]
52 getppid: Callable[[], int]
53 set_parent_death: Callable[[int], None]
54 stop_requested: Callable[[], bool]
55 monotonic: Callable[[], float]
56 sleep: Callable[[float], None]
57
58
59def _identifier(value: str, label: str) -> str:
60 if IDENTIFIER_RE.fullmatch(value) is None:
61 message = f"{label} is not a bounded SEGGER identifier"
62 raise RemoteError(message)
63 return value
64
65
66def _port(value: str) -> int:
67 if not value.isascii() or not value.isdecimal():
68 _fail("port must be decimal")
69 port = int(value, 10)
70 if not PORT_MIN <= port <= PORT_MAX:
71 _fail("port is outside the unprivileged TCP range")
72 return port
73
74
75def _closed_arguments(arguments: list[str]) -> tuple[str, str, int]:
76 """Parse only the exact argv produced by the local transport authority."""
77 if len(arguments) != REMOTE_ARG_COUNT or arguments[0] != "--":
78 _fail("expected -- DEVICE SERIAL PORT")
79 return (
80 _identifier(arguments[1], "device"),
81 _identifier(arguments[2], "serial"),
82 _port(arguments[3]),
83 )
84
85
86def _server_path() -> str:
87 selected = shutil.which("JLinkGDBServerCLExe") or shutil.which("JLinkGDBServer")
88 if selected is None:
89 _fail("J-Link GDB server is not installed on the rig")
90 try:
91 path = Path(selected).resolve(strict=True)
92 observed = path.stat()
93 except OSError as exc:
94 message = "J-Link GDB server path is unavailable"
95 raise RemoteError(message) from exc
96 if (
97 not path.is_absolute()
98 or not stat.S_ISREG(observed.st_mode)
99 or not os.access(path, os.X_OK)
100 or observed.st_uid not in {0, os.getuid()}
101 or observed.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
102 ):
103 _fail("J-Link GDB server path is not a protected executable")
104 return str(path)
105
106
107def _listener_inodes(port: int, proc_root: Path = Path("/proc")) -> set[str]:
108 """Return Linux TCP-listener socket inodes for one local port."""
109 found: set[str] = set()
110 for name in ("tcp", "tcp6"):
111 path = proc_root / "net" / name
112 try:
113 lines = path.read_text(encoding="ascii").splitlines()[1:]
114 except OSError as exc:
115 message = f"cannot inspect {path}"
116 raise RemoteError(message) from exc
117 for line in lines:
118 fields = line.split()
119 if len(fields) < PROC_FIELDS_MIN or fields[3] != LISTEN_STATE:
120 continue
121 try:
122 observed_port = int(fields[1].rsplit(":", 1)[1], 16)
123 except (IndexError, ValueError) as exc:
124 message = f"malformed listener table {path}"
125 raise RemoteError(message) from exc
126 if observed_port == port:
127 found.add(fields[9])
128 return found
129
130
131def _process_socket_inodes(pid: int, proc_root: Path = Path("/proc")) -> set[str]:
132 """Return socket inodes retained by one exact, unreaped process."""
133 descriptors = proc_root / str(pid) / "fd"
134 try:
135 entries = tuple(descriptors.iterdir())
136 except OSError as exc:
137 message = "cannot inspect J-Link server descriptors"
138 raise RemoteError(message) from exc
139 found: set[str] = set()
140 for entry in entries:
141 try:
142 target = str(entry.readlink())
143 except OSError:
144 continue
145 if target.startswith("socket:[") and target.endswith("]"):
146 found.add(target[8:-1])
147 return found
148
149
150def _port_busy(port: int) -> bool:
151 return bool(_listener_inodes(port))
152
153
154def _listener_owned(pid: int, port: int) -> bool:
155 listeners = _listener_inodes(port)
156 return bool(listeners and listeners & _process_socket_inodes(pid))
157
158
159def _channel_open() -> bool:
160 """Keep the server only while the owning SSH stdout channel is live."""
161 poller = select.poll()
162 poller.register(sys.stdout.fileno(), select.POLLOUT | select.POLLERR | select.POLLHUP)
163 return not any(
164 events & (select.POLLERR | select.POLLHUP | select.POLLNVAL)
165 for _descriptor, events in poller.poll(0)
166 )
167
168
169def _set_parent_death(expected_parent: int) -> None:
170 """Make loss of the ssh-owned command shell terminate this supervisor."""
171 if not sys.platform.startswith("linux") or expected_parent <= 1:
172 _fail("remote parent-death authority requires Linux")
173 library = ctypes.CDLL(None, use_errno=True)
174 if library.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0) != 0:
175 error = ctypes.get_errno()
176 _fail(f"cannot install parent-death signal: errno {error}")
177 if os.getppid() != expected_parent:
178 _fail("remote command parent changed during startup")
179
180
181def _request_stop(_signal_number: int, _frame: object) -> None:
182 _STOP_REQUESTED[0] = True
183
184
185def _install_signal_handlers() -> None:
186 for selected in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
187 signal.signal(selected, _request_stop)
188
189
190def _stop_requested() -> bool:
191 return _STOP_REQUESTED[0]
192
193
194def _spawn(arguments: list[str]) -> subprocess.Popen[bytes]:
195 return subprocess.Popen( # noqa: S603 - executable is protected and argv is closed.
196 arguments,
197 stdin=subprocess.DEVNULL,
198 stdout=None,
199 stderr=None,
200 close_fds=True,
201 )
202
203
204def _default_hooks() -> SupervisorHooks:
205 return SupervisorHooks(
206 _spawn,
207 _port_busy,
208 _listener_owned,
209 _channel_open,
210 os.getppid,
211 _set_parent_death,
212 _stop_requested,
213 time.monotonic,
214 time.sleep,
215 )
216
217
218def _stop_child(process: object) -> None:
219 """Signal only the retained, direct child while its PID cannot be reused."""
220 if process.poll() is not None:
221 return
222 process.terminate()
223 try:
224 process.wait(timeout=STOP_TIMEOUT_SECONDS)
225 except subprocess.TimeoutExpired:
226 if process.poll() is None:
227 process.kill()
228 process.wait(timeout=STOP_TIMEOUT_SECONDS)
229
230
231def _ready_line(line: str) -> None:
232 print(line, flush=True)
233
234
235def supervise(
236 arguments: list[str],
237 port: int,
238 hooks: SupervisorHooks | None = None,
239 ready: Callable[[str], None] = _ready_line,
240) -> int:
241 """Run one direct child until it exits or either owner boundary disappears."""
242 selected = _default_hooks() if hooks is None else hooks
243 parent = selected.getppid()
244 selected.set_parent_death(parent)
245 if selected.port_busy(port):
246 _fail(f"TCP port {port} already has a listener")
247 process = selected.spawn(arguments)
248 stopping = True
249 try:
250 deadline = selected.monotonic() + START_TIMEOUT_SECONDS
251 while selected.monotonic() < deadline:
252 result = process.poll()
253 if result is not None:
254 stopping = False
255 _fail(f"J-Link server exited before listening ({result})")
256 if selected.getppid() != parent or not selected.channel_open():
257 _fail("owning SSH channel closed before server readiness")
258 if selected.stop_requested():
259 _fail("remote server startup was interrupted")
260 if selected.listener_owned(process.pid, port):
261 ready(f"RA8_REMOTE_GDB_READY port={port}")
262 break
263 selected.sleep(POLL_SECONDS)
264 else:
265 _fail(f"timed out waiting for owned listener on port {port}")
266
267 while process.poll() is None:
268 if (
269 selected.stop_requested()
270 or selected.getppid() != parent
271 or not selected.channel_open()
272 ):
273 return 0
274 selected.sleep(POLL_SECONDS)
275 stopping = False
276 return int(process.returncode)
277 finally:
278 if stopping:
279 _stop_child(process)
280
281
282class _FakeProcess:
283 """Minimal retained-child model for offline lifecycle tests."""
284
285 def __init__(self, exit_after: int | None = None, *, ignore_terminate: bool = False) -> None:
286 self.pid = 4242
287 self.returncode: int | None = None
288 self.polls = 0
289 self.exit_after = exit_after
290 self.ignore_terminate = ignore_terminate
291 self.terminated = 0
292 self.killed = 0
293
294 def poll(self) -> int | None:
295 self.polls += 1
296 if self.exit_after is not None and self.polls >= self.exit_after:
297 self.returncode = 0
298 return self.returncode
299
300 def terminate(self) -> None:
301 self.terminated += 1
302 if not self.ignore_terminate:
303 self.returncode = -signal.SIGTERM
304
305 def kill(self) -> None:
306 self.killed += 1
307 self.returncode = -signal.SIGKILL
308
309 def wait(self, timeout: float) -> int:
310 del timeout
311 if self.returncode is None:
312 command = "fake"
313 raise subprocess.TimeoutExpired(command, STOP_TIMEOUT_SECONDS)
314 return self.returncode
315
316
317def _fake_hooks(
318 process: _FakeProcess,
319 *,
320 busy: bool = False,
321 channel: Callable[[], bool] = lambda: True,
322 parent: Callable[[], int] = lambda: 77,
323 stop: Callable[[], bool] = lambda: False,
324) -> SupervisorHooks:
325 clock = [0.0]
326
327 def sleep(interval: float) -> None:
328 clock[0] += interval
329
330 return SupervisorHooks(
331 lambda _arguments: process,
332 lambda _port: busy,
333 lambda _pid, _port: True,
334 channel,
335 parent,
336 lambda _parent: None,
337 stop,
338 lambda: clock[0],
339 sleep,
340 )
341
342
343def _proc_fixture(root: Path, pid: int, port: int) -> None:
344 """Create one listener table and one process descriptor for identity tests."""
345 (root / "net").mkdir(parents=True)
346 (root / str(pid) / "fd").mkdir(parents=True)
347 header = (
348 "sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"
349 )
350 row = f"0: 0100007F:{port:04X} 00000000:0000 0A 0:0 0:0 0 0 0 98765\n"
351 for name in ("tcp", "tcp6"):
352 (root / "net" / name).write_text(header + (row if name == "tcp" else ""), encoding="ascii")
353 (root / str(pid) / "fd" / "4").symlink_to("socket:[98765]")
354
355
356def _lifecycle_cases(failures: list[str]) -> None:
357 """Prove natural exit, channel loss, busy port, and pre-ready exit."""
358 ready: list[str] = []
359 process = _FakeProcess(exit_after=5)
360 try:
361 result = supervise(["/protected/server"], 2331, _fake_hooks(process), ready.append)
362 if result != 0 or ready != ["RA8_REMOTE_GDB_READY port=2331"] or process.terminated:
363 failures.append("natural direct-child lifecycle was not preserved")
364 except RemoteError as exc:
365 failures.append(f"valid lifecycle failed: {exc}")
366
367 process = _FakeProcess()
368 channels = iter((True, False))
369 try:
370 result = supervise(
371 ["/protected/server"],
372 2331,
373 _fake_hooks(process, channel=lambda: next(channels, False)),
374 lambda _line: None,
375 )
376 if result != 0 or process.terminated != 1 or process.killed:
377 failures.append("channel loss did not terminate exactly one retained child")
378 except RemoteError as exc:
379 failures.append(f"channel-loss lifecycle failed: {exc}")
380
381 process = _FakeProcess()
382 try:
383 supervise(["/protected/server"], 2331, _fake_hooks(process, busy=True))
384 failures.append("pre-existing listener was accepted")
385 except RemoteError:
386 if process.terminated or process.polls:
387 failures.append("busy-port refusal touched an unspawned process")
388
389 process = _FakeProcess(exit_after=1)
390 try:
391 supervise(["/protected/server"], 2331, _fake_hooks(process))
392 failures.append("pre-readiness server exit was accepted")
393 except RemoteError:
394 if process.terminated:
395 failures.append("already-reaped PID was signalled during cleanup")
396
397
398def _identity_cases(failures: list[str]) -> None:
399 """Prove listener ownership is bound to the direct process descriptor."""
400 with tempfile.TemporaryDirectory(prefix="ra8-remote-gdb-proc-", dir="/tmp") as directory:
401 proc = Path(directory)
402 _proc_fixture(proc, 4242, 2331)
403 if not (_listener_inodes(2331, proc) & _process_socket_inodes(4242, proc)):
404 failures.append("owned listener identity was not recognized")
405 if _listener_inodes(2332, proc):
406 failures.append("unowned listener identity was accepted")
407 try:
408 _process_socket_inodes(4243, proc)
409 failures.append("absent process identity was accepted")
410 except RemoteError:
411 pass
412
413
414def _owner_loss_cases(failures: list[str]) -> None:
415 """Prove every liveness boundary cleans the same retained child."""
416 process = _FakeProcess()
417 parents = iter((77, 77, 78))
418 result = supervise(
419 ["/protected/server"],
420 2331,
421 _fake_hooks(process, parent=lambda: next(parents, 78)),
422 lambda _line: None,
423 )
424 if result != 0 or process.terminated != 1:
425 failures.append("remote parent loss did not stop the retained child")
426
427 process = _FakeProcess()
428 stops = iter((False, True))
429 result = supervise(
430 ["/protected/server"],
431 2331,
432 _fake_hooks(process, stop=lambda: next(stops, True)),
433 lambda _line: None,
434 )
435 if result != 0 or process.terminated != 1:
436 failures.append("remote signal request did not stop the retained child")
437
438 process = _FakeProcess(ignore_terminate=True)
439 _stop_child(process)
440 if process.terminated != 1 or process.killed != 1:
441 failures.append("unresponsive retained child did not receive bounded escalation")
442
443
444def _input_cases(failures: list[str]) -> None:
445 """Prove unsafe remote fields remain rejected in both input classes."""
446 for value in ("", "-1", "1023", "65536", "23 31"):
447 try:
448 _port(value)
449 failures.append(f"unsafe port passed: {value!r}")
450 except RemoteError:
451 pass
452 for value in ("bad value", "-device", "bad;value"):
453 try:
454 _identifier(value, "device")
455 failures.append(f"unsafe identifier passed: {value!r}")
456 except RemoteError:
457 pass
458 try:
459 parsed = _closed_arguments(["--", "R7KA8D2KF_CPU0", "123456789", "2331"])
460 if parsed != ("R7KA8D2KF_CPU0", "123456789", 2331):
461 failures.append("valid closed remote argv changed during parsing")
462 except RemoteError as exc:
463 failures.append(f"valid closed remote argv failed: {exc}")
464 for arguments in (
465 ["R7KA8D2KF_CPU0", "123456789", "2331"],
466 ["--", "123456789", "2331", "R7KA8D2KF_CPU0"],
467 ["--", "R7KA8D2KF_CPU0", "123456789", "2331", "extra"],
468 ):
469 try:
470 _closed_arguments(arguments)
471 failures.append(f"invalid closed remote argv passed: {arguments!r}")
472 except RemoteError:
473 pass
474
475
476def selftest() -> int:
477 """Exercise both lifecycle directions without opening a socket or signalling."""
478 failures: list[str] = []
479 _lifecycle_cases(failures)
480 _identity_cases(failures)
481 _owner_loss_cases(failures)
482 _input_cases(failures)
483 if failures:
484 for failure in failures:
485 print(f" [FAIL] {failure}", file=sys.stderr)
486 return 1
487 print("remote_gdb_remote.py: PASS (direct-child/readiness/channel/PID-reuse)")
488 return 0
489
490
491def main() -> int:
492 """Validate the closed remote argv and supervise the selected server."""
493 arguments = sys.argv[1:]
494 if arguments == ["--selftest"]:
495 return selftest()
496 try:
497 device, serial, port = _closed_arguments(arguments)
498 server = _server_path()
499 _install_signal_handlers()
500 argv = [
501 server,
502 "-device",
503 device,
504 "-if",
505 "SWD",
506 "-speed",
507 "1000",
508 "-port",
509 str(port),
510 "-nogui",
511 "-select",
512 f"USB={serial}",
513 ]
514 return supervise(argv, port)
515 except RemoteError as exc:
516 print(f"remote_gdb_remote: {exc}", file=sys.stderr)
517 return 2
518
519
520if __name__ == "__main__":
521 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298