Skip to content

Commit 0c7c759

Browse files
authored
Merge pull request #113 from rsasaki0109/agent/fix-local-env-and-benchmark-cleanup
Fix local environment and benchmark cleanup
2 parents 8d23121 + be0d571 commit 0c7c759

5 files changed

Lines changed: 272 additions & 15 deletions

File tree

CMakeLists.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,14 @@ if(BUILD_TESTING)
789789
NAME bootstrap_colcon_workspace_shell
790790
COMMAND bash -n ${CMAKE_CURRENT_SOURCE_DIR}/scripts/bootstrap_colcon_workspace.sh
791791
)
792+
add_test(
793+
NAME setup_local_env
794+
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_setup_local_env.py
795+
)
796+
add_test(
797+
NAME benchmark_runner_cleanup
798+
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_benchmark_runner_cleanup.py
799+
)
792800
add_test(
793801
NAME koide_hard_imu_deskew_smoke_shell
794802
COMMAND bash -n ${CMAKE_CURRENT_SOURCE_DIR}/scripts/run_koide_hard_imu_deskew_smoke.sh

scripts/benchmark_runner

Lines changed: 123 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22

33
import argparse
4+
import atexit
45
import csv
56
import json
67
import math
@@ -147,6 +148,46 @@ class ResourceMonitor(threading.Thread):
147148
last_wall = current_wall
148149

149150

151+
class ManagedProcessCleanup:
152+
"""Best-effort process-group cleanup for normal and exceptional exits."""
153+
154+
def __init__(self, timeout_sec: float) -> None:
155+
self._timeout_sec = timeout_sec
156+
self._processes: List[ManagedProcess] = []
157+
self._monitor: Optional[ResourceMonitor] = None
158+
self._lock = threading.Lock()
159+
self._cleaned = False
160+
161+
def add(self, managed: ManagedProcess) -> ManagedProcess:
162+
with self._lock:
163+
self._processes.append(managed)
164+
return managed
165+
166+
def set_monitor(self, monitor: ResourceMonitor) -> None:
167+
with self._lock:
168+
self._monitor = monitor
169+
170+
def stop_all(self) -> None:
171+
with self._lock:
172+
if self._cleaned:
173+
return
174+
self._cleaned = True
175+
processes = list(reversed(self._processes))
176+
monitor = self._monitor
177+
if monitor is not None:
178+
monitor.stop()
179+
monitor.join(timeout=2.0)
180+
for managed in processes:
181+
try:
182+
stop_process(managed, self._timeout_sec)
183+
except (OSError, subprocess.SubprocessError):
184+
try:
185+
managed.stdout_stream.close()
186+
managed.stderr_stream.close()
187+
except OSError:
188+
pass
189+
190+
150191
def build_stats(samples: List[Dict[str, float]]) -> Dict[str, Optional[float]]:
151192
cpu_values = [sample["cpu_percent"] for sample in samples]
152193
rss_values = [sample["rss_mb"] for sample in samples]
@@ -177,11 +218,17 @@ def launch_process(
177218
env = os.environ.copy()
178219
if extra_env:
179220
env.update(extra_env)
221+
222+
def prepare_child_process() -> None:
223+
os.setsid()
224+
signal.pthread_sigmask(
225+
signal.SIG_UNBLOCK, {signal.SIGINT, signal.SIGTERM})
226+
180227
process = subprocess.Popen(
181228
["bash", "-lc", command],
182229
stdout=stdout_stream,
183230
stderr=stderr_stream,
184-
preexec_fn=os.setsid,
231+
preexec_fn=prepare_child_process,
185232
env=env,
186233
)
187234
return ManagedProcess(
@@ -195,22 +242,66 @@ def launch_process(
195242
)
196243

197244

245+
def launch_registered_process(
246+
cleanup: ManagedProcessCleanup,
247+
name: str,
248+
command: str,
249+
output_dir: str,
250+
extra_env: Optional[Dict[str, str]] = None,
251+
) -> ManagedProcess:
252+
"""Launch a process group and immediately register it for cleanup."""
253+
termination_signals = {signal.SIGINT, signal.SIGTERM}
254+
previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, termination_signals)
255+
try:
256+
managed = launch_process(name, command, output_dir, extra_env)
257+
return cleanup.add(managed)
258+
finally:
259+
signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask)
260+
261+
262+
def process_group_exists(process_group_id: int) -> bool:
263+
try:
264+
os.killpg(process_group_id, 0)
265+
except ProcessLookupError:
266+
return False
267+
except PermissionError:
268+
return True
269+
return True
270+
271+
272+
def wait_for_process_group_exit(
273+
managed: ManagedProcess, process_group_id: int, timeout_sec: float
274+
) -> bool:
275+
deadline = time.monotonic() + timeout_sec
276+
while process_group_exists(process_group_id) and time.monotonic() < deadline:
277+
managed.process.poll()
278+
time.sleep(0.02)
279+
managed.process.poll()
280+
return not process_group_exists(process_group_id)
281+
282+
198283
def stop_process(managed: ManagedProcess, timeout_sec: float) -> int:
284+
process_group_id = managed.process.pid
285+
for shutdown_signal in (signal.SIGINT, signal.SIGTERM, signal.SIGKILL):
286+
if not process_group_exists(process_group_id):
287+
break
288+
try:
289+
os.killpg(process_group_id, shutdown_signal)
290+
except ProcessLookupError:
291+
break
292+
if wait_for_process_group_exit(managed, process_group_id, timeout_sec):
293+
break
294+
199295
if managed.process.poll() is None:
200-
os.killpg(os.getpgid(managed.process.pid), signal.SIGINT)
201296
try:
202297
managed.process.wait(timeout=timeout_sec)
203298
except subprocess.TimeoutExpired:
204-
os.killpg(os.getpgid(managed.process.pid), signal.SIGTERM)
205-
try:
206-
managed.process.wait(timeout=timeout_sec)
207-
except subprocess.TimeoutExpired:
208-
os.killpg(os.getpgid(managed.process.pid), signal.SIGKILL)
209-
managed.process.wait(timeout=timeout_sec)
299+
managed.process.kill()
300+
managed.process.wait(timeout=timeout_sec)
210301

211302
managed.stdout_stream.close()
212303
managed.stderr_stream.close()
213-
return int(managed.process.returncode)
304+
return int(managed.process.returncode if managed.process.returncode is not None else -1)
214305

215306

216307
def detect_process_died_positions(log_path: str, target_process_pattern: str) -> Dict[str, int]:
@@ -369,9 +460,17 @@ def wait_for_pid(pattern: str, timeout_sec: float) -> Optional[int]:
369460
return None
370461

371462

463+
def exit_on_termination_signal(signum: int, _frame: object) -> None:
464+
"""Convert SIGTERM into normal interpreter unwinding so atexit cleanup runs."""
465+
raise SystemExit(128 + signum)
466+
467+
372468
def main() -> int:
373469
args = parse_args()
374470
os.makedirs(args.output_dir, exist_ok=True)
471+
cleanup = ManagedProcessCleanup(args.shutdown_timeout)
472+
atexit.register(cleanup.stop_all)
473+
signal.signal(signal.SIGTERM, exit_on_termination_signal)
375474

376475
pose_csv_path = os.path.join(args.output_dir, "pose_trace.csv")
377476
diagnostic_csv_path = os.path.join(args.output_dir, "alignment_status.csv")
@@ -400,10 +499,14 @@ def main() -> int:
400499

401500
started_at = now_iso()
402501
settle_start_monotonic = time.monotonic()
403-
system = launch_process("system", system_command, args.output_dir, launch_env)
404-
recorder = launch_process("pose_recorder", recorder_command, args.output_dir, launch_env)
502+
system = launch_registered_process(
503+
cleanup, "system", system_command, args.output_dir, launch_env)
504+
recorder = launch_registered_process(
505+
cleanup, "pose_recorder", recorder_command, args.output_dir, launch_env)
405506
diagnostic_recorder = (
406-
launch_process("diagnostic_recorder", diagnostic_recorder_command, args.output_dir, launch_env)
507+
launch_registered_process(
508+
cleanup, "diagnostic_recorder", diagnostic_recorder_command,
509+
args.output_dir, launch_env)
407510
if args.diagnostic_topic
408511
else None
409512
)
@@ -412,13 +515,15 @@ def main() -> int:
412515
monitored_pid = wait_for_pid(args.target_process_pattern, args.settle_seconds)
413516
if monitored_pid is not None:
414517
monitor = ResourceMonitor(monitored_pid, resource_csv_path, args.monitor_interval)
518+
cleanup.set_monitor(monitor)
415519
monitor.start()
416520

417521
settle_elapsed = time.monotonic() - settle_start_monotonic
418522
time.sleep(max(0.0, args.settle_seconds - settle_elapsed))
419523

420524
bag_started_monotonic = time.monotonic()
421-
bag = launch_process("bag_play", bag_command, args.output_dir, launch_env)
525+
bag = launch_registered_process(
526+
cleanup, "bag_play", bag_command, args.output_dir, launch_env)
422527
bag_stopped_by_runner = False
423528
if args.bag_duration > 0.0:
424529
deadline = bag_started_monotonic + args.bag_duration
@@ -524,4 +629,8 @@ def main() -> int:
524629

525630

526631
if __name__ == "__main__":
527-
sys.exit(main())
632+
try:
633+
exit_code = main()
634+
except KeyboardInterrupt:
635+
exit_code = 130
636+
sys.exit(exit_code)

scripts/setup_local_env.sh

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ else
1515
fi
1616
unset _lidarloc_ws_root_default
1717
_lidarloc_prefix="${LIDAR_LOCALIZATION_LOCAL_PREFIX:-${_lidarloc_ws_root}/local_prefix}"
18-
_lidarloc_overlay="${LIDAR_LOCALIZATION_OVERLAY:-${_lidarloc_ws_root}/build_ws/install/setup.bash}"
18+
if [[ -n "${LIDAR_LOCALIZATION_OVERLAY:-}" ]]; then
19+
_lidarloc_overlay="${LIDAR_LOCALIZATION_OVERLAY}"
20+
elif [[ -f "${_lidarloc_ws_root}/install/setup.bash" ]]; then
21+
_lidarloc_overlay="${_lidarloc_ws_root}/install/setup.bash"
22+
else
23+
# Keep compatibility with the older nested workspace layout.
24+
_lidarloc_overlay="${_lidarloc_ws_root}/build_ws/install/setup.bash"
25+
fi
1926

2027
_lidarloc_ros_distro=""
2128
for _lidarloc_candidate in "${LIDAR_LOCALIZATION_ROS_DISTRO:-}" "${ROS_DISTRO:-}" humble jazzy; do
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env python3
2+
3+
from pathlib import Path
4+
import signal
5+
import subprocess
6+
import tempfile
7+
import time
8+
import unittest
9+
10+
11+
REPO = Path(__file__).resolve().parents[1]
12+
RUNNER = REPO / "scripts" / "benchmark_runner"
13+
14+
15+
def child_pids(pid: int) -> list[int]:
16+
path = Path(f"/proc/{pid}/task/{pid}/children")
17+
try:
18+
return [int(value) for value in path.read_text().split()]
19+
except FileNotFoundError:
20+
return []
21+
22+
23+
def cmdline(pid: int) -> str:
24+
try:
25+
return Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode()
26+
except FileNotFoundError:
27+
return ""
28+
29+
30+
class BenchmarkRunnerCleanupTest(unittest.TestCase):
31+
def run_interrupted_benchmark(self, interrupt_signal: signal.Signals, expected_code: int):
32+
with tempfile.TemporaryDirectory() as tmp:
33+
output = Path(tmp) / "output"
34+
process = subprocess.Popen(
35+
[
36+
str(RUNNER),
37+
"--bag-path", str(Path(tmp) / "unused_bag"),
38+
"--output-dir", str(output),
39+
"--system-command", "exec -a benchmark_cleanup_system sleep 60",
40+
"--bag-command", "exec -a benchmark_cleanup_bag sleep 60",
41+
"--diagnostic-topic", "",
42+
"--target-process-pattern", "benchmark-target-that-does-not-exist",
43+
"--settle-seconds", "0.1",
44+
"--post-roll-seconds", "0",
45+
"--shutdown-timeout", "0.2",
46+
],
47+
stdout=subprocess.DEVNULL,
48+
stderr=subprocess.DEVNULL,
49+
)
50+
try:
51+
deadline = time.monotonic() + 5.0
52+
named_children = []
53+
while time.monotonic() < deadline:
54+
named_children = [
55+
pid for pid in child_pids(process.pid)
56+
if "benchmark_cleanup_" in cmdline(pid)]
57+
if len(named_children) == 2:
58+
break
59+
time.sleep(0.02)
60+
self.assertEqual(len(named_children), 2)
61+
tracked = child_pids(process.pid)
62+
63+
process.send_signal(interrupt_signal)
64+
self.assertEqual(process.wait(timeout=5.0), expected_code)
65+
deadline = time.monotonic() + 2.0
66+
while (
67+
any(Path(f"/proc/{pid}").exists() for pid in tracked)
68+
and time.monotonic() < deadline
69+
):
70+
time.sleep(0.02)
71+
survivors = [pid for pid in tracked if Path(f"/proc/{pid}").exists()]
72+
self.assertFalse(survivors, f"managed processes survived cleanup: {survivors}")
73+
finally:
74+
if process.poll() is None:
75+
process.kill()
76+
process.wait(timeout=5.0)
77+
78+
def test_sigint_stops_all_managed_process_groups(self):
79+
self.run_interrupted_benchmark(signal.SIGINT, 130)
80+
81+
def test_sigterm_stops_all_managed_process_groups(self):
82+
self.run_interrupted_benchmark(signal.SIGTERM, 128 + signal.SIGTERM)
83+
84+
85+
if __name__ == "__main__":
86+
unittest.main()

test/test_setup_local_env.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env python3
2+
3+
from pathlib import Path
4+
import os
5+
import subprocess
6+
import tempfile
7+
import unittest
8+
9+
10+
REPO = Path(__file__).resolve().parents[1]
11+
SCRIPT = REPO / "scripts" / "setup_local_env.sh"
12+
13+
14+
class SetupLocalEnvTest(unittest.TestCase):
15+
def run_setup(self, workspace: Path, explicit: Path | None = None) -> str:
16+
env = os.environ.copy()
17+
env["LIDAR_LOCALIZATION_WS_ROOT"] = str(workspace)
18+
env["LIDAR_LOCALIZATION_ROS_DISTRO"] = os.environ.get("ROS_DISTRO", "jazzy")
19+
env.pop("LIDAR_LOCALIZATION_OVERLAY", None)
20+
if explicit is not None:
21+
env["LIDAR_LOCALIZATION_OVERLAY"] = str(explicit)
22+
result = subprocess.run(
23+
["bash", "-c", f'source "{SCRIPT}" && printf %s "$LIDAR_TEST_OVERLAY_SOURCED"'],
24+
check=True, capture_output=True, text=True, env=env)
25+
return result.stdout
26+
27+
def test_conventional_workspace_install_is_sourced(self):
28+
with tempfile.TemporaryDirectory() as tmp:
29+
workspace = Path(tmp)
30+
setup = workspace / "install" / "setup.bash"
31+
setup.parent.mkdir(parents=True)
32+
setup.write_text("export LIDAR_TEST_OVERLAY_SOURCED=conventional\n")
33+
self.assertEqual(self.run_setup(workspace), "conventional")
34+
35+
def test_explicit_overlay_has_priority(self):
36+
with tempfile.TemporaryDirectory() as tmp:
37+
workspace = Path(tmp)
38+
conventional = workspace / "install" / "setup.bash"
39+
explicit = workspace / "explicit_setup.bash"
40+
conventional.parent.mkdir(parents=True)
41+
conventional.write_text("export LIDAR_TEST_OVERLAY_SOURCED=conventional\n")
42+
explicit.write_text("export LIDAR_TEST_OVERLAY_SOURCED=explicit\n")
43+
self.assertEqual(self.run_setup(workspace, explicit), "explicit")
44+
45+
46+
if __name__ == "__main__":
47+
unittest.main()

0 commit comments

Comments
 (0)