ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
hil_convergence_safety_runtime_root_swap.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Transactional suite-root swap proofs for the image supervisor."""
4
5from __future__ import annotations
6
7import os
8import select
9import signal
10import subprocess
11from collections.abc import Callable
12from contextlib import suppress
13from pathlib import Path
14
15import hil_convergence_safety_runtime_mutations as runtime_mutations
16
17PipeFactory = Callable[[int], tuple[int, int]]
18SourceWriter = Callable[[Path, str, str, str], tuple[Path, Path, Path]]
19RootSwapHooks = tuple[Callable[..., object], Callable[[], Path]]
20
21
22def _runtime_api(name: str) -> Callable[..., object]:
23 """Resolve one mutation helper dynamically to preserve test patching."""
24 return runtime_mutations.__dict__[name]
25
26
27def _root_gate_sources(
28 sources: runtime_mutations.SourceBundle, phase: str, handshake: str
29) -> runtime_mutations.SourceBundle:
30 """Inject one descriptor handshake at an exact root-authority boundary."""
31 supervisor, process_source, cases = sources
32 if phase == "pre-open":
33 anchor = " before = root.lstat()\n"
34 if supervisor.count(anchor) != 1:
35 message = "pre-open suite-root mutation authority is not unique"
36 raise runtime_mutations.RuntimeMutationError(message)
37 supervisor = supervisor.replace(anchor, anchor + handshake, 1)
38 elif phase == "post-open":
39 anchor = " anchored_root = _anchored_root_path(descriptor)\n"
40 if cases.count(anchor) != 1:
41 message = "post-open suite-root mutation authority is not unique"
42 raise runtime_mutations.RuntimeMutationError(message)
43 cases = cases.replace(anchor, anchor + handshake, 1)
44 else:
45 message = f"unknown root replacement phase: {phase}"
46 raise runtime_mutations.RuntimeMutationError(message)
47 return supervisor, process_source, cases
48
49
50def _retained_receipts_match(saved: Path, baseline: set[str], phase: str) -> bool:
51 """Require exact retained-inode effects and their bound receipt contents."""
52 added = {path.name for path in saved.iterdir()} - baseline
53 if phase == "pre-open":
54 return added == set()
55 expected = {
56 "supervisor-missing-entry.bound",
57 "supervisor-missing-entry.outer",
58 "supervisor-missing-entry.status",
59 }
60 if added != expected:
61 return False
62 try:
63 bound = (saved / "supervisor-missing-entry.bound").read_bytes().strip()
64 outer = (saved / "supervisor-missing-entry.outer").read_bytes().strip()
65 status = (saved / "supervisor-missing-entry.status").read_bytes()
66 except OSError:
67 return False
68 return bound.isdigit() and outer == bound and status == b"127\n"
69
70
71def _complete_root_swap(
72 process: subprocess.Popen[bytes],
73 descriptors: tuple[int, int],
74 paths: tuple[Path, Path],
75 fixture: tuple[set[str], str, bool],
76) -> tuple[int | None, bool, bool, bool, tuple[int, int] | None]:
77 """Perform one synchronized swap and report effects on both root inodes."""
78 ready_read, go_write = descriptors
79 original, saved = paths
80 baseline, phase, inject_after_rename = fixture
81 readable, _, _ = select.select((ready_read,), (), (), runtime_mutations.RESIDUE_TIMEOUT_SECONDS)
82 if not readable or os.read(ready_read, 1) != b"R":
83 with suppress(ProcessLookupError):
84 os.killpg(process.pid, signal.SIGKILL)
85 status, clean, _stderr = _runtime_api("_collect_runner")(process)
86 return status, clean, False, False, None
87 original.rename(saved)
88 _runtime_api("_move_owned_root")(original, saved)
89 if inject_after_rename:
90 message = "injected root-swap failure after rename"
91 raise runtime_mutations.RuntimeMutationError(message)
92 replacement_identity = _runtime_api("_create_replacement_root")(original)
93 os.write(go_write, b"G")
94 status, clean, _stderr = _runtime_api("_collect_runner")(process)
95 return (
96 status,
97 clean and _runtime_api("_no_residue")((original, saved)),
98 not tuple(original.iterdir()),
99 _retained_receipts_match(saved, baseline, phase),
100 replacement_identity,
101 )
102
103
104def _path_is_present(path: Path) -> bool:
105 """Report path presence without treating a broken symlink as absent."""
106 return path.exists() or path.is_symlink()
107
108
109def _cleanup_swap_roots(
110 paths: tuple[Path, Path],
111 identities: tuple[tuple[int, int], tuple[int, int] | None],
112) -> runtime_mutations.RuntimeMutationError | None:
113 """Attempt every bound root removal while preserving the first refusal."""
114 original, saved = paths
115 original_identity, replacement_identity = identities
116 first_error: runtime_mutations.RuntimeMutationError | None = None
117 candidates: list[tuple[Path, tuple[int, int]]] = []
118 if replacement_identity is not None and _path_is_present(original):
119 candidates.append((original, replacement_identity))
120 if _path_is_present(saved):
121 candidates.append((saved, original_identity))
122 elif _path_is_present(original):
123 candidates.append((original, original_identity))
124 for path, identity in candidates:
125 error = _root_removal_error(path, identity)
126 if first_error is None:
127 first_error = error
128 return first_error
129
130
131def _root_removal_error(
132 path: Path, identity: tuple[int, int]
133) -> runtime_mutations.RuntimeMutationError | None:
134 """Attempt one bound root removal and return its refusal."""
135 try:
136 _runtime_api("_remove_root")(path, identity)
137 except runtime_mutations.RuntimeMutationError as error:
138 return error
139 return None
140
141
142def _finalize_root_swap(
143 process: subprocess.Popen[bytes] | None,
144 descriptors: set[int],
145 paths: tuple[Path, Path],
146 identities: tuple[tuple[int, int], tuple[int, int] | None],
147 proof: tuple[bool, bool, set[str]],
148) -> tuple[bool, bool]:
149 """Dispose the runner, prove descriptor closure, then remove bound roots."""
150 cleanup_clean, injected, baseline = proof
151 first_error: (
152 OSError | runtime_mutations.RuntimeMutationError | subprocess.TimeoutExpired | None
153 ) = None
154 process_resolved = process is None
155 if process is not None:
156 try:
157 _status, cleanup_clean = _runtime_api("_dispose_runner")(process)
158 process_resolved = cleanup_clean
159 except (
160 OSError,
161 runtime_mutations.RuntimeMutationError,
162 subprocess.TimeoutExpired,
163 ) as error:
164 first_error = error
165 try:
166 _runtime_api("_close_owned_descriptors")(descriptors)
167 except OSError as error:
168 if first_error is None:
169 first_error = error
170 if not process_resolved and first_error is None:
171 first_error = runtime_mutations.RuntimeMutationError(
172 "root-swap runner authority remained unresolved"
173 )
174 original, saved = paths
175 injected_state = (
176 injected
177 and not _path_is_present(original)
178 and _path_is_present(saved)
179 and {path.name for path in saved.iterdir()} == baseline
180 and _runtime_api("_no_residue")(paths)
181 )
182 if process_resolved:
183 root_error = _cleanup_swap_roots(paths, identities)
184 if first_error is None:
185 first_error = root_error
186 if first_error is not None:
187 raise first_error
188 return cleanup_clean, injected_state
189
190
191def _prepare_root_gate(
192 sources: runtime_mutations.SourceBundle,
193 phase: str,
194 root: Path,
195 hooks: tuple[PipeFactory, SourceWriter] | None = None,
196) -> tuple[Path, Path, Path, dict[str, str], tuple[int, int, int, int], set[int]]:
197 """Create one handshake whose descriptors remain in an exact owned set."""
198 owned: set[int] = set()
199 pipe_factory, source_writer = hooks or (os.pipe2, _runtime_api("_write_sources"))
200 try:
201 ready_read, ready_write = pipe_factory(os.O_CLOEXEC)
202 owned.update((ready_read, ready_write))
203 go_read, go_write = pipe_factory(os.O_CLOEXEC)
204 owned.update((go_read, go_write))
205 descriptors = (ready_read, ready_write, go_read, go_write)
206 environment = dict(os.environ)
207 environment["RA8_RUNTIME_READY_FD"] = str(ready_write)
208 environment["RA8_RUNTIME_GO_FD"] = str(go_read)
209 handshake = (
210 ' os.write(int(os.environ["RA8_RUNTIME_READY_FD"]), b"R")\n'
211 ' os.read(int(os.environ["RA8_RUNTIME_GO_FD"]), 1)\n'
212 )
213 supervisor, process_source, cases = _root_gate_sources(sources, phase, handshake)
214 main_path, process_path, cases_path = source_writer(root, supervisor, process_source, cases)
215 except (OSError, runtime_mutations.RuntimeMutationError) as primary:
216 try:
217 _runtime_api("_close_owned_descriptors")(owned)
218 except OSError as cleanup_error:
219 raise primary from cleanup_error
220 raise
221 else:
222 return main_path, process_path, cases_path, environment, descriptors, owned
223
224
225def _public_root_swap_result(
226 injected: bool,
227 result: tuple[int | None, bool, bool, bool, tuple[int, int] | None] | None,
228 cleanup_clean: bool,
229 injected_state: bool,
230) -> tuple[int | None, bool, bool, bool]:
231 """Return one public result only after the cleanup proof completed."""
232 if injected:
233 return None, cleanup_clean, injected_state, injected_state
234 if result is None:
235 message = "root-swap runtime produced no result"
236 raise runtime_mutations.RuntimeMutationError(message)
237 return result[:4]
238
239
240def _create_swap_paths(
241 saved_selector: Callable[[], Path],
242) -> tuple[Path, Path, tuple[int, int]]:
243 """Create the first root only when the absent saved path is also acquired."""
244 original, identity = _runtime_api("_create_root")()
245 try:
246 saved = saved_selector()
247 except runtime_mutations.RuntimeMutationError as primary:
248 try:
249 _runtime_api("_remove_root")(original, identity)
250 except runtime_mutations.RuntimeMutationError as cleanup_error:
251 raise primary from cleanup_error
252 raise
253 if saved == original:
254 _runtime_api("_remove_root")(original, identity)
255 message = "saved supervisor runtime root aliases its live original"
256 raise runtime_mutations.RuntimeMutationError(message)
257 return original, saved, identity
258
259
260def _start_root_swap_runner(
261 paths: tuple[Path, Path, Path],
262 environment: dict[str, str],
263 descriptors: tuple[int, int, int, int],
264 owned: set[int],
265 root: Path,
266) -> subprocess.Popen[bytes]:
267 """Launch a synchronized swap runner and release its inherited gate ends."""
268 main_path, process_path, cases_path = paths
269 _ready_read, ready_write, go_read, _go_write = descriptors
270 process = _runtime_api("_start_supervisor")(
271 main_path,
272 process_path,
273 cases_path,
274 ("--selftest-missing-entry", str(root), _runtime_api("_identity_text")(root)),
275 runtime_mutations.SupervisorStart(
276 extra_descriptors=(ready_write, go_read), environment=environment
277 ),
278 )
279 _runtime_api("_release_owned_descriptor")(owned, ready_write)
280 _runtime_api("_release_owned_descriptor")(owned, go_read)
281 return process
282
283
284def _replace_root_after_gate(
285 sources: runtime_mutations.SourceBundle,
286 *,
287 phase: str,
288 inject_after_rename: bool = False,
289 hooks: RootSwapHooks | None = None,
290) -> tuple[int | None, bool, bool, bool]:
291 """Replace a suite root at one deterministic retained-authority boundary."""
292 gate_preparer, saved_selector = hooks or (
293 _runtime_api("_prepare_root_gate"),
294 _runtime_api("_new_root_path"),
295 )
296 original, saved, original_identity = _create_swap_paths(saved_selector)
297 replacement_identity: tuple[int, int] | None = None
298 process: subprocess.Popen[bytes] | None = None
299 cleanup_clean = True
300 owned_descriptors: set[int] = set()
301 baseline: set[str] = set()
302 result: tuple[int | None, bool, bool, bool, tuple[int, int] | None] | None = None
303 injected = False
304 injected_state = False
305 try:
306 (
307 main_path,
308 process_path,
309 cases_path,
310 environment,
311 descriptors,
312 owned_descriptors,
313 ) = gate_preparer(sources, phase, original)
314 ready_read, _ready_write, _go_read, go_write = descriptors
315 baseline = {path.name for path in original.iterdir()}
316 process = _start_root_swap_runner(
317 (main_path, process_path, cases_path),
318 environment,
319 descriptors,
320 owned_descriptors,
321 original,
322 )
323 result = _complete_root_swap(
324 process,
325 (ready_read, go_write),
326 (original, saved),
327 (baseline, phase, inject_after_rename),
328 )
329 _status, cleanup_clean, _replacement_empty, _retained_bound, replacement_identity = result
330 process = None
331 _runtime_api("_release_owned_descriptor")(owned_descriptors, go_write)
332 except runtime_mutations.RuntimeMutationError as error:
333 if not inject_after_rename or str(error) != "injected root-swap failure after rename":
334 raise
335 injected = True
336 finally:
337 cleanup_clean, injected_state = _finalize_root_swap(
338 process,
339 owned_descriptors,
340 (original, saved),
341 (original_identity, replacement_identity),
342 (cleanup_clean, injected, baseline),
343 )
344 return _public_root_swap_result(inject_after_rename, result, cleanup_clean, injected_state)