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()
root_dir: pathlib._local.Path
73    @property
74    def root_dir(self) -> Path:
75        return resolved_storage_root_dir(self._settings)
is_started: bool
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()
state: str
82    @property
83    def state(self) -> str:
84        return self._state
is_recording: bool
86    @property
87    def is_recording(self) -> bool:
88        return self._state == "recording"
def snapshot(self) -> RecordingSnapshot:
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        )
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 stop_recording( self) -> concurrent.futures._base.Future[RecordingStopSummary]:
137    def stop_recording(self) -> Future[RecordingStopSummary]:
138        return self._submit_command(self._stop_recording_worker)
def add_marker(self, label: str | None = None) -> concurrent.futures._base.Future[None]:
140    def add_marker(self, label: str | None = None) -> Future[None]:
141        return self._submit_command(self._add_marker_worker, label)
def add_segment( self, start_ns: int, end_ns: int, label: str | None = None) -> concurrent.futures._base.Future[None]:
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        )
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:
17@dataclass(frozen=True, slots=True)
18class RecordingSnapshot:
19    state: str
20    is_started: bool
21    is_recording: bool
22    root_dir: str
RecordingSnapshot(state: str, is_started: bool, is_recording: bool, root_dir: str)
state: str
is_started: bool
is_recording: bool
root_dir: str
@dataclass(frozen=True, slots=True)
class RecordingStartSummary:
25@dataclass(frozen=True, slots=True)
26class RecordingStartSummary:
27    recording_id: str
28    recording_path: str
29    started_at_ns: int
RecordingStartSummary(recording_id: str, recording_path: str, started_at_ns: int)
recording_id: str
recording_path: str
started_at_ns: int
@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]
RecordingStopSummary( recording_id: str, recording_path: str, started_at_ns: int, stopped_at_ns: int, status: str, frame_counts_by_stream: dict[str, int])
recording_id: str
recording_path: str
started_at_ns: int
stopped_at_ns: int
status: str
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
bus
is_started: bool
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()
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
def snapshot(self) -> ReplaySnapshot:
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 recordings(self) -> tuple[ReplayRecordingSummary, ...]:
130    def recordings(self) -> tuple[ReplayRecordingSummary, ...]:
131        return self._recordings
def markers(self) -> tuple[ReplayMarker, ...]:
133    def markers(self) -> tuple[ReplayMarker, ...]:
134        return self._markers
def segments(self) -> tuple[ReplaySegment, ...]:
136    def segments(self) -> tuple[ReplaySegment, ...]:
137        return self._segments
def export_jobs(self) -> tuple[ExportJobSnapshot, ...]:
139    def export_jobs(self) -> tuple[ExportJobSnapshot, ...]:
140        return self._export_service.jobs()
def refresh_recordings( self) -> concurrent.futures._base.Future[tuple[ReplayRecordingSummary, ...]]:
142    def refresh_recordings(self) -> Future[tuple[ReplayRecordingSummary, ...]]:
143        return self._submit_command(self._refresh_recordings_worker)
def open_recording( self, recording_path: str | pathlib._local.Path) -> concurrent.futures._base.Future[ReplaySnapshot]:
145    def open_recording(self, recording_path: str | Path) -> Future[ReplaySnapshot]:
146        return self._submit_command(self._open_recording_worker, recording_path)
def play(self) -> concurrent.futures._base.Future[None]:
148    def play(self) -> Future[None]:
149        return self._submit_command(self._play_worker)
def pause(self) -> concurrent.futures._base.Future[None]:
151    def pause(self) -> Future[None]:
152        return self._submit_command(self._pause_worker)
def stop(self) -> concurrent.futures._base.Future[None]:
154    def stop(self) -> Future[None]:
155        return self._submit_command(self._stop_worker)
def seek(self, position_ns: int) -> concurrent.futures._base.Future[None]:
157    def seek(self, position_ns: int) -> Future[None]:
158        return self._submit_command(self._seek_worker, position_ns)
def set_speed(self, multiplier: float) -> concurrent.futures._base.Future[float]:
160    def set_speed(self, multiplier: float) -> Future[float]:
161        return self._submit_command(self._set_speed_worker, multiplier)
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]:
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))
def delete_recording( self, recording_id: str) -> concurrent.futures._base.Future[tuple[ReplayRecordingSummary, ...]]:
170    def delete_recording(self, recording_id: str) -> Future[tuple[ReplayRecordingSummary, ...]]:
171        return self._submit_command(self._delete_recording_worker, recording_id)
@dataclass(frozen=True, slots=True)
class ReplayMarker:
67@dataclass(frozen=True, slots=True)
68class ReplayMarker:
69    timestamp_ns: int
70    label: str | None
ReplayMarker(timestamp_ns: int, label: str | None)
timestamp_ns: int
label: str | None
@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)
recording_id: str
recording_label: str | None
recording_path: str
stream_ids: tuple[str, ...]
session_name: str | None
experiment_name: str | None
started_at_ns: int | None
duration_ns: int | None
status: str | None
total_frames: int | None
@dataclass(frozen=True, slots=True)
class ReplaySegment:
73@dataclass(frozen=True, slots=True)
74class ReplaySegment:
75    start_ns: int
76    end_ns: int
77    label: str | None
ReplaySegment(start_ns: int, end_ns: int, label: str | None)
start_ns: int
end_ns: int
label: str | None
@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
ReplaySnapshot( state: str, is_started: bool, recording_id: str | None, recording_path: str | None, position_ns: int, duration_ns: int, speed_multiplier: float)
state: str
is_started: bool
recording_id: str | None
recording_path: str | None
position_ns: int
duration_ns: int
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 = ""
ExportJobSnapshot( job_id: str, recording_id: str, state: str, progress: float, output_path: str | None, error: str | None, request_summary: str = '')
job_id: str
recording_id: str
state: str
progress: float
output_path: str | None
error: str | None
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()
def open_stream(self, *, maxsize: int = 1024) -> EventStream:
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
def publish(self, event: BackendEvent) -> None:
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)
@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"
DriverConnectionLostEvent( driver_id: str, detail: object | None = None, kind: Literal['driver_connection_lost'] = 'driver_connection_lost')
driver_id: str
detail: object | None
kind: Literal['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"
DriverExecutorFailedEvent( driver_id: str, detail: object, kind: Literal['driver_executor_failed'] = 'driver_executor_failed')
driver_id: str
detail: object
kind: Literal['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
closed: bool
35    @property
36    def closed(self) -> bool:
37        return self._closed
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
def close(self) -> None:
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)
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()
consumer_name: str
47    @property
48    def consumer_name(self) -> str:
49        return self._consumer_name
dropped_count: int
51    @property
52    def dropped_count(self) -> int:
53        return self._dropped_count
overflowed: bool
55    @property
56    def overflowed(self) -> bool:
57        return self._overflowed
closed: bool
59    @property
60    def closed(self) -> bool:
61        return self._closed
def read( self, *, block: bool = True, timeout: float | None = None) -> modlink_sdk.FrameEnvelope:
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]
def read_many( self, *, max_items: int | None = None) -> list[modlink_sdk.FrameEnvelope]:
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
def close(self) -> None:
 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)
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.

FrameStreamOverflowError(consumer_name: str)
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}")
consumer_name
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
DriverSnapshot( driver_id: str, display_name: str, supported_providers: tuple[str, ...], is_running: bool, is_connected: bool, is_streaming: bool)
driver_id: str
display_name: str
supported_providers: tuple[str, ...]
is_running: bool
is_connected: bool
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
bus
settings: SettingsStore
77    @property
78    def settings(self) -> SettingsStore:
79        return self._settings
recording: RecordingBackend
81    @property
82    def recording(self) -> RecordingBackend:
83        return self._recording
replay: ReplayBackend
85    @property
86    def replay(self) -> ReplayBackend:
87        return self._replay
def recording_snapshot(self) -> RecordingSnapshot:
89    def recording_snapshot(self) -> RecordingSnapshot:
90        return self._recording.snapshot()
def replay_snapshot(self) -> ReplaySnapshot:
92    def replay_snapshot(self) -> ReplaySnapshot:
93        return self._replay.snapshot()
def driver_portals(self) -> tuple[modlink_core.drivers.portal.core.DriverPortal, ...]:
95    def driver_portals(self) -> tuple[DriverPortal, ...]:
96        return tuple(self._driver_portals.values())
def driver_snapshots(self) -> tuple[DriverSnapshot, ...]:
98    def driver_snapshots(self) -> tuple[DriverSnapshot, ...]:
99        return tuple(portal.snapshot() for portal in self._driver_portals.values())
def driver_portal( self, driver_id: str) -> modlink_core.drivers.portal.core.DriverPortal | None:
101    def driver_portal(self, driver_id: str) -> DriverPortal | None:
102        return self._driver_portals.get(driver_id)
def settings_snapshot(self) -> dict[str, typing.Any]:
104    def settings_snapshot(self) -> dict[str, Any]:
105        return self._settings.snapshot()
def open_event_stream(self, *, maxsize: int = 1024) -> EventStream:
107    def open_event_stream(self, *, maxsize: int = 1024) -> EventStream:
108        return self._event_broker.open_stream(maxsize=maxsize)
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"
RecordingFailedEvent( recording_id: str, recording_path: str, frame_counts_by_stream: dict[str, int], reason: str, ts_ns: int, kind: Literal['recording_failed'] = 'recording_failed')
recording_id: str
recording_path: str
frame_counts_by_stream: dict[str, int]
reason: str
ts_ns: int
kind: Literal['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"
SettingChangedEvent( key: str, value: Any, ts: float, kind: Literal['setting_changed'] = 'setting_changed')
key: str
value: Any
ts: float
kind: Literal['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))
ROOT_RESERVED = frozenset({'load', 'add', 'save', 'snapshot', 'path'})
path: pathlib._local.Path | None
246    @property
247    def path(self) -> Path | None:
248        return self._path
def add( self, **specs: modlink_core.settings.spec.SettingsSpec) -> SettingsStore:
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
def snapshot(self) -> dict[str, object]:
272    def snapshot(self) -> dict[str, object]:
273        with self._lock:
274            return self._root.dump_dict()
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()
def add_descriptor(self, descriptor: modlink_sdk.StreamDescriptor) -> None:
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 add_descriptors(self, descriptors: Iterable[modlink_sdk.StreamDescriptor]) -> None:
191    def add_descriptors(self, descriptors: Iterable[StreamDescriptor]) -> None:
192        for descriptor in descriptors:
193            self.add_descriptor(descriptor)
def remove_descriptor(self, stream_id: str) -> None:
195    def remove_descriptor(self, stream_id: str) -> None:
196        self._descriptors.pop(str(stream_id), None)
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
def descriptor(self, stream_id: str) -> modlink_sdk.StreamDescriptor | None:
228    def descriptor(self, stream_id: str) -> StreamDescriptor | None:
229        return self._descriptors.get(stream_id)
def descriptors(self) -> dict[str, modlink_sdk.StreamDescriptor]:
231    def descriptors(self) -> dict[str, StreamDescriptor]:
232        return dict(self._descriptors)