3"""Supervise one reconciler child process group and administrative stops."""
5from __future__
import annotations
12from collections.abc
import Callable, Iterator, Sequence
13from contextlib
import contextmanager, suppress
14from dataclasses
import dataclass
16import fleet_model
as fm
17import fleet_mutation_lock
as fml
24 """Mutable signal state shared by the handler and foreground transaction."""
26 process: subprocess.Popen[str] |
None =
None
27 process_signal: int |
None =
None
30STOP_STATE = StopState()
33@dataclass(frozen=True)
35 """Captured command status and output."""
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")
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)
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)
64def stop_handlers() -> Iterator[None]:
65 """Install TERM/HUP/INT handlers for one complete locked transaction."""
66 STOP_STATE.process_signal =
None
68 process_signal: signal.signal(process_signal, _stop_requested)
69 for process_signal
in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT)
74 for process_signal, handler
in previous.items():
75 signal.signal(process_signal, handler)
78def interrupted_status() -> int:
79 """Return the shell status for the first pending stop, or zero."""
80 if STOP_STATE.process_signal
is None:
82 return 128 + STOP_STATE.process_signal
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:
90 os.killpg(process.pid, 0)
91 except ProcessLookupError:
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)
101 return process.communicate(timeout=10)
102 except subprocess.TimeoutExpired:
103 _signal_process_group(process, signal.SIGKILL)
104 return process.communicate()
110 timeout_seconds: float = 4 * 60 * 60,
111 guardian: bool =
False,
112 before_publication: Callable[[subprocess.Popen[str]],
None] |
None =
None,
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(
120 f
"fleet-reconcile: interrupted by signal {signal_before}\n",
122 guardian_kwargs = fml.guardian_subprocess_kwargs()
if guardian
else {}
123 process = subprocess.Popen(
127 stdout=subprocess.PIPE,
128 stderr=subprocess.PIPE,
129 start_new_session=
True,
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)
138 stdout, stderr = process.communicate(timeout=timeout_seconds)
139 except subprocess.TimeoutExpired
as error:
140 stdout, stderr = _stop_child_group(process)
141 return CommandResult(
143 _timeout_output(stdout
or error.stdout),
144 _timeout_output(stderr
or error.stderr) +
"fleet-reconcile: command timed out\n",
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,
155 stderr + f
"fleet-reconcile: interrupted by signal {STOP_STATE.process_signal}\n",
157 return CommandResult(process.returncode, stdout, stderr)
160def run_selftest() -> list[str]:
161 """Prove a stop in the child-publication gap is forwarded immediately."""
162 failures: list[str] = []
164 def request_stop(_process: subprocess.Popen[str]) ->
None:
165 os.kill(os.getpid(), signal.SIGTERM)
167 with stop_handlers():
168 result = command_runner(
169 [sys.executable,
"-c",
"import time; time.sleep(60)"],
170 before_publication=request_stop,
172 if result.status != 128 + signal.SIGTERM:
173 failures.append(
"signal pending during child publication was not forwarded")
174 STOP_STATE.process_signal =
None