pycyphal2 2.0.0.dev3__tar.gz → 2.0.0.dev4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {pycyphal2-2.0.0.dev3/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev4}/PKG-INFO +1 -1
  2. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/__init__.py +1 -1
  3. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_node.py +89 -18
  4. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_publisher.py +7 -21
  5. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_subscriber.py +3 -19
  6. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_interface.py +5 -4
  7. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_media_slcan.py +3 -4
  8. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_wire.py +1 -2
  9. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/pythoncan.py +21 -16
  10. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/socketcan.py +30 -14
  11. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/udp.py +40 -16
  12. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4/src/pycyphal2.egg-info}/PKG-INFO +1 -1
  13. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_monitor.py +27 -0
  14. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_parity.py +0 -2
  15. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_parity_coverage.py +3 -3
  16. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_pubsub.py +60 -0
  17. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_reliable.py +0 -3
  18. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_udp.py +131 -10
  19. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/LICENSE +0 -0
  20. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/README.md +0 -0
  21. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/pyproject.toml +0 -0
  22. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/setup.cfg +0 -0
  23. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_api.py +0 -0
  24. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_hash.py +0 -0
  25. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_header.py +0 -0
  26. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_transport.py +0 -0
  27. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/__init__.py +0 -0
  28. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_reassembly.py +0 -0
  29. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_transport.py +0 -0
  30. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/webserial.py +0 -0
  31. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2/py.typed +0 -0
  32. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/SOURCES.txt +0 -0
  33. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
  34. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/requires.txt +0 -0
  35. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/top_level.txt +0 -0
  36. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_gossip.py +0 -0
  37. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_hash.py +0 -0
  38. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_header.py +0 -0
  39. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_integration.py +0 -0
  40. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_names.py +0 -0
  41. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_reorder.py +0 -0
  42. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_rpc.py +0 -0
  43. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_scout.py +0 -0
  44. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev4}/tests/test_topic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycyphal2
3
- Version: 2.0.0.dev3
3
+ Version: 2.0.0.dev4
4
4
  Summary: Pure-Python implementation of Cyphal -- a simple and robust real-time publish/subscribe stack that runs anywhere.
5
5
  Author-email: Pavel Kirienko and OpenCyphal team <pavel@opencyphal.org>
6
6
  License: MIT
@@ -155,7 +155,7 @@ from ._transport import SubjectWriter as SubjectWriter
155
155
  from ._transport import Transport as Transport
156
156
  from ._transport import TransportArrival as TransportArrival
157
157
 
158
- __version__ = "2.0.0.dev3"
158
+ __version__ = "2.0.0.dev4"
159
159
 
160
160
  # pdoc needs __all__ to display re-exported members.
161
161
  __all__ = [
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  from collections import OrderedDict
5
+ from collections.abc import Coroutine
5
6
  import logging
6
7
  import math
7
8
  import os
@@ -28,7 +29,7 @@ from ._header import (
28
29
  deserialize_header,
29
30
  )
30
31
  from ._transport import SubjectWriter, Transport, TransportArrival
31
- from ._api import Topic, Node, Publisher, Subscriber, Breadcrumb, Closable, Instant, Priority, SendError
32
+ from ._api import Topic, Node, Publisher, Subscriber, Breadcrumb, Closable, ClosedError, Instant, Priority, SendError
32
33
  from ._api import SUBJECT_ID_PINNED_MAX
33
34
 
34
35
  if TYPE_CHECKING:
@@ -58,6 +59,22 @@ ACK_SEQNO_MAX_LAG = 100000
58
59
  U64_MASK = (1 << 64) - 1
59
60
 
60
61
 
62
+ def ack_is_last_attempt(current_ack_deadline_ns: int, current_ack_timeout: float, total_deadline_ns: int) -> bool:
63
+ """True if doubling the ACK timeout would overrun the total deadline, so this is the last retry."""
64
+ next_ack_timeout_ns = round(current_ack_timeout * 2 * 1e9)
65
+ remaining_budget_ns = total_deadline_ns - current_ack_deadline_ns
66
+ return remaining_budget_ns < next_ack_timeout_ns
67
+
68
+
69
+ def ack_window(deadline_ns: int, ack_timeout: float) -> tuple[int, bool] | None:
70
+ """Next reliable-delivery ACK window: (ack_deadline_ns, is_last_attempt), or None if past the deadline."""
71
+ now_ns = Instant.now().ns
72
+ if now_ns >= deadline_ns:
73
+ return None
74
+ ack_deadline_ns = min(deadline_ns, now_ns + round(ack_timeout * 1e9))
75
+ return ack_deadline_ns, ack_is_last_attempt(ack_deadline_ns, ack_timeout, deadline_ns)
76
+
77
+
61
78
  class GossipScope(Enum):
62
79
  UNICAST = auto()
63
80
  BROADCAST = auto()
@@ -115,6 +132,24 @@ def _name_is_homeful(name: str) -> bool:
115
132
  return name == "~" or name.startswith("~/")
116
133
 
117
134
 
135
+ def _is_valid_wire_name(name: str) -> bool:
136
+ """True if `name` is a well-formed *resolved* wire topic name, as required of names received in gossip:
137
+ nonempty, length-bounded, printable ASCII (33-126), already normalized (no leading/trailing/duplicate
138
+ '/'), verbatim (no '*'/'>' pattern tokens), not homeful ('~'/'~/...'), and pin-free (no '#<id>' suffix).
139
+ The last two are stripped/expanded by resolve_name before a name reaches the wire, so their presence
140
+ means the gossip is unresolved/non-canonical and must not create a local topic."""
141
+ return (
142
+ bool(name)
143
+ and len(name) <= TOPIC_NAME_MAX
144
+ and "*" not in name
145
+ and ">" not in name
146
+ and not _name_is_homeful(name)
147
+ and _name_consume_pin_suffix(name)[1] is None
148
+ and all(33 <= ord(ch) <= 126 for ch in name)
149
+ and _name_normalize(name) == name
150
+ )
151
+
152
+
118
153
  def resolve_name(
119
154
  name: str, home: str, namespace: str, remaps: dict[str, str] | None = None
120
155
  ) -> tuple[str, int | None, bool]:
@@ -345,10 +380,8 @@ class PublishTracker:
345
380
  """Tracks a pending reliable publication awaiting ACKs."""
346
381
 
347
382
  tag: int
348
- deadline_ns: int
349
383
  ack_event: asyncio.Event
350
384
  acknowledged: bool = False
351
- data: bytes | None = None
352
385
  ack_timeout: float = ACK_BASELINE_DEFAULT_TIMEOUT
353
386
  compromised: bool = False
354
387
  remaining: set[int] = field(default_factory=set)
@@ -507,7 +540,6 @@ class NodeImpl(Node):
507
540
  self._remaps: dict[str, str] = {}
508
541
  self._closed = False
509
542
  self.loop = asyncio.get_running_loop()
510
- self._now_mono = time.monotonic()
511
543
  self._monitor_callbacks: dict[int, Callable[[Topic], None]] = {}
512
544
  self._next_monitor_callback_id = 0
513
545
 
@@ -573,7 +605,31 @@ class NodeImpl(Node):
573
605
  def transport(self) -> Transport:
574
606
  return self._transport
575
607
 
608
+ def _raise_if_closed(self) -> None:
609
+ if self._closed:
610
+ raise ClosedError(f"Node '{self._home}' is closed")
611
+
612
+ def _spawn_detached(self, coro: Coroutine[Any, Any, None], what: str) -> None:
613
+ """Fire-and-forget a short-lived send: skip when closed, and never let its exception go unobserved."""
614
+ if self._closed:
615
+ coro.close()
616
+ return
617
+
618
+ def _done(task: asyncio.Task[None]) -> None:
619
+ if task.cancelled():
620
+ return
621
+ ex = task.exception()
622
+ if ex is None:
623
+ return
624
+ if isinstance(ex, (SendError, OSError)):
625
+ _logger.debug("%s send failed: %s", what, ex)
626
+ else:
627
+ _logger.error("%s task crashed: %s", what, ex, exc_info=ex)
628
+
629
+ self.loop.create_task(coro).add_done_callback(_done)
630
+
576
631
  def remap(self, spec: str | dict[str, str]) -> None:
632
+ self._raise_if_closed()
577
633
  if isinstance(spec, str):
578
634
  spec = dict(x.split("=", 1) for x in spec.split() if "=" in x)
579
635
  assert isinstance(spec, dict)
@@ -584,6 +640,7 @@ class NodeImpl(Node):
584
640
  def advertise(self, name: str) -> Publisher:
585
641
  from ._publisher import PublisherImpl
586
642
 
643
+ self._raise_if_closed()
587
644
  resolved, pin, verbatim = resolve_name(name, self._home, self._namespace, self._remaps)
588
645
  if not verbatim:
589
646
  raise ValueError("Cannot advertise on a pattern name")
@@ -602,6 +659,7 @@ class NodeImpl(Node):
602
659
  def subscribe(self, name: str, *, reordering_window: float | None = None) -> Subscriber:
603
660
  from ._subscriber import SubscriberImpl
604
661
 
662
+ self._raise_if_closed()
605
663
  resolved, pin, verbatim = resolve_name(name, self._home, self._namespace, self._remaps)
606
664
  if pin is not None and not verbatim:
607
665
  raise ValueError("Pattern names cannot be pinned")
@@ -637,6 +695,7 @@ class NodeImpl(Node):
637
695
  return subscriber
638
696
 
639
697
  def monitor(self, callback: Callable[[Topic], None]) -> Closable:
698
+ self._raise_if_closed()
640
699
  callback_id = self._next_monitor_callback_id
641
700
  self._next_monitor_callback_id += 1
642
701
  self._monitor_callbacks[callback_id] = callback
@@ -653,6 +712,7 @@ class NodeImpl(Node):
653
712
  _logger.exception("monitor() callback failed for %s", topic)
654
713
 
655
714
  async def scout(self, pattern: str) -> None:
715
+ self._raise_if_closed()
656
716
  resolved, pin, _ = resolve_name(pattern, self._home, self._namespace, self._remaps)
657
717
  if pin is not None:
658
718
  raise ValueError("Cannot scout a pinned name/pattern")
@@ -800,12 +860,10 @@ class NodeImpl(Node):
800
860
  tracker.remaining.clear()
801
861
 
802
862
  @staticmethod
803
- def prepare_publish_tracker(topic: TopicImpl, tag: int, deadline_ns: int, data: bytes) -> PublishTracker:
863
+ def prepare_publish_tracker(topic: TopicImpl, tag: int) -> PublishTracker:
804
864
  tracker = PublishTracker(
805
865
  tag=tag,
806
- deadline_ns=deadline_ns,
807
866
  ack_event=asyncio.Event(),
808
- data=data,
809
867
  )
810
868
  tracker.ack_timeout = ACK_BASELINE_DEFAULT_TIMEOUT
811
869
  for assoc in sorted(topic.associations.values(), key=lambda x: x.remote_id):
@@ -1036,14 +1094,6 @@ class NodeImpl(Node):
1036
1094
 
1037
1095
  root.scout_task = self.loop.create_task(do_send())
1038
1096
 
1039
- def send_scout(self, pattern: str) -> None:
1040
- """Send a scout message to discover topics matching a pattern."""
1041
-
1042
- async def do_send() -> None:
1043
- await self._send_scout_once(pattern)
1044
-
1045
- self.loop.create_task(do_send())
1046
-
1047
1097
  # -- Message Dispatch --
1048
1098
 
1049
1099
  def on_subject_arrival(self, subject_id: int, arrival: TransportArrival) -> None:
@@ -1055,6 +1105,8 @@ class NodeImpl(Node):
1055
1105
  self.dispatch_arrival(arrival, subject_id=None, unicast=True)
1056
1106
 
1057
1107
  def dispatch_arrival(self, arrival: TransportArrival, *, subject_id: int | None, unicast: bool) -> None:
1108
+ if self._closed:
1109
+ return # Drop late arrivals after close instead of mutating state / spawning sends.
1058
1110
  msg = arrival.message
1059
1111
  if len(msg) < HEADER_SIZE:
1060
1112
  _logger.debug("Drop short msg len=%d", len(msg))
@@ -1207,7 +1259,7 @@ class NodeImpl(Node):
1207
1259
  except (SendError, OSError) as e:
1208
1260
  _logger.debug("ACK send failed: %s", e)
1209
1261
 
1210
- self.loop.create_task(do_send())
1262
+ self._spawn_detached(do_send(), "ACK")
1211
1263
 
1212
1264
  def on_msg_ack(self, arrival: TransportArrival, hdr: MsgAckHeader | MsgNackHeader) -> None:
1213
1265
  topic = self.topics_by_hash.get(hdr.topic_hash)
@@ -1295,7 +1347,7 @@ class NodeImpl(Node):
1295
1347
  except (SendError, OSError) as e:
1296
1348
  _logger.debug("RSP ACK send failed: %s", e)
1297
1349
 
1298
- self.loop.create_task(do_send())
1350
+ self._spawn_detached(do_send(), "RSP ACK")
1299
1351
 
1300
1352
  def on_gossip(
1301
1353
  self,
@@ -1306,6 +1358,8 @@ class NodeImpl(Node):
1306
1358
  ) -> None:
1307
1359
  name = ""
1308
1360
  if hdr.name_len > 0:
1361
+ # Best-effort decode for diagnostics/monitoring; an invalid name cannot create a topic because
1362
+ # topic_subscribe_if_matching validates the character set before creating one.
1309
1363
  name = payload[: hdr.name_len].decode("utf-8", errors="replace")
1310
1364
 
1311
1365
  topic = self.topics_by_hash.get(hdr.topic_hash)
@@ -1374,6 +1428,14 @@ class NodeImpl(Node):
1374
1428
  now: float,
1375
1429
  ) -> TopicImpl | None:
1376
1430
  """Create an implicit topic if any pattern subscriber matches the name."""
1431
+ # REFERENCE PARITY: the reference does not (yet) validate gossip-name characters here -- it trusts
1432
+ # the hash. We additionally reject names that are not valid resolved wire names (non-normalized,
1433
+ # non-verbatim by this implementation's rule, homeful, or pinned) so untrusted wire input cannot
1434
+ # create a local topic with such a name. Consistent with the documented whitespace-strip deviation;
1435
+ # the reference may adopt the same validation later.
1436
+ if not _is_valid_wire_name(name):
1437
+ _logger.debug("Gossip drop invalid wire name hash=%016x", topic_hash)
1438
+ return None
1377
1439
  # Validate that the hash matches the name to prevent corrupt gossip from creating inconsistencies.
1378
1440
  if rapidhash(name) != topic_hash:
1379
1441
  _logger.debug("Gossip hash mismatch for '%s': got %016x, expected %016x", name, topic_hash, rapidhash(name))
@@ -1398,12 +1460,15 @@ class NodeImpl(Node):
1398
1460
  def on_scout(self, arrival: TransportArrival, hdr: ScoutHeader, payload: bytes) -> None:
1399
1461
  if hdr.pattern_len == 0 or hdr.pattern_len > TOPIC_NAME_MAX or len(payload) < hdr.pattern_len:
1400
1462
  return
1463
+ # Best-effort decode; an invalid pattern simply matches no local topic names.
1401
1464
  pattern = payload[: hdr.pattern_len].decode("utf-8", errors="replace")
1402
1465
  _logger.debug("Scout received pattern='%s' from %016x", pattern, arrival.remote_id)
1403
1466
  for topic in list(self.topics_by_name.values()):
1404
1467
  subs = match_pattern(pattern, topic.name)
1405
1468
  if subs is not None:
1406
- self.loop.create_task(self.send_gossip_unicast(topic, arrival.remote_id, arrival.priority))
1469
+ self._spawn_detached(
1470
+ self.send_gossip_unicast(topic, arrival.remote_id, arrival.priority), "gossip unicast"
1471
+ )
1407
1472
 
1408
1473
  # -- Implicit Topic GC --
1409
1474
 
@@ -1474,6 +1539,12 @@ class NodeImpl(Node):
1474
1539
  return
1475
1540
  self._closed = True
1476
1541
  _logger.info("Node closing home='%s'", self._home)
1542
+ # Unblock anything awaiting on a subscriber (`async for`): closing each enqueues StopAsyncIteration,
1543
+ # otherwise a default (no-liveness-timeout) subscriber would wait on its queue forever. (Reliable
1544
+ # publishes / response streams are deadline-bounded and resolve on their own.)
1545
+ for root in list(self.sub_roots_verbatim.values()) + list(self.sub_roots_pattern.values()):
1546
+ for sub in list(root.subscribers):
1547
+ sub.close()
1477
1548
  self._gc_task.cancel()
1478
1549
  for root in list(self.sub_roots_pattern.values()):
1479
1550
  if root.scout_task is not None:
@@ -8,7 +8,7 @@ from dataclasses import dataclass
8
8
  from ._api import DeliveryError, Instant, LivenessError, Priority, SendError
9
9
  from ._api import Publisher, Topic, ResponseStream, Response
10
10
  from ._header import MsgBeHeader, MsgRelHeader, RspBeHeader, RspRelHeader
11
- from ._node import ACK_BASELINE_DEFAULT_TIMEOUT, NodeImpl, PublishTracker, SESSION_LIFETIME, TopicImpl
11
+ from ._node import ACK_BASELINE_DEFAULT_TIMEOUT, NodeImpl, PublishTracker, SESSION_LIFETIME, TopicImpl, ack_window
12
12
  from ._transport import TransportArrival
13
13
 
14
14
  _logger = logging.getLogger(__name__)
@@ -124,7 +124,7 @@ class PublisherImpl(Publisher):
124
124
  )
125
125
  self._topic.request_futures[tag] = stream
126
126
 
127
- tracker = self._prepare_reliable_publish_tracker(tag, delivery_deadline.ns, payload)
127
+ tracker = self._prepare_reliable_publish_tracker(tag)
128
128
  try:
129
129
  initial_window = await self._reliable_publish_start(delivery_deadline, tag, payload, tracker)
130
130
  except asyncio.CancelledError:
@@ -169,12 +169,6 @@ class PublisherImpl(Publisher):
169
169
  finally:
170
170
  self._release_reliable_publish_tracker(tag, tracker)
171
171
 
172
- @staticmethod
173
- def _ack_is_last_attempt(current_ack_deadline_ns: int, current_ack_timeout: float, total_deadline_ns: int) -> bool:
174
- next_ack_timeout_ns = round(current_ack_timeout * 2 * 1e9)
175
- remaining_budget_ns = total_deadline_ns - current_ack_deadline_ns
176
- return remaining_budget_ns < next_ack_timeout_ns
177
-
178
172
  @staticmethod
179
173
  def _ack_window_is_compromised(deadline_ns: int, current_ack_timeout: float) -> bool:
180
174
  return Instant.now().ns >= (deadline_ns - round(current_ack_timeout * 1e9))
@@ -189,16 +183,8 @@ class PublisherImpl(Publisher):
189
183
  )
190
184
  return hdr.serialize() + payload
191
185
 
192
- @staticmethod
193
- def _reliable_publish_window(deadline_ns: int, ack_timeout: float) -> tuple[int, bool] | None:
194
- now_ns = Instant.now().ns
195
- if now_ns >= deadline_ns:
196
- return None
197
- ack_deadline_ns = min(deadline_ns, now_ns + round(ack_timeout * 1e9))
198
- return ack_deadline_ns, PublisherImpl._ack_is_last_attempt(ack_deadline_ns, ack_timeout, deadline_ns)
199
-
200
- def _prepare_reliable_publish_tracker(self, tag: int, deadline_ns: int, payload: bytes) -> PublishTracker:
201
- tracker = self._node.prepare_publish_tracker(self._topic, tag, deadline_ns, payload)
186
+ def _prepare_reliable_publish_tracker(self, tag: int) -> PublishTracker:
187
+ tracker = self._node.prepare_publish_tracker(self._topic, tag)
202
188
  tracker.ack_timeout = self.ack_timeout
203
189
  self._topic.publish_futures[tag] = tracker
204
190
  return tracker
@@ -231,7 +217,7 @@ class PublisherImpl(Publisher):
231
217
  payload: bytes,
232
218
  tracker: PublishTracker,
233
219
  ) -> tuple[int, bool]:
234
- initial_window = self._reliable_publish_window(deadline.ns, tracker.ack_timeout)
220
+ initial_window = ack_window(deadline.ns, tracker.ack_timeout)
235
221
  if initial_window is None:
236
222
  raise DeliveryError("Reliable publish not acknowledged before deadline")
237
223
  ack_deadline_ns, _ = initial_window
@@ -277,7 +263,7 @@ class PublisherImpl(Publisher):
277
263
  if last_attempt:
278
264
  break
279
265
  tracker.ack_timeout *= 2
280
- next_window = self._reliable_publish_window(deadline.ns, tracker.ack_timeout)
266
+ next_window = ack_window(deadline.ns, tracker.ack_timeout)
281
267
  if next_window is None:
282
268
  break
283
269
  ack_deadline_ns, last_attempt = next_window
@@ -292,7 +278,7 @@ class PublisherImpl(Publisher):
292
278
  raise DeliveryError("Reliable publish not acknowledged before deadline")
293
279
 
294
280
  async def _reliable_publish(self, deadline: Instant, tag: int, payload: bytes) -> None:
295
- tracker = self._prepare_reliable_publish_tracker(tag, deadline.ns, payload)
281
+ tracker = self._prepare_reliable_publish_tracker(tag)
296
282
  try:
297
283
  initial_window = await self._reliable_publish_start(deadline, tag, payload, tracker)
298
284
  await self._reliable_publish_continue(deadline, tag, payload, tracker, initial_window)
@@ -15,6 +15,7 @@ from ._node import (
15
15
  NodeImpl,
16
16
  SubscriberRoot,
17
17
  TopicImpl,
18
+ ack_window,
18
19
  match_pattern,
19
20
  )
20
21
 
@@ -220,9 +221,6 @@ class SubscriberImpl(Subscriber):
220
221
 
221
222
  state.timeout_handle = loop.call_later(delay, on_timeout)
222
223
 
223
- def _arm_reorder_timeout(self, state: ReorderingState) -> None:
224
- self._rearm_reorder_timeout(state)
225
-
226
224
  def _drop_stale_reordering(self, now: float) -> None:
227
225
  stale = [key for key, state in self._reordering.items() if (state.last_active_at + SESSION_LIFETIME) < now]
228
226
  for key in stale:
@@ -337,7 +335,7 @@ class BreadcrumbImpl(Breadcrumb):
337
335
 
338
336
  ack_timeout = ACK_BASELINE_DEFAULT_TIMEOUT * (1 << int(self._priority))
339
337
  try:
340
- initial_window = _ack_window(deadline.ns, ack_timeout)
338
+ initial_window = ack_window(deadline.ns, ack_timeout)
341
339
  if initial_window is None:
342
340
  raise DeliveryError("Reliable response not acknowledged before deadline")
343
341
 
@@ -371,7 +369,7 @@ class BreadcrumbImpl(Breadcrumb):
371
369
  if last_attempt:
372
370
  break
373
371
  ack_timeout *= 2
374
- next_window = _ack_window(deadline.ns, ack_timeout)
372
+ next_window = ack_window(deadline.ns, ack_timeout)
375
373
  if next_window is None:
376
374
  break
377
375
  ack_deadline_ns, last_attempt = next_window
@@ -414,17 +412,3 @@ class RespondTracker:
414
412
  self.done = True
415
413
  self.nacked = not positive
416
414
  self.ack_event.set()
417
-
418
-
419
- def _ack_is_last_attempt(current_ack_deadline_ns: int, current_ack_timeout: float, total_deadline_ns: int) -> bool:
420
- next_ack_timeout_ns = round(current_ack_timeout * 2 * 1e9)
421
- remaining_budget_ns = total_deadline_ns - current_ack_deadline_ns
422
- return remaining_budget_ns < next_ack_timeout_ns
423
-
424
-
425
- def _ack_window(deadline_ns: int, ack_timeout: float) -> tuple[int, bool] | None:
426
- now_ns = Instant.now().ns
427
- if now_ns >= deadline_ns:
428
- return None
429
- ack_deadline_ns = min(deadline_ns, now_ns + round(ack_timeout * 1e9))
430
- return ack_deadline_ns, _ack_is_last_attempt(ack_deadline_ns, ack_timeout, deadline_ns)
@@ -7,7 +7,8 @@ import itertools
7
7
 
8
8
  from .. import Closable, Instant
9
9
 
10
- _CAN_EXT_ID_MASK = (1 << 29) - 1
10
+ CAN_EXT_ID_MASK = (1 << 29) - 1
11
+ CAN_STD_ID_MASK = (1 << 11) - 1
11
12
 
12
13
 
13
14
  @dataclass(frozen=True)
@@ -18,7 +19,7 @@ class Frame:
18
19
  data: bytes
19
20
 
20
21
  def __post_init__(self) -> None:
21
- if not isinstance(self.id, int) or not (0 <= self.id <= _CAN_EXT_ID_MASK):
22
+ if not isinstance(self.id, int) or not (0 <= self.id <= CAN_EXT_ID_MASK):
22
23
  raise ValueError(f"Invalid CAN identifier: {self.id!r}")
23
24
  data = bytes(self.data)
24
25
  if len(data) > 64:
@@ -39,9 +40,9 @@ class Filter:
39
40
  mask: int
40
41
 
41
42
  def __post_init__(self) -> None:
42
- if not (0 <= self.id <= _CAN_EXT_ID_MASK):
43
+ if not (0 <= self.id <= CAN_EXT_ID_MASK):
43
44
  raise ValueError(f"Invalid CAN identifier: {self.id!r}")
44
- if not (0 <= self.mask <= _CAN_EXT_ID_MASK):
45
+ if not (0 <= self.mask <= CAN_EXT_ID_MASK):
45
46
  raise ValueError(f"Invalid CAN mask: {self.mask!r}")
46
47
 
47
48
  @property
@@ -6,12 +6,11 @@ from __future__ import annotations
6
6
 
7
7
  import logging
8
8
 
9
- from ._interface import Frame
10
- from ._wire import CAN_EXT_ID_MASK, DLC_TO_LENGTH, MTU_CAN_CLASSIC
9
+ from ._interface import CAN_EXT_ID_MASK, CAN_STD_ID_MASK, Frame
10
+ from ._wire import DLC_TO_LENGTH, MTU_CAN_CLASSIC
11
11
 
12
12
  _logger = logging.getLogger(__name__)
13
13
 
14
- _CAN_STD_ID_MASK = (1 << 11) - 1
15
14
  _CR = 0x0D # ACK / carriage return
16
15
  _LF = 0x0A
17
16
  _BEL = 0x07 # NACK / bell
@@ -157,7 +156,7 @@ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -
157
156
  if len(line) < expected:
158
157
  _logger.debug("SLCAN drop data dlc mismatch len=%d expected=%d", len(line), expected)
159
158
  return None
160
- if id_length == 3 and identifier > _CAN_STD_ID_MASK:
159
+ if id_length == 3 and identifier > CAN_STD_ID_MASK:
161
160
  _logger.debug("SLCAN drop invalid standard id=%x", identifier)
162
161
  return None
163
162
  data = _parse_hex_bytes(line[header_length:expected])
@@ -10,9 +10,8 @@ from .._hash import (
10
10
  CRC16CCITT_FALSE_RESIDUE,
11
11
  crc16ccitt_false_add,
12
12
  )
13
- from ._interface import Filter
13
+ from ._interface import CAN_EXT_ID_MASK, Filter
14
14
 
15
- CAN_EXT_ID_MASK = (1 << 29) - 1
16
15
  NODE_ID_MAX = 127
17
16
  NODE_ID_ANONYMOUS = 0xFF
18
17
  NODE_ID_CAPACITY = NODE_ID_MAX + 1
@@ -18,7 +18,7 @@ import logging
18
18
  import threading
19
19
 
20
20
  from .._api import ClosedError, Instant
21
- from ._interface import Filter, Interface, TimestampedFrame
21
+ from ._interface import CAN_EXT_ID_MASK, Filter, Interface, TimestampedFrame
22
22
 
23
23
  try:
24
24
  import can
@@ -27,8 +27,11 @@ except ImportError:
27
27
 
28
28
  _logger = logging.getLogger(__name__)
29
29
 
30
- _RX_POLL_TIMEOUT = 0.1
31
- _CAN_EXT_ID_MASK = (1 << 29) - 1
30
+ # RX thread poll cadence. This also bounds how long filter()/close() may block the event loop while
31
+ # they quiesce the RX thread (it can only acknowledge a pause between recv() calls). Kept short so that
32
+ # admin stall stays small. ponytail: to remove the stall entirely, hand pending filters to the RX
33
+ # thread to apply between recv() calls instead of pausing it from the loop thread.
34
+ _RX_POLL_TIMEOUT = 0.02
32
35
 
33
36
 
34
37
  class PythonCANInterface(Interface):
@@ -96,6 +99,7 @@ class PythonCANInterface(Interface):
96
99
  self._raise_if_closed()
97
100
  if self._tx_task is None:
98
101
  self._tx_task = self._loop.create_task(self._tx_loop())
102
+ self._tx_task.add_done_callback(self._on_task_done)
99
103
  for chunk in data:
100
104
  self._tx_seq += 1
101
105
  self._tx_queue.put_nowait((id, self._tx_seq, deadline.ns, bytes(chunk)))
@@ -151,15 +155,12 @@ class PythonCANInterface(Interface):
151
155
  loop = asyncio.get_running_loop()
152
156
  while not self._closed:
153
157
  try:
154
- identifier, _seq, deadline_ns, payload = await self._tx_queue.get()
158
+ identifier, seq, deadline_ns, payload = await self._tx_queue.get()
155
159
  except asyncio.CancelledError:
156
160
  raise
157
161
  if self._closed:
158
162
  return
159
- if Instant.now().ns >= deadline_ns:
160
- _logger.debug("PythonCAN tx drop expired iface=%s id=%08x", self._name, identifier)
161
- continue
162
- timeout = max(0.0, (deadline_ns - Instant.now().ns) * 1e-9)
163
+ timeout = (deadline_ns - Instant.now().ns) * 1e-9
163
164
  if timeout <= 0.0:
164
165
  _logger.debug("PythonCAN tx drop expired iface=%s id=%08x", self._name, identifier)
165
166
  continue
@@ -172,14 +173,10 @@ class PythonCANInterface(Interface):
172
173
  )
173
174
  try:
174
175
  await asyncio.wait_for(loop.run_in_executor(None, self._bus.send, msg, timeout), timeout=timeout)
175
- except asyncio.TimeoutError:
176
- self._tx_queue.put_nowait((identifier, self._tx_seq, deadline_ns, payload))
177
- self._tx_seq += 1
178
- await asyncio.sleep(0.001)
179
- except can.CanError as ex:
176
+ except (asyncio.TimeoutError, can.CanError) as ex:
177
+ # Re-queue with the original seq so the frame keeps its place within its transfer.
180
178
  _logger.debug("PythonCAN tx retry iface=%s err=%s", self._name, ex)
181
- self._tx_queue.put_nowait((identifier, self._tx_seq, deadline_ns, payload))
182
- self._tx_seq += 1
179
+ self._tx_queue.put_nowait((identifier, seq, deadline_ns, payload))
183
180
  await asyncio.sleep(0.001)
184
181
  except OSError as ex:
185
182
  self._fail(ex)
@@ -229,6 +226,14 @@ class PythonCANInterface(Interface):
229
226
  _logger.error("PythonCAN interface %s failed: %s", self._name, ex)
230
227
  self.close()
231
228
 
229
+ def _on_task_done(self, task: asyncio.Task[None]) -> None:
230
+ # Surface an unexpected TX-task crash as an interface failure instead of swallowing it.
231
+ if task.cancelled() or self._closed:
232
+ return
233
+ ex = task.exception()
234
+ if ex is not None:
235
+ self._fail(ex)
236
+
232
237
  def _raise_if_closed(self) -> None:
233
238
  if self._closed:
234
239
  if self._failure is not None:
@@ -258,4 +263,4 @@ def _parse_message(msg: can.Message) -> TimestampedFrame | None:
258
263
  if msg.is_remote_frame:
259
264
  _logger.debug("PythonCAN drop remote frame id=%08x", msg.arbitration_id)
260
265
  return None
261
- return TimestampedFrame(id=msg.arbitration_id & _CAN_EXT_ID_MASK, data=bytes(msg.data), timestamp=Instant.now())
266
+ return TimestampedFrame(id=msg.arbitration_id & CAN_EXT_ID_MASK, data=bytes(msg.data), timestamp=Instant.now())
@@ -21,12 +21,14 @@ _logger = logging.getLogger(__name__)
21
21
 
22
22
  _CAN_FILTER_CAPACITY = 64
23
23
  _CAN_INTERFACE_TYPE = 280
24
- _CAN_CLASSIC_MTU = 16
25
- _CAN_FD_MTU = 72
26
24
  _CANFD_FDF = getattr(socket, "CANFD_FDF", 0)
27
25
  _CAN_FRAME_STRUCT = struct.Struct("=IB3x8s")
28
26
  _CANFD_FRAME_STRUCT = struct.Struct("=IBBBB64s")
29
27
  _CAN_FILTER_STRUCT = struct.Struct("=II")
28
+ # SocketCAN frame sizes — sizeof(struct can_frame)=16, sizeof(struct canfd_frame)=72 (NOT the 8/64 payload MTU).
29
+ # The kernel also reports these as the CAN netdev MTU, so they double as the FD-capability threshold.
30
+ _CLASSIC_FRAME_SIZE = _CAN_FRAME_STRUCT.size
31
+ _FD_FRAME_SIZE = _CANFD_FRAME_STRUCT.size
30
32
  _TRANSIENT_TX_ERRNO = {errno.EAGAIN, errno.EWOULDBLOCK, errno.ENOBUFS, errno.ENOMEM, errno.EBUSY}
31
33
 
32
34
 
@@ -37,7 +39,7 @@ class SocketCANInterface(Interface):
37
39
  self._sock.setblocking(False)
38
40
  self._sock.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_LOOPBACK, 1)
39
41
  self._sock.bind((self._name,))
40
- self._fd = self._read_iface_mtu() >= _CAN_FD_MTU
42
+ self._fd = self._read_iface_mtu() >= _FD_FRAME_SIZE
41
43
  if self._fd:
42
44
  self._sock.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_FD_FRAMES, 1)
43
45
  self._closed = False
@@ -74,6 +76,7 @@ class SocketCANInterface(Interface):
74
76
  self._raise_if_closed()
75
77
  if self._tx_task is None:
76
78
  self._tx_task = asyncio.get_running_loop().create_task(self._tx_loop())
79
+ self._tx_task.add_done_callback(self._on_task_done)
77
80
  for chunk in data:
78
81
  self._tx_seq += 1
79
82
  self._tx_queue.put_nowait((id, self._tx_seq, deadline.ns, bytes(chunk)))
@@ -94,7 +97,7 @@ class SocketCANInterface(Interface):
94
97
  async def receive(self) -> TimestampedFrame:
95
98
  self._raise_if_closed()
96
99
  loop = asyncio.get_running_loop()
97
- recv_size = _CAN_FD_MTU if self._fd else _CAN_CLASSIC_MTU
100
+ recv_size = _FD_FRAME_SIZE if self._fd else _CLASSIC_FRAME_SIZE
98
101
  while True:
99
102
  try:
100
103
  raw = await loop.sock_recv(self._sock, recv_size)
@@ -128,14 +131,17 @@ class SocketCANInterface(Interface):
128
131
  raise
129
132
  if self._closed:
130
133
  return
131
- if Instant.now().ns >= deadline_ns:
132
- _logger.debug("SocketCAN tx drop expired iface=%s id=%08x", self._name, identifier)
133
- continue
134
- frame = self._encode(identifier, payload)
135
- timeout = max(0.0, (deadline_ns - Instant.now().ns) * 1e-9)
134
+ timeout = (deadline_ns - Instant.now().ns) * 1e-9
136
135
  if timeout <= 0.0:
137
136
  _logger.debug("SocketCAN tx drop expired iface=%s id=%08x", self._name, identifier)
138
137
  continue
138
+ try:
139
+ frame = self._encode(identifier, payload)
140
+ except ValueError as ex:
141
+ # An unencodable frame (e.g. oversized on a Classic-only interface) is a single bad
142
+ # frame, not an interface failure: drop it instead of letting it kill the TX task.
143
+ _logger.warning("SocketCAN tx drop unencodable iface=%s id=%08x: %s", self._name, identifier, ex)
144
+ continue
139
145
  try:
140
146
  await asyncio.wait_for(loop.sock_sendall(self._sock, frame), timeout=timeout)
141
147
  except asyncio.TimeoutError:
@@ -159,6 +165,14 @@ class SocketCANInterface(Interface):
159
165
  _logger.error("SocketCAN interface %s failed: %s", self._name, ex)
160
166
  self.close()
161
167
 
168
+ def _on_task_done(self, task: asyncio.Task[None]) -> None:
169
+ # Surface an unexpected TX-task crash as an interface failure instead of swallowing it.
170
+ if task.cancelled() or self._closed:
171
+ return
172
+ ex = task.exception()
173
+ if ex is not None:
174
+ self._fail(ex)
175
+
162
176
  def _raise_if_closed(self) -> None:
163
177
  if self._closed:
164
178
  if self._failure is not None:
@@ -172,7 +186,9 @@ class SocketCANInterface(Interface):
172
186
  def _encode(self, identifier: int, data: bytes) -> bytes:
173
187
  if len(data) > 8:
174
188
  if not self._fd:
175
- raise ClosedError(f"SocketCAN interface {self._name} is not CAN FD-capable")
189
+ raise ValueError(
190
+ f"SocketCAN interface {self._name} cannot send a {len(data)}-byte frame on Classic CAN"
191
+ )
176
192
  return _CANFD_FRAME_STRUCT.pack(
177
193
  socket.CAN_EFF_FLAG | (identifier & socket.CAN_EFF_MASK),
178
194
  len(data),
@@ -189,14 +205,14 @@ class SocketCANInterface(Interface):
189
205
 
190
206
  @staticmethod
191
207
  def _decode(raw: bytes) -> TimestampedFrame | None:
192
- if len(raw) < _CAN_CLASSIC_MTU:
208
+ if len(raw) < _CLASSIC_FRAME_SIZE:
193
209
  _logger.debug("SocketCAN drop short len=%d", len(raw))
194
210
  return None
195
- if len(raw) >= _CAN_FD_MTU:
196
- can_id, length, _flags, _reserved0, _reserved1, data = _CANFD_FRAME_STRUCT.unpack(raw[:_CAN_FD_MTU])
211
+ if len(raw) >= _FD_FRAME_SIZE:
212
+ can_id, length, _flags, _reserved0, _reserved1, data = _CANFD_FRAME_STRUCT.unpack(raw[:_FD_FRAME_SIZE])
197
213
  payload = data[: min(length, 64)]
198
214
  else:
199
- can_id, length, data = _CAN_FRAME_STRUCT.unpack(raw[:_CAN_CLASSIC_MTU])
215
+ can_id, length, data = _CAN_FRAME_STRUCT.unpack(raw[:_CLASSIC_FRAME_SIZE])
200
216
  payload = data[: min(length, 8)]
201
217
  if (can_id & socket.CAN_EFF_FLAG) == 0 or (can_id & (socket.CAN_RTR_FLAG | socket.CAN_ERR_FLAG)) != 0:
202
218
  _logger.debug("SocketCAN drop non-extended or non-data id=%08x", can_id)
@@ -172,7 +172,6 @@ def _frame_is_valid(header: _FrameHeader, payload_chunk: bytes | memoryview) ->
172
172
  class _Fragment:
173
173
  offset: int
174
174
  data: bytes
175
- crc: int
176
175
 
177
176
  @property
178
177
  def end(self) -> int:
@@ -210,7 +209,7 @@ class _TransferSlot:
210
209
  )
211
210
 
212
211
  def update(self, timestamp_ns: int, header: _FrameHeader, payload_chunk: bytes) -> bytes | None:
213
- if self._accept_fragment(header.frame_payload_offset, payload_chunk, header.prefix_crc):
212
+ if self._accept_fragment(header.frame_payload_offset, payload_chunk):
214
213
  self.ts_max_ns = max(self.ts_max_ns, timestamp_ns)
215
214
  self.ts_min_ns = min(self.ts_min_ns, timestamp_ns)
216
215
  crc_end = header.frame_payload_offset + len(payload_chunk)
@@ -221,7 +220,7 @@ class _TransferSlot:
221
220
  return None
222
221
  return self._finalize_payload()
223
222
 
224
- def _accept_fragment(self, offset: int, data: bytes, crc: int) -> bool:
223
+ def _accept_fragment(self, offset: int, data: bytes) -> bool:
225
224
  left = offset
226
225
  right = offset + len(data)
227
226
  for frag in self.fragments:
@@ -244,7 +243,7 @@ class _TransferSlot:
244
243
  v_left = min(left, left_neighbor.offset + 1) if left_neighbor is not None else left
245
244
  v_right = max(right, max(right_neighbor.end, 1) - 1) if right_neighbor is not None else right
246
245
  self.fragments = [frag for frag in self.fragments if not (frag.offset >= v_left and frag.end <= v_right)]
247
- self.fragments.append(_Fragment(offset=offset, data=data, crc=crc))
246
+ self.fragments.append(_Fragment(offset=offset, data=data))
248
247
  self.fragments.sort(key=lambda frag: frag.offset)
249
248
  self.covered_prefix = self._compute_covered_prefix()
250
249
  return True
@@ -474,10 +473,15 @@ class Interface:
474
473
  mtu_link: int
475
474
  """Link-layer MTU. E.g., 1500 for Ethernet, ~64K for loopback."""
476
475
 
476
+ def __post_init__(self) -> None:
477
+ # Validate at construction (not via assert, which `python -O` strips) so a too-small MTU can
478
+ # never produce a negative mtu_cyphal that would make segmentation loop forever.
479
+ if self.mtu_link < _CYPHAL_MTU_LINK_MIN:
480
+ raise ValueError(f"mtu_link must be >= {_CYPHAL_MTU_LINK_MIN}, got {self.mtu_link}")
481
+
477
482
  @property
478
483
  def mtu_cyphal(self) -> int:
479
484
  """Max Cyphal frame payload: mtu_link - 60 (IPv4 max) - 8 (UDP) - 32 (Cyphal header)."""
480
- assert self.mtu_link >= _CYPHAL_MTU_LINK_MIN
481
485
  return self.mtu_link - _CYPHAL_OVERHEAD_MAX
482
486
 
483
487
 
@@ -516,18 +520,22 @@ class _UDPSubjectWriter(SubjectWriter):
516
520
  except (OSError, SendError) as e:
517
521
  errors.append(e)
518
522
 
523
+ if errors and success_count == 0:
524
+ _logger.error("Send failed on all interfaces for subject %d", self._subject_id)
525
+ raise SendError("send failed on all interfaces") from ExceptionGroup(
526
+ "send failed on all interfaces", errors
527
+ )
519
528
  if errors:
520
- eg = ExceptionGroup("send failed on some interfaces", errors)
521
- if success_count == 0:
522
- _logger.error("Send failed on all interfaces for subject %d", self._subject_id)
523
- raise SendError("send failed on all interfaces") from eg
529
+ # Redundant transport: delivery via at least one interface is a success. Warn but do not
530
+ # raise, otherwise the caller would treat a delivered transfer as failed and retry it,
531
+ # duplicating it on the interfaces that already succeeded. This mirrors the CAN transport
532
+ # (see _CANTransportImpl.send_transfer) and the reference cy_udp_posix push semantics.
524
533
  _logger.warning(
525
- "Send failed on %d/%d interfaces for subject %d",
526
- len(errors),
534
+ "Send succeeded on %d/%d interfaces for subject %d",
535
+ success_count,
527
536
  len(errors) + success_count,
528
537
  self._subject_id,
529
538
  )
530
- raise eg
531
539
 
532
540
  _logger.debug("Subject tx done sid=%d tid=%d", self._subject_id, transfer_id)
533
541
 
@@ -843,8 +851,16 @@ class _UDPTransportImpl(UDPTransport):
843
851
  _logger.warning("No endpoint known for remote_id=0x%016x", remote_id)
844
852
  raise SendError("No endpoint known for remote_id")
845
853
  if errors:
846
- raise ExceptionGroup("unicast send failed on some interfaces", errors)
847
- _logger.debug("Unicast sent %d frames to remote_id=0x%016x", len(frames), remote_id)
854
+ # Redundant transport: delivery via at least one interface is a success. Warn but do not
855
+ # raise, otherwise a delivered transfer would be reported as failed and retried (mirrors
856
+ # _UDPSubjectWriter.__call__ and the reference cy_udp_posix unicast push semantics).
857
+ _logger.warning(
858
+ "Unicast succeeded on %d/%d interfaces for remote_id=0x%016x",
859
+ success_count,
860
+ len(errors) + success_count,
861
+ remote_id,
862
+ )
863
+ _logger.debug("Unicast sent to remote_id=0x%016x", remote_id)
848
864
 
849
865
  def close(self) -> None:
850
866
  if self._closed:
@@ -947,7 +963,11 @@ class _UDPTransportImpl(UDPTransport):
947
963
  return
948
964
  if arrival is not None and self._unicast_handler is not None:
949
965
  _logger.debug("Unicast transfer complete from sender_uid=0x%016x", arrival.remote_id)
950
- self._unicast_handler(arrival)
966
+ try:
967
+ self._unicast_handler(arrival)
968
+ except Exception:
969
+ # A raising handler must not kill the receive loop; drop the arrival and keep serving.
970
+ _logger.exception("Unicast handler raised iface=%d", iface_idx)
951
971
 
952
972
  def _process_subject_datagram(
953
973
  self,
@@ -997,4 +1017,8 @@ class _UDPTransportImpl(UDPTransport):
997
1017
  if arrival is not None:
998
1018
  _logger.debug("Subject %d transfer complete from sender_uid=0x%016x", subject_id, arrival.remote_id)
999
1019
  if handler is not None:
1000
- handler(arrival)
1020
+ try:
1021
+ handler(arrival)
1022
+ except Exception:
1023
+ # A raising handler must not kill the receive loop; drop the arrival and keep serving.
1024
+ _logger.exception("Subject %d handler raised", subject_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycyphal2
3
- Version: 2.0.0.dev3
3
+ Version: 2.0.0.dev4
4
4
  Summary: Pure-Python implementation of Cyphal -- a simple and robust real-time publish/subscribe stack that runs anywhere.
5
5
  Author-email: Pavel Kirienko and OpenCyphal team <pavel@opencyphal.org>
6
6
  License: MIT
@@ -164,6 +164,33 @@ async def test_monitor_unknown_topic_preserves_decoded_wire_name(name_bytes: byt
164
164
  node.close()
165
165
 
166
166
 
167
+ async def test_gossip_wire_path_drops_malformed_name() -> None:
168
+ """End-to-end wire path: a gossip with a malformed name must not create a local topic, even with a
169
+ match-all pattern subscriber present (a valid name over the same path IS created -- the control)."""
170
+ node = new_node(MockTransport(node_id=1), home="n1")
171
+ node.subscribe(">") # Match-all, so only the name validation can prevent topic creation.
172
+ try:
173
+ bad = "foo bar" # A space is not a valid topic-name character.
174
+ _deliver_gossip(
175
+ node,
176
+ _make_gossip_arrival(topic_hash=rapidhash(bad), evictions=0, name_bytes=bad.encode()),
177
+ "broadcast",
178
+ topic_hash=rapidhash(bad),
179
+ )
180
+ assert bad not in node.topics_by_name # Malformed name dropped on the wire path.
181
+
182
+ good = "sensor/temp"
183
+ _deliver_gossip(
184
+ node,
185
+ _make_gossip_arrival(topic_hash=rapidhash(good), evictions=0, name_bytes=good.encode()),
186
+ "broadcast",
187
+ topic_hash=rapidhash(good),
188
+ )
189
+ assert good in node.topics_by_name # Control: a valid name over the same path IS created.
190
+ finally:
191
+ node.close()
192
+
193
+
167
194
  async def test_monitor_is_not_invoked_for_inline_gossip_on_message_reception() -> None:
168
195
  node = new_node(MockTransport(node_id=1), home="n1")
169
196
  pub = node.advertise("/topic")
@@ -95,7 +95,6 @@ async def test_association_slack_nack_capped():
95
95
 
96
96
  tracker = PublishTracker(
97
97
  tag=tag,
98
- deadline_ns=(pycyphal2.Instant.now() + 10.0).ns,
99
98
  remaining={42},
100
99
  ack_event=asyncio.Event(),
101
100
  )
@@ -136,7 +135,6 @@ async def test_association_ack_resets_slack():
136
135
 
137
136
  tracker = PublishTracker(
138
137
  tag=tag,
139
- deadline_ns=(pycyphal2.Instant.now() + 10.0).ns,
140
138
  remaining={42},
141
139
  ack_event=asyncio.Event(),
142
140
  )
@@ -215,7 +215,7 @@ async def test_prepare_publish_tracker_skips_saturated_associations_and_release_
215
215
  topic.associations = {10: live, 11: saturated}
216
216
 
217
217
  tag = topic.next_tag()
218
- tracker = node.prepare_publish_tracker(topic, tag, (pycyphal2.Instant.now() + 1.0).ns, b"data")
218
+ tracker = node.prepare_publish_tracker(topic, tag)
219
219
 
220
220
  assert tracker.remaining == {10}
221
221
  assert tracker.associations == [live]
@@ -243,7 +243,7 @@ async def test_publish_tracker_release_compromised_does_not_penalize_association
243
243
  assoc = Association(remote_id=10, last_seen=0.0, slack=ASSOC_SLACK_LIMIT - 1)
244
244
  topic.associations = {10: assoc}
245
245
  tag = topic.next_tag()
246
- tracker = node.prepare_publish_tracker(topic, tag, (pycyphal2.Instant.now() + 1.0).ns, b"data")
246
+ tracker = node.prepare_publish_tracker(topic, tag)
247
247
  tracker.compromised = True
248
248
 
249
249
  node.publish_tracker_release(topic, tracker)
@@ -266,7 +266,7 @@ async def test_reliable_publish_scheduler_lag_does_not_penalize_association() ->
266
266
  topic.associations = {10: assoc}
267
267
  tag = topic.next_tag()
268
268
  deadline = pycyphal2.Instant(ns=1_000_000_000)
269
- tracker = pub._prepare_reliable_publish_tracker(tag, deadline.ns, b"data")
269
+ tracker = pub._prepare_reliable_publish_tracker(tag)
270
270
  tracker.ack_timeout = 0.2
271
271
 
272
272
  now_ns = 0
@@ -36,6 +36,66 @@ async def test_basic_best_effort_pubsub():
36
36
  node.close()
37
37
 
38
38
 
39
+ async def test_node_operations_after_close_raise():
40
+ """Public node operations reject use after close() instead of mutating a dead node."""
41
+ net = MockNetwork()
42
+ tr = MockTransport(node_id=1, network=net)
43
+ node = new_node(tr, home="test_node")
44
+ node.close()
45
+
46
+ with pytest.raises(pycyphal2.ClosedError):
47
+ node.advertise("my/topic")
48
+ with pytest.raises(pycyphal2.ClosedError):
49
+ node.subscribe("my/topic")
50
+ with pytest.raises(pycyphal2.ClosedError):
51
+ node.monitor(lambda _t: None)
52
+ with pytest.raises(pycyphal2.ClosedError):
53
+ node.remap("a=b")
54
+ with pytest.raises(pycyphal2.ClosedError):
55
+ await node.scout("pattern/*")
56
+
57
+
58
+ async def test_node_close_unblocks_pending_subscriber():
59
+ """Closing the node ends a pending `async for` on a subscriber instead of hanging it forever."""
60
+ net = MockNetwork()
61
+ tr = MockTransport(node_id=1, network=net)
62
+ node = new_node(tr, home="test_node")
63
+ sub = node.subscribe("my/topic")
64
+ task = asyncio.create_task(sub.__anext__())
65
+ await asyncio.sleep(0) # Let the task start awaiting on the queue.
66
+
67
+ node.close()
68
+
69
+ with pytest.raises(StopAsyncIteration):
70
+ await asyncio.wait_for(task, timeout=1.0)
71
+
72
+
73
+ async def test_gossip_rejects_malformed_names():
74
+ """A gossiped name that is not a normalized verbatim topic name must not become a local topic.
75
+
76
+ A match-all '>' pattern subscriber is registered so that, WITHOUT the name guard, every name below
77
+ would be created -- this makes the test fail on the unfixed code rather than passing vacuously (the
78
+ hash matches the name in each case, so only the name guard can reject it).
79
+ """
80
+ from pycyphal2._hash import rapidhash
81
+
82
+ net = MockNetwork()
83
+ tr = MockTransport(node_id=1, network=net)
84
+ node = new_node(tr, home="test_node")
85
+ try:
86
+ node.subscribe(">") # Match-all pattern: the name guard is then the only thing that can reject.
87
+ # Sanity: a valid normalized name IS created through this subscriber, proving the path is live.
88
+ good = node.topic_subscribe_if_matching("sensor/temp", rapidhash("sensor/temp"), 0, 0, 0.0)
89
+ assert good is not None
90
+
91
+ for bad_name in ["foo bar", "foo//bar", "/foo", "foo/", "foo/*", "foo/>", "foo#123", "~/foo", "~", ""]:
92
+ result = node.topic_subscribe_if_matching(bad_name, rapidhash(bad_name), 0, 0, 0.0)
93
+ assert result is None, bad_name
94
+ assert bad_name not in node.topics_by_name
95
+ finally:
96
+ node.close()
97
+
98
+
39
99
  async def test_publish_multiple_messages():
40
100
  """Multiple messages should arrive in order."""
41
101
  net = MockNetwork()
@@ -532,7 +532,6 @@ async def test_msg_ack_dispatch():
532
532
  tag = topic.next_tag()
533
533
  tracker = PublishTracker(
534
534
  tag=tag,
535
- deadline_ns=(pycyphal2.Instant.now() + 10.0).ns,
536
535
  remaining={42},
537
536
  ack_event=asyncio.Event(),
538
537
  )
@@ -573,7 +572,6 @@ async def test_msg_nack_dispatch():
573
572
  tag = topic.next_tag()
574
573
  tracker = PublishTracker(
575
574
  tag=tag,
576
- deadline_ns=(pycyphal2.Instant.now() + 10.0).ns,
577
575
  remaining={42},
578
576
  ack_event=asyncio.Event(),
579
577
  )
@@ -1000,7 +998,6 @@ async def test_multicast_msg_ack_ignored():
1000
998
  tag = topic.next_tag()
1001
999
  tracker = PublishTracker(
1002
1000
  tag=tag,
1003
- deadline_ns=(pycyphal2.Instant.now() + 10.0).ns,
1004
1001
  remaining={42},
1005
1002
  ack_event=asyncio.Event(),
1006
1003
  )
@@ -475,13 +475,13 @@ class TestTransferSlot:
475
475
  ),
476
476
  0,
477
477
  )
478
- assert slot._accept_fragment(0, b"a" * 30, 0)
478
+ assert slot._accept_fragment(0, b"a" * 30)
479
479
  assert slot.covered_prefix == 30
480
- assert slot._accept_fragment(50, b"b" * 30, 0)
480
+ assert slot._accept_fragment(50, b"b" * 30)
481
481
  assert slot.covered_prefix == 30
482
- assert slot._accept_fragment(30, b"c" * 20, 0)
482
+ assert slot._accept_fragment(30, b"c" * 20)
483
483
  assert slot.covered_prefix == 80
484
- assert slot._accept_fragment(80, b"d" * 20, 0)
484
+ assert slot._accept_fragment(80, b"d" * 20)
485
485
  assert slot.covered_prefix == 100
486
486
 
487
487
  def test_contained_fragment_rejected(self):
@@ -491,8 +491,8 @@ class TestTransferSlot:
491
491
  ),
492
492
  0,
493
493
  )
494
- assert slot._accept_fragment(0, b"A" * 4, 0)
495
- assert not slot._accept_fragment(1, b"B" * 2, 0)
494
+ assert slot._accept_fragment(0, b"A" * 4)
495
+ assert not slot._accept_fragment(1, b"B" * 2)
496
496
  assert [(frag.offset, frag.data) for frag in slot.fragments] == [(0, b"AAAA")]
497
497
 
498
498
  def test_bridge_fragment_evicts_victim(self):
@@ -502,10 +502,10 @@ class TestTransferSlot:
502
502
  ),
503
503
  0,
504
504
  )
505
- assert slot._accept_fragment(0, b"AAAA", 0)
506
- assert slot._accept_fragment(4, b"BB", 0)
507
- assert slot._accept_fragment(6, b"CCCC", 0)
508
- assert slot._accept_fragment(2, b"XXXXXX", 0)
505
+ assert slot._accept_fragment(0, b"AAAA")
506
+ assert slot._accept_fragment(4, b"BB")
507
+ assert slot._accept_fragment(6, b"CCCC")
508
+ assert slot._accept_fragment(2, b"XXXXXX")
509
509
  assert [(frag.offset, frag.data) for frag in slot.fragments] == [(0, b"AAAA"), (2, b"XXXXXX"), (6, b"CCCC")]
510
510
 
511
511
  def test_furthest_reaching_crc_is_used(self):
@@ -1187,3 +1187,124 @@ class TestAsyncSendto:
1187
1187
  await t.async_sendto(sock, b"fail", ("127.0.0.1", 9999), deadline)
1188
1188
  finally:
1189
1189
  t.close()
1190
+
1191
+
1192
+ @pytest.mark.asyncio
1193
+ async def test_subject_send_succeeds_when_one_redundant_interface_fails() -> None:
1194
+ """Redundant transport: a transfer delivered on >=1 interface is a success, not a raise.
1195
+
1196
+ Regression: a partial-interface failure used to raise an ExceptionGroup, making the caller treat a
1197
+ delivered transfer as failed and retry it, duplicating it on the interfaces that already succeeded.
1198
+ """
1199
+ iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500)
1200
+ pub = UDPTransport.new(interfaces=[iface, iface])
1201
+ assert isinstance(pub, _UDPTransportImpl)
1202
+ try:
1203
+ real_sendto = pub.async_sendto
1204
+
1205
+ async def flaky_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-def]
1206
+ if sock is pub.tx_socks[0]:
1207
+ raise OSError("interface 0 is down")
1208
+ await real_sendto(sock, data, addr, deadline)
1209
+
1210
+ with patch.object(pub, "async_sendto", flaky_sendto):
1211
+ writer = pub.subject_advertise(10)
1212
+ await writer(Instant.now() + 2.0, Priority.NOMINAL, b"redundant") # Must not raise.
1213
+
1214
+ async def all_fail(sock, data, addr, deadline): # type: ignore[no-untyped-def]
1215
+ raise OSError("all interfaces down")
1216
+
1217
+ with patch.object(pub, "async_sendto", all_fail):
1218
+ writer_all = pub.subject_advertise(11)
1219
+ with pytest.raises(SendError):
1220
+ await writer_all(Instant.now() + 2.0, Priority.NOMINAL, b"nope")
1221
+ finally:
1222
+ pub.close()
1223
+
1224
+
1225
+ def test_interface_rejects_subminimum_mtu() -> None:
1226
+ """A link MTU below the Cyphal minimum is rejected at construction, not via a strippable assert."""
1227
+ with pytest.raises(ValueError, match="mtu_link must be"):
1228
+ Interface(address=IPv4Address("127.0.0.1"), mtu_link=100)
1229
+
1230
+
1231
+ @pytest.mark.asyncio
1232
+ async def test_raising_subject_handler_does_not_kill_rx_loop() -> None:
1233
+ """A subject handler that raises must not tear down the receive loop; later transfers still arrive."""
1234
+ pub = UDPTransport.new_loopback()
1235
+ sub = UDPTransport.new_loopback()
1236
+ try:
1237
+ received: list[bytes] = []
1238
+
1239
+ def handler(arrival: TransportArrival) -> None:
1240
+ received.append(arrival.message)
1241
+ raise RuntimeError("handler boom") # Raised on every delivery.
1242
+
1243
+ sub.subject_listen(10, handler)
1244
+ writer = pub.subject_advertise(10)
1245
+ await writer(Instant.now() + 2.0, Priority.NOMINAL, b"first")
1246
+ await writer(Instant.now() + 2.0, Priority.NOMINAL, b"second")
1247
+
1248
+ for _ in range(200):
1249
+ if len(received) >= 2:
1250
+ break
1251
+ await asyncio.sleep(0.01)
1252
+ assert received == [b"first", b"second"] # Second arrived => loop survived the first raise.
1253
+ finally:
1254
+ pub.close()
1255
+ sub.close()
1256
+
1257
+
1258
+ @pytest.mark.asyncio
1259
+ async def test_unicast_succeeds_when_one_redundant_interface_fails() -> None:
1260
+ """Unicast, like subject send, must succeed if delivered on >=1 interface (used by reliable replies)."""
1261
+ iface = Interface(address=IPv4Address("127.0.0.1"), mtu_link=1500)
1262
+ t = UDPTransport.new(interfaces=[iface, iface])
1263
+ assert isinstance(t, _UDPTransportImpl)
1264
+ try:
1265
+ remote = 5
1266
+ # Pretend both interfaces have learned an endpoint for the remote node.
1267
+ t._remote_endpoints[(remote, 0)] = ("127.0.0.1", t.tx_socks[0].getsockname()[1])
1268
+ t._remote_endpoints[(remote, 1)] = ("127.0.0.1", t.tx_socks[1].getsockname()[1])
1269
+ real_sendto = t.async_sendto
1270
+
1271
+ async def flaky_sendto(sock, data, addr, deadline): # type: ignore[no-untyped-def]
1272
+ if sock is t.tx_socks[0]:
1273
+ raise OSError("interface 0 is down")
1274
+ await real_sendto(sock, data, addr, deadline)
1275
+
1276
+ with patch.object(t, "async_sendto", flaky_sendto):
1277
+ await t.unicast(Instant.now() + 2.0, Priority.NOMINAL, remote, b"redundant") # Must not raise.
1278
+
1279
+ async def all_fail(sock, data, addr, deadline): # type: ignore[no-untyped-def]
1280
+ raise OSError("all interfaces down")
1281
+
1282
+ with patch.object(t, "async_sendto", all_fail):
1283
+ with pytest.raises(SendError):
1284
+ await t.unicast(Instant.now() + 2.0, Priority.NOMINAL, remote, b"nope")
1285
+ finally:
1286
+ t.close()
1287
+
1288
+
1289
+ @pytest.mark.asyncio
1290
+ async def test_raising_unicast_handler_does_not_kill_rx() -> None:
1291
+ """A raising unicast handler must not tear down the receive path; later transfers still arrive."""
1292
+ t = UDPTransport.new_loopback()
1293
+ assert isinstance(t, _UDPTransportImpl)
1294
+ try:
1295
+ received: list[bytes] = []
1296
+
1297
+ def handler(arrival: TransportArrival) -> None:
1298
+ received.append(arrival.message)
1299
+ raise RuntimeError("handler boom")
1300
+
1301
+ t.unicast_listen(handler)
1302
+ uid = 0x1234
1303
+ first = _segment_transfer(Priority.NOMINAL, 0, uid, b"first", 1400)[0]
1304
+ second = _segment_transfer(Priority.NOMINAL, 1, uid, b"second", 1400)[0]
1305
+ # Feed two single-frame transfers through the same code path the RX loop uses.
1306
+ t._process_unicast_datagram(first, "127.0.0.1", 40000, 0, Instant.now())
1307
+ t._process_unicast_datagram(second, "127.0.0.1", 40000, 0, Instant.now())
1308
+ assert received == [b"first", b"second"] # Second processed => the first raise was contained.
1309
+ finally:
1310
+ t.close()
File without changes
File without changes
File without changes