modlink_core
1from .bus import FrameStream, FrameStreamOverflowError, StreamBus 2from .event_stream import ( 3 BackendEventBroker, 4 EventStream, 5 EventStreamOverflowError, 6 StreamClosedError, 7) 8from .events import ( 9 DriverConnectionLostEvent, 10 DriverExecutorFailedEvent, 11 RecordingFailedEvent, 12 SettingChangedEvent, 13) 14from .logging_setup import configure_host_logging 15from .models import ( 16 DriverSnapshot, 17 ExportJobSnapshot, 18 RecordingSnapshot, 19 RecordingStartSummary, 20 RecordingStopSummary, 21 ReplayMarker, 22 ReplayRecordingSummary, 23 ReplaySegment, 24 ReplaySnapshot, 25) 26from .recording import RecordingBackend 27from .replay import ReplayBackend 28from .runtime import ModLinkEngine 29from .settings import ( 30 SettingsStore, 31) 32 33__all__ = [ 34 "RecordingBackend", 35 "RecordingSnapshot", 36 "RecordingStartSummary", 37 "RecordingStopSummary", 38 "ReplayBackend", 39 "ReplayMarker", 40 "ReplayRecordingSummary", 41 "ReplaySegment", 42 "ReplaySnapshot", 43 "ExportJobSnapshot", 44 "BackendEventBroker", 45 "DriverConnectionLostEvent", 46 "DriverExecutorFailedEvent", 47 "EventStream", 48 "EventStreamOverflowError", 49 "FrameStream", 50 "FrameStreamOverflowError", 51 "configure_host_logging", 52 "DriverSnapshot", 53 "ModLinkEngine", 54 "RecordingFailedEvent", 55 "SettingChangedEvent", 56 "SettingsStore", 57 "StreamClosedError", 58 "StreamBus", 59]
class
RecordingBackend:
47class RecordingBackend: 48 """Threaded recording backend that persists bus frames to disk.""" 49 50 def __init__( 51 self, 52 bus: StreamBus, 53 *, 54 settings: SettingsStore, 55 publish_event: Callable[[BackendEvent], None], 56 parent: object | None = None, 57 ) -> None: 58 self._bus = bus 59 self._settings = settings 60 self._publish_event = publish_event 61 self._parent = parent 62 self._command_queue: queue.Queue[RecordingCommand | object] = queue.Queue() 63 self._shutdown_sentinel = object() 64 self._thread: threading.Thread | None = None 65 self._frame_stream: FrameStream | None = None 66 self._state = "idle" 67 self._started = False 68 self._accepting_commands = False 69 self._worker_exit_error: Exception | None = None 70 self._active_recording: ActiveRecording | None = None 71 self._lock = threading.RLock() 72 73 @property 74 def root_dir(self) -> Path: 75 return resolved_storage_root_dir(self._settings) 76 77 @property 78 def is_started(self) -> bool: 79 thread = self._thread 80 return self._started and thread is not None and thread.is_alive() 81 82 @property 83 def state(self) -> str: 84 return self._state 85 86 @property 87 def is_recording(self) -> bool: 88 return self._state == "recording" 89 90 def snapshot(self) -> RecordingSnapshot: 91 return RecordingSnapshot( 92 state=self._state, 93 is_started=self.is_started, 94 is_recording=self.is_recording, 95 root_dir=str(self.root_dir), 96 ) 97 98 def start(self) -> None: 99 if self.is_started: 100 self._started = True 101 self._accepting_commands = True 102 return 103 logger.info("Starting recording backend") 104 if self._frame_stream is None or self._frame_stream.closed: 105 self._frame_stream = self._open_frame_stream() 106 thread = threading.Thread( 107 target=self._run, 108 name="modlink.recording", 109 daemon=True, 110 ) 111 self._thread = thread 112 self._worker_exit_error = None 113 self._accepting_commands = True 114 self._started = True 115 thread.start() 116 logger.info("Recording backend started") 117 118 def start_recording( 119 self, 120 recording_label: str | None = None, 121 *, 122 session_name: str | None = None, 123 experiment_name: str | None = None, 124 ) -> Future[RecordingStartSummary]: 125 storage_root_dir = self.root_dir 126 recordings_root_dir = storage_root_dir / "recordings" 127 return self._submit_command( 128 self._start_recording_worker, 129 str(storage_root_dir), 130 str(recordings_root_dir), 131 recording_label, 132 session_name, 133 experiment_name, 134 self._bus.descriptors(), 135 ) 136 137 def stop_recording(self) -> Future[RecordingStopSummary]: 138 return self._submit_command(self._stop_recording_worker) 139 140 def add_marker(self, label: str | None = None) -> Future[None]: 141 return self._submit_command(self._add_marker_worker, label) 142 143 def add_segment( 144 self, 145 start_ns: int, 146 end_ns: int, 147 label: str | None = None, 148 ) -> Future[None]: 149 return self._submit_command( 150 self._add_segment_worker, 151 start_ns, 152 end_ns, 153 label, 154 ) 155 156 def shutdown(self, *, timeout_ms: int = 3000) -> None: 157 logger.info("Shutting down recording backend") 158 with self._lock: 159 self._accepting_commands = False 160 thread = self._thread 161 worker_exit_error = self._worker_exit_error 162 163 if thread is None or not thread.is_alive(): 164 self._finalize_shutdown() 165 if worker_exit_error is not None: 166 self._worker_exit_error = None 167 raise worker_exit_error 168 return 169 170 self._command_queue.put(self._shutdown_sentinel) 171 thread.join(max(0, timeout_ms) / 1000) 172 173 if thread.is_alive(): 174 raise TimeoutError(f"recording shutdown timed out after {timeout_ms}ms") 175 176 self._finalize_shutdown() 177 worker_exit_error = self._take_worker_exit_error() 178 if worker_exit_error is not None: 179 raise worker_exit_error 180 logger.info("Recording backend shut down") 181 182 def _run(self) -> None: 183 exit_error: Exception | None = None 184 while True: 185 try: 186 if self._drain_commands(): 187 return 188 189 frame_stream = self._frame_stream 190 if frame_stream is None: 191 time.sleep(0.01) 192 continue 193 194 try: 195 frame = frame_stream.read(timeout=0.05) 196 except queue.Empty: 197 continue 198 except StreamClosedError: 199 if not self._started: 200 return 201 logger.warning("Recording frame stream closed unexpectedly; reopening stream") 202 self._frame_stream = self._open_frame_stream() 203 continue 204 except FrameStreamOverflowError as exc: 205 self._handle_frame_stream_overflow(exc.consumer_name) 206 continue 207 208 if not self.is_recording: 209 continue 210 self._on_frame_worker(frame) 211 except Exception as exc: 212 logger.exception("Recording worker exited with an unexpected error") 213 exit_error = exc 214 break 215 216 with self._lock: 217 self._worker_exit_error = exit_error 218 self._started = False 219 self._accepting_commands = False 220 221 def _drain_commands(self) -> bool: 222 while True: 223 try: 224 item = self._command_queue.get_nowait() 225 except queue.Empty: 226 return False 227 228 if item is self._shutdown_sentinel: 229 try: 230 self._shutdown_worker() 231 finally: 232 self._fail_pending_commands("ACQ_SHUTDOWN") 233 return True 234 235 command, _future = item 236 command() 237 238 def _on_frame_worker(self, frame: object) -> None: 239 if not isinstance(frame, FrameEnvelope): 240 return 241 if self._active_recording is None: 242 return 243 244 stream_id = frame.stream_id 245 next_index = self._active_recording.frame_counts_by_stream[stream_id] + 1 246 try: 247 append_recording_frame( 248 Path(self._active_recording.root_dir), 249 self._active_recording.recording_id, 250 frame, 251 frame_index=next_index, 252 ) 253 self._active_recording.frame_counts_by_stream[stream_id] = next_index 254 except Exception: 255 logger.exception("Recording append failed; marking recording as failed") 256 self._fail_recording_worker("write_failed") 257 258 def _start_recording_worker( 259 self, 260 root_dir: str, 261 recordings_dir: str, 262 recording_label: object, 263 session_name: object, 264 experiment_name: object, 265 recording_descriptors: object, 266 ) -> RecordingStartSummary: 267 if self._active_recording is not None: 268 raise RuntimeError("ACQ_ALREADY_RECORDING") 269 270 if not isinstance(recording_descriptors, dict) or not all( 271 isinstance(value, StreamDescriptor) for value in recording_descriptors.values() 272 ): 273 raise RuntimeError("ACQ_INVALID_DESCRIPTOR_SNAPSHOT") 274 275 started_at_ns = time.time_ns() 276 try: 277 recording_id = create_recording( 278 Path(root_dir), 279 recording_descriptors, 280 recording_label=recording_label or None, 281 session_name=session_name if isinstance(session_name, str) else None, 282 experiment_name=experiment_name if isinstance(experiment_name, str) else None, 283 ) 284 except Exception as exc: 285 self._active_recording = None 286 raise RuntimeError(f"ACQ_START_FAILED: {type(exc).__name__}: {exc}") from exc 287 288 recording_path = str(Path(recordings_dir) / recording_id) 289 self._active_recording = ActiveRecording( 290 root_dir=root_dir, 291 recording_id=recording_id, 292 recording_path=recording_path, 293 started_at_ns=started_at_ns, 294 frame_counts_by_stream={stream_id: 0 for stream_id in recording_descriptors}, 295 ) 296 self._set_state("recording") 297 logger.info("Started recording %s at %s", recording_id, recording_path) 298 return RecordingStartSummary( 299 recording_id=recording_id, 300 recording_path=recording_path, 301 started_at_ns=started_at_ns, 302 ) 303 304 def _stop_recording_worker(self) -> RecordingStopSummary: 305 return self._stop_recording_worker_with_policy(publish_failure=True) 306 307 def _stop_recording_worker_with_policy( 308 self, 309 *, 310 publish_failure: bool, 311 ) -> RecordingStopSummary: 312 if self._active_recording is None: 313 raise RuntimeError("ACQ_STOP_IGNORED: not recording") 314 315 _ = publish_failure 316 stopped_at_ns = time.time_ns() 317 active_recording = self._active_recording 318 self._active_recording = None 319 self._set_state("idle") 320 logger.info("Stopped recording %s", active_recording.recording_id) 321 summary = RecordingStopSummary( 322 recording_id=active_recording.recording_id, 323 recording_path=active_recording.recording_path, 324 started_at_ns=active_recording.started_at_ns, 325 stopped_at_ns=stopped_at_ns, 326 status="completed", 327 frame_counts_by_stream=dict(active_recording.frame_counts_by_stream), 328 ) 329 try: 330 finalize_recording( 331 Path(active_recording.root_dir), 332 active_recording.recording_id, 333 started_at_ns=active_recording.started_at_ns, 334 stopped_at_ns=stopped_at_ns, 335 status="completed", 336 frame_counts_by_stream=dict(active_recording.frame_counts_by_stream), 337 ) 338 except Exception as exc: 339 logger.warning( 340 "Failed to finalize recording manifest %s: %s", active_recording.recording_id, exc 341 ) 342 return summary 343 344 def _add_marker_worker(self, label: object) -> None: 345 if self._active_recording is None: 346 raise RuntimeError("ACQ_MARKER_REJECTED: not recording") 347 348 timestamp_ns = time.time_ns() 349 normalized_label = label or None 350 try: 351 add_recording_marker( 352 Path(self._active_recording.root_dir), 353 self._active_recording.recording_id, 354 timestamp_ns=timestamp_ns, 355 label=normalized_label, 356 ) 357 except Exception as exc: 358 raise RuntimeError(f"ACQ_MARKER_FAILED: {type(exc).__name__}: {exc}") from exc 359 360 def _add_segment_worker( 361 self, 362 start_ns: object, 363 end_ns: object, 364 label: object, 365 ) -> None: 366 if self._active_recording is None: 367 raise RuntimeError("ACQ_SEGMENT_REJECTED: not recording") 368 369 try: 370 start_value = int(start_ns) 371 end_value = int(end_ns) 372 except (TypeError, ValueError): 373 raise RuntimeError("ACQ_INVALID_SEGMENT_RANGE") 374 375 if start_value > end_value: 376 raise RuntimeError("ACQ_INVALID_SEGMENT_RANGE: start_ns > end_ns") 377 378 normalized_label = label or None 379 try: 380 add_recording_segment( 381 Path(self._active_recording.root_dir), 382 self._active_recording.recording_id, 383 start_ns=start_value, 384 end_ns=end_value, 385 label=normalized_label, 386 ) 387 except Exception as exc: 388 raise RuntimeError(f"ACQ_SEGMENT_FAILED: {type(exc).__name__}: {exc}") from exc 389 390 def _handle_frame_stream_overflow(self, consumer_name: str) -> None: 391 logger.warning("Recording frame stream overflowed for consumer '%s'", consumer_name) 392 if self._active_recording is not None: 393 self._fail_recording_worker("frame_stream_overflow") 394 self._replace_frame_stream() 395 396 def _replace_frame_stream(self) -> None: 397 previous_stream = self._frame_stream 398 self._frame_stream = self._open_frame_stream() 399 if previous_stream is not None: 400 previous_stream.close() 401 402 def _open_frame_stream(self) -> FrameStream: 403 return self._bus.open_frame_stream( 404 maxsize=256, 405 drop_policy="error", 406 consumer_name=RECORDING_CONSUMER_NAME, 407 ) 408 409 def _shutdown_worker(self) -> None: 410 if self._active_recording is not None: 411 self._stop_recording_worker_with_policy(publish_failure=False) 412 413 def _fail_recording_worker(self, reason: str) -> None: 414 active_recording = self._active_recording 415 if active_recording is None: 416 return 417 418 stopped_at_ns = time.time_ns() 419 self._active_recording = None 420 self._set_state("idle") 421 try: 422 finalize_recording( 423 Path(active_recording.root_dir), 424 active_recording.recording_id, 425 started_at_ns=active_recording.started_at_ns, 426 stopped_at_ns=stopped_at_ns, 427 status="failed", 428 frame_counts_by_stream=dict(active_recording.frame_counts_by_stream), 429 ) 430 except Exception as exc: 431 logger.warning( 432 "Failed to finalize failed recording manifest %s: %s", 433 active_recording.recording_id, 434 exc, 435 ) 436 self._publish_recording_failed( 437 active_recording, 438 stopped_at_ns=stopped_at_ns, 439 reason=reason, 440 ) 441 442 def _publish_recording_failed( 443 self, 444 active_recording: ActiveRecording, 445 *, 446 stopped_at_ns: int, 447 reason: str, 448 ) -> None: 449 self._publish_event( 450 RecordingFailedEvent( 451 recording_id=active_recording.recording_id, 452 recording_path=active_recording.recording_path, 453 frame_counts_by_stream=dict(active_recording.frame_counts_by_stream), 454 reason=reason, 455 ts_ns=stopped_at_ns, 456 ) 457 ) 458 459 def _set_state(self, state: str) -> None: 460 with self._lock: 461 if state == self._state: 462 return 463 self._state = state 464 465 def _submit_command( 466 self, 467 worker_method: Callable[..., object], 468 *args: object, 469 ) -> Future[object]: 470 future: Future[object] = Future() 471 if not self.is_started: 472 future.set_exception(RuntimeError("ACQ_NOT_STARTED")) 473 return future 474 if not self._accepting_commands: 475 future.set_exception(RuntimeError("ACQ_SHUTTING_DOWN")) 476 return future 477 478 def _run_command() -> None: 479 try: 480 result = worker_method(*args) 481 except Exception as exc: 482 if not future.done(): 483 future.set_exception(exc) 484 return 485 if not future.done(): 486 future.set_result(result) 487 488 self._command_queue.put((_run_command, future)) 489 return future 490 491 def _fail_pending_commands(self, message: str) -> None: 492 while True: 493 try: 494 item = self._command_queue.get_nowait() 495 except queue.Empty: 496 return 497 if item is self._shutdown_sentinel: 498 continue 499 _command, future = item 500 if future is None or future.done(): 501 continue 502 future.set_exception(RuntimeError(message)) 503 504 def _finalize_shutdown(self) -> None: 505 self._set_state("idle") 506 self._started = False 507 self._accepting_commands = True 508 self._thread = None 509 if self._frame_stream is not None: 510 self._frame_stream.close() 511 self._frame_stream = None 512 513 def _take_worker_exit_error(self) -> Exception | None: 514 with self._lock: 515 error = self._worker_exit_error 516 self._worker_exit_error = None 517 return error
Threaded recording backend that persists bus frames to disk.
RecordingBackend( bus: StreamBus, *, settings: SettingsStore, publish_event: Callable[[BackendEvent], None], parent: object | None = None)
50 def __init__( 51 self, 52 bus: StreamBus, 53 *, 54 settings: SettingsStore, 55 publish_event: Callable[[BackendEvent], None], 56 parent: object | None = None, 57 ) -> None: 58 self._bus = bus 59 self._settings = settings 60 self._publish_event = publish_event 61 self._parent = parent 62 self._command_queue: queue.Queue[RecordingCommand | object] = queue.Queue() 63 self._shutdown_sentinel = object() 64 self._thread: threading.Thread | None = None 65 self._frame_stream: FrameStream | None = None 66 self._state = "idle" 67 self._started = False 68 self._accepting_commands = False 69 self._worker_exit_error: Exception | None = None 70 self._active_recording: ActiveRecording | None = None 71 self._lock = threading.RLock()
def
start(self) -> None:
98 def start(self) -> None: 99 if self.is_started: 100 self._started = True 101 self._accepting_commands = True 102 return 103 logger.info("Starting recording backend") 104 if self._frame_stream is None or self._frame_stream.closed: 105 self._frame_stream = self._open_frame_stream() 106 thread = threading.Thread( 107 target=self._run, 108 name="modlink.recording", 109 daemon=True, 110 ) 111 self._thread = thread 112 self._worker_exit_error = None 113 self._accepting_commands = True 114 self._started = True 115 thread.start() 116 logger.info("Recording backend started")
def
start_recording( self, recording_label: str | None = None, *, session_name: str | None = None, experiment_name: str | None = None) -> concurrent.futures._base.Future[RecordingStartSummary]:
118 def start_recording( 119 self, 120 recording_label: str | None = None, 121 *, 122 session_name: str | None = None, 123 experiment_name: str | None = None, 124 ) -> Future[RecordingStartSummary]: 125 storage_root_dir = self.root_dir 126 recordings_root_dir = storage_root_dir / "recordings" 127 return self._submit_command( 128 self._start_recording_worker, 129 str(storage_root_dir), 130 str(recordings_root_dir), 131 recording_label, 132 session_name, 133 experiment_name, 134 self._bus.descriptors(), 135 )
def
add_segment( self, start_ns: int, end_ns: int, label: str | None = None) -> concurrent.futures._base.Future[None]:
def
shutdown(self, *, timeout_ms: int = 3000) -> None:
156 def shutdown(self, *, timeout_ms: int = 3000) -> None: 157 logger.info("Shutting down recording backend") 158 with self._lock: 159 self._accepting_commands = False 160 thread = self._thread 161 worker_exit_error = self._worker_exit_error 162 163 if thread is None or not thread.is_alive(): 164 self._finalize_shutdown() 165 if worker_exit_error is not None: 166 self._worker_exit_error = None 167 raise worker_exit_error 168 return 169 170 self._command_queue.put(self._shutdown_sentinel) 171 thread.join(max(0, timeout_ms) / 1000) 172 173 if thread.is_alive(): 174 raise TimeoutError(f"recording shutdown timed out after {timeout_ms}ms") 175 176 self._finalize_shutdown() 177 worker_exit_error = self._take_worker_exit_error() 178 if worker_exit_error is not None: 179 raise worker_exit_error 180 logger.info("Recording backend shut down")
@dataclass(frozen=True, slots=True)
class
RecordingSnapshot:
@dataclass(frozen=True, slots=True)
class
RecordingStartSummary:
@dataclass(frozen=True, slots=True)
class
RecordingStopSummary:
32@dataclass(frozen=True, slots=True) 33class RecordingStopSummary: 34 recording_id: str 35 recording_path: str 36 started_at_ns: int 37 stopped_at_ns: int 38 status: str 39 frame_counts_by_stream: dict[str, int]
class
ReplayBackend:
39class ReplayBackend: 40 def __init__( 41 self, 42 *, 43 settings: SettingsStore, 44 parent: object | None = None, 45 ) -> None: 46 self._settings = settings 47 self._parent = parent 48 self.bus = StreamBus(event_broker=BackendEventBroker(), parent=self) 49 self._export_service = ExportService() 50 self._command_queue: queue.Queue[ReplayCommand | object] = queue.Queue() 51 self._shutdown_sentinel = object() 52 self._thread: threading.Thread | None = None 53 self._state = "idle" 54 self._started = False 55 self._accepting_commands = False 56 self._worker_exit_error: Exception | None = None 57 self._lock = threading.RLock() 58 self._recordings: tuple[ReplayRecordingSummary, ...] = () 59 self._reader: RecordingReader | None = None 60 self._markers: tuple[ReplayMarker, ...] = () 61 self._segments: tuple[ReplaySegment, ...] = () 62 self._position_ns = 0 63 self._speed_multiplier = 1.0 64 self._timeline_index = 0 65 self._play_started_wall_ns = 0 66 self._play_started_position_ns = 0 67 68 @property 69 def is_started(self) -> bool: 70 thread = self._thread 71 return self._started and thread is not None and thread.is_alive() 72 73 def start(self) -> None: 74 if self.is_started: 75 self._started = True 76 self._accepting_commands = True 77 return 78 logger.info("Starting replay backend") 79 self._export_service.start() 80 thread = threading.Thread( 81 target=self._run, 82 name="modlink.replay", 83 daemon=True, 84 ) 85 self._thread = thread 86 self._worker_exit_error = None 87 self._accepting_commands = True 88 self._started = True 89 thread.start() 90 self.refresh_recordings() 91 92 def shutdown(self, *, timeout_ms: int = 3000) -> None: 93 logger.info("Shutting down replay backend") 94 with self._lock: 95 self._accepting_commands = False 96 thread = self._thread 97 worker_exit_error = self._worker_exit_error 98 99 if thread is None or not thread.is_alive(): 100 self._finalize_shutdown() 101 self._export_service.shutdown(timeout_ms=timeout_ms) 102 if worker_exit_error is not None: 103 self._worker_exit_error = None 104 raise worker_exit_error 105 return 106 107 self._command_queue.put(self._shutdown_sentinel) 108 thread.join(max(0, timeout_ms) / 1000) 109 if thread.is_alive(): 110 raise TimeoutError(f"replay shutdown timed out after {timeout_ms}ms") 111 112 self._finalize_shutdown() 113 self._export_service.shutdown(timeout_ms=timeout_ms) 114 worker_exit_error = self._take_worker_exit_error() 115 if worker_exit_error is not None: 116 raise worker_exit_error 117 118 def snapshot(self) -> ReplaySnapshot: 119 reader = self._reader 120 return ReplaySnapshot( 121 state=self._state, 122 is_started=self.is_started, 123 recording_id=None if reader is None else reader.recording_id, 124 recording_path=None if reader is None else str(reader.recording_path), 125 position_ns=int(self._position_ns), 126 duration_ns=0 if reader is None else int(reader.duration_ns), 127 speed_multiplier=float(self._speed_multiplier), 128 ) 129 130 def recordings(self) -> tuple[ReplayRecordingSummary, ...]: 131 return self._recordings 132 133 def markers(self) -> tuple[ReplayMarker, ...]: 134 return self._markers 135 136 def segments(self) -> tuple[ReplaySegment, ...]: 137 return self._segments 138 139 def export_jobs(self) -> tuple[ExportJobSnapshot, ...]: 140 return self._export_service.jobs() 141 142 def refresh_recordings(self) -> Future[tuple[ReplayRecordingSummary, ...]]: 143 return self._submit_command(self._refresh_recordings_worker) 144 145 def open_recording(self, recording_path: str | Path) -> Future[ReplaySnapshot]: 146 return self._submit_command(self._open_recording_worker, recording_path) 147 148 def play(self) -> Future[None]: 149 return self._submit_command(self._play_worker) 150 151 def pause(self) -> Future[None]: 152 return self._submit_command(self._pause_worker) 153 154 def stop(self) -> Future[None]: 155 return self._submit_command(self._stop_worker) 156 157 def seek(self, position_ns: int) -> Future[None]: 158 return self._submit_command(self._seek_worker, position_ns) 159 160 def set_speed(self, multiplier: float) -> Future[float]: 161 return self._submit_command(self._set_speed_worker, multiplier) 162 163 def start_export( 164 self, 165 request: ExportRequest | str, 166 output_root_dir: str | Path | None = None, 167 ) -> Future[ExportJobSnapshot]: 168 return self._submit_command(self._start_export_worker, (request, output_root_dir)) 169 170 def delete_recording(self, recording_id: str) -> Future[tuple[ReplayRecordingSummary, ...]]: 171 return self._submit_command(self._delete_recording_worker, recording_id) 172 173 def _run(self) -> None: 174 exit_error: Exception | None = None 175 while True: 176 try: 177 if self._drain_commands(): 178 return 179 if self._state != "playing": 180 time.sleep(0.01) 181 continue 182 self._tick_playback() 183 except Exception as exc: 184 logger.exception("Replay worker exited with an unexpected error") 185 exit_error = exc 186 break 187 188 with self._lock: 189 self._worker_exit_error = exit_error 190 self._started = False 191 self._accepting_commands = False 192 193 def _drain_commands(self) -> bool: 194 while True: 195 try: 196 item = self._command_queue.get_nowait() 197 except queue.Empty: 198 return False 199 200 if item is self._shutdown_sentinel: 201 return True 202 203 command, _future = item 204 command() 205 206 def _refresh_recordings_worker(self) -> tuple[ReplayRecordingSummary, ...]: 207 root_dir = resolved_storage_root_dir(self._settings) 208 summaries: list[ReplayRecordingSummary] = [] 209 for manifest in list_recordings(root_dir): 210 recording_id = manifest.get("recording_id") 211 if not isinstance(recording_id, str) or recording_id == "": 212 continue 213 stream_ids_payload = manifest.get("stream_ids", []) 214 stream_ids = tuple( 215 stream_id for stream_id in stream_ids_payload if isinstance(stream_id, str) 216 ) 217 label = manifest.get("recording_label") 218 session_name = manifest.get("session_name") 219 experiment_name = manifest.get("experiment_name") 220 started_at_ns = manifest.get("started_at_ns") 221 duration_ns_raw = manifest.get("duration_ns") 222 status = manifest.get("status") 223 frame_counts = manifest.get("frame_counts_by_stream") 224 total_frames = sum(frame_counts.values()) if isinstance(frame_counts, dict) else None 225 summaries.append( 226 ReplayRecordingSummary( 227 recording_id=recording_id, 228 recording_label=label 229 if isinstance(label, str) or label is None 230 else str(label), 231 recording_path=str(root_dir / "recordings" / recording_id), 232 stream_ids=stream_ids, 233 session_name=session_name if isinstance(session_name, str) else None, 234 experiment_name=experiment_name if isinstance(experiment_name, str) else None, 235 started_at_ns=started_at_ns if isinstance(started_at_ns, int) else None, 236 duration_ns=duration_ns_raw if isinstance(duration_ns_raw, int) else None, 237 status=status if isinstance(status, str) else None, 238 total_frames=total_frames, 239 ) 240 ) 241 self._recordings = tuple(summaries) 242 return self._recordings 243 244 def _open_recording_worker(self, recording_path: str | Path) -> ReplaySnapshot: 245 logger.info("Opening replay recording from %s", recording_path) 246 reader = RecordingReader(recording_path) 247 self._reader = reader 248 self._markers = reader.markers() 249 self._segments = reader.segments() 250 self._position_ns = 0 251 self._speed_multiplier = 1.0 252 self._timeline_index = 0 253 self._play_started_wall_ns = 0 254 self._play_started_position_ns = 0 255 self._rebuild_bus(reader) 256 self._set_state("ready") 257 logger.debug( 258 "Replay recording opened: recording_id=%s duration_ns=%s stream_count=%s", 259 reader.recording_id, 260 reader.duration_ns, 261 len(reader.descriptors()), 262 ) 263 return self.snapshot() 264 265 def _play_worker(self) -> None: 266 reader = self._require_reader() 267 if not reader.frames(): 268 raise RuntimeError("REPLAY_EMPTY_RECORDING") 269 if self._state == "playing": 270 return 271 if self._state == "finished": 272 self._position_ns = 0 273 self._timeline_index = 0 274 self._play_started_wall_ns = time.monotonic_ns() 275 self._play_started_position_ns = self._position_ns 276 self._set_state("playing") 277 278 def _pause_worker(self) -> None: 279 if self._state != "playing": 280 raise RuntimeError("REPLAY_NOT_PLAYING") 281 self._sync_playback_position(time.monotonic_ns()) 282 self._set_state("paused") 283 284 def _stop_worker(self) -> None: 285 self._require_reader() 286 self._position_ns = 0 287 self._timeline_index = 0 288 self._play_started_wall_ns = 0 289 self._play_started_position_ns = 0 290 self._set_state("ready") 291 292 def _set_speed_worker(self, multiplier: object) -> float: 293 try: 294 resolved = float(multiplier) 295 except (TypeError, ValueError) as exc: 296 raise RuntimeError("REPLAY_INVALID_SPEED") from exc 297 if resolved not in {1.0, 2.0, 4.0}: 298 raise RuntimeError("REPLAY_INVALID_SPEED") 299 if self._state == "playing": 300 current_time_ns = time.monotonic_ns() 301 self._sync_playback_position(current_time_ns) 302 self._play_started_wall_ns = current_time_ns 303 self._play_started_position_ns = self._position_ns 304 self._speed_multiplier = resolved 305 return self._speed_multiplier 306 307 def _seek_worker(self, position_ns: object) -> None: 308 reader = self._require_reader() 309 try: 310 target_ns = int(position_ns) 311 except (TypeError, ValueError) as exc: 312 raise RuntimeError("REPLAY_INVALID_SEEK_POSITION") from exc 313 target_ns = max(0, min(target_ns, reader.duration_ns)) 314 315 # Use bisect to find the correct timeline index for the target position. 316 timeline = reader.frames() 317 timestamps = [ref.relative_timestamp_ns for ref in timeline] 318 self._timeline_index = bisect.bisect_right(timestamps, target_ns) 319 self._position_ns = target_ns 320 321 if self._state == "playing": 322 # Reset wall-clock anchor so playback continues from the new position. 323 self._play_started_wall_ns = time.monotonic_ns() 324 self._play_started_position_ns = target_ns 325 elif self._state == "finished": 326 self._set_state("paused") 327 328 def _start_export_worker(self, request: object) -> ExportJobSnapshot: 329 if not isinstance(request, tuple) or len(request) != 2: 330 raise RuntimeError("REPLAY_EXPORT_FORMAT_UNSUPPORTED") 331 raw_request, output_root_dir = request 332 if isinstance(raw_request, ExportRequest): 333 export_request = raw_request 334 elif isinstance(raw_request, str) and raw_request.strip(): 335 reader = self._require_reader() 336 format_id = raw_request.strip() 337 required_payload = format_id.split("_")[0] 338 selections = tuple( 339 StreamSelection(stream_id=stream_id, format_id=format_id) 340 for stream_id, descriptor in reader.descriptors().items() 341 if descriptor.payload_type == required_payload 342 ) 343 if not selections: 344 return self._export_service.enqueue_failed( 345 recording_id=reader.recording_id, 346 request_summary=format_id, 347 error=f"no {required_payload!r} streams in recording", 348 ) 349 export_request = ExportRequest( 350 mode=ExportMode.SINGLE, 351 recording_ids=(reader.recording_id,), 352 streams=selections, 353 ) 354 else: 355 raise RuntimeError("REPLAY_EXPORT_FORMAT_UNSUPPORTED") 356 export_root_dir = ( 357 Path(output_root_dir) 358 if isinstance(output_root_dir, str | Path) and str(output_root_dir).strip() 359 else resolved_export_root_dir(self._settings) 360 ) 361 storage_root_dir = resolved_storage_root_dir(self._settings) 362 return self._export_service.enqueue(export_request, export_root_dir, storage_root_dir) 363 364 def _delete_recording_worker(self, recording_id: object) -> tuple[ReplayRecordingSummary, ...]: 365 if not isinstance(recording_id, str) or not recording_id.strip(): 366 raise RuntimeError("REPLAY_DELETE_INVALID_ID") 367 368 # If the recording is currently open for replay, drop the reader and 369 # clear the bus so the deletion can't race with playback frame loads. 370 reader = self._reader 371 if reader is not None and reader.recording_id == recording_id: 372 self._reader = None 373 self._markers = () 374 self._segments = () 375 self._position_ns = 0 376 self._timeline_index = 0 377 self._play_started_wall_ns = 0 378 self._play_started_position_ns = 0 379 for stream_id in tuple(self.bus.descriptors()): 380 self.bus.remove_descriptor(stream_id) 381 self._set_state("idle") 382 383 root_dir = resolved_storage_root_dir(self._settings) 384 try: 385 delete_recording(root_dir, recording_id) 386 except FileNotFoundError as exc: 387 raise RuntimeError(f"REPLAY_DELETE_NOT_FOUND: {recording_id}") from exc 388 logger.info("Deleted replay recording %s", recording_id) 389 return self._refresh_recordings_worker() 390 391 def _tick_playback(self) -> None: 392 current_time_ns = time.monotonic_ns() 393 target_position_ns = self._sync_playback_position(current_time_ns) 394 reader = self._reader 395 if reader is None: 396 return 397 if ( 398 self._timeline_index >= len(reader.frames()) 399 and target_position_ns >= reader.duration_ns 400 ): 401 self._position_ns = reader.duration_ns 402 self._set_state("finished") 403 return 404 time.sleep(0.005) 405 406 def _sync_playback_position(self, current_time_ns: int) -> int: 407 reader = self._reader 408 if reader is None: 409 return self._position_ns 410 target_position_ns = min( 411 reader.duration_ns, 412 int( 413 self._play_started_position_ns 414 + (current_time_ns - self._play_started_wall_ns) * self._speed_multiplier 415 ), 416 ) 417 self._emit_due_frames(target_position_ns) 418 self._position_ns = target_position_ns 419 return target_position_ns 420 421 def _emit_due_frames(self, target_position_ns: int) -> None: 422 reader = self._reader 423 if reader is None: 424 return 425 timeline = reader.frames() 426 while self._timeline_index < len(timeline): 427 ref = timeline[self._timeline_index] 428 if ref.relative_timestamp_ns > target_position_ns: 429 break 430 self.bus.ingest_frame(reader.load_frame(ref)) 431 self._timeline_index += 1 432 433 def _rebuild_bus(self, reader: RecordingReader) -> None: 434 for stream_id in tuple(self.bus.descriptors()): 435 self.bus.remove_descriptor(stream_id) 436 self.bus.add_descriptors(reader.descriptors().values()) 437 438 def _set_state(self, state: str) -> None: 439 with self._lock: 440 if self._state == state: 441 return 442 self._state = state 443 444 def _require_reader(self) -> RecordingReader: 445 if self._reader is None: 446 raise RuntimeError("REPLAY_NOT_OPEN") 447 return self._reader 448 449 def _submit_command( 450 self, 451 worker_method: Callable[..., object], 452 *args: object, 453 ) -> Future[object]: 454 future: Future[object] = Future() 455 if not self.is_started: 456 future.set_exception(RuntimeError("REPLAY_NOT_STARTED")) 457 return future 458 if not self._accepting_commands: 459 future.set_exception(RuntimeError("REPLAY_SHUTTING_DOWN")) 460 return future 461 462 def _run_command() -> None: 463 try: 464 result = worker_method(*args) 465 except Exception as exc: 466 if not future.done(): 467 future.set_exception(exc) 468 return 469 if not future.done(): 470 future.set_result(result) 471 472 self._command_queue.put((_run_command, future)) 473 return future 474 475 def _finalize_shutdown(self) -> None: 476 self._set_state("idle") 477 self._started = False 478 self._accepting_commands = True 479 self._thread = None 480 481 def _take_worker_exit_error(self) -> Exception | None: 482 with self._lock: 483 error = self._worker_exit_error 484 self._worker_exit_error = None 485 return error
ReplayBackend( *, settings: SettingsStore, parent: object | None = None)
40 def __init__( 41 self, 42 *, 43 settings: SettingsStore, 44 parent: object | None = None, 45 ) -> None: 46 self._settings = settings 47 self._parent = parent 48 self.bus = StreamBus(event_broker=BackendEventBroker(), parent=self) 49 self._export_service = ExportService() 50 self._command_queue: queue.Queue[ReplayCommand | object] = queue.Queue() 51 self._shutdown_sentinel = object() 52 self._thread: threading.Thread | None = None 53 self._state = "idle" 54 self._started = False 55 self._accepting_commands = False 56 self._worker_exit_error: Exception | None = None 57 self._lock = threading.RLock() 58 self._recordings: tuple[ReplayRecordingSummary, ...] = () 59 self._reader: RecordingReader | None = None 60 self._markers: tuple[ReplayMarker, ...] = () 61 self._segments: tuple[ReplaySegment, ...] = () 62 self._position_ns = 0 63 self._speed_multiplier = 1.0 64 self._timeline_index = 0 65 self._play_started_wall_ns = 0 66 self._play_started_position_ns = 0
def
start(self) -> None:
73 def start(self) -> None: 74 if self.is_started: 75 self._started = True 76 self._accepting_commands = True 77 return 78 logger.info("Starting replay backend") 79 self._export_service.start() 80 thread = threading.Thread( 81 target=self._run, 82 name="modlink.replay", 83 daemon=True, 84 ) 85 self._thread = thread 86 self._worker_exit_error = None 87 self._accepting_commands = True 88 self._started = True 89 thread.start() 90 self.refresh_recordings()
def
shutdown(self, *, timeout_ms: int = 3000) -> None:
92 def shutdown(self, *, timeout_ms: int = 3000) -> None: 93 logger.info("Shutting down replay backend") 94 with self._lock: 95 self._accepting_commands = False 96 thread = self._thread 97 worker_exit_error = self._worker_exit_error 98 99 if thread is None or not thread.is_alive(): 100 self._finalize_shutdown() 101 self._export_service.shutdown(timeout_ms=timeout_ms) 102 if worker_exit_error is not None: 103 self._worker_exit_error = None 104 raise worker_exit_error 105 return 106 107 self._command_queue.put(self._shutdown_sentinel) 108 thread.join(max(0, timeout_ms) / 1000) 109 if thread.is_alive(): 110 raise TimeoutError(f"replay shutdown timed out after {timeout_ms}ms") 111 112 self._finalize_shutdown() 113 self._export_service.shutdown(timeout_ms=timeout_ms) 114 worker_exit_error = self._take_worker_exit_error() 115 if worker_exit_error is not None: 116 raise worker_exit_error
118 def snapshot(self) -> ReplaySnapshot: 119 reader = self._reader 120 return ReplaySnapshot( 121 state=self._state, 122 is_started=self.is_started, 123 recording_id=None if reader is None else reader.recording_id, 124 recording_path=None if reader is None else str(reader.recording_path), 125 position_ns=int(self._position_ns), 126 duration_ns=0 if reader is None else int(reader.duration_ns), 127 speed_multiplier=float(self._speed_multiplier), 128 )
def
refresh_recordings( self) -> concurrent.futures._base.Future[tuple[ReplayRecordingSummary, ...]]:
def
open_recording( self, recording_path: str | pathlib._local.Path) -> concurrent.futures._base.Future[ReplaySnapshot]:
def
start_export( self, request: modlink_core.replay.export_request.ExportRequest | str, output_root_dir: str | pathlib._local.Path | None = None) -> concurrent.futures._base.Future[ExportJobSnapshot]:
def
delete_recording( self, recording_id: str) -> concurrent.futures._base.Future[tuple[ReplayRecordingSummary, ...]]:
@dataclass(frozen=True, slots=True)
class
ReplayMarker:
@dataclass(frozen=True, slots=True)
class
ReplayRecordingSummary:
53@dataclass(frozen=True, slots=True) 54class ReplayRecordingSummary: 55 recording_id: str 56 recording_label: str | None 57 recording_path: str 58 stream_ids: tuple[str, ...] 59 session_name: str | None = None 60 experiment_name: str | None = None 61 started_at_ns: int | None = None 62 duration_ns: int | None = None 63 status: str | None = None 64 total_frames: int | None = None
ReplayRecordingSummary( recording_id: str, recording_label: str | None, recording_path: str, stream_ids: tuple[str, ...], session_name: str | None = None, experiment_name: str | None = None, started_at_ns: int | None = None, duration_ns: int | None = None, status: str | None = None, total_frames: int | None = None)
@dataclass(frozen=True, slots=True)
class
ReplaySegment:
@dataclass(frozen=True, slots=True)
class
ReplaySnapshot:
42@dataclass(frozen=True, slots=True) 43class ReplaySnapshot: 44 state: str 45 is_started: bool 46 recording_id: str | None 47 recording_path: str | None 48 position_ns: int 49 duration_ns: int 50 speed_multiplier: float
@dataclass(frozen=True, slots=True)
class
ExportJobSnapshot:
80@dataclass(frozen=True, slots=True) 81class ExportJobSnapshot: 82 job_id: str 83 recording_id: str 84 state: str 85 progress: float 86 output_path: str | None 87 error: str | None 88 request_summary: str = ""
class
BackendEventBroker:
119class BackendEventBroker: 120 def __init__(self) -> None: 121 self._streams: list[EventStream] = [] 122 self._lock = RLock() 123 124 def open_stream(self, *, maxsize: int = 1024) -> EventStream: 125 stream = EventStream(self, maxsize=maxsize) 126 with self._lock: 127 self._streams.append(stream) 128 return stream 129 130 def publish(self, event: BackendEvent) -> None: 131 with self._lock: 132 streams = tuple(self._streams) 133 for stream in streams: 134 stream._publish(event) 135 136 def _close_stream(self, stream: EventStream) -> None: 137 with self._lock: 138 try: 139 self._streams.remove(stream) 140 except ValueError: 141 pass 142 stream._push_closed()
@dataclass(frozen=True, slots=True)
class
DriverConnectionLostEvent:
8@dataclass(frozen=True, slots=True) 9class DriverConnectionLostEvent: 10 driver_id: str 11 detail: object | None = None 12 kind: Literal["driver_connection_lost"] = "driver_connection_lost"
@dataclass(frozen=True, slots=True)
class
DriverExecutorFailedEvent:
15@dataclass(frozen=True, slots=True) 16class DriverExecutorFailedEvent: 17 driver_id: str 18 detail: object 19 kind: Literal["driver_executor_failed"] = "driver_executor_failed"
class
EventStream:
22class EventStream: 23 def __init__( 24 self, 25 broker: BackendEventBroker, 26 *, 27 maxsize: int = 1024, 28 ) -> None: 29 normalized_maxsize = max(1, int(maxsize)) 30 self._broker = broker 31 self._queue: queue.Queue[BackendEvent | object] = queue.Queue(maxsize=normalized_maxsize) 32 self._lock = RLock() 33 self._closed = False 34 35 @property 36 def closed(self) -> bool: 37 return self._closed 38 39 def read( 40 self, 41 *, 42 block: bool = True, 43 timeout: float | None = None, 44 ) -> BackendEvent: 45 item = self._queue.get(block=block, timeout=timeout) 46 if item is _STREAM_CLOSED: 47 raise StreamClosedError("event stream is closed") 48 if item is _STREAM_OVERFLOW: 49 raise EventStreamOverflowError("event stream overflowed") 50 return item # type: ignore[return-value] 51 52 def read_many(self, *, max_items: int | None = None) -> list[BackendEvent]: 53 if max_items is not None and max_items <= 0: 54 return [] 55 56 items: list[BackendEvent] = [] 57 while max_items is None or len(items) < max_items: 58 try: 59 item = self._queue.get_nowait() 60 except queue.Empty: 61 break 62 if item is _STREAM_CLOSED: 63 if items: 64 return items 65 raise StreamClosedError("event stream is closed") 66 if item is _STREAM_OVERFLOW: 67 if items: 68 return items 69 raise EventStreamOverflowError("event stream overflowed") 70 items.append(item) # type: ignore[arg-type] 71 return items 72 73 def close(self) -> None: 74 with self._lock: 75 if self._closed: 76 return 77 self._closed = True 78 self._broker._close_stream(self) 79 80 def _publish(self, event: BackendEvent) -> None: 81 with self._lock: 82 if self._closed: 83 return 84 try: 85 self._queue.put_nowait(event) 86 return 87 except queue.Full: 88 pass 89 90 try: 91 self._queue.get_nowait() 92 except queue.Empty: 93 pass 94 95 try: 96 self._queue.put_nowait(_STREAM_OVERFLOW) 97 except queue.Full: 98 pass 99 100 def _push_closed(self) -> None: 101 with self._lock: 102 try: 103 self._queue.put_nowait(_STREAM_CLOSED) 104 return 105 except queue.Full: 106 pass 107 108 try: 109 self._queue.get_nowait() 110 except queue.Empty: 111 pass 112 113 try: 114 self._queue.put_nowait(_STREAM_CLOSED) 115 except queue.Full: 116 pass
EventStream( broker: BackendEventBroker, *, maxsize: int = 1024)
23 def __init__( 24 self, 25 broker: BackendEventBroker, 26 *, 27 maxsize: int = 1024, 28 ) -> None: 29 normalized_maxsize = max(1, int(maxsize)) 30 self._broker = broker 31 self._queue: queue.Queue[BackendEvent | object] = queue.Queue(maxsize=normalized_maxsize) 32 self._lock = RLock() 33 self._closed = False
def
read( self, *, block: bool = True, timeout: float | None = None) -> BackendEvent:
39 def read( 40 self, 41 *, 42 block: bool = True, 43 timeout: float | None = None, 44 ) -> BackendEvent: 45 item = self._queue.get(block=block, timeout=timeout) 46 if item is _STREAM_CLOSED: 47 raise StreamClosedError("event stream is closed") 48 if item is _STREAM_OVERFLOW: 49 raise EventStreamOverflowError("event stream overflowed") 50 return item # type: ignore[return-value]
def
read_many(self, *, max_items: int | None = None) -> list[BackendEvent]:
52 def read_many(self, *, max_items: int | None = None) -> list[BackendEvent]: 53 if max_items is not None and max_items <= 0: 54 return [] 55 56 items: list[BackendEvent] = [] 57 while max_items is None or len(items) < max_items: 58 try: 59 item = self._queue.get_nowait() 60 except queue.Empty: 61 break 62 if item is _STREAM_CLOSED: 63 if items: 64 return items 65 raise StreamClosedError("event stream is closed") 66 if item is _STREAM_OVERFLOW: 67 if items: 68 return items 69 raise EventStreamOverflowError("event stream overflowed") 70 items.append(item) # type: ignore[arg-type] 71 return items
class
EventStreamOverflowError(builtins.Exception):
14class EventStreamOverflowError(Exception): 15 """Raised when the low-frequency event stream overflows."""
Raised when the low-frequency event stream overflows.
class
FrameStream:
26class FrameStream: 27 def __init__( 28 self, 29 bus: StreamBus, 30 *, 31 maxsize: int = 256, 32 drop_policy: FrameDropPolicy = "drop_oldest", 33 consumer_name: str, 34 ) -> None: 35 if drop_policy not in {"drop_oldest", "error"}: 36 raise ValueError(f"unsupported frame drop policy: {drop_policy}") 37 normalized_maxsize = max(1, int(maxsize)) 38 self._bus = bus 39 self._queue: queue.Queue[FrameEnvelope | object] = queue.Queue(maxsize=normalized_maxsize) 40 self._drop_policy = drop_policy 41 self._consumer_name = consumer_name 42 self._dropped_count = 0 43 self._closed = False 44 self._overflowed = False 45 self._lock = RLock() 46 47 @property 48 def consumer_name(self) -> str: 49 return self._consumer_name 50 51 @property 52 def dropped_count(self) -> int: 53 return self._dropped_count 54 55 @property 56 def overflowed(self) -> bool: 57 return self._overflowed 58 59 @property 60 def closed(self) -> bool: 61 return self._closed 62 63 def read( 64 self, 65 *, 66 block: bool = True, 67 timeout: float | None = None, 68 ) -> FrameEnvelope: 69 item = self._queue.get(block=block, timeout=timeout) 70 if item is _FRAME_STREAM_CLOSED: 71 raise StreamClosedError("frame stream is closed") 72 if item is _FRAME_STREAM_OVERFLOW: 73 raise FrameStreamOverflowError(self._consumer_name) 74 return item # type: ignore[return-value] 75 76 def read_many(self, *, max_items: int | None = None) -> list[FrameEnvelope]: 77 if max_items is not None and max_items <= 0: 78 return [] 79 80 items: list[FrameEnvelope] = [] 81 while max_items is None or len(items) < max_items: 82 try: 83 item = self._queue.get_nowait() 84 except queue.Empty: 85 break 86 if item is _FRAME_STREAM_CLOSED: 87 if items: 88 return items 89 raise StreamClosedError("frame stream is closed") 90 if item is _FRAME_STREAM_OVERFLOW: 91 if items: 92 return items 93 raise FrameStreamOverflowError(self._consumer_name) 94 items.append(item) # type: ignore[arg-type] 95 return items 96 97 def close(self) -> None: 98 with self._lock: 99 if self._closed: 100 return 101 self._closed = True 102 self._bus._close_frame_stream(self) 103 104 def _publish(self, frame: FrameEnvelope) -> None: 105 with self._lock: 106 if self._closed: 107 return 108 if self._overflowed and self._drop_policy == "error": 109 self._dropped_count += 1 110 return 111 try: 112 self._queue.put_nowait(frame) 113 return 114 except queue.Full: 115 self._dropped_count += 1 116 117 if self._drop_policy == "drop_oldest": 118 self._drop_oldest_and_enqueue(frame) 119 return 120 121 self._overflowed = True 122 self._clear_queue_locked() 123 self._push_control_locked(_FRAME_STREAM_OVERFLOW) 124 125 def _drop_oldest_and_enqueue(self, frame: FrameEnvelope) -> None: 126 try: 127 self._queue.get_nowait() 128 except queue.Empty: 129 pass 130 try: 131 self._queue.put_nowait(frame) 132 except queue.Full: 133 pass 134 135 def _clear_queue_locked(self) -> None: 136 while True: 137 try: 138 self._queue.get_nowait() 139 except queue.Empty: 140 break 141 142 def _push_closed(self) -> None: 143 with self._lock: 144 self._push_control_locked(_FRAME_STREAM_CLOSED) 145 146 def _push_control_locked(self, item: object) -> None: 147 try: 148 self._queue.put_nowait(item) 149 return 150 except queue.Full: 151 pass 152 153 try: 154 self._queue.get_nowait() 155 except queue.Empty: 156 pass 157 158 try: 159 self._queue.put_nowait(item) 160 except queue.Full: 161 pass
FrameStream( bus: StreamBus, *, maxsize: int = 256, drop_policy: Literal['drop_oldest', 'error'] = 'drop_oldest', consumer_name: str)
27 def __init__( 28 self, 29 bus: StreamBus, 30 *, 31 maxsize: int = 256, 32 drop_policy: FrameDropPolicy = "drop_oldest", 33 consumer_name: str, 34 ) -> None: 35 if drop_policy not in {"drop_oldest", "error"}: 36 raise ValueError(f"unsupported frame drop policy: {drop_policy}") 37 normalized_maxsize = max(1, int(maxsize)) 38 self._bus = bus 39 self._queue: queue.Queue[FrameEnvelope | object] = queue.Queue(maxsize=normalized_maxsize) 40 self._drop_policy = drop_policy 41 self._consumer_name = consumer_name 42 self._dropped_count = 0 43 self._closed = False 44 self._overflowed = False 45 self._lock = RLock()
63 def read( 64 self, 65 *, 66 block: bool = True, 67 timeout: float | None = None, 68 ) -> FrameEnvelope: 69 item = self._queue.get(block=block, timeout=timeout) 70 if item is _FRAME_STREAM_CLOSED: 71 raise StreamClosedError("frame stream is closed") 72 if item is _FRAME_STREAM_OVERFLOW: 73 raise FrameStreamOverflowError(self._consumer_name) 74 return item # type: ignore[return-value]
76 def read_many(self, *, max_items: int | None = None) -> list[FrameEnvelope]: 77 if max_items is not None and max_items <= 0: 78 return [] 79 80 items: list[FrameEnvelope] = [] 81 while max_items is None or len(items) < max_items: 82 try: 83 item = self._queue.get_nowait() 84 except queue.Empty: 85 break 86 if item is _FRAME_STREAM_CLOSED: 87 if items: 88 return items 89 raise StreamClosedError("frame stream is closed") 90 if item is _FRAME_STREAM_OVERFLOW: 91 if items: 92 return items 93 raise FrameStreamOverflowError(self._consumer_name) 94 items.append(item) # type: ignore[arg-type] 95 return items
class
FrameStreamOverflowError(builtins.Exception):
20class FrameStreamOverflowError(Exception): 21 def __init__(self, consumer_name: str) -> None: 22 self.consumer_name = consumer_name 23 super().__init__(f"frame stream overflowed: consumer_name={consumer_name}")
Common base class for all non-exit exceptions.
def
configure_host_logging( *, log_path: str | pathlib._local.Path | None = None, app_name: str = 'ModLink Studio', app_author: str = 'ModLink', log_filename: str = 'modlink.log', console: bool = True, debug: bool = False, max_bytes: int = 5242880, backup_count: int = 5) -> pathlib._local.Path:
26def configure_host_logging( 27 *, 28 log_path: str | Path | None = None, 29 app_name: str = "ModLink Studio", 30 app_author: str = "ModLink", 31 log_filename: str = "modlink.log", 32 console: bool = True, 33 debug: bool = False, 34 max_bytes: int = DEFAULT_MAX_LOG_BYTES, 35 backup_count: int = DEFAULT_BACKUP_COUNT, 36) -> Path: 37 resolved_path = ( 38 Path(log_path) 39 if log_path is not None 40 else Path(user_log_path(app_name, app_author)) / log_filename 41 ) 42 resolved_path.parent.mkdir(parents=True, exist_ok=True) 43 44 root_logger = logging.getLogger() 45 _remove_managed_handlers(root_logger) 46 root_logger.setLevel(logging.WARNING) 47 log_level = logging.DEBUG if debug else logging.INFO 48 49 formatter = logging.Formatter(DEFAULT_LOG_FORMAT) 50 file_handler = RotatingFileHandler( 51 resolved_path, 52 maxBytes=max(1, int(max_bytes)), 53 backupCount=max(1, int(backup_count)), 54 encoding="utf-8", 55 ) 56 file_handler.setLevel(log_level) 57 file_handler.setFormatter(formatter) 58 _mark_managed(file_handler) 59 root_logger.addHandler(file_handler) 60 61 if console: 62 console_handler = logging.StreamHandler(sys.stderr) 63 console_handler.setLevel(log_level) 64 console_handler.setFormatter(formatter) 65 _mark_managed(console_handler) 66 root_logger.addHandler(console_handler) 67 68 for logger_name in _MANAGED_LOGGER_NAMES: 69 logger = logging.getLogger(logger_name) 70 logger.setLevel(log_level) 71 logger.propagate = True 72 73 logging.captureWarnings(True) 74 logging.getLogger(__name__).info( 75 "Configured logging at %s (level=%s)", 76 resolved_path, 77 logging.getLevelName(log_level), 78 ) 79 return resolved_path
@dataclass(frozen=True, slots=True)
class
DriverSnapshot:
7@dataclass(frozen=True, slots=True) 8class DriverSnapshot: 9 driver_id: str 10 display_name: str 11 supported_providers: tuple[str, ...] 12 is_running: bool 13 is_connected: bool 14 is_streaming: bool
class
ModLinkEngine:
28class ModLinkEngine: 29 """Application engine that owns shared services and driver threads.""" 30 31 def __init__( 32 self, 33 *, 34 settings_path: str | Path | None = None, 35 settings_version: int = 1, 36 parent: object | None = None, 37 ) -> None: 38 resolved_driver_factories = tuple(discover_driver_factories()) 39 logger.info( 40 "Starting ModLink engine with %d driver factories", len(resolved_driver_factories) 41 ) 42 self._parent = parent 43 self._event_broker = BackendEventBroker() 44 self._settings = SettingsStore( 45 path=_resolve_settings_path(settings_path), 46 version=settings_version, 47 on_change=self._publish_setting_changed, 48 ) 49 declare_core_settings(self._settings) 50 if self._settings.path is not None and self._settings.path.exists(): 51 self._settings.load(ignore_unknown=True) 52 self.bus = StreamBus(event_broker=self._event_broker, parent=self) 53 self._recording = RecordingBackend( 54 self.bus, 55 settings=self._settings, 56 publish_event=self._event_broker.publish, 57 parent=self, 58 ) 59 self._replay = ReplayBackend( 60 settings=self._settings, 61 parent=self, 62 ) 63 self._driver_portals: dict[str, DriverPortal] = {} 64 attached_portals: list[DriverPortal] = [] 65 try: 66 for factory in resolved_driver_factories: 67 portal = self._attach_driver(factory) 68 attached_portals.append(portal) 69 self._recording.start() 70 self._replay.start() 71 logger.info("ModLink engine started with %d driver portals", len(self._driver_portals)) 72 except Exception as exc: 73 logger.exception("Engine startup failed; rolling back attached services") 74 self._rollback_startup(attached_portals, exc) 75 raise 76 77 @property 78 def settings(self) -> SettingsStore: 79 return self._settings 80 81 @property 82 def recording(self) -> RecordingBackend: 83 return self._recording 84 85 @property 86 def replay(self) -> ReplayBackend: 87 return self._replay 88 89 def recording_snapshot(self) -> RecordingSnapshot: 90 return self._recording.snapshot() 91 92 def replay_snapshot(self) -> ReplaySnapshot: 93 return self._replay.snapshot() 94 95 def driver_portals(self) -> tuple[DriverPortal, ...]: 96 return tuple(self._driver_portals.values()) 97 98 def driver_snapshots(self) -> tuple[DriverSnapshot, ...]: 99 return tuple(portal.snapshot() for portal in self._driver_portals.values()) 100 101 def driver_portal(self, driver_id: str) -> DriverPortal | None: 102 return self._driver_portals.get(driver_id) 103 104 def settings_snapshot(self) -> dict[str, Any]: 105 return self._settings.snapshot() 106 107 def open_event_stream(self, *, maxsize: int = 1024) -> EventStream: 108 return self._event_broker.open_stream(maxsize=maxsize) 109 110 def _publish_setting_changed(self, key: str, value: Any) -> None: 111 self._event_broker.publish( 112 SettingChangedEvent(key=key, value=value, ts=time.time()), 113 ) 114 115 def _attach_driver(self, driver_factory: DriverFactory) -> DriverPortal: 116 portal = DriverPortal( 117 driver_factory, 118 publish_event=self._event_broker.publish, 119 frame_sink=self.bus.ingest_frame, 120 parent=self, 121 ) 122 driver_id = portal.driver_id.strip() 123 if driver_id in self._driver_portals: 124 raise ValueError(f"driver_id '{driver_id}' is already installed") 125 126 descriptors = portal.descriptors() 127 self.bus.add_descriptors(descriptors) 128 try: 129 portal.start(timeout_ms=DEFAULT_DRIVER_STARTUP_TIMEOUT_MS) 130 except Exception: 131 for descriptor in descriptors: 132 self.bus.remove_descriptor(descriptor.stream_id) 133 raise 134 135 self._driver_portals[driver_id] = portal 136 return portal 137 138 def _rollback_startup( 139 self, 140 attached_portals: list[DriverPortal], 141 startup_error: Exception, 142 ) -> None: 143 cleanup_failures: list[str] = [] 144 for portal in reversed(attached_portals): 145 try: 146 portal.stop() 147 except Exception as exc: 148 logger.exception( 149 "Engine startup rollback failed while stopping driver '%s'", portal.driver_id 150 ) 151 cleanup_failures.append( 152 "cleanup failed while stopping driver " 153 f"'{portal.driver_id}': {type(exc).__name__}: {exc}" 154 ) 155 for descriptor in portal.descriptors(): 156 self.bus.remove_descriptor(descriptor.stream_id) 157 158 self._driver_portals.clear() 159 try: 160 self._recording.shutdown() 161 except Exception as exc: 162 logger.exception("Engine startup rollback failed while shutting down recording") 163 cleanup_failures.append( 164 f"cleanup failed while shutting down recording: {type(exc).__name__}: {exc}" 165 ) 166 try: 167 self._replay.shutdown() 168 except Exception as exc: 169 logger.exception("Engine startup rollback failed while shutting down replay") 170 cleanup_failures.append( 171 f"cleanup failed while shutting down replay: {type(exc).__name__}: {exc}" 172 ) 173 174 for note in cleanup_failures: 175 startup_error.add_note(note) 176 177 def shutdown(self) -> None: 178 logger.info("Shutting down ModLink engine") 179 first_error: Exception | None = None 180 for portal in self._driver_portals.values(): 181 try: 182 portal.stop() 183 except Exception as exc: 184 logger.exception( 185 "Engine shutdown failed while stopping driver '%s'", portal.driver_id 186 ) 187 if first_error is None: 188 first_error = exc 189 self._driver_portals.clear() 190 try: 191 self._recording.shutdown() 192 except Exception as exc: 193 logger.exception("Engine shutdown failed while shutting down recording") 194 if first_error is None: 195 first_error = exc 196 try: 197 self._replay.shutdown() 198 except Exception as exc: 199 logger.exception("Engine shutdown failed while shutting down replay") 200 if first_error is None: 201 first_error = exc 202 if first_error is not None: 203 raise first_error 204 logger.info("ModLink engine shut down")
Application engine that owns shared services and driver threads.
ModLinkEngine( *, settings_path: str | pathlib._local.Path | None = None, settings_version: int = 1, parent: object | None = None)
31 def __init__( 32 self, 33 *, 34 settings_path: str | Path | None = None, 35 settings_version: int = 1, 36 parent: object | None = None, 37 ) -> None: 38 resolved_driver_factories = tuple(discover_driver_factories()) 39 logger.info( 40 "Starting ModLink engine with %d driver factories", len(resolved_driver_factories) 41 ) 42 self._parent = parent 43 self._event_broker = BackendEventBroker() 44 self._settings = SettingsStore( 45 path=_resolve_settings_path(settings_path), 46 version=settings_version, 47 on_change=self._publish_setting_changed, 48 ) 49 declare_core_settings(self._settings) 50 if self._settings.path is not None and self._settings.path.exists(): 51 self._settings.load(ignore_unknown=True) 52 self.bus = StreamBus(event_broker=self._event_broker, parent=self) 53 self._recording = RecordingBackend( 54 self.bus, 55 settings=self._settings, 56 publish_event=self._event_broker.publish, 57 parent=self, 58 ) 59 self._replay = ReplayBackend( 60 settings=self._settings, 61 parent=self, 62 ) 63 self._driver_portals: dict[str, DriverPortal] = {} 64 attached_portals: list[DriverPortal] = [] 65 try: 66 for factory in resolved_driver_factories: 67 portal = self._attach_driver(factory) 68 attached_portals.append(portal) 69 self._recording.start() 70 self._replay.start() 71 logger.info("ModLink engine started with %d driver portals", len(self._driver_portals)) 72 except Exception as exc: 73 logger.exception("Engine startup failed; rolling back attached services") 74 self._rollback_startup(attached_portals, exc) 75 raise
settings: SettingsStore
recording: RecordingBackend
def
shutdown(self) -> None:
177 def shutdown(self) -> None: 178 logger.info("Shutting down ModLink engine") 179 first_error: Exception | None = None 180 for portal in self._driver_portals.values(): 181 try: 182 portal.stop() 183 except Exception as exc: 184 logger.exception( 185 "Engine shutdown failed while stopping driver '%s'", portal.driver_id 186 ) 187 if first_error is None: 188 first_error = exc 189 self._driver_portals.clear() 190 try: 191 self._recording.shutdown() 192 except Exception as exc: 193 logger.exception("Engine shutdown failed while shutting down recording") 194 if first_error is None: 195 first_error = exc 196 try: 197 self._replay.shutdown() 198 except Exception as exc: 199 logger.exception("Engine shutdown failed while shutting down replay") 200 if first_error is None: 201 first_error = exc 202 if first_error is not None: 203 raise first_error 204 logger.info("ModLink engine shut down")
@dataclass(frozen=True, slots=True)
class
RecordingFailedEvent:
22@dataclass(frozen=True, slots=True) 23class RecordingFailedEvent: 24 recording_id: str 25 recording_path: str 26 frame_counts_by_stream: dict[str, int] 27 reason: str 28 ts_ns: int 29 kind: Literal["recording_failed"] = "recording_failed"
@dataclass(frozen=True, slots=True)
class
SettingChangedEvent:
32@dataclass(frozen=True, slots=True) 33class SettingChangedEvent: 34 key: str 35 value: Any 36 ts: float 37 kind: Literal["setting_changed"] = "setting_changed"
class
SettingsStore:
230class SettingsStore: 231 ROOT_RESERVED = frozenset({"add", "load", "path", "save", "snapshot"}) 232 233 def __init__( 234 self, 235 *, 236 path: str | Path | None = None, 237 version: int = 1, 238 on_change: Any = None, 239 ) -> None: 240 object.__setattr__(self, "_path", None if path is None else Path(path)) 241 object.__setattr__(self, "_version", version) 242 object.__setattr__(self, "_on_change", on_change) 243 object.__setattr__(self, "_lock", RLock()) 244 object.__setattr__(self, "_root", SettingsNode(name="", parent=None, store=self)) 245 246 @property 247 def path(self) -> Path | None: 248 return self._path 249 250 def add(self, **specs: SettingsSpec) -> SettingsStore: 251 with self._lock: 252 overlap = self.ROOT_RESERVED.intersection(specs) 253 if overlap: 254 reserved_name = next(iter(sorted(overlap))) 255 raise ValueError(f"reserved key: {reserved_name!r}") 256 self._root.add(**specs) 257 return self 258 259 def __getattr__(self, name: str) -> SettingsNode | SettingItem: 260 if name.startswith("_"): 261 raise AttributeError(name) 262 with self._lock: 263 return self._root.get_child(name) 264 265 def __setattr__(self, name: str, value: object) -> None: 266 if name.startswith("_"): 267 object.__setattr__(self, name, value) 268 return 269 with self._lock: 270 setattr(self._root, name, value) 271 272 def snapshot(self) -> dict[str, object]: 273 with self._lock: 274 return self._root.dump_dict() 275 276 def save(self, path: str | Path | None = None) -> None: 277 with self._lock: 278 resolved_path = self._resolve_path(path) 279 resolved_path.parent.mkdir(parents=True, exist_ok=True) 280 payload = { 281 "_version": self._version, 282 "values": self._root.dump_dict(), 283 } 284 _atomic_write_text( 285 resolved_path, 286 json.dumps(payload, indent=2, ensure_ascii=False), 287 ) 288 289 def load( 290 self, 291 path: str | Path | None = None, 292 *, 293 ignore_unknown: bool = False, 294 ) -> None: 295 with self._lock: 296 resolved_path = self._resolve_path(path) 297 payload = json.loads(resolved_path.read_text(encoding="utf-8")) 298 if not isinstance(payload, dict): 299 raise TypeError("settings file must contain a JSON object") 300 301 file_version = payload.get("_version") 302 if file_version is not None and file_version != self._version: 303 raise ValueError( 304 f"settings version mismatch: file={file_version}, expected={self._version}" 305 ) 306 307 values = payload.get("values", {}) 308 previous_snapshot = self.snapshot() 309 previous = self._root.collect_leaf_values() 310 self._root.reset(notify=False) 311 try: 312 self._root.apply_dict(values, notify=False, ignore_unknown=ignore_unknown) 313 except Exception: 314 self._root.reset(notify=False) 315 self._root.apply_dict(previous_snapshot, notify=False) 316 raise 317 current = self._root.collect_leaf_values() 318 for key, value in current.items(): 319 if previous.get(key) != value: 320 self._emit_change(key, value) 321 322 def _resolve_path(self, path: str | Path | None) -> Path: 323 resolved_path = self._path if path is None else Path(path) 324 if resolved_path is None: 325 raise ValueError("path is required") 326 return resolved_path 327 328 def _emit_change(self, key: str, value: object) -> None: 329 on_change = self._on_change 330 if on_change is None: 331 return 332 on_change(key, value)
SettingsStore( *, path: str | pathlib._local.Path | None = None, version: int = 1, on_change: Any = None)
233 def __init__( 234 self, 235 *, 236 path: str | Path | None = None, 237 version: int = 1, 238 on_change: Any = None, 239 ) -> None: 240 object.__setattr__(self, "_path", None if path is None else Path(path)) 241 object.__setattr__(self, "_version", version) 242 object.__setattr__(self, "_on_change", on_change) 243 object.__setattr__(self, "_lock", RLock()) 244 object.__setattr__(self, "_root", SettingsNode(name="", parent=None, store=self))
def
save(self, path: str | pathlib._local.Path | None = None) -> None:
276 def save(self, path: str | Path | None = None) -> None: 277 with self._lock: 278 resolved_path = self._resolve_path(path) 279 resolved_path.parent.mkdir(parents=True, exist_ok=True) 280 payload = { 281 "_version": self._version, 282 "values": self._root.dump_dict(), 283 } 284 _atomic_write_text( 285 resolved_path, 286 json.dumps(payload, indent=2, ensure_ascii=False), 287 )
def
load( self, path: str | pathlib._local.Path | None = None, *, ignore_unknown: bool = False) -> None:
289 def load( 290 self, 291 path: str | Path | None = None, 292 *, 293 ignore_unknown: bool = False, 294 ) -> None: 295 with self._lock: 296 resolved_path = self._resolve_path(path) 297 payload = json.loads(resolved_path.read_text(encoding="utf-8")) 298 if not isinstance(payload, dict): 299 raise TypeError("settings file must contain a JSON object") 300 301 file_version = payload.get("_version") 302 if file_version is not None and file_version != self._version: 303 raise ValueError( 304 f"settings version mismatch: file={file_version}, expected={self._version}" 305 ) 306 307 values = payload.get("values", {}) 308 previous_snapshot = self.snapshot() 309 previous = self._root.collect_leaf_values() 310 self._root.reset(notify=False) 311 try: 312 self._root.apply_dict(values, notify=False, ignore_unknown=ignore_unknown) 313 except Exception: 314 self._root.reset(notify=False) 315 self._root.apply_dict(previous_snapshot, notify=False) 316 raise 317 current = self._root.collect_leaf_values() 318 for key, value in current.items(): 319 if previous.get(key) != value: 320 self._emit_change(key, value)
class
StreamClosedError(builtins.Exception):
10class StreamClosedError(Exception): 11 """Raised when a blocking stream read is interrupted by stream closure."""
Raised when a blocking stream read is interrupted by stream closure.
class
StreamBus:
164class StreamBus: 165 """Stores stream descriptors and fans out accepted frames to frame streams.""" 166 167 _STREAM_COUNTER = count(1) 168 169 def __init__( 170 self, 171 *, 172 event_broker: BackendEventBroker, 173 parent: object | None = None, 174 ) -> None: 175 self._event_broker = event_broker 176 self._parent = parent 177 self._descriptors: dict[str, StreamDescriptor] = {} 178 self._frame_streams: list[FrameStream] = [] 179 self._lock = RLock() 180 181 def add_descriptor(self, descriptor: StreamDescriptor) -> None: 182 existing_descriptor = self._descriptors.get(descriptor.stream_id) 183 if existing_descriptor is not None: 184 if existing_descriptor == descriptor: 185 return 186 187 raise ValueError(f"conflicting descriptor for stream_id '{descriptor.stream_id}'") 188 189 self._descriptors[descriptor.stream_id] = descriptor 190 191 def add_descriptors(self, descriptors: Iterable[StreamDescriptor]) -> None: 192 for descriptor in descriptors: 193 self.add_descriptor(descriptor) 194 195 def remove_descriptor(self, stream_id: str) -> None: 196 self._descriptors.pop(str(stream_id), None) 197 198 def ingest_frame(self, frame: object) -> bool: 199 if not isinstance(frame, FrameEnvelope): 200 return False 201 202 if frame.stream_id not in self._descriptors: 203 return False 204 205 with self._lock: 206 streams = tuple(self._frame_streams) 207 for stream in streams: 208 stream._publish(frame) 209 return True 210 211 def open_frame_stream( 212 self, 213 *, 214 maxsize: int = 256, 215 drop_policy: FrameDropPolicy = "drop_oldest", 216 consumer_name: str | None = None, 217 ) -> FrameStream: 218 stream = FrameStream( 219 self, 220 maxsize=maxsize, 221 drop_policy=drop_policy, 222 consumer_name=consumer_name or self._next_consumer_name(), 223 ) 224 with self._lock: 225 self._frame_streams.append(stream) 226 return stream 227 228 def descriptor(self, stream_id: str) -> StreamDescriptor | None: 229 return self._descriptors.get(stream_id) 230 231 def descriptors(self) -> dict[str, StreamDescriptor]: 232 return dict(self._descriptors) 233 234 def _close_frame_stream(self, stream: FrameStream) -> None: 235 with self._lock: 236 try: 237 self._frame_streams.remove(stream) 238 except ValueError: 239 pass 240 stream._push_closed() 241 242 @classmethod 243 def _next_consumer_name(cls) -> str: 244 return f"frame_stream_{next(cls._STREAM_COUNTER)}"
Stores stream descriptors and fans out accepted frames to frame streams.
StreamBus( *, event_broker: BackendEventBroker, parent: object | None = None)
169 def __init__( 170 self, 171 *, 172 event_broker: BackendEventBroker, 173 parent: object | None = None, 174 ) -> None: 175 self._event_broker = event_broker 176 self._parent = parent 177 self._descriptors: dict[str, StreamDescriptor] = {} 178 self._frame_streams: list[FrameStream] = [] 179 self._lock = RLock()
181 def add_descriptor(self, descriptor: StreamDescriptor) -> None: 182 existing_descriptor = self._descriptors.get(descriptor.stream_id) 183 if existing_descriptor is not None: 184 if existing_descriptor == descriptor: 185 return 186 187 raise ValueError(f"conflicting descriptor for stream_id '{descriptor.stream_id}'") 188 189 self._descriptors[descriptor.stream_id] = descriptor
def
ingest_frame(self, frame: object) -> bool:
198 def ingest_frame(self, frame: object) -> bool: 199 if not isinstance(frame, FrameEnvelope): 200 return False 201 202 if frame.stream_id not in self._descriptors: 203 return False 204 205 with self._lock: 206 streams = tuple(self._frame_streams) 207 for stream in streams: 208 stream._publish(frame) 209 return True
def
open_frame_stream( self, *, maxsize: int = 256, drop_policy: Literal['drop_oldest', 'error'] = 'drop_oldest', consumer_name: str | None = None) -> FrameStream:
211 def open_frame_stream( 212 self, 213 *, 214 maxsize: int = 256, 215 drop_policy: FrameDropPolicy = "drop_oldest", 216 consumer_name: str | None = None, 217 ) -> FrameStream: 218 stream = FrameStream( 219 self, 220 maxsize=maxsize, 221 drop_policy=drop_policy, 222 consumer_name=consumer_name or self._next_consumer_name(), 223 ) 224 with self._lock: 225 self._frame_streams.append(stream) 226 return stream