@@ -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].
0 commit comments