benchlab-pytools 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benchlab/__init__.py +14 -0
- benchlab/__main__.py +21 -0
- benchlab/config/README.md +325 -0
- benchlab/config/__init__.py +9 -0
- benchlab/config/config_client.py +631 -0
- benchlab/config/config_manager.py +495 -0
- benchlab/config/config_tool.py +358 -0
- benchlab/config/diff.py +163 -0
- benchlab/config/examples/complete_config.json +56 -0
- benchlab/config/examples/rgb_lighting.json +24 -0
- benchlab/config/examples/simple_fan.json +32 -0
- benchlab/config/schema.py +138 -0
- benchlab/core/README.md +154 -0
- benchlab/core/__init__.py +78 -0
- benchlab/core/config.py +145 -0
- benchlab/core/datasource.py +1209 -0
- benchlab/core/datasource_manager.py +418 -0
- benchlab/core/device_registry.py +206 -0
- benchlab/core/discovery.py +92 -0
- benchlab/core/infrastructure.py +309 -0
- benchlab/core/process_manager.py +356 -0
- benchlab/core/retry.py +85 -0
- benchlab/core/shared_serial.py +235 -0
- benchlab/core/statistics.py +224 -0
- benchlab/csv_log/README.md +206 -0
- benchlab/csv_log/__init__.py +5 -0
- benchlab/csv_log/csv_logger_enhanced.py +416 -0
- benchlab/csv_log/message_batcher.py +361 -0
- benchlab/csv_log/requirements.txt +3 -0
- benchlab/csv_log/smart_retry.py +466 -0
- benchlab/graph/README.md +117 -0
- benchlab/graph/__init__.py +5 -0
- benchlab/graph/app.py +353 -0
- benchlab/graph/requirements.txt +2 -0
- benchlab/graph/runner.py +42 -0
- benchlab/graph/ui.py +288 -0
- benchlab/hwinfo/README.md +135 -0
- benchlab/hwinfo/__init__.py +5 -0
- benchlab/hwinfo/hwinfo_export.py +386 -0
- benchlab/hwinfo/requirements.txt +1 -0
- benchlab/launcher.py +387 -0
- benchlab/link/.env.EXAMPLE +10 -0
- benchlab/link/README.md +172 -0
- benchlab/link/__init__.py +1 -0
- benchlab/link/link_main.py +479 -0
- benchlab/main.py +462 -0
- benchlab/menu.py +729 -0
- benchlab/menu_classic.py +410 -0
- benchlab/mqtt/README.md +201 -0
- benchlab/mqtt/__init__.py +5 -0
- benchlab/mqtt/mqtt.config_template +2 -0
- benchlab/mqtt/mqtt_publisher.py +540 -0
- benchlab/mqtt/requirements.txt +3 -0
- benchlab/prefs.py +67 -0
- benchlab/restapi/.env.example +17 -0
- benchlab/restapi/__init__.py +5 -0
- benchlab/restapi/favicon.ico +0 -0
- benchlab/restapi/readme.md +202 -0
- benchlab/restapi/requirements.txt +5 -0
- benchlab/restapi/telemetry_api.py +808 -0
- benchlab/sources.py +515 -0
- benchlab/tools.py +147 -0
- benchlab/tui/README.md +266 -0
- benchlab/tui/__init__.py +5 -0
- benchlab/tui/config.py +477 -0
- benchlab/tui/requirements.txt +2 -0
- benchlab/tui/tui_core.py +1298 -0
- benchlab/tui/tui_main.py +418 -0
- benchlab/vu/README.md +64 -0
- benchlab/vu/VU-Server/NOTICE.md +13 -0
- benchlab/vu/VU-Server/README.md +80 -0
- benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
- benchlab/vu/VU-Server/__init__.py +0 -0
- benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
- benchlab/vu/VU-Server/community_applications.md +13 -0
- benchlab/vu/VU-Server/config.yaml +9 -0
- benchlab/vu/VU-Server/database.py +256 -0
- benchlab/vu/VU-Server/dial_driver.py +534 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
- benchlab/vu/VU-Server/dials/__init__.py +0 -0
- benchlab/vu/VU-Server/dials/base_logger.py +100 -0
- benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
- benchlab/vu/VU-Server/installer/install.nsi +148 -0
- benchlab/vu/VU-Server/make_version.py +40 -0
- benchlab/vu/VU-Server/pylintrc +628 -0
- benchlab/vu/VU-Server/requirements.txt +9 -0
- benchlab/vu/VU-Server/serial_driver.py +244 -0
- benchlab/vu/VU-Server/server.py +670 -0
- benchlab/vu/VU-Server/server_config.py +255 -0
- benchlab/vu/VU-Server/server_dial_handler.py +334 -0
- benchlab/vu/VU-Server/vu_notifications.py +24 -0
- benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
- benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
- benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
- benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
- benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
- benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
- benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
- benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
- benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
- benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
- benchlab/vu/VU-Server/www/favicon.ico +0 -0
- benchlab/vu/VU-Server/www/favicon.png +0 -0
- benchlab/vu/VU-Server/www/index.html +47 -0
- benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
- benchlab/vu/VU-Server/www/views/dial.html +286 -0
- benchlab/vu/VU-Server/www/views/footer.html +27 -0
- benchlab/vu/VU-Server/www/views/header.html +86 -0
- benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
- benchlab/vu/VU-Server/www/views/start.html +124 -0
- benchlab/vu/__init__.py +5 -0
- benchlab/vu/assets/Barlow-Bold.ttf +0 -0
- benchlab/vu/assets/bl_dial_144x200.png +0 -0
- benchlab/vu/assets/bl_logo_144x200.png +0 -0
- benchlab/vu/devices.py +166 -0
- benchlab/vu/requirements.txt +24 -0
- benchlab/vu/sensors.py +18 -0
- benchlab/vu/vu_dial.config_template +90 -0
- benchlab/vu/vu_logo_gen.py +97 -0
- benchlab/vu/vu_server.config_template +5 -0
- benchlab/vu/vu_server_config.py +40 -0
- benchlab/vu/vu_server_manager.py +192 -0
- benchlab/vu/vu_tui.py +919 -0
- benchlab/vu/vu_updater.py +540 -0
- benchlab/wigidash/README.md +182 -0
- benchlab/wigidash/__init__.py +5 -0
- benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
- benchlab/wigidash/assets/Inter.ttf +0 -0
- benchlab/wigidash/assets/benchlab.png +0 -0
- benchlab/wigidash/benchlab_fleet.py +150 -0
- benchlab/wigidash/benchlab_graph.py +517 -0
- benchlab/wigidash/benchlab_overview.py +426 -0
- benchlab/wigidash/benchlab_telemetry.py +105 -0
- benchlab/wigidash/benchlab_ui.py +190 -0
- benchlab/wigidash/benchlab_utils.py +182 -0
- benchlab/wigidash/requirements.txt +9 -0
- benchlab/wigidash/wigidash_device.py +541 -0
- benchlab/wigidash/wigidash_manager.py +342 -0
- benchlab/wigidash/wigidash_session.py +355 -0
- benchlab/wigidash/wigidash_usb.py +247 -0
- benchlab/wigidash/wigidash_widget.py +88 -0
- benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
- benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
- benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
- benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
- benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Process Manager for BENCHLAB
|
|
3
|
+
|
|
4
|
+
Structured subprocess management for the infrastructure services
|
|
5
|
+
(FastAPI server, MQTT publisher, embedded amqtt broker).
|
|
6
|
+
|
|
7
|
+
Replaces the ad-hoc ``subprocess.Popen`` + env-var PID pattern in main.py
|
|
8
|
+
with proper lifecycle management: start, health-check, stop, restart,
|
|
9
|
+
and graceful shutdown of all managed processes.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
pm = ProcessManager.get_instance()
|
|
13
|
+
|
|
14
|
+
# Start a managed service
|
|
15
|
+
pm.start_service(
|
|
16
|
+
name="fastapi",
|
|
17
|
+
cmd=["python", "-m", "benchlab", "fastapi"],
|
|
18
|
+
health_check=lambda: _ping_http("http://127.0.0.1:8000/health"),
|
|
19
|
+
timeout=20,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Stop a specific service
|
|
23
|
+
pm.stop_service("fastapi")
|
|
24
|
+
|
|
25
|
+
# Stop everything (called on tool exit)
|
|
26
|
+
pm.shutdown_all()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Callable, Dict, Optional
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("benchlab.core.process_manager")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Data classes
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ManagedProcess:
|
|
50
|
+
"""State for a single managed subprocess."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
cmd: list[str]
|
|
54
|
+
process: Optional[subprocess.Popen] = None
|
|
55
|
+
health_check: Optional[Callable[[], bool]] = None
|
|
56
|
+
stdout_log: str = ""
|
|
57
|
+
stderr_log: str = ""
|
|
58
|
+
started_at: float = 0.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Singleton
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ProcessManager:
|
|
67
|
+
"""Thread-safe singleton that manages infrastructure service lifecycles."""
|
|
68
|
+
|
|
69
|
+
_instance: Optional["ProcessManager"] = None
|
|
70
|
+
_lock = threading.Lock()
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self._services: Dict[str, ManagedProcess] = {}
|
|
74
|
+
self._mutex = threading.Lock()
|
|
75
|
+
|
|
76
|
+
# -- singleton helpers ---------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def get_instance(cls) -> "ProcessManager":
|
|
80
|
+
if cls._instance is None:
|
|
81
|
+
with cls._lock:
|
|
82
|
+
if cls._instance is None:
|
|
83
|
+
cls._instance = cls()
|
|
84
|
+
return cls._instance
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def reset(cls) -> None:
|
|
88
|
+
"""Reset singleton (primarily for testing)."""
|
|
89
|
+
with cls._lock:
|
|
90
|
+
inst = cls._instance
|
|
91
|
+
if inst is not None:
|
|
92
|
+
inst.shutdown_all()
|
|
93
|
+
cls._instance = None
|
|
94
|
+
|
|
95
|
+
# -- service management --------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def start_service(
|
|
98
|
+
self,
|
|
99
|
+
name: str,
|
|
100
|
+
cmd: list[str] | str,
|
|
101
|
+
health_check: Optional[Callable[[], bool]] = None,
|
|
102
|
+
timeout: float = 20.0,
|
|
103
|
+
cwd: Optional[str] = None,
|
|
104
|
+
env: Optional[dict] = None,
|
|
105
|
+
) -> bool:
|
|
106
|
+
"""Start a service and wait for it to become healthy.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
name: Human-readable service label (must be unique).
|
|
110
|
+
cmd: Command string or list of arguments.
|
|
111
|
+
health_check: Callable returning True when the service is ready.
|
|
112
|
+
If ``None``, success is determined by the process
|
|
113
|
+
staying alive for *timeout* seconds.
|
|
114
|
+
timeout: Seconds to wait for health_check to succeed.
|
|
115
|
+
cwd: Working directory for the subprocess.
|
|
116
|
+
env: Environment variables (defaults to os.environ copy).
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
``True`` if the service started and passed the health check.
|
|
120
|
+
"""
|
|
121
|
+
with self._mutex:
|
|
122
|
+
if name in self._services:
|
|
123
|
+
existing = self._services[name]
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Service %s already registered — stopping it first", name)
|
|
126
|
+
self._stop_process(existing)
|
|
127
|
+
|
|
128
|
+
if isinstance(cmd, str):
|
|
129
|
+
cmd_list = [cmd]
|
|
130
|
+
else:
|
|
131
|
+
cmd_list = cmd
|
|
132
|
+
|
|
133
|
+
mp = ManagedProcess(name=name, cmd=cmd_list, health_check=health_check)
|
|
134
|
+
|
|
135
|
+
# Use shell execution when cmd is a single string (e.g. inline Python
|
|
136
|
+
# one-liner)
|
|
137
|
+
use_shell = isinstance(cmd, str)
|
|
138
|
+
|
|
139
|
+
log_file_prefix = os.path.join(
|
|
140
|
+
os.path.dirname(
|
|
141
|
+
os.path.abspath(__file__)),
|
|
142
|
+
"..",
|
|
143
|
+
"logs",
|
|
144
|
+
f"svc_{name}")
|
|
145
|
+
os.makedirs(os.path.dirname(log_file_prefix), exist_ok=True)
|
|
146
|
+
|
|
147
|
+
stdout_path = f"{log_file_prefix}_stdout.log"
|
|
148
|
+
stderr_path = f"{log_file_prefix}_stderr.log"
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
env = env or os.environ.copy()
|
|
152
|
+
# Open log files and store file handles for later cleanup
|
|
153
|
+
stdout_file = open(stdout_path, "w", encoding="utf-8")
|
|
154
|
+
stderr_file = open(stderr_path, "w", encoding="utf-8")
|
|
155
|
+
mp.process = subprocess.Popen(
|
|
156
|
+
cmd_list,
|
|
157
|
+
stdout=stdout_file,
|
|
158
|
+
stderr=stderr_file,
|
|
159
|
+
cwd=cwd,
|
|
160
|
+
env=env,
|
|
161
|
+
shell=use_shell,
|
|
162
|
+
# On Linux, ensure the process doesn't inherit unnecessary file
|
|
163
|
+
# descriptors
|
|
164
|
+
close_fds=True,
|
|
165
|
+
)
|
|
166
|
+
# Store file handles for cleanup when service stops
|
|
167
|
+
mp._stdout_file = stdout_file
|
|
168
|
+
mp._stderr_file = stderr_file
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
logger.error("Failed to start service %s: %s", name, exc)
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
mp.started_at = time.time()
|
|
174
|
+
logger.info("Started service %s (PID %d)", name, mp.process.pid)
|
|
175
|
+
|
|
176
|
+
# Wait for health check
|
|
177
|
+
healthy = self._wait_for_health(mp, timeout)
|
|
178
|
+
if not healthy:
|
|
179
|
+
# Capture log content for diagnostics
|
|
180
|
+
try:
|
|
181
|
+
with open(stderr_path, "r", encoding="utf-8") as f:
|
|
182
|
+
mp.stderr_log = f.read()
|
|
183
|
+
with open(stdout_path, "r", encoding="utf-8") as f:
|
|
184
|
+
mp.stdout_log = f.read()
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
logger.warning(
|
|
188
|
+
"Service %s failed health check within %.0fs",
|
|
189
|
+
name,
|
|
190
|
+
timeout)
|
|
191
|
+
with self._mutex:
|
|
192
|
+
self._services[name] = mp
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
with self._mutex:
|
|
196
|
+
self._services[name] = mp
|
|
197
|
+
logger.info("Service %s is healthy", name)
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
def stop_service(self, name: str) -> bool:
|
|
201
|
+
"""Stop a specific service (graceful SIGTERM → wait → SIGKILL)."""
|
|
202
|
+
with self._mutex:
|
|
203
|
+
mp = self._services.pop(name, None)
|
|
204
|
+
if mp is None:
|
|
205
|
+
logger.debug("Service %s not found", name)
|
|
206
|
+
return False
|
|
207
|
+
return self._stop_process(mp)
|
|
208
|
+
|
|
209
|
+
def get_service(self, name: str) -> Optional[ManagedProcess]:
|
|
210
|
+
with self._mutex:
|
|
211
|
+
return self._services.get(name)
|
|
212
|
+
|
|
213
|
+
def list_services(self) -> list[str]:
|
|
214
|
+
with self._mutex:
|
|
215
|
+
return list(self._services.keys())
|
|
216
|
+
|
|
217
|
+
def is_running(self, name: str) -> bool:
|
|
218
|
+
mp = self.get_service(name)
|
|
219
|
+
if mp is None or mp.process is None:
|
|
220
|
+
return False
|
|
221
|
+
return mp.process.poll() is None
|
|
222
|
+
|
|
223
|
+
def shutdown_all(self) -> None:
|
|
224
|
+
"""Stop all managed services. Safe to call multiple times."""
|
|
225
|
+
with self._mutex:
|
|
226
|
+
names = list(self._services.keys())
|
|
227
|
+
for name in names:
|
|
228
|
+
self.stop_service(name)
|
|
229
|
+
|
|
230
|
+
# -- internals -----------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _wait_for_health(self, mp: ManagedProcess, timeout: float) -> bool:
|
|
233
|
+
"""Block until health_check passes or timeout. Also fails if the
|
|
234
|
+
process dies early."""
|
|
235
|
+
deadline = time.time() + timeout
|
|
236
|
+
while time.time() < deadline:
|
|
237
|
+
# Check if process crashed
|
|
238
|
+
ret = mp.process.poll()
|
|
239
|
+
if ret is not None:
|
|
240
|
+
logger.error(
|
|
241
|
+
"Service %s exited unexpectedly with code %d",
|
|
242
|
+
mp.name,
|
|
243
|
+
ret)
|
|
244
|
+
try:
|
|
245
|
+
# try to capture the log
|
|
246
|
+
log_base = os.path.join(
|
|
247
|
+
os.path.dirname(
|
|
248
|
+
os.path.abspath(__file__)),
|
|
249
|
+
"..",
|
|
250
|
+
"logs",
|
|
251
|
+
f"svc_{
|
|
252
|
+
mp.name}")
|
|
253
|
+
with open(f"{log_base}_stderr.log", "r",
|
|
254
|
+
encoding="utf-8") as f:
|
|
255
|
+
mp.stderr_log = f.read()
|
|
256
|
+
except OSError:
|
|
257
|
+
pass
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
if mp.health_check is None:
|
|
261
|
+
# No health check → consider healthy after 1 second alive
|
|
262
|
+
if time.time() - mp.started_at >= 1.0:
|
|
263
|
+
return True
|
|
264
|
+
elif mp.health_check():
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
time.sleep(0.5)
|
|
268
|
+
|
|
269
|
+
logger.error(
|
|
270
|
+
"Service %s health check timed out after %.0fs",
|
|
271
|
+
mp.name,
|
|
272
|
+
timeout)
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
def _stop_process(
|
|
276
|
+
self,
|
|
277
|
+
mp: ManagedProcess,
|
|
278
|
+
graceful_timeout: float = 5.0) -> bool:
|
|
279
|
+
"""Terminate or kill a single ManagedProcess."""
|
|
280
|
+
proc = mp.process
|
|
281
|
+
if proc is None:
|
|
282
|
+
# Clean up any open file handles
|
|
283
|
+
self._close_log_files(mp)
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
if proc.poll() is not None:
|
|
287
|
+
logger.info(
|
|
288
|
+
"Service %s already exited (code %d)",
|
|
289
|
+
mp.name,
|
|
290
|
+
proc.returncode)
|
|
291
|
+
self._close_log_files(mp)
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
# Graceful: SIGTERM / taskkill
|
|
295
|
+
logger.info(
|
|
296
|
+
"Stopping service %s (PID %d) gracefully.",
|
|
297
|
+
mp.name,
|
|
298
|
+
proc.pid)
|
|
299
|
+
self._send_terminate(proc)
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
proc.wait(timeout=graceful_timeout)
|
|
303
|
+
logger.info("Service %s stopped gracefully", mp.name)
|
|
304
|
+
self._close_log_files(mp)
|
|
305
|
+
return True
|
|
306
|
+
except subprocess.TimeoutExpired:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
# Forceful: SIGKILL / taskkill /F
|
|
310
|
+
logger.warning("Service %s did not stop gracefully — killing", mp.name)
|
|
311
|
+
try:
|
|
312
|
+
self._send_kill(proc)
|
|
313
|
+
proc.wait(timeout=3)
|
|
314
|
+
logger.info("Service %s killed", mp.name)
|
|
315
|
+
self._close_log_files(mp)
|
|
316
|
+
return True
|
|
317
|
+
except Exception:
|
|
318
|
+
logger.error("Failed to kill service %s", mp.name)
|
|
319
|
+
self._close_log_files(mp)
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _close_log_files(mp: ManagedProcess) -> None:
|
|
324
|
+
"""Close any open log file handles to prevent file descriptor leaks."""
|
|
325
|
+
for attr in ('_stdout_file', '_stderr_file'):
|
|
326
|
+
f = getattr(mp, attr, None)
|
|
327
|
+
if f is not None:
|
|
328
|
+
try:
|
|
329
|
+
f.flush()
|
|
330
|
+
f.close()
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
finally:
|
|
334
|
+
setattr(mp, attr, None)
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _send_terminate(proc: subprocess.Popen) -> None:
|
|
338
|
+
if sys.platform == "win32":
|
|
339
|
+
subprocess.run(
|
|
340
|
+
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
|
341
|
+
capture_output=True,
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
import signal
|
|
345
|
+
os.kill(proc.pid, signal.SIGTERM)
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _send_kill(proc: subprocess.Popen) -> None:
|
|
349
|
+
if sys.platform == "win32":
|
|
350
|
+
subprocess.run(
|
|
351
|
+
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
|
352
|
+
capture_output=True,
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
import signal
|
|
356
|
+
os.kill(proc.pid, signal.SIGKILL)
|
benchlab/core/retry.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Simple retry utilities used across Benchlab data sources.
|
|
2
|
+
|
|
3
|
+
The library originally performed a single attempt to connect to a serial
|
|
4
|
+
port, FastAPI server or MQTT broker. In production environments a
|
|
5
|
+
transient failure (e.g. the device is temporarily busy or the network
|
|
6
|
+
is momentarily unavailable) should not cause the whole tool to abort.
|
|
7
|
+
|
|
8
|
+
This module provides a lightweight ``RetryPolicy`` dataclass and a
|
|
9
|
+
``retry`` decorator that can be applied to any callable. The decorator
|
|
10
|
+
retries the wrapped function according to the policy and logs each
|
|
11
|
+
attempt using the module‑level ``logger``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
import logging
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Callable, TypeVar, Any, cast
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("benchlab.core.retry")
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RetryPolicy:
|
|
28
|
+
"""Configuration for retry behaviour.
|
|
29
|
+
|
|
30
|
+
Attributes
|
|
31
|
+
----------
|
|
32
|
+
max_retries: int
|
|
33
|
+
Maximum number of attempts (the initial call counts as the first
|
|
34
|
+
attempt). ``0`` means no retries.
|
|
35
|
+
backoff_factor: float
|
|
36
|
+
Multiplier applied to the base delay after each failure. The
|
|
37
|
+
actual sleep time is ``base_delay * (backoff_factor ** attempt)``.
|
|
38
|
+
base_delay: float
|
|
39
|
+
Initial delay in seconds before the first retry.
|
|
40
|
+
allowed_exceptions: tuple[type[BaseException], ...]
|
|
41
|
+
Exceptions that trigger a retry. All other exceptions are raised
|
|
42
|
+
immediately.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
max_retries: int = 3
|
|
46
|
+
backoff_factor: float = 2.0
|
|
47
|
+
base_delay: float = 0.5
|
|
48
|
+
allowed_exceptions: tuple[type[BaseException], ...] = (Exception,)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def retry(
|
|
52
|
+
policy: RetryPolicy) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
|
53
|
+
"""Decorator that retries a function according to *policy*.
|
|
54
|
+
|
|
55
|
+
The wrapped function is called up to ``policy.max_retries + 1``
|
|
56
|
+
times. If the function succeeds, its result is returned immediately.
|
|
57
|
+
If it raises an exception listed in ``policy.allowed_exceptions`` the
|
|
58
|
+
decorator sleeps for an exponentially‑increasing delay and retries.
|
|
59
|
+
After exhausting all attempts the last exception is re‑raised.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
|
63
|
+
def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
64
|
+
attempt = 0
|
|
65
|
+
while True:
|
|
66
|
+
try:
|
|
67
|
+
return cast(T, func(*args, **kwargs))
|
|
68
|
+
except policy.allowed_exceptions as exc:
|
|
69
|
+
if attempt >= policy.max_retries:
|
|
70
|
+
logger.error(
|
|
71
|
+
f"{func.__name__} failed after "
|
|
72
|
+
f"{attempt + 1} attempts: {exc}"
|
|
73
|
+
)
|
|
74
|
+
raise
|
|
75
|
+
delay = policy.base_delay * \
|
|
76
|
+
(policy.backoff_factor ** attempt)
|
|
77
|
+
logger.warning(
|
|
78
|
+
f"{func.__name__} attempt {attempt + 1} failed "
|
|
79
|
+
f"({exc}); retrying in {delay:.2f}s"
|
|
80
|
+
)
|
|
81
|
+
time.sleep(delay)
|
|
82
|
+
attempt += 1
|
|
83
|
+
return wrapper
|
|
84
|
+
|
|
85
|
+
return decorator
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Serial Connection Manager for BENCHLAB
|
|
3
|
+
|
|
4
|
+
Provides a singleton-based serial connection pool that prevents
|
|
5
|
+
multiple services (FastAPI, MQTT, etc.) from competing for the
|
|
6
|
+
same physical serial port. (BUG-1.4)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
import serial
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("benchlab.core.shared_serial")
|
|
17
|
+
|
|
18
|
+
# BENCHLAB devices communicate over USB CDC at a fixed 115200 baud.
|
|
19
|
+
# benchlab_pycore.core.serial_io never exposed a connection-opening helper
|
|
20
|
+
# (its own docs just call serial.Serial(port, 115200, timeout=1) directly) —
|
|
21
|
+
# this wraps that same pattern so callers get a consistent
|
|
22
|
+
# "None on failure" contract instead of talking to pyserial directly.
|
|
23
|
+
_BENCHLAB_BAUDRATE = 115200
|
|
24
|
+
_BENCHLAB_TIMEOUT = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def open_serial_connection(port: str) -> Optional[serial.Serial]:
|
|
28
|
+
"""Open a serial connection to a BENCHLAB device.
|
|
29
|
+
|
|
30
|
+
Returns None if the port can't be opened.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
return serial.Serial(
|
|
34
|
+
port,
|
|
35
|
+
_BENCHLAB_BAUDRATE,
|
|
36
|
+
timeout=_BENCHLAB_TIMEOUT)
|
|
37
|
+
except (serial.SerialException, OSError) as e:
|
|
38
|
+
logger.warning("Failed to open serial port %s: %s", port, e)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SharedSerialManager:
|
|
43
|
+
"""Thread-safe singleton that manages serial port ownership.
|
|
44
|
+
|
|
45
|
+
Use this to ensure only one reader owns a serial port at a time,
|
|
46
|
+
while allowing multiple consumers to receive telemetry data.
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
mgr = SharedSerialManager.get_instance()
|
|
50
|
+
conn = mgr.acquire_connection("COM3")
|
|
51
|
+
if conn:
|
|
52
|
+
data = read_sensors(conn.ser)
|
|
53
|
+
mgr.release_connection("COM3")
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_instance = None
|
|
57
|
+
_instance_lock = threading.Lock()
|
|
58
|
+
|
|
59
|
+
def __init__(self):
|
|
60
|
+
# port -> {ser, ref_count, lock, owner}
|
|
61
|
+
self._connections: Dict[str, Dict[str, Any]] = {}
|
|
62
|
+
self._lock = threading.Lock()
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def get_instance(cls) -> "SharedSerialManager":
|
|
66
|
+
"""Get or create the singleton instance."""
|
|
67
|
+
if cls._instance is None:
|
|
68
|
+
with cls._instance_lock:
|
|
69
|
+
if cls._instance is None:
|
|
70
|
+
cls._instance = cls()
|
|
71
|
+
return cls._instance
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def reset(cls) -> None:
|
|
75
|
+
"""Reset singleton (primarily for testing)."""
|
|
76
|
+
with cls._instance_lock:
|
|
77
|
+
if cls._instance is not None:
|
|
78
|
+
cls._instance._shutdown()
|
|
79
|
+
cls._instance = None
|
|
80
|
+
|
|
81
|
+
def acquire_connection(
|
|
82
|
+
self,
|
|
83
|
+
port: str,
|
|
84
|
+
open_func=None,
|
|
85
|
+
timeout: float = 5.0
|
|
86
|
+
) -> Optional["SharedConnection"]:
|
|
87
|
+
"""Acquire a shared serial connection to a port.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
port: Serial port identifier (e.g., "COM3", "/dev/ttyUSB0")
|
|
91
|
+
open_func: Callback to open the serial port if not already open.
|
|
92
|
+
Signature: open_func(port) -> serial_connection
|
|
93
|
+
timeout: How long to wait if port is being opened by another thread
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
SharedConnection wrapper, or None if port can't be opened
|
|
97
|
+
"""
|
|
98
|
+
if open_func is None:
|
|
99
|
+
open_func = open_serial_connection
|
|
100
|
+
|
|
101
|
+
start = time.time()
|
|
102
|
+
while time.time() - start < timeout:
|
|
103
|
+
with self._lock:
|
|
104
|
+
# Check if already open
|
|
105
|
+
if port in self._connections:
|
|
106
|
+
entry = self._connections[port]
|
|
107
|
+
entry["ref_count"] += 1
|
|
108
|
+
logger.debug(
|
|
109
|
+
"Reusing shared connection to %s (ref_count=%d)",
|
|
110
|
+
port,
|
|
111
|
+
entry["ref_count"])
|
|
112
|
+
return SharedConnection(self, port, entry["ser"])
|
|
113
|
+
|
|
114
|
+
# Try to claim it
|
|
115
|
+
self._connections[port] = {
|
|
116
|
+
"ser": None,
|
|
117
|
+
"ref_count": 1,
|
|
118
|
+
"lock": threading.Lock(),
|
|
119
|
+
"owner": threading.current_thread().name,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Open outside the main lock to avoid blocking
|
|
123
|
+
try:
|
|
124
|
+
ser = open_func(port)
|
|
125
|
+
if ser:
|
|
126
|
+
with self._lock:
|
|
127
|
+
self._connections[port]["ser"] = ser
|
|
128
|
+
|
|
129
|
+
logger.info("Opened new shared connection to %s", port)
|
|
130
|
+
return SharedConnection(self, port, ser)
|
|
131
|
+
else:
|
|
132
|
+
with self._lock:
|
|
133
|
+
del self._connections[port]
|
|
134
|
+
logger.warning("Failed to open serial port %s", port)
|
|
135
|
+
return None
|
|
136
|
+
except Exception as e:
|
|
137
|
+
with self._lock:
|
|
138
|
+
del self._connections[port]
|
|
139
|
+
logger.warning("Exception opening port %s: %s", port, e)
|
|
140
|
+
time.sleep(0.5)
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
logger.error("Timeout acquiring connection to %s", port)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def release_connection(self, port: str) -> None:
|
|
147
|
+
"""Release a shared serial connection.
|
|
148
|
+
|
|
149
|
+
When ref_count reaches 0, the connection is closed.
|
|
150
|
+
"""
|
|
151
|
+
with self._lock:
|
|
152
|
+
if port not in self._connections:
|
|
153
|
+
logger.warning(
|
|
154
|
+
"Attempted to release non-existent connection on %s", port)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
entry = self._connections[port]
|
|
158
|
+
entry["ref_count"] -= 1
|
|
159
|
+
|
|
160
|
+
if entry["ref_count"] <= 0:
|
|
161
|
+
# Last user released - close the port
|
|
162
|
+
ser = entry["ser"]
|
|
163
|
+
del self._connections[port]
|
|
164
|
+
|
|
165
|
+
if ser:
|
|
166
|
+
try:
|
|
167
|
+
ser.close()
|
|
168
|
+
logger.info("Closed shared connection to %s", port)
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
def is_port_owned(self, port: str) -> bool:
|
|
173
|
+
"""Check if a port currently has an open shared connection."""
|
|
174
|
+
with self._lock:
|
|
175
|
+
return port in self._connections
|
|
176
|
+
|
|
177
|
+
def get_active_ports(self) -> list:
|
|
178
|
+
"""Get list of ports with active shared connections."""
|
|
179
|
+
with self._lock:
|
|
180
|
+
return list(self._connections.keys())
|
|
181
|
+
|
|
182
|
+
def _shutdown(self) -> None:
|
|
183
|
+
"""Close all connections (called by reset() or cleanup)."""
|
|
184
|
+
with self._lock:
|
|
185
|
+
for port, entry in list(self._connections.items()):
|
|
186
|
+
ser = entry["ser"]
|
|
187
|
+
if ser:
|
|
188
|
+
try:
|
|
189
|
+
ser.close()
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
self._connections.clear()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SharedConnection:
|
|
196
|
+
"""Wrapper around a shared serial connection.
|
|
197
|
+
|
|
198
|
+
Use `conn.ser` to access the underlying serial connection.
|
|
199
|
+
Call `conn.release()` when done.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, manager: SharedSerialManager, port: str, ser):
|
|
203
|
+
self._manager = manager
|
|
204
|
+
self._port = port
|
|
205
|
+
self._released = False
|
|
206
|
+
self.ser = ser # Direct access to the serial connection
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def port(self) -> str:
|
|
210
|
+
return self._port
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def is_released(self) -> bool:
|
|
214
|
+
return self._released
|
|
215
|
+
|
|
216
|
+
def release(self) -> None:
|
|
217
|
+
"""Release this reference to the shared connection."""
|
|
218
|
+
if not self._released:
|
|
219
|
+
self._released = True
|
|
220
|
+
self._manager.release_connection(self._port)
|
|
221
|
+
|
|
222
|
+
def __enter__(self):
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
226
|
+
self.release()
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
def __del__(self):
|
|
230
|
+
# Safety net - don't leak connections
|
|
231
|
+
if not self._released:
|
|
232
|
+
logger.warning(
|
|
233
|
+
"SharedConnection %s was garbage-collected without release()",
|
|
234
|
+
self._port)
|
|
235
|
+
self.release()
|