ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
hil_convergence_safety_runtime_launcher.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Transactional three-source launcher for image-supervisor runtime proofs."""
4
5from __future__ import annotations
6
7import os
8import subprocess
9from collections.abc import Callable
10from dataclasses import dataclass
11from pathlib import Path
12
13DescriptorCloser = Callable[[int], None]
14PYTHON_INTERPRETER = "/usr/bin/python3"
15
16
17@dataclass(frozen=True)
18class SupervisorStart:
19 """Optional launch authorities kept together at the Popen boundary."""
20
21 extra_descriptors: tuple[int, ...] = ()
22 environment: dict[str, str] | None = None
23 source_opener: Callable[[Path, int], int] = os.open
24 descriptor_closer: DescriptorCloser = os.close
25
26
27@dataclass(frozen=True)
28class LaunchResult:
29 """Retain a started runner and both errors for caller-owned resolution."""
30
31 process: subprocess.Popen[bytes] | None
32 start_error: OSError | ValueError | subprocess.SubprocessError | None
33 close_error: OSError | None
34
35
36def close_owned_descriptors(descriptors: set[int], closer: DescriptorCloser = os.close) -> None:
37 """Attempt each close once after irrevocably releasing numeric authority."""
38 first_error: OSError | None = None
39 for descriptor in sorted(descriptors):
40 descriptors.remove(descriptor)
41 try:
42 closer(descriptor)
43 except OSError as error:
44 if first_error is None:
45 first_error = error
46 if first_error is not None:
47 raise first_error
48
49
50def open_supervisor_sources(
51 main_path: Path,
52 process_path: Path,
53 cases_path: Path,
54 opener: Callable[[Path, int], int] = os.open,
55 closer: DescriptorCloser = os.close,
56) -> tuple[int, int, int, set[int]]:
57 """Acquire all source descriptors or exhaustively release predecessors."""
58 owned: set[int] = set()
59 try:
60 main_descriptor = opener(main_path, os.O_RDONLY | os.O_NOFOLLOW)
61 owned.add(main_descriptor)
62 process_descriptor = opener(process_path, os.O_RDONLY | os.O_NOFOLLOW)
63 owned.add(process_descriptor)
64 cases_descriptor = opener(cases_path, os.O_RDONLY | os.O_NOFOLLOW)
65 owned.add(cases_descriptor)
66 except OSError as open_error:
67 try:
68 close_owned_descriptors(owned, closer)
69 except OSError as cleanup_error:
70 raise open_error from cleanup_error
71 raise
72 return main_descriptor, process_descriptor, cases_descriptor, owned
73
74
75def _spawn(
76 descriptors: tuple[int, ...],
77 arguments: tuple[str, ...],
78 environment: dict[str, str] | None,
79) -> subprocess.Popen[bytes]:
80 """Spawn the fixed interpreter with three bound source descriptors."""
81 main_descriptor, process_descriptor, cases_descriptor = descriptors[:3]
82 return subprocess.Popen( # noqa: S603 -- current interpreter and bound source FDs
83 (
84 PYTHON_INTERPRETER,
85 "-B",
86 "-I",
87 "-S",
88 f"/proc/self/fd/{main_descriptor}",
89 "--process-fd",
90 str(process_descriptor),
91 "--cases-fd",
92 str(cases_descriptor),
93 *arguments,
94 ),
95 pass_fds=descriptors,
96 start_new_session=True,
97 stdout=subprocess.PIPE,
98 stderr=subprocess.PIPE,
99 env=environment,
100 )
101
102
103def launch(
104 paths: tuple[Path, Path, Path],
105 arguments: tuple[str, ...],
106 config: SupervisorStart,
107) -> LaunchResult:
108 """Open, spawn, and release every authenticated source exactly once."""
109 main_path, process_path, cases_path = paths
110 main, process_source, cases, owned = open_supervisor_sources(
111 main_path,
112 process_path,
113 cases_path,
114 config.source_opener,
115 config.descriptor_closer,
116 )
117 descriptors = main, process_source, cases, *config.extra_descriptors
118 process: subprocess.Popen[bytes] | None = None
119 start_error: OSError | ValueError | subprocess.SubprocessError | None = None
120 close_error: OSError | None = None
121 try:
122 process = _spawn(descriptors, arguments, config.environment)
123 except (OSError, ValueError, subprocess.SubprocessError) as error:
124 start_error = error
125 finally:
126 try:
127 close_owned_descriptors(owned, config.descriptor_closer)
128 except OSError as error:
129 close_error = error
130 return LaunchResult(process, start_error, close_error)