feat(simulation): qualify real S1B provider lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-24 18:26:43 +03:00
parent e1809ac9e1
commit 01ec12263a
7 changed files with 813 additions and 6 deletions
+16
View File
@@ -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",
]
+74 -6
View File
@@ -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)
+190
View File
@@ -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