3"""Supervise exactly one remote J-Link GDB server process."""
5from __future__
import annotations
18from collections.abc
import Callable
19from dataclasses
import dataclass
20from pathlib
import Path
21from typing
import NoReturn
25IDENTIFIER_RE = re.compile(
r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}")
27START_TIMEOUT_SECONDS = 10.0
28STOP_TIMEOUT_SECONDS = 5.0
33_STOP_REQUESTED = [
False]
36class RemoteError(RuntimeError):
37 """The remote server could not be started or supervised safely."""
40def _fail(message: str) -> NoReturn:
41 raise RemoteError(message)
44@dataclass(frozen=True)
46 """Injectable boundaries keep the selftest offline and non-signalling."""
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]
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)
66def _port(value: str) -> int:
67 if not value.isascii()
or not value.isdecimal():
68 _fail(
"port must be decimal")
70 if not PORT_MIN <= port <= PORT_MAX:
71 _fail(
"port is outside the unprivileged TCP range")
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")
80 _identifier(arguments[1],
"device"),
81 _identifier(arguments[2],
"serial"),
86def _server_path() -> str:
87 selected = shutil.which(
"JLinkGDBServerCLExe")
or shutil.which(
"JLinkGDBServer")
89 _fail(
"J-Link GDB server is not installed on the rig")
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
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)
103 _fail(
"J-Link GDB server path is not a protected executable")
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
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
118 fields = line.split()
119 if len(fields) < PROC_FIELDS_MIN
or fields[3] != LISTEN_STATE:
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:
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"
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:
142 target = str(entry.readlink())
145 if target.startswith(
"socket:[")
and target.endswith(
"]"):
146 found.add(target[8:-1])
150def _port_busy(port: int) -> bool:
151 return bool(_listener_inodes(port))
154def _listener_owned(pid: int, port: int) -> bool:
155 listeners = _listener_inodes(port)
156 return bool(listeners
and listeners & _process_socket_inodes(pid))
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)
164 events & (select.POLLERR | select.POLLHUP | select.POLLNVAL)
165 for _descriptor, events
in poller.poll(0)
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")
181def _request_stop(_signal_number: int, _frame: object) ->
None:
182 _STOP_REQUESTED[0] =
True
185def _install_signal_handlers() -> None:
186 for selected
in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
187 signal.signal(selected, _request_stop)
190def _stop_requested() -> bool:
191 return _STOP_REQUESTED[0]
194def _spawn(arguments: list[str]) -> subprocess.Popen[bytes]:
195 return subprocess.Popen(
197 stdin=subprocess.DEVNULL,
204def _default_hooks() -> SupervisorHooks:
205 return SupervisorHooks(
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:
224 process.wait(timeout=STOP_TIMEOUT_SECONDS)
225 except subprocess.TimeoutExpired:
226 if process.poll()
is None:
228 process.wait(timeout=STOP_TIMEOUT_SECONDS)
231def _ready_line(line: str) ->
None:
232 print(line, flush=
True)
236 arguments: list[str],
238 hooks: SupervisorHooks |
None =
None,
239 ready: Callable[[str],
None] = _ready_line,
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)
250 deadline = selected.monotonic() + START_TIMEOUT_SECONDS
251 while selected.monotonic() < deadline:
252 result = process.poll()
253 if result
is not None:
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}")
263 selected.sleep(POLL_SECONDS)
265 _fail(f
"timed out waiting for owned listener on port {port}")
267 while process.poll()
is None:
269 selected.stop_requested()
270 or selected.getppid() != parent
271 or not selected.channel_open()
274 selected.sleep(POLL_SECONDS)
276 return int(process.returncode)
283 """Minimal retained-child model for offline lifecycle tests."""
285 def __init__(self, exit_after: int |
None =
None, *, ignore_terminate: bool =
False) ->
None:
287 self.returncode: int |
None =
None
289 self.exit_after = exit_after
290 self.ignore_terminate = ignore_terminate
294 def poll(self) -> int | None:
296 if self.exit_after
is not None and self.polls >= self.exit_after:
298 return self.returncode
300 def terminate(self) -> None:
302 if not self.ignore_terminate:
303 self.returncode = -signal.SIGTERM
305 def kill(self) -> None:
307 self.returncode = -signal.SIGKILL
309 def wait(self, timeout: float) -> int:
311 if self.returncode
is None:
313 raise subprocess.TimeoutExpired(command, STOP_TIMEOUT_SECONDS)
314 return self.returncode
318 process: _FakeProcess,
321 channel: Callable[[], bool] =
lambda:
True,
322 parent: Callable[[], int] =
lambda: 77,
323 stop: Callable[[], bool] =
lambda:
False,
327 def sleep(interval: float) ->
None:
330 return SupervisorHooks(
331 lambda _arguments: process,
333 lambda _pid, _port:
True,
336 lambda _parent:
None,
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)
348 "sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"
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]")
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)
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}")
367 process = _FakeProcess()
368 channels = iter((
True,
False))
371 [
"/protected/server"],
373 _fake_hooks(process, channel=
lambda: next(channels,
False)),
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}")
381 process = _FakeProcess()
383 supervise([
"/protected/server"], 2331, _fake_hooks(process, busy=
True))
384 failures.append(
"pre-existing listener was accepted")
386 if process.terminated
or process.polls:
387 failures.append(
"busy-port refusal touched an unspawned process")
389 process = _FakeProcess(exit_after=1)
391 supervise([
"/protected/server"], 2331, _fake_hooks(process))
392 failures.append(
"pre-readiness server exit was accepted")
394 if process.terminated:
395 failures.append(
"already-reaped PID was signalled during cleanup")
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")
408 _process_socket_inodes(4243, proc)
409 failures.append(
"absent process identity was accepted")
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))
419 [
"/protected/server"],
421 _fake_hooks(process, parent=
lambda: next(parents, 78)),
424 if result != 0
or process.terminated != 1:
425 failures.append(
"remote parent loss did not stop the retained child")
427 process = _FakeProcess()
428 stops = iter((
False,
True))
430 [
"/protected/server"],
432 _fake_hooks(process, stop=
lambda: next(stops,
True)),
435 if result != 0
or process.terminated != 1:
436 failures.append(
"remote signal request did not stop the retained child")
438 process = _FakeProcess(ignore_terminate=
True)
440 if process.terminated != 1
or process.killed != 1:
441 failures.append(
"unresponsive retained child did not receive bounded escalation")
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"):
449 failures.append(f
"unsafe port passed: {value!r}")
452 for value
in (
"bad value",
"-device",
"bad;value"):
454 _identifier(value,
"device")
455 failures.append(f
"unsafe identifier passed: {value!r}")
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}")
465 [
"R7KA8D2KF_CPU0",
"123456789",
"2331"],
466 [
"--",
"123456789",
"2331",
"R7KA8D2KF_CPU0"],
467 [
"--",
"R7KA8D2KF_CPU0",
"123456789",
"2331",
"extra"],
470 _closed_arguments(arguments)
471 failures.append(f
"invalid closed remote argv passed: {arguments!r}")
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)
484 for failure
in failures:
485 print(f
" [FAIL] {failure}", file=sys.stderr)
487 print(
"remote_gdb_remote.py: PASS (direct-child/readiness/channel/PID-reuse)")
492 """Validate the closed remote argv and supervise the selected server."""
493 arguments = sys.argv[1:]
494 if arguments == [
"--selftest"]:
497 device, serial, port = _closed_arguments(arguments)
498 server = _server_path()
499 _install_signal_handlers()
514 return supervise(argv, port)
515 except RemoteError
as exc:
516 print(f
"remote_gdb_remote: {exc}", file=sys.stderr)
520if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.