Skip to content

Commit 57a392e

Browse files
committed
Merge commit 'd751bc97f5de6359d5f2fa91bf6d766304ba5bbc' into feat/execution-speed-override
# Conflicts: # parol6/server/controller.py
2 parents 17096e5 + d751bc9 commit 57a392e

4 files changed

Lines changed: 115 additions & 24 deletions

File tree

‎parol6/client/async_client.py‎

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -595,14 +595,43 @@ async def _send(self, cmd: msgspec.Struct) -> int:
595595
self._transport.sendto(self._tx_buf)
596596
return 1
597597

598-
async def _request(self, cmd: msgspec.Struct) -> Response | None:
598+
def _drop_stale_replies(self) -> None:
599+
"""Discard replies already queued when a new request is about to go out.
600+
601+
Nothing awaits them: their caller's deadline expired mid-flight. The
602+
wire carries no request id, so handing one to the next request would
603+
answer it with the wrong struct and leave every later query one reply
604+
behind.
605+
"""
606+
kept = []
607+
while True:
608+
try:
609+
item = self._rx_queue.get_nowait()
610+
except asyncio.QueueEmpty:
611+
break
612+
try:
613+
stale = isinstance(
614+
decode_message(item[0]), (ResponseMsg, OkMsg, ErrorMsg)
615+
)
616+
except msgspec.DecodeError:
617+
stale = False
618+
if not stale:
619+
kept.append(item)
620+
for item in kept:
621+
self._rx_queue.put_nowait(item)
622+
623+
async def _request(
624+
self, cmd: msgspec.Struct, timeout: float | None = None
625+
) -> Response | None:
599626
"""Send a query command and wait for a typed response.
600627
601628
Drains the receive queue until a ResponseMsg is found or timeout.
602629
Non-ResponseMsg datagrams (e.g. status broadcasts) are discarded.
603630
604631
Args:
605632
cmd: Typed command struct
633+
timeout: Per-call deadline; when given, the query is sent once
634+
with no retries so the deadline is the caller's total wait.
606635
607636
Returns:
608637
Typed Response struct, or None on timeout.
@@ -613,11 +642,14 @@ async def _request(self, cmd: msgspec.Struct) -> Response | None:
613642
await self._ensure_endpoint()
614643
assert self._transport is not None
615644
data = encode_command(cmd)
616-
for attempt in range(self.retries + 1):
645+
wait = self.timeout if timeout is None else timeout
646+
attempts = self.retries + 1 if timeout is None else 1
647+
for attempt in range(attempts):
617648
try:
618649
async with self._req_lock:
650+
self._drop_stale_replies()
619651
self._transport.sendto(data)
620-
end_time = time.monotonic() + self.timeout
652+
end_time = time.monotonic() + wait
621653
while time.monotonic() < end_time:
622654
try:
623655
resp_data, _ = await asyncio.wait_for(
@@ -644,7 +676,7 @@ async def _request(self, cmd: msgspec.Struct) -> Response | None:
644676
pass
645677
except Exception:
646678
break
647-
if attempt < self.retries:
679+
if attempt < attempts - 1:
648680
backoff = min(0.5, 0.05 * (2**attempt)) + random.uniform(0, 0.05)
649681
await asyncio.sleep(backoff)
650682
return None
@@ -664,6 +696,7 @@ async def _request_ok_raw(self, data: bytes, timeout: float) -> OkMsg:
664696

665697
end_time = time.monotonic() + timeout
666698
async with self._req_lock:
699+
self._drop_stale_replies()
667700
self._transport.sendto(data)
668701
while time.monotonic() < end_time:
669702
try:
@@ -876,12 +909,8 @@ async def io(self, *, timeout: float | None = None) -> list[int] | None:
876909
isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0
877910
):
878911
raise ValueError("I/O timeout must be positive and finite")
879-
try:
880-
async with asyncio.timeout(timeout):
881-
resp = await self._request(IOCmd())
882-
return resp.io if isinstance(resp, IOResultStruct) else None
883-
except TimeoutError:
884-
return None
912+
resp = await self._request(IOCmd(), timeout=timeout)
913+
return resp.io if isinstance(resp, IOResultStruct) else None
885914

886915
async def joint_speeds(self) -> list[float] | None:
887916
"""Current joint speeds in steps/sec [J1, J2, J3, J4, J5, J6].

‎parol6/server/controller.py‎

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -345,13 +345,7 @@ def _handle_estop(self, state: ControllerState) -> None:
345345
logger.warning("E-STOP activated")
346346
self.estop_active = True
347347
self._segment_player.cancel(state)
348-
self._planner.sync_tool(
349-
state.current_tool,
350-
variant_key=state.current_tool_variant,
351-
tcp_offset_m=state.tcp_offset_m,
352-
tcp_rotation_rad=state.tcp_rotation_rad,
353-
)
354-
self._planner.sync_shapes(state.shapes)
348+
self._resync_planner(state)
355349
if self._executor.active_command:
356350
self._executor.cancel_active_command("E-Stop activated")
357351
self._executor.clear_queue("E-Stop activated")
@@ -812,6 +806,22 @@ def _handle_query(
812806
addr, make_error(ErrorCode.COMM_DECODE_ERROR, detail=str(e))
813807
)
814808

809+
def _resync_planner(self, state: ControllerState) -> None:
810+
"""Bring the planner subprocess back to the controller's tool and world.
811+
812+
The planner applies SET_TCP_TRANSFORM / SELECT_TOOL / SET_SHAPES at
813+
plan time, when the command is still queued. Cancelling that queue
814+
leaves the planner holding a change the controller never applied,
815+
and every later plan would be solved against it.
816+
"""
817+
self._planner.sync_tool(
818+
state.current_tool,
819+
variant_key=state.current_tool_variant,
820+
tcp_offset_m=state.tcp_offset_m,
821+
tcp_rotation_rad=state.tcp_rotation_rad,
822+
)
823+
self._planner.sync_shapes(state.shapes)
824+
815825
def _handle_system_command(
816826
self,
817827
command: SystemCommand,
@@ -842,6 +852,7 @@ def _handle_system_command(
842852
self._segment_player.cancel(state)
843853
self._executor.cancel_active_command(reason)
844854
self._executor.clear_queue(reason)
855+
self._resync_planner(state)
845856
# A pause holds the queue it interrupted; that queue is gone.
846857
state.execution_paused = False
847858

@@ -852,14 +863,8 @@ def _handle_system_command(
852863
self._segment_player.cancel(state)
853864
self._executor.cancel_active_command("Reset")
854865
self._executor.clear_queue("Reset")
866+
self._resync_planner(state)
855867
state.execution_paused = False
856-
self._planner.sync_tool(
857-
state.current_tool,
858-
variant_key=state.current_tool_variant,
859-
tcp_offset_m=state.tcp_offset_m,
860-
tcp_rotation_rad=state.tcp_rotation_rad,
861-
)
862-
self._planner.sync_shapes(state.shapes)
863868

864869
# Infrastructure side effects (only 2-3 commands trigger these)
865870
if command._switch_simulator is not None:

‎tests/integration/test_digital_io.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,32 @@ async def query(rbt):
4848
await absent.write_io(0, 1, timeout=invalid)
4949

5050
asyncio.run(missing_peer())
51+
52+
53+
def test_late_replies_never_answer_the_next_request(ports, server_proc):
54+
"""A reply that lands after its caller's deadline expired is not served
55+
to the next request: the wire carries no request ids, so a stale reply
56+
would answer the wrong query and leave every later one a reply behind."""
57+
from parol6.protocol.wire import IOResultStruct, pack_ok, pack_response
58+
59+
async def scenario():
60+
async with AsyncRobotClient(
61+
host=ports.server_ip, port=ports.server_port, timeout=5.0
62+
) as rbt:
63+
await rbt._ensure_endpoint()
64+
peer = (ports.server_ip, ports.server_port)
65+
rbt._rx_queue.put_nowait(
66+
(pack_response(IOResultStruct(io=[0, 0, 0, 0, 1])), peer)
67+
)
68+
pose = await rbt.pose()
69+
assert pose is not None and len(pose) == 6
70+
assert await rbt.angles() is not None
71+
rbt._rx_queue.put_nowait((pack_ok(), peer))
72+
index = await rbt.delay(0.1)
73+
assert index >= 1, "a stale index-less OK must not stand in for the ack"
74+
assert await rbt.wait_command(index, timeout=5)
75+
for _ in range(5):
76+
await rbt.io(timeout=1e-4)
77+
assert await rbt.pose() is not None
78+
79+
asyncio.run(scenario())

‎tests/integration/test_stop_semantics.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,31 @@ def test_estop_latches_until_reset(client: RobotClient, server_proc):
8989
"canceled motion resurfaced after reset"
9090
)
9191
assert client.home(wait=True, timeout=30.0) >= 0
92+
93+
94+
def test_stop_discards_a_queued_tcp_transform_from_the_planner_too(
95+
client: RobotClient, server_proc
96+
):
97+
"""The planner applies SET_TCP_TRANSFORM when it plans, while the command
98+
is still queued; a Stop that cancels the queue must take that transform
99+
back from the planner, or every later plan is solved against a TCP the
100+
controller never applied."""
101+
assert client.home(wait=True, timeout=30.0) >= 0
102+
index = client.set_tcp_transform(0, 0, 0, 0, 0, 0)
103+
assert index >= 0 and client.wait_command(index, timeout=5.0)
104+
start = client.angles()
105+
pose = client.pose()
106+
assert start is not None and pose is not None
107+
assert client.delay(3.0) >= 0
108+
assert client.set_tcp_transform(0, 0, 40, 0, 90, 0) >= 0
109+
assert client.stop() == 1
110+
assert client.tcp_transform() == pytest.approx([0] * 6)
111+
# A move to the pose the arm is already at is planned against the
112+
# controller's TCP and therefore goes nowhere.
113+
index = client.move_l(pose, duration=1.0, wait=False)
114+
assert index >= 0 and client.wait_command(index, timeout=10.0)
115+
after = client.angles()
116+
assert after is not None
117+
assert np.allclose(after, start, atol=0.5), (
118+
f"the planner kept the cancelled TCP: {start} -> {after}"
119+
)

0 commit comments

Comments
 (0)