feat(simulation): qualify real S1B provider lifecycle
This commit is contained in:
@@ -49,6 +49,15 @@ from k1link.simulation.s0 import (
|
||||
load_s0_profile,
|
||||
run_s0_doctor,
|
||||
)
|
||||
from k1link.simulation.stock_rover import (
|
||||
LIFECYCLE_PROFILE_SCHEMA,
|
||||
StockRoverLifecycleProfile,
|
||||
StockRoverProfileError,
|
||||
StockRoverTargetPaths,
|
||||
load_stock_rover_lifecycle_profile,
|
||||
stock_rover_process_environment,
|
||||
stock_rover_process_specs,
|
||||
)
|
||||
from k1link.simulation.worker import (
|
||||
LocalProcessWorkerAdapter,
|
||||
S0WorkerGuard,
|
||||
@@ -68,6 +77,7 @@ __all__ = [
|
||||
"DifferentialControlSetpoint",
|
||||
"DoctorVerdict",
|
||||
"LocalProcessWorkerAdapter",
|
||||
"LIFECYCLE_PROFILE_SCHEMA",
|
||||
"OwnedProcess",
|
||||
"PosixProcessSupervisor",
|
||||
"ProcessSpec",
|
||||
@@ -91,6 +101,9 @@ __all__ = [
|
||||
"S0DoctorReport",
|
||||
"S0Profile",
|
||||
"S0ProfileError",
|
||||
"StockRoverLifecycleProfile",
|
||||
"StockRoverProfileError",
|
||||
"StockRoverTargetPaths",
|
||||
"SimulationContractError",
|
||||
"SimulationApplicationService",
|
||||
"SimulationOrchestratorError",
|
||||
@@ -101,5 +114,8 @@ __all__ = [
|
||||
"WorkerStartResult",
|
||||
"WorkerStopResult",
|
||||
"load_s0_profile",
|
||||
"load_stock_rover_lifecycle_profile",
|
||||
"run_s0_doctor",
|
||||
"stock_rover_process_environment",
|
||||
"stock_rover_process_specs",
|
||||
]
|
||||
|
||||
@@ -27,6 +27,9 @@ class ProcessSpec:
|
||||
shutdown_order: int
|
||||
environment: tuple[tuple[str, str], ...] = ()
|
||||
startup_grace_seconds: float = 0.05
|
||||
ready_log_patterns: tuple[str, ...] = ()
|
||||
health_timeout_seconds: float = 0.0
|
||||
health_poll_interval_seconds: float = 0.05
|
||||
interrupt_timeout_seconds: float = 2.0
|
||||
terminate_timeout_seconds: float = 1.0
|
||||
|
||||
@@ -39,10 +42,18 @@ class ProcessSpec:
|
||||
raise ValueError("process order must not be negative")
|
||||
if (
|
||||
self.startup_grace_seconds < 0
|
||||
or self.health_timeout_seconds < 0
|
||||
or self.health_poll_interval_seconds <= 0
|
||||
or self.interrupt_timeout_seconds < 0
|
||||
or self.terminate_timeout_seconds < 0
|
||||
):
|
||||
raise ValueError("process timeouts must not be negative")
|
||||
if any(not pattern or "\x00" in pattern for pattern in self.ready_log_patterns):
|
||||
raise ValueError("ready log patterns must be nonempty and NUL-free")
|
||||
if len(self.ready_log_patterns) != len(set(self.ready_log_patterns)):
|
||||
raise ValueError("ready log patterns must be unique")
|
||||
if self.ready_log_patterns and self.health_timeout_seconds <= 0:
|
||||
raise ValueError("ready log patterns require a positive health timeout")
|
||||
keys = [key for key, _ in self.environment]
|
||||
if len(keys) != len(set(keys)) or any(
|
||||
not key or "=" in key or "\x00" in key or "\x00" in value
|
||||
@@ -221,17 +232,73 @@ class PosixProcessSupervisor:
|
||||
close_fds=True,
|
||||
)
|
||||
self._processes[spec.process_id] = (spec, process)
|
||||
if spec.startup_grace_seconds:
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
phase = "before readiness" if spec.ready_log_patterns else "during startup"
|
||||
raise ProcessSupervisorError(
|
||||
f"provider {spec.process_id} exited {phase} with {return_code}"
|
||||
)
|
||||
try:
|
||||
process_group_id = os.getpgid(process.pid)
|
||||
except ProcessLookupError as exc:
|
||||
return_code = process.poll()
|
||||
phase = "before readiness" if spec.ready_log_patterns else "during startup"
|
||||
raise ProcessSupervisorError(
|
||||
f"provider {spec.process_id} exited {phase} with {return_code}"
|
||||
) from exc
|
||||
if process_group_id != process.pid:
|
||||
raise ProcessSupervisorError(
|
||||
f"provider {spec.process_id} did not acquire a dedicated process group"
|
||||
)
|
||||
if spec.ready_log_patterns:
|
||||
self._wait_until_ready(
|
||||
process,
|
||||
spec,
|
||||
stdout_path=stdout_path,
|
||||
stderr_path=stderr_path,
|
||||
)
|
||||
elif spec.startup_grace_seconds:
|
||||
time.sleep(spec.startup_grace_seconds)
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
raise ProcessSupervisorError(
|
||||
f"provider {spec.process_id} exited during startup with {return_code}"
|
||||
)
|
||||
if os.getpgid(process.pid) != process.pid:
|
||||
raise ProcessSupervisorError(
|
||||
f"provider {spec.process_id} did not acquire a dedicated process group"
|
||||
)
|
||||
|
||||
def _wait_until_ready(
|
||||
self,
|
||||
process: subprocess.Popen[bytes],
|
||||
spec: ProcessSpec,
|
||||
*,
|
||||
stdout_path: Path,
|
||||
stderr_path: Path,
|
||||
) -> None:
|
||||
pending = {pattern: pattern.encode("utf-8") for pattern in spec.ready_log_patterns}
|
||||
deadline = time.monotonic() + spec.health_timeout_seconds
|
||||
while pending:
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
raise ProcessSupervisorError(
|
||||
f"provider {spec.process_id} exited before readiness with {return_code}; "
|
||||
f"missing markers: {', '.join(pending)}"
|
||||
)
|
||||
for path in (stdout_path, stderr_path):
|
||||
content = path.read_bytes()
|
||||
pending = {
|
||||
pattern: encoded
|
||||
for pattern, encoded in pending.items()
|
||||
if encoded not in content
|
||||
}
|
||||
if not pending:
|
||||
return
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise ProcessSupervisorError(
|
||||
f"provider {spec.process_id} did not reach readiness within "
|
||||
f"{spec.health_timeout_seconds:g} seconds; "
|
||||
f"missing markers: {', '.join(pending)}"
|
||||
)
|
||||
time.sleep(min(spec.health_poll_interval_seconds, remaining))
|
||||
|
||||
def _stop_group(
|
||||
self,
|
||||
@@ -266,7 +333,8 @@ class PosixProcessSupervisor:
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
process.wait(timeout=0)
|
||||
return not group_signal_supported or not _group_exists(pgid)
|
||||
if not group_signal_supported or not _group_exists(pgid):
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return process.poll() is not None and (
|
||||
not group_signal_supported or not _group_exists(pgid)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import yaml
|
||||
|
||||
from k1link.simulation.process_supervisor import ProcessSpec
|
||||
|
||||
LIFECYCLE_PROFILE_SCHEMA: Final = "missioncore.stock-rover-lifecycle/v1"
|
||||
EXPECTED_D_ROOT: Final = Path("/mnt/d/NDC_MISSIONCORE/simulation")
|
||||
|
||||
|
||||
class StockRoverProfileError(ValueError):
|
||||
"""The reviewed stock-rover lifecycle profile is unsafe or incomplete."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StockRoverLifecycleProfile:
|
||||
profile_id: str
|
||||
speed_factor: int
|
||||
dwell_seconds: float
|
||||
agent_ready_log_patterns: tuple[str, ...]
|
||||
px4_ready_log_patterns: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StockRoverTargetPaths:
|
||||
d_root: Path
|
||||
px4_root: Path
|
||||
agent_prefix: Path
|
||||
process_home: Path
|
||||
xdg_cache_home: Path
|
||||
|
||||
@classmethod
|
||||
def from_d_root(cls, d_root: Path) -> StockRoverTargetPaths:
|
||||
root = d_root.expanduser().resolve()
|
||||
if root != EXPECTED_D_ROOT:
|
||||
raise StockRoverProfileError(
|
||||
f"stock-rover lifecycle requires exact D root {EXPECTED_D_ROOT}"
|
||||
)
|
||||
return cls(
|
||||
d_root=root,
|
||||
px4_root=root / "source/PX4-Autopilot",
|
||||
agent_prefix=root / "runtime/micro-xrce-dds-agent/2.4.3",
|
||||
process_home=root / "runtime/s1/home",
|
||||
xdg_cache_home=root / "cache/s1/xdg",
|
||||
)
|
||||
|
||||
@property
|
||||
def agent_binary(self) -> Path:
|
||||
return self.agent_prefix / "bin/MicroXRCEAgent"
|
||||
|
||||
@property
|
||||
def scenario_path(self) -> Path:
|
||||
return self.px4_root / "Tools/simulation/gz/worlds/rover.sdf"
|
||||
|
||||
|
||||
def load_stock_rover_lifecycle_profile(path: Path) -> StockRoverLifecycleProfile:
|
||||
try:
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
|
||||
raise StockRoverProfileError("stock-rover profile is not valid UTF-8 YAML") from exc
|
||||
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
|
||||
raise StockRoverProfileError("stock-rover profile must be a YAML mapping")
|
||||
expected_keys = {
|
||||
"schema_version",
|
||||
"profile_id",
|
||||
"speed_factor",
|
||||
"dwell_seconds",
|
||||
"authority",
|
||||
"agent_ready_log_patterns",
|
||||
"px4_ready_log_patterns",
|
||||
}
|
||||
if set(document) != expected_keys:
|
||||
raise StockRoverProfileError("stock-rover profile keys do not match the v1 schema")
|
||||
if document["schema_version"] != LIFECYCLE_PROFILE_SCHEMA:
|
||||
raise StockRoverProfileError("stock-rover profile schema is incompatible")
|
||||
authority = document["authority"]
|
||||
if authority != {
|
||||
"simulation_or_shadow_only": True,
|
||||
"actuator_authority": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"direct_actuator_setpoints_allowed": False,
|
||||
}:
|
||||
raise StockRoverProfileError("stock-rover lifecycle cannot grant actuator authority")
|
||||
profile_id = document["profile_id"]
|
||||
speed_factor = document["speed_factor"]
|
||||
dwell_seconds = document["dwell_seconds"]
|
||||
if not isinstance(profile_id, str) or not profile_id:
|
||||
raise StockRoverProfileError("stock-rover profile id must be nonempty")
|
||||
if speed_factor != 1:
|
||||
raise StockRoverProfileError("S1B lifecycle admits only speed factor 1")
|
||||
if not isinstance(dwell_seconds, int | float) or isinstance(dwell_seconds, bool):
|
||||
raise StockRoverProfileError("stock-rover dwell must be numeric")
|
||||
if not 1 <= float(dwell_seconds) <= 30:
|
||||
raise StockRoverProfileError("stock-rover dwell must be between 1 and 30 seconds")
|
||||
return StockRoverLifecycleProfile(
|
||||
profile_id=profile_id,
|
||||
speed_factor=speed_factor,
|
||||
dwell_seconds=float(dwell_seconds),
|
||||
agent_ready_log_patterns=_patterns(
|
||||
document["agent_ready_log_patterns"],
|
||||
"agent",
|
||||
),
|
||||
px4_ready_log_patterns=_patterns(
|
||||
document["px4_ready_log_patterns"],
|
||||
"PX4",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def stock_rover_process_environment(
|
||||
paths: StockRoverTargetPaths,
|
||||
) -> dict[str, str]:
|
||||
paths.process_home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
paths.xdg_cache_home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
return {
|
||||
"HOME": str(paths.process_home),
|
||||
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
||||
"LOGNAME": "missioncore",
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"USER": "missioncore",
|
||||
"XDG_CACHE_HOME": str(paths.xdg_cache_home),
|
||||
}
|
||||
|
||||
|
||||
def stock_rover_process_specs(
|
||||
paths: StockRoverTargetPaths,
|
||||
profile: StockRoverLifecycleProfile,
|
||||
) -> tuple[ProcessSpec, ...]:
|
||||
return (
|
||||
ProcessSpec(
|
||||
process_id="micro-xrce-dds-agent",
|
||||
argv=(
|
||||
str(paths.agent_binary),
|
||||
"udp4",
|
||||
"-p",
|
||||
"8888",
|
||||
"-v",
|
||||
"4",
|
||||
),
|
||||
start_order=10,
|
||||
shutdown_order=30,
|
||||
environment=(("LD_LIBRARY_PATH", str(paths.agent_prefix / "lib")),),
|
||||
startup_grace_seconds=0,
|
||||
ready_log_patterns=profile.agent_ready_log_patterns,
|
||||
health_timeout_seconds=15,
|
||||
health_poll_interval_seconds=0.1,
|
||||
interrupt_timeout_seconds=3,
|
||||
terminate_timeout_seconds=2,
|
||||
),
|
||||
ProcessSpec(
|
||||
process_id="px4-gazebo-stock-rover",
|
||||
argv=(
|
||||
"/usr/bin/make",
|
||||
"-C",
|
||||
str(paths.px4_root),
|
||||
"px4_sitl",
|
||||
"gz_rover_ackermann",
|
||||
),
|
||||
start_order=20,
|
||||
shutdown_order=40,
|
||||
environment=(
|
||||
("HEADLESS", "1"),
|
||||
("PX4_SIM_SPEED_FACTOR", str(profile.speed_factor)),
|
||||
),
|
||||
startup_grace_seconds=0,
|
||||
ready_log_patterns=profile.px4_ready_log_patterns,
|
||||
health_timeout_seconds=120,
|
||||
health_poll_interval_seconds=0.25,
|
||||
interrupt_timeout_seconds=10,
|
||||
terminate_timeout_seconds=5,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _patterns(value: object, label: str) -> tuple[str, ...]:
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or not value
|
||||
or any(not isinstance(item, str) or not item or "\x00" in item for item in value)
|
||||
):
|
||||
raise StockRoverProfileError(f"{label} ready patterns must be nonempty strings")
|
||||
patterns = tuple(value)
|
||||
if len(patterns) != len(set(patterns)):
|
||||
raise StockRoverProfileError(f"{label} ready patterns must be unique")
|
||||
return patterns
|
||||
Reference in New Issue
Block a user