fix requests queued while the arm is down never being answered

_run_control never drained the job queue while the arm had not been reached, so
every request that arrived during an outage waited for a connection that might
never come. That breaks the one-response-per-request rule the node states as
absolute, and it was circular: requests are queued before the arm is reached
because recover is what a caller sends then, yet recover is served by the
control thread and so was the one service that could never run while down.

Serve the queue during the wait between connection attempts. Non-motion services
are tried on the arm, which is what lets recover and clear reach a controller
that answers while refusing to serve. A motion is refused rather than held: the
arm is somewhere unknown and nobody is watching, so a held motion would fire
unattended the moment the link came back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 12:32:31 -05:00
co-authored by Claude Opus 5
parent 608e22078e
commit 4427da9c34
4 changed files with 104 additions and 5 deletions
+6 -1
View File
@@ -30,11 +30,16 @@ class FakeArm:
self.recover_error = None
self.block = None
self.stopped = False
self.connected = True
# --- reads ---
def is_connected(self):
return True
return self.connected
def connect(self):
self.calls.append("connect")
return self.connected
def is_ready(self):
return self.ready
+53 -2
View File
@@ -6,17 +6,18 @@ import time
import pytest
from dora_ulite6.config import Config, MotionConfig
from dora_ulite6.control import MOVING, PREEMPTED, READY, Control, ServiceCall
from dora_ulite6.control import MOVING, PREEMPTED, READY, UNAVAILABLE, Control, ServiceCall
from dora_ulite6.pose import RobotPose
from tests.conftest import FakeArm, wait_for
def build(monkeypatch, **config):
def build(monkeypatch, connected=True, **config):
"""Return a started Control over a fake arm, and the arms it built."""
arms = []
def make(cfg):
arm = FakeArm(cfg)
arm.connected = connected
arms.append(arm)
return arm
@@ -87,6 +88,56 @@ def test_a_first_start_still_runs_init_mode(monkeypatch):
control.shutdown()
# --- an arm that is not serving yet ---
def messages_on(control, output, seen):
"""Collect messages on one output, draining the outbox as the event loop would."""
seen.extend(control.take_messages())
return [message for message in seen if message.output == output]
def test_a_request_sent_while_the_arm_is_down_is_still_answered(monkeypatch):
# An arm that is down is an outcome to report, not a reason to stop
# answering. `recover` in particular has to reach a controller that answers
# while refusing to serve — that state is the one it exists to escape.
control = build(monkeypatch, connected=False, reconnect_interval_s=0.05)
seen = []
try:
wait_for(lambda: UNAVAILABLE in control.take_state_changes())
control.submit(ServiceCall(request_id="r1", row={"service": "recover"}))
responses = wait_for(lambda: messages_on(control, "response", seen))
assert responses[0].metadata["request_id"] == "r1"
assert responses[0].fields["ok"] is True
assert "recover" in control.arms[0].calls
finally:
control.shutdown()
def test_a_motion_sent_while_the_arm_is_down_is_refused_rather_than_held(monkeypatch):
# Holding it would be worse than silence: the arm is somewhere unknown and
# nobody is watching, so a held motion would fire unattended the moment the
# link came back.
control = build(monkeypatch, connected=False, reconnect_interval_s=0.05)
seen = []
try:
wait_for(lambda: UNAVAILABLE in control.take_state_changes())
control.start_motion(ServiceCall(request_id="m1", row={"service": "home"}))
responses = wait_for(lambda: messages_on(control, "response", seen))
assert responses[0].metadata["request_id"] == "m1"
assert responses[0].fields["ok"] is False
control.arms[0].connected = True
wait_for(lambda: READY in control.take_state_changes())
assert "go_home" not in control.arms[0].calls
finally:
control.shutdown()
# --- jogging ---