Skip to content
22 changes: 22 additions & 0 deletions src/glider/core/async_utils.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion src/glider/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/glider/core/data_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/glider/core/glider_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
10 changes: 8 additions & 2 deletions src/glider/gui/dialogs/agent_settings_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down
15 changes: 12 additions & 3 deletions src/glider/gui/panels/agent_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand All @@ -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")

Expand Down
56 changes: 51 additions & 5 deletions src/glider/gui/panels/node_editor_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions src/glider/gui/panels/runner_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/glider/hal/base_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
3 changes: 3 additions & 0 deletions src/glider/nodes/hardware/analog_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
3 changes: 3 additions & 0 deletions src/glider/nodes/hardware/digital_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
29 changes: 25 additions & 4 deletions src/glider/nodes/logic/flow_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand All @@ -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),
],
Expand All @@ -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."""
Expand All @@ -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)
Expand Down
28 changes: 15 additions & 13 deletions src/glider/nodes/vision/zone_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading