ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
fleet_reconcile_process.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Supervise one reconciler child process group and administrative stops."""
4
5from __future__ import annotations
6
7import os
8import signal
9import subprocess
10import sys
11import time
12from collections.abc import Callable, Iterator, Sequence
13from contextlib import contextmanager, suppress
14from dataclasses import dataclass
15
16import fleet_model as fm
17import fleet_mutation_lock as fml
18
19TIMEOUT_STATUS = 124
20
21
22@dataclass
23class StopState:
24 """Mutable signal state shared by the handler and foreground transaction."""
25
26 process: subprocess.Popen[str] | None = None
27 process_signal: int | None = None
28
29
30STOP_STATE = StopState()
31
32
33@dataclass(frozen=True)
34class CommandResult:
35 """Captured command status and output."""
36
37 status: int
38 stdout: str
39 stderr: str
40
41
42def _timeout_output(value: str | bytes | None) -> str:
43 """Normalize output captured by a timed-out text subprocess."""
44 if isinstance(value, bytes):
45 return value.decode("utf-8", errors="replace")
46 return value or ""
47
48
49def _signal_process_group(process: subprocess.Popen[str], process_signal: int) -> None:
50 """Signal the entire fleet-command process group if it still exists."""
51 with suppress(ProcessLookupError):
52 os.killpg(process.pid, process_signal)
53
54
55def _stop_requested(process_signal: int, _frame: object) -> None:
56 """Record an administrative stop and terminate only the owned child group."""
57 if STOP_STATE.process_signal is None:
58 STOP_STATE.process_signal = process_signal
59 if STOP_STATE.process is not None:
60 _signal_process_group(STOP_STATE.process, signal.SIGTERM)
61
62
63@contextmanager
64def stop_handlers() -> Iterator[None]:
65 """Install TERM/HUP/INT handlers for one complete locked transaction."""
66 STOP_STATE.process_signal = None
67 previous = {
68 process_signal: signal.signal(process_signal, _stop_requested)
69 for process_signal in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT)
70 }
71 try:
72 yield
73 finally:
74 for process_signal, handler in previous.items():
75 signal.signal(process_signal, handler)
76
77
78def interrupted_status() -> int:
79 """Return the shell status for the first pending stop, or zero."""
80 if STOP_STATE.process_signal is None:
81 return 0
82 return 128 + STOP_STATE.process_signal
83
84
85def _wait_process_group(process: subprocess.Popen[str], timeout_seconds: float) -> bool:
86 """Wait until no process remains in the owned process group."""
87 deadline = time.monotonic() + timeout_seconds
88 while time.monotonic() < deadline:
89 try:
90 os.killpg(process.pid, 0)
91 except ProcessLookupError:
92 return True
93 time.sleep(0.01)
94 return False
95
96
97def _stop_child_group(process: subprocess.Popen[str]) -> tuple[str, str]:
98 """Terminate a fleet command and wait for its complete owned process group."""
99 _signal_process_group(process, signal.SIGTERM)
100 try:
101 return process.communicate(timeout=10)
102 except subprocess.TimeoutExpired:
103 _signal_process_group(process, signal.SIGKILL)
104 return process.communicate()
105
106
107def command_runner(
108 argv: Sequence[str],
109 *,
110 timeout_seconds: float = 4 * 60 * 60,
111 guardian: bool = False,
112 before_publication: Callable[[subprocess.Popen[str]], None] | None = None,
113) -> CommandResult:
114 """Run one exact fleet command and capture its evidence."""
115 signal_before = STOP_STATE.process_signal
116 if signal_before is not None:
117 return CommandResult(
118 128 + signal_before,
119 "",
120 f"fleet-reconcile: interrupted by signal {signal_before}\n",
121 )
122 guardian_kwargs = fml.guardian_subprocess_kwargs() if guardian else {}
123 process = subprocess.Popen( # noqa: S603 -- executable and verbs are fixed below
124 list(argv),
125 cwd=fm.REPO_ROOT,
126 text=True,
127 stdout=subprocess.PIPE,
128 stderr=subprocess.PIPE,
129 start_new_session=True,
130 **guardian_kwargs,
131 )
132 if before_publication is not None:
133 before_publication(process)
134 STOP_STATE.process = process
135 if STOP_STATE.process_signal is not None:
136 _signal_process_group(process, signal.SIGTERM)
137 try:
138 stdout, stderr = process.communicate(timeout=timeout_seconds)
139 except subprocess.TimeoutExpired as error:
140 stdout, stderr = _stop_child_group(process)
141 return CommandResult(
142 TIMEOUT_STATUS,
143 _timeout_output(stdout or error.stdout),
144 _timeout_output(stderr or error.stderr) + "fleet-reconcile: command timed out\n",
145 )
146 finally:
147 STOP_STATE.process = None
148 if STOP_STATE.process_signal is not None and STOP_STATE.process_signal != signal_before:
149 if not _wait_process_group(process, 2):
150 _signal_process_group(process, signal.SIGKILL)
151 _wait_process_group(process, 2)
152 return CommandResult(
153 128 + STOP_STATE.process_signal,
154 stdout,
155 stderr + f"fleet-reconcile: interrupted by signal {STOP_STATE.process_signal}\n",
156 )
157 return CommandResult(process.returncode, stdout, stderr)
158
159
160def run_selftest() -> list[str]:
161 """Prove a stop in the child-publication gap is forwarded immediately."""
162 failures: list[str] = []
163
164 def request_stop(_process: subprocess.Popen[str]) -> None:
165 os.kill(os.getpid(), signal.SIGTERM)
166
167 with stop_handlers():
168 result = command_runner(
169 [sys.executable, "-c", "import time; time.sleep(60)"],
170 before_publication=request_stop,
171 )
172 if result.status != 128 + signal.SIGTERM:
173 failures.append("signal pending during child publication was not forwarded")
174 STOP_STATE.process_signal = None
175 return failures