diff --git a/src/glider/core/async_utils.py b/src/glider/core/async_utils.py new file mode 100644 index 0000000..aa768ee --- /dev/null +++ b/src/glider/core/async_utils.py @@ -0,0 +1,22 @@ +"""Helpers for fire-and-forget asyncio tasks.""" + +import asyncio +import logging + +logger = logging.getLogger(__name__) + + +def log_task_exception(task: asyncio.Task) -> None: + """ + Done-callback that surfaces exceptions from fire-and-forget tasks. + + Use as: `task.add_done_callback(log_task_exception)`. Without this, + Python's default behavior only prints a "Task exception was never retrieved" + warning when the task is garbage-collected, which happens non-deterministically + and loses the traceback. + """ + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.error("Unhandled error in background task: %s", exc, exc_info=exc) diff --git a/src/glider/core/config.py b/src/glider/core/config.py index b5f0f63..695c131 100644 --- a/src/glider/core/config.py +++ b/src/glider/core/config.py @@ -20,7 +20,7 @@ class TimingConfig: # UI refresh intervals (milliseconds) device_refresh_interval_ms: int = 250 - elapsed_timer_interval_ms: int = 1000 + elapsed_timer_interval_ms: int = 250 # Hardware polling defaults (seconds) default_poll_interval: float = 0.1 diff --git a/src/glider/core/data_recorder.py b/src/glider/core/data_recorder.py index ce86738..316f1fb 100644 --- a/src/glider/core/data_recorder.py +++ b/src/glider/core/data_recorder.py @@ -268,7 +268,10 @@ async def start( # Start sampling task self._recording = True + from glider.core.async_utils import log_task_exception + self._sample_task = asyncio.create_task(self._sampling_loop()) + self._sample_task.add_done_callback(log_task_exception) except Exception: self._file.close() self._file = None diff --git a/src/glider/core/glider_core.py b/src/glider/core/glider_core.py index 671f48b..a213bb5 100644 --- a/src/glider/core/glider_core.py +++ b/src/glider/core/glider_core.py @@ -287,7 +287,10 @@ def _on_node_update(self, node_id: str, output_name: str, value: Any) -> None: import os filename = os.path.basename(str(value)) - asyncio.create_task(self._data_recorder.record_event("AudioPlayback", filename)) + task = asyncio.create_task( + self._data_recorder.record_event("AudioPlayback", filename) + ) + task.add_done_callback(self._log_task_exception) def _on_flow_complete(self) -> None: """Handle flow completion (EndExperiment reached).""" diff --git a/src/glider/gui/dialogs/agent_settings_dialog.py b/src/glider/gui/dialogs/agent_settings_dialog.py index 5c6a7c9..50bb7a1 100644 --- a/src/glider/gui/dialogs/agent_settings_dialog.py +++ b/src/glider/gui/dialogs/agent_settings_dialog.py @@ -306,7 +306,10 @@ def _on_provider_changed(self) -> None: def _refresh_models(self) -> None: """Refresh available models from provider.""" try: - asyncio.create_task(self._async_refresh_models()) + from glider.core.async_utils import log_task_exception + + task = asyncio.create_task(self._async_refresh_models()) + task.add_done_callback(log_task_exception) except RuntimeError: logger.debug("No event loop available for async task") @@ -339,7 +342,10 @@ async def _async_refresh_models(self) -> None: def _test_connection(self) -> None: """Test connection to the LLM provider.""" try: - asyncio.create_task(self._async_test_connection()) + from glider.core.async_utils import log_task_exception + + task = asyncio.create_task(self._async_test_connection()) + task.add_done_callback(log_task_exception) except RuntimeError: logger.debug("No event loop available for async task") diff --git a/src/glider/gui/panels/agent_panel.py b/src/glider/gui/panels/agent_panel.py index 4a1ee37..56b487d 100644 --- a/src/glider/gui/panels/agent_panel.py +++ b/src/glider/gui/panels/agent_panel.py @@ -340,7 +340,10 @@ def _process_message(self, text: str) -> None: # Process asynchronously try: - asyncio.create_task(self._async_process(text)) + from glider.core.async_utils import log_task_exception + + task = asyncio.create_task(self._async_process(text)) + task.add_done_callback(log_task_exception) except RuntimeError: logger.debug("No event loop available for async task") @@ -411,7 +414,10 @@ def _on_confirm_actions(self) -> None: if self._controller: try: - asyncio.create_task(self._execute_confirmed()) + from glider.core.async_utils import log_task_exception + + task = asyncio.create_task(self._execute_confirmed()) + task.add_done_callback(log_task_exception) except RuntimeError: logger.debug("No event loop available for async task") @@ -435,7 +441,10 @@ def _on_reject_actions(self) -> None: if self._controller: try: - asyncio.create_task(self._execute_rejected()) + from glider.core.async_utils import log_task_exception + + task = asyncio.create_task(self._execute_rejected()) + task.add_done_callback(log_task_exception) except RuntimeError: logger.debug("No event loop available for async task") diff --git a/src/glider/gui/panels/node_editor_controller.py b/src/glider/gui/panels/node_editor_controller.py index dad6c87..e7ee1da 100644 --- a/src/glider/gui/panels/node_editor_controller.py +++ b/src/glider/gui/panels/node_editor_controller.py @@ -473,17 +473,63 @@ def _update_properties_panel(self, node_id: str) -> None: elif node_type == "Delay": self._add_section_header(props_layout, "CONFIGURATION") - duration_spin = QSpinBox() - duration_spin.setRange(0, 3600) - saved_duration = 1 + + saved_duration = 1.0 + saved_unit = "seconds" if node_config and node_config.state: - saved_duration = node_config.state.get("duration", 1) + saved_duration = float(node_config.state.get("duration", 1.0)) + saved_unit = node_config.state.get("unit", "seconds") + + duration_spin = QDoubleSpinBox() + duration_spin.setDecimals(3) + duration_spin.setRange(0.0, 3_600_000.0) duration_spin.setValue(saved_duration) - duration_spin.setSuffix(" sec") duration_spin.valueChanged.connect( lambda val, nid=node_id: self._on_node_property_changed(nid, "duration", val) ) + + unit_combo = QComboBox() + unit_combo.addItem("sec", "seconds") + unit_combo.addItem("ms", "milliseconds") + unit_combo.setCurrentIndex(0 if saved_unit == "seconds" else 1) + unit_combo.currentIndexChanged.connect( + lambda _idx, nid=node_id, c=unit_combo: self._on_node_property_changed( + nid, "unit", c.currentData() + ) + ) + props_layout.addRow("Duration:", duration_spin) + props_layout.addRow("Unit:", unit_combo) + + elif node_type == "Timer": + self._add_section_header(props_layout, "CONFIGURATION") + + saved_interval = 1.0 + saved_unit = "seconds" + if node_config and node_config.state: + saved_interval = float(node_config.state.get("interval", 1.0)) + saved_unit = node_config.state.get("unit", "seconds") + + interval_spin = QDoubleSpinBox() + interval_spin.setDecimals(3) + interval_spin.setRange(0.0, 3_600_000.0) + interval_spin.setValue(saved_interval) + interval_spin.valueChanged.connect( + lambda val, nid=node_id: self._on_node_property_changed(nid, "interval", val) + ) + + unit_combo = QComboBox() + unit_combo.addItem("sec", "seconds") + unit_combo.addItem("ms", "milliseconds") + unit_combo.setCurrentIndex(0 if saved_unit == "seconds" else 1) + unit_combo.currentIndexChanged.connect( + lambda _idx, nid=node_id, c=unit_combo: self._on_node_property_changed( + nid, "unit", c.currentData() + ) + ) + + props_layout.addRow("Interval:", interval_spin) + props_layout.addRow("Unit:", unit_combo) elif node_type == "StartFunction": self._add_section_header(props_layout, "FUNCTION") diff --git a/src/glider/gui/panels/runner_panel.py b/src/glider/gui/panels/runner_panel.py index 6413f47..225fc59 100644 --- a/src/glider/gui/panels/runner_panel.py +++ b/src/glider/gui/panels/runner_panel.py @@ -176,6 +176,13 @@ def _setup_ui(self): self._elapsed_timer.setInterval(config.timing.elapsed_timer_interval_ms) self._elapsed_timer.timeout.connect(self._update_elapsed_time) + # --- TEMPORARY: main-thread stall instrument (remove in Task 9) --- + self._stall_last_tick: float | None = None + self._stall_timer = QTimer(self) + self._stall_timer.setInterval(50) + self._stall_timer.timeout.connect(self._check_main_thread_stall) + self._stall_timer.start() + # --- Public API --- def refresh_devices(self) -> None: @@ -247,6 +254,19 @@ def _on_experiment_name_changed(self, name: str) -> None: self._core.session.mark_dirty() self.experiment_name_changed.emit(name) + def _check_main_thread_stall(self) -> None: + """TEMPORARY: log gaps in the Qt event loop > 200ms.""" + now = time.monotonic() + if self._stall_last_tick is not None: + gap = now - self._stall_last_tick + if gap > 0.200: + logger.warning( + "Main-thread stall: %.3fs (QTimer coalesced %d ticks)", + gap, + max(0, int(gap / 0.050) - 1), + ) + self._stall_last_tick = now + def _update_elapsed_time(self) -> None: """Update the elapsed time display.""" if self._experiment_start_time is None: diff --git a/src/glider/hal/base_board.py b/src/glider/hal/base_board.py index a0e2c34..ada28a0 100644 --- a/src/glider/hal/base_board.py +++ b/src/glider/hal/base_board.py @@ -354,7 +354,10 @@ def start_reconnect(self) -> None: """Start the automatic reconnection process.""" if self._auto_reconnect and self._reconnect_task is None: self._set_state(BoardConnectionState.RECONNECTING) + from glider.core.async_utils import log_task_exception + self._reconnect_task = asyncio.create_task(self._attempt_reconnect()) + self._reconnect_task.add_done_callback(log_task_exception) def stop_reconnect(self) -> None: """Stop the automatic reconnection process.""" diff --git a/src/glider/nodes/hardware/analog_nodes.py b/src/glider/nodes/hardware/analog_nodes.py index 0d3019b..35f063e 100644 --- a/src/glider/nodes/hardware/analog_nodes.py +++ b/src/glider/nodes/hardware/analog_nodes.py @@ -114,7 +114,10 @@ def get_display_value(self) -> str: async def start(self) -> None: """Start continuous reading if enabled.""" if self._continuous: + from glider.core.async_utils import log_task_exception + self._polling_task = asyncio.create_task(self._poll_loop()) + self._polling_task.add_done_callback(log_task_exception) async def stop(self) -> None: """Stop continuous reading.""" diff --git a/src/glider/nodes/hardware/digital_nodes.py b/src/glider/nodes/hardware/digital_nodes.py index ca463db..d81971a 100644 --- a/src/glider/nodes/hardware/digital_nodes.py +++ b/src/glider/nodes/hardware/digital_nodes.py @@ -151,7 +151,10 @@ async def hardware_operation(self) -> None: async def start(self) -> None: """Start continuous polling if enabled.""" if self._continuous: + from glider.core.async_utils import log_task_exception + self._polling_task = asyncio.create_task(self._poll_loop()) + self._polling_task.add_done_callback(log_task_exception) async def stop(self) -> None: """Stop continuous polling.""" diff --git a/src/glider/nodes/logic/flow_nodes.py b/src/glider/nodes/logic/flow_nodes.py index b25f34c..c3b4d14 100644 --- a/src/glider/nodes/logic/flow_nodes.py +++ b/src/glider/nodes/logic/flow_nodes.py @@ -46,7 +46,7 @@ class DelayNode(ExecNode): definition = NodeDefinition( name="Delay", category=NodeCategory.LOGIC, - description="Delay execution for specified seconds", + description="Delay execution for specified duration (seconds or milliseconds)", inputs=[ PortDefinition(name="exec", port_type=PortType.EXEC), PortDefinition(name="Duration", data_type=float, default_value=1.0), @@ -67,6 +67,11 @@ async def execute(self) -> None: duration = float(self._state["duration"]) else: duration = float(self.get_input(1) or 1.0) + + unit = self._state.get("unit", "seconds") + if unit == "milliseconds": + duration = duration / 1000.0 + duration = max(0, duration) await asyncio.sleep(duration) @@ -89,7 +94,7 @@ class TimerNode(ExecNode): name="Interval", data_type=float, default_value=1.0, - description="Interval in seconds", + description="Interval (seconds or milliseconds, per Unit setting)", ), PortDefinition(name="Enabled", data_type=bool, default_value=True), ], @@ -112,9 +117,12 @@ def count(self) -> int: async def start(self) -> None: """Start the timer.""" + from glider.core.async_utils import log_task_exception + self._count = 0 self._paused = False self._timer_task = asyncio.create_task(self._timer_loop()) + self._timer_task.add_done_callback(log_task_exception) async def stop(self) -> None: """Stop the timer.""" @@ -134,15 +142,28 @@ async def resume(self) -> None: """Resume the timer.""" self._paused = False + def _effective_interval(self) -> float: + """Return the interval in seconds, honoring the state 'interval' override and unit.""" + if "interval" in self._state: + raw = self._state["interval"] + else: + raw = self.get_input(0) + if raw is None: + raw = 1.0 + interval = float(raw) + if self._state.get("unit", "seconds") == "milliseconds": + interval = interval / 1000.0 + return max(0.01, interval) + async def _timer_loop(self) -> None: """Timer loop that triggers at intervals.""" next_tick = time.monotonic() while True: try: - interval = float(self.get_input(0) or 1.0) + interval = self._effective_interval() enabled = bool(self.get_input(1) if self.get_input(1) is not None else True) - next_tick += max(0.01, interval) + next_tick += interval sleep_time = next_tick - time.monotonic() if sleep_time > 0: await asyncio.sleep(sleep_time) diff --git a/src/glider/nodes/vision/zone_nodes.py b/src/glider/nodes/vision/zone_nodes.py index dab9016..0f7a263 100644 --- a/src/glider/nodes/vision/zone_nodes.py +++ b/src/glider/nodes/vision/zone_nodes.py @@ -67,10 +67,15 @@ def __init__(self): self._occupied = False self._object_count = 0 self._exec_callbacks: list[Callable[[int], None]] = [] + self._main_loop: asyncio.AbstractEventLoop | None = None # Set outputs to initial values self._outputs = [False, 0, None, None] + async def start(self) -> None: + """Capture the main event loop so CV-thread callbacks can post to it.""" + self._main_loop = asyncio.get_running_loop() + @property def zone_id(self) -> str: """ID of the zone this node monitors.""" @@ -134,23 +139,20 @@ def update_zone_state( self.set_output(1, object_count) # Object Count # Trigger exec outputs on events. update_zone_state() is called from the - # CV thread, so asyncio.create_task() would fail (no running loop in that - # thread). Instead, marshal the coroutine to the main event loop using - # run_coroutine_threadsafe(). + # CV thread, so we need a reference to the main event loop captured at + # start(). asyncio.get_event_loop() from a non-main thread returns a new, + # non-running loop on Python >=3.12, silently dropping exec outputs. + loop = self._main_loop + if loop is None: + logger.debug("Zone '%s': main loop not captured, skipping exec outputs", self._zone_name) + return + if entered: - try: - loop = asyncio.get_event_loop() - asyncio.run_coroutine_threadsafe(self._fire_exec_output("On Enter"), loop) - except RuntimeError: - logger.debug("No event loop available for On Enter exec output") + asyncio.run_coroutine_threadsafe(self._fire_exec_output("On Enter"), loop) logger.debug(f"Zone '{self._zone_name}': On Enter triggered") if exited: - try: - loop = asyncio.get_event_loop() - asyncio.run_coroutine_threadsafe(self._fire_exec_output("On Exit"), loop) - except RuntimeError: - logger.debug("No event loop available for On Exit exec output") + asyncio.run_coroutine_threadsafe(self._fire_exec_output("On Exit"), loop) logger.debug(f"Zone '{self._zone_name}': On Exit triggered") def update_event(self) -> None: diff --git a/src/glider/vision/camera_manager.py b/src/glider/vision/camera_manager.py index ceccd76..cc5e6c0 100644 --- a/src/glider/vision/camera_manager.py +++ b/src/glider/vision/camera_manager.py @@ -1927,12 +1927,14 @@ def on_frame(self, callback: Callable[[np.ndarray, float], None]) -> None: Args: callback: Function called with (frame, timestamp) for each frame """ - self._frame_callbacks.append(callback) + with self._lock: + self._frame_callbacks.append(callback) def remove_frame_callback(self, callback: Callable[[np.ndarray, float], None]) -> None: """Remove a frame callback.""" - if callback in self._frame_callbacks: - self._frame_callbacks.remove(callback) + with self._lock: + if callback in self._frame_callbacks: + self._frame_callbacks.remove(callback) def apply_settings(self, settings: CameraSettings) -> None: """ @@ -2250,8 +2252,12 @@ def _capture_loop_inner(self) -> None: self._frame_queue.put((frame, timestamp)) # Notify callbacks (each gets its own copy so one callback - # cannot mutate the frame seen by subsequent callbacks) - for callback in self._frame_callbacks: + # cannot mutate the frame seen by subsequent callbacks). Snapshot + # the list under the lock so registrations from the main thread + # cannot mutate it mid-iteration. + with self._lock: + callbacks = list(self._frame_callbacks) + for callback in callbacks: try: callback(frame.copy(), timestamp) except Exception as e: diff --git a/src/glider/vision/multi_video_recorder.py b/src/glider/vision/multi_video_recorder.py index 2b52a30..d6ca98d 100644 --- a/src/glider/vision/multi_video_recorder.py +++ b/src/glider/vision/multi_video_recorder.py @@ -165,77 +165,124 @@ async def start( fwt_kwargs["max_queue_size"] = self._buffer_size with self._lock: - # Create writer for each camera - for camera_id, camera in self._multi_cam.cameras.items(): - settings = self._multi_cam.get_camera_settings(camera_id) - if settings is None: - continue - - # Determine FPS - actual_fps = camera.current_fps - recording_fps = actual_fps if actual_fps > 1.0 else settings.fps - self._recording_fps[camera_id] = recording_fps - - # Generate filename - filename = self._generate_filename(experiment_name, camera_id) - file_path = self._output_dir / filename - - # Create writer - writer = cv2.VideoWriter(str(file_path), fourcc, recording_fps, settings.resolution) - - if not writer.isOpened(): - logger.error(f"Failed to create video writer for {camera_id}: {file_path}") - continue - - self._writers[camera_id] = writer - self._file_paths[camera_id] = file_path - self._frame_counts[camera_id] = 0 - self._frames_dropped[camera_id] = 0 - - # Wrap in FrameWriterThread - fwt = FrameWriterThread(writer, **fwt_kwargs) - fwt.start() - self._writer_threads[camera_id] = fwt - - # Register frame callback - if not self._frame_callbacks_registered.get(camera_id, False): - self._multi_cam.on_frame(camera_id, self._on_frame) - self._frame_callbacks_registered[camera_id] = True - - logger.info(f"Recording {camera_id} to {file_path} at {recording_fps:.1f} fps") - - # Create annotated writer for primary camera - primary_id = self._multi_cam.primary_camera_id - if record_annotated and primary_id and primary_id in self._multi_cam.cameras: - primary_settings = self._multi_cam.get_camera_settings(primary_id) - if primary_settings: - annotated_filename = self._generate_filename( - experiment_name, primary_id, is_annotated=True + try: + # Create writer for each camera + for camera_id, camera in self._multi_cam.cameras.items(): + settings = self._multi_cam.get_camera_settings(camera_id) + if settings is None: + continue + + # Determine FPS + actual_fps = camera.current_fps + recording_fps = actual_fps if actual_fps > 1.0 else settings.fps + self._recording_fps[camera_id] = recording_fps + + # Generate filename + filename = self._generate_filename(experiment_name, camera_id) + file_path = self._output_dir / filename + + # Create writer + writer = cv2.VideoWriter( + str(file_path), fourcc, recording_fps, settings.resolution ) - self._annotated_file_path = self._output_dir / annotated_filename - - recording_fps = self._recording_fps.get(primary_id, primary_settings.fps) - self._annotated_writer = cv2.VideoWriter( - str(self._annotated_file_path), - fourcc, - recording_fps, - primary_settings.resolution, + + if not writer.isOpened(): + logger.error( + f"Failed to create video writer for {camera_id}: {file_path}" + ) + continue + + self._writers[camera_id] = writer + self._file_paths[camera_id] = file_path + self._frame_counts[camera_id] = 0 + self._frames_dropped[camera_id] = 0 + + # Wrap in FrameWriterThread + fwt = FrameWriterThread(writer, **fwt_kwargs) + fwt.start() + self._writer_threads[camera_id] = fwt + + # Register frame callback + if not self._frame_callbacks_registered.get(camera_id, False): + self._multi_cam.on_frame(camera_id, self._on_frame) + self._frame_callbacks_registered[camera_id] = True + + logger.info( + f"Recording {camera_id} to {file_path} at {recording_fps:.1f} fps" ) - if self._annotated_writer.isOpened(): - self._annotated_writer_thread = FrameWriterThread( - self._annotated_writer, **fwt_kwargs + # Create annotated writer for primary camera + primary_id = self._multi_cam.primary_camera_id + if record_annotated and primary_id and primary_id in self._multi_cam.cameras: + primary_settings = self._multi_cam.get_camera_settings(primary_id) + if primary_settings: + annotated_filename = self._generate_filename( + experiment_name, primary_id, is_annotated=True + ) + self._annotated_file_path = self._output_dir / annotated_filename + + recording_fps = self._recording_fps.get( + primary_id, primary_settings.fps + ) + self._annotated_writer = cv2.VideoWriter( + str(self._annotated_file_path), + fourcc, + recording_fps, + primary_settings.resolution, ) - self._annotated_writer_thread.start() - logger.info(f"Recording annotated video to {self._annotated_file_path}") - else: - logger.warning("Failed to create annotated video writer") - self._annotated_writer = None + + if self._annotated_writer.isOpened(): + self._annotated_writer_thread = FrameWriterThread( + self._annotated_writer, **fwt_kwargs + ) + self._annotated_writer_thread.start() + logger.info( + f"Recording annotated video to {self._annotated_file_path}" + ) + else: + logger.warning("Failed to create annotated video writer") + self._annotated_writer = None + except Exception: + # Partial failure: release any writers/threads we already allocated + # so we don't leak handles or leave zombie threads. + self._release_partial_writers() + raise self._state = RecordingState.RECORDING logger.info(f"Started multi-camera recording ({len(self._writers)} cameras)") return self._file_paths + def _release_partial_writers(self) -> None: + """Release all writers and join threads allocated during a failed start().""" + for cam_id, fwt in list(self._writer_threads.items()): + try: + fwt.stop() + except Exception: + logger.exception("Error stopping FrameWriterThread for %s", cam_id) + self._writer_threads.clear() + + for cam_id, writer in list(self._writers.items()): + try: + writer.release() + except Exception: + logger.exception("Error releasing VideoWriter for %s", cam_id) + self._writers.clear() + + if self._annotated_writer_thread is not None: + try: + self._annotated_writer_thread.stop() + except Exception: + logger.exception("Error stopping annotated FrameWriterThread") + self._annotated_writer_thread = None + if self._annotated_writer is not None: + try: + self._annotated_writer.release() + except Exception: + logger.exception("Error releasing annotated VideoWriter") + self._annotated_writer = None + self._annotated_file_path = None + self._file_paths.clear() + def _on_frame(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """ Handle incoming frame for recording. diff --git a/src/glider/vision/tracking_logger.py b/src/glider/vision/tracking_logger.py index 4368e43..7e003db 100644 --- a/src/glider/vision/tracking_logger.py +++ b/src/glider/vision/tracking_logger.py @@ -177,10 +177,25 @@ async def start( self._prev_positions.clear() self._cumulative_distances.clear() - # Open file and create writer + # Open file and create writer. If any subsequent write raises (e.g., + # metadata serialization error), close the file and clear state so the + # caller does not see a recording session with a leaked fd. self._file = open(self._file_path, "w", newline="", encoding="utf-8") - self._writer = csv.writer(self._file) + try: + self._writer = csv.writer(self._file) + self._write_header_and_metadata(experiment_name, session) + except Exception: + self._file.close() + self._file = None + self._writer = None + raise + self._recording = True + logger.info(f"Started tracking log: {self._file_path}") + return self._file_path + + def _write_header_and_metadata(self, experiment_name: str, session) -> None: + """Write all header / metadata rows. Raises if any write fails.""" # Write metadata header self._writer.writerow(["# GLIDER Tracking Data"]) self._writer.writerow(["# Experiment", experiment_name]) @@ -266,10 +281,6 @@ async def start( ) self._file.flush() - self._recording = True - logger.info(f"Started tracking log: {self._file_path}") - return self._file_path - def log_frame( self, timestamp: float, diff --git a/tests/unit/core/test_config.py b/tests/unit/core/test_config.py index ee08dc8..559d3e4 100644 --- a/tests/unit/core/test_config.py +++ b/tests/unit/core/test_config.py @@ -26,7 +26,7 @@ def test_default_values(self): config = TimingConfig() assert config.device_refresh_interval_ms == 250 - assert config.elapsed_timer_interval_ms == 1000 + assert config.elapsed_timer_interval_ms == 250 assert config.default_poll_interval == 0.1 assert config.board_ready_timeout == 10.0 assert config.board_operation_timeout == 5.0 diff --git a/tests/unit/nodes/test_flow_nodes.py b/tests/unit/nodes/test_flow_nodes.py new file mode 100644 index 0000000..0cab847 --- /dev/null +++ b/tests/unit/nodes/test_flow_nodes.py @@ -0,0 +1,103 @@ +"""Tests for flow control nodes: DelayNode, TimerNode.""" + +import asyncio +from unittest.mock import patch + +import pytest + +from glider.nodes.logic.flow_nodes import DelayNode, TimerNode + + +class TestDelayNode: + """Tests for DelayNode unit-aware duration handling.""" + + @pytest.mark.asyncio + async def test_execute_seconds_default_unit(self): + """Duration without unit defaults to seconds.""" + node = DelayNode() + node._state["duration"] = 0.5 + + with patch("asyncio.sleep") as mock_sleep: + mock_sleep.return_value = None + await node.execute() + + # Called once with seconds value + mock_sleep.assert_awaited_once_with(0.5) + + @pytest.mark.asyncio + async def test_execute_milliseconds_unit_converts_to_seconds(self): + """unit='milliseconds' divides duration by 1000 before sleeping.""" + node = DelayNode() + node._state["duration"] = 500 + node._state["unit"] = "milliseconds" + + with patch("asyncio.sleep") as mock_sleep: + mock_sleep.return_value = None + await node.execute() + + mock_sleep.assert_awaited_once_with(0.5) + + @pytest.mark.asyncio + async def test_execute_milliseconds_unit_with_port_input(self): + """When no state duration, port input is interpreted under the unit.""" + node = DelayNode() + node._state["unit"] = "milliseconds" + # Input port index 1 is Duration + node._inputs[1] = 250 + + with patch("asyncio.sleep") as mock_sleep: + mock_sleep.return_value = None + await node.execute() + + mock_sleep.assert_awaited_once_with(0.25) + + @pytest.mark.asyncio + async def test_execute_negative_clamped_to_zero(self): + """Negative durations are clamped to 0 regardless of unit.""" + node = DelayNode() + node._state["duration"] = -5 + node._state["unit"] = "milliseconds" + + with patch("asyncio.sleep") as mock_sleep: + mock_sleep.return_value = None + await node.execute() + + mock_sleep.assert_awaited_once_with(0) + + +class TestTimerNode: + """Tests for TimerNode unit-aware interval handling.""" + + def test_effective_interval_seconds_default(self): + """Default interval unit is seconds (no conversion).""" + node = TimerNode() + node._inputs[0] = 2.0 # Interval port + assert node._effective_interval() == 2.0 + + def test_effective_interval_milliseconds(self): + """unit='milliseconds' divides interval by 1000.""" + node = TimerNode() + node._state["unit"] = "milliseconds" + node._inputs[0] = 500 + assert node._effective_interval() == 0.5 + + def test_effective_interval_none_returns_default_seconds(self): + """When input is None and no state, falls back to 1.0 seconds.""" + node = TimerNode() + node._inputs[0] = None + assert node._effective_interval() == 1.0 + + def test_effective_interval_enforces_minimum(self): + """Interval below 10 ms (0.01 s) is clamped upward.""" + node = TimerNode() + node._state["unit"] = "milliseconds" + node._inputs[0] = 1 # 1 ms -> below 10 ms floor + assert node._effective_interval() == 0.01 + + def test_effective_interval_state_overrides_port(self): + """State 'interval' takes precedence over port input.""" + node = TimerNode() + node._inputs[0] = 5.0 # would be seconds via port + node._state["interval"] = 250 + node._state["unit"] = "milliseconds" + assert node._effective_interval() == 0.25