pycyphal2 2.0.0.dev3__tar.gz → 2.0.0.dev5__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.dev5}/PKG-INFO +1 -1
  2. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/__init__.py +1 -1
  3. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_node.py +92 -19
  4. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_publisher.py +7 -21
  5. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_subscriber.py +3 -19
  6. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_interface.py +5 -4
  7. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_media_slcan.py +3 -4
  8. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_wire.py +1 -2
  9. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/pythoncan.py +21 -16
  10. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/socketcan.py +30 -14
  11. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/udp.py +40 -16
  12. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5/src/pycyphal2.egg-info}/PKG-INFO +1 -1
  13. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_integration.py +2 -2
  14. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_monitor.py +27 -0
  15. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_parity.py +0 -2
  16. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_parity_coverage.py +3 -3
  17. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_pubsub.py +60 -0
  18. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_reliable.py +0 -3
  19. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_topic.py +37 -10
  20. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_udp.py +131 -10
  21. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/LICENSE +0 -0
  22. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/README.md +0 -0
  23. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/pyproject.toml +0 -0
  24. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/setup.cfg +0 -0
  25. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_api.py +0 -0
  26. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_hash.py +0 -0
  27. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_header.py +0 -0
  28. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_transport.py +0 -0
  29. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/__init__.py +0 -0
  30. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_reassembly.py +0 -0
  31. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_transport.py +0 -0
  32. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/webserial.py +0 -0
  33. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/py.typed +0 -0
  34. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2.egg-info/SOURCES.txt +0 -0
  35. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
  36. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2.egg-info/requires.txt +0 -0
  37. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2.egg-info/top_level.txt +0 -0
  38. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_gossip.py +0 -0
  39. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_hash.py +0 -0
  40. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_header.py +0 -0
  41. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_names.py +0 -0
  42. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_reorder.py +0 -0
  43. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_rpc.py +0 -0
  44. {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_scout.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.dev5
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.dev5"
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]:
@@ -211,7 +246,9 @@ def compute_subject_id(topic_hash: int, evictions: int, modulus: int) -> int:
211
246
  """Compute the subject-ID for a topic given its hash, evictions, and subject-ID modulus."""
212
247
  if evictions >= EVICTIONS_PINNED_MIN:
213
248
  return 0xFFFFFFFF - evictions
214
- return SUBJECT_ID_PINNED_MAX + 1 + ((topic_hash + (evictions * evictions)) % modulus)
249
+ h = topic_hash % modulus
250
+ e = evictions % modulus
251
+ return SUBJECT_ID_PINNED_MAX + 1 + ((h + ((e * e) % modulus)) % modulus)
215
252
 
216
253
 
217
254
  # =====================================================================================================================
@@ -345,10 +382,8 @@ class PublishTracker:
345
382
  """Tracks a pending reliable publication awaiting ACKs."""
346
383
 
347
384
  tag: int
348
- deadline_ns: int
349
385
  ack_event: asyncio.Event
350
386
  acknowledged: bool = False
351
- data: bytes | None = None
352
387
  ack_timeout: float = ACK_BASELINE_DEFAULT_TIMEOUT
353
388
  compromised: bool = False
354
389
  remaining: set[int] = field(default_factory=set)
@@ -507,7 +542,6 @@ class NodeImpl(Node):
507
542
  self._remaps: dict[str, str] = {}
508
543
  self._closed = False
509
544
  self.loop = asyncio.get_running_loop()
510
- self._now_mono = time.monotonic()
511
545
  self._monitor_callbacks: dict[int, Callable[[Topic], None]] = {}
512
546
  self._next_monitor_callback_id = 0
513
547
 
@@ -573,7 +607,31 @@ class NodeImpl(Node):
573
607
  def transport(self) -> Transport:
574
608
  return self._transport
575
609
 
610
+ def _raise_if_closed(self) -> None:
611
+ if self._closed:
612
+ raise ClosedError(f"Node '{self._home}' is closed")
613
+
614
+ def _spawn_detached(self, coro: Coroutine[Any, Any, None], what: str) -> None:
615
+ """Fire-and-forget a short-lived send: skip when closed, and never let its exception go unobserved."""
616
+ if self._closed:
617
+ coro.close()
618
+ return
619
+
620
+ def _done(task: asyncio.Task[None]) -> None:
621
+ if task.cancelled():
622
+ return
623
+ ex = task.exception()
624
+ if ex is None:
625
+ return
626
+ if isinstance(ex, (SendError, OSError)):
627
+ _logger.debug("%s send failed: %s", what, ex)
628
+ else:
629
+ _logger.error("%s task crashed: %s", what, ex, exc_info=ex)
630
+
631
+ self.loop.create_task(coro).add_done_callback(_done)
632
+
576
633
  def remap(self, spec: str | dict[str, str]) -> None:
634
+ self._raise_if_closed()
577
635
  if isinstance(spec, str):
578
636
  spec = dict(x.split("=", 1) for x in spec.split() if "=" in x)
579
637
  assert isinstance(spec, dict)
@@ -584,6 +642,7 @@ class NodeImpl(Node):
584
642
  def advertise(self, name: str) -> Publisher:
585
643
  from ._publisher import PublisherImpl
586
644
 
645
+ self._raise_if_closed()
587
646
  resolved, pin, verbatim = resolve_name(name, self._home, self._namespace, self._remaps)
588
647
  if not verbatim:
589
648
  raise ValueError("Cannot advertise on a pattern name")
@@ -602,6 +661,7 @@ class NodeImpl(Node):
602
661
  def subscribe(self, name: str, *, reordering_window: float | None = None) -> Subscriber:
603
662
  from ._subscriber import SubscriberImpl
604
663
 
664
+ self._raise_if_closed()
605
665
  resolved, pin, verbatim = resolve_name(name, self._home, self._namespace, self._remaps)
606
666
  if pin is not None and not verbatim:
607
667
  raise ValueError("Pattern names cannot be pinned")
@@ -637,6 +697,7 @@ class NodeImpl(Node):
637
697
  return subscriber
638
698
 
639
699
  def monitor(self, callback: Callable[[Topic], None]) -> Closable:
700
+ self._raise_if_closed()
640
701
  callback_id = self._next_monitor_callback_id
641
702
  self._next_monitor_callback_id += 1
642
703
  self._monitor_callbacks[callback_id] = callback
@@ -653,6 +714,7 @@ class NodeImpl(Node):
653
714
  _logger.exception("monitor() callback failed for %s", topic)
654
715
 
655
716
  async def scout(self, pattern: str) -> None:
717
+ self._raise_if_closed()
656
718
  resolved, pin, _ = resolve_name(pattern, self._home, self._namespace, self._remaps)
657
719
  if pin is not None:
658
720
  raise ValueError("Cannot scout a pinned name/pattern")
@@ -800,12 +862,10 @@ class NodeImpl(Node):
800
862
  tracker.remaining.clear()
801
863
 
802
864
  @staticmethod
803
- def prepare_publish_tracker(topic: TopicImpl, tag: int, deadline_ns: int, data: bytes) -> PublishTracker:
865
+ def prepare_publish_tracker(topic: TopicImpl, tag: int) -> PublishTracker:
804
866
  tracker = PublishTracker(
805
867
  tag=tag,
806
- deadline_ns=deadline_ns,
807
868
  ack_event=asyncio.Event(),
808
- data=data,
809
869
  )
810
870
  tracker.ack_timeout = ACK_BASELINE_DEFAULT_TIMEOUT
811
871
  for assoc in sorted(topic.associations.values(), key=lambda x: x.remote_id):
@@ -1036,14 +1096,6 @@ class NodeImpl(Node):
1036
1096
 
1037
1097
  root.scout_task = self.loop.create_task(do_send())
1038
1098
 
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
1099
  # -- Message Dispatch --
1048
1100
 
1049
1101
  def on_subject_arrival(self, subject_id: int, arrival: TransportArrival) -> None:
@@ -1055,6 +1107,8 @@ class NodeImpl(Node):
1055
1107
  self.dispatch_arrival(arrival, subject_id=None, unicast=True)
1056
1108
 
1057
1109
  def dispatch_arrival(self, arrival: TransportArrival, *, subject_id: int | None, unicast: bool) -> None:
1110
+ if self._closed:
1111
+ return # Drop late arrivals after close instead of mutating state / spawning sends.
1058
1112
  msg = arrival.message
1059
1113
  if len(msg) < HEADER_SIZE:
1060
1114
  _logger.debug("Drop short msg len=%d", len(msg))
@@ -1207,7 +1261,7 @@ class NodeImpl(Node):
1207
1261
  except (SendError, OSError) as e:
1208
1262
  _logger.debug("ACK send failed: %s", e)
1209
1263
 
1210
- self.loop.create_task(do_send())
1264
+ self._spawn_detached(do_send(), "ACK")
1211
1265
 
1212
1266
  def on_msg_ack(self, arrival: TransportArrival, hdr: MsgAckHeader | MsgNackHeader) -> None:
1213
1267
  topic = self.topics_by_hash.get(hdr.topic_hash)
@@ -1295,7 +1349,7 @@ class NodeImpl(Node):
1295
1349
  except (SendError, OSError) as e:
1296
1350
  _logger.debug("RSP ACK send failed: %s", e)
1297
1351
 
1298
- self.loop.create_task(do_send())
1352
+ self._spawn_detached(do_send(), "RSP ACK")
1299
1353
 
1300
1354
  def on_gossip(
1301
1355
  self,
@@ -1306,6 +1360,8 @@ class NodeImpl(Node):
1306
1360
  ) -> None:
1307
1361
  name = ""
1308
1362
  if hdr.name_len > 0:
1363
+ # Best-effort decode for diagnostics/monitoring; an invalid name cannot create a topic because
1364
+ # topic_subscribe_if_matching validates the character set before creating one.
1309
1365
  name = payload[: hdr.name_len].decode("utf-8", errors="replace")
1310
1366
 
1311
1367
  topic = self.topics_by_hash.get(hdr.topic_hash)
@@ -1374,6 +1430,14 @@ class NodeImpl(Node):
1374
1430
  now: float,
1375
1431
  ) -> TopicImpl | None:
1376
1432
  """Create an implicit topic if any pattern subscriber matches the name."""
1433
+ # REFERENCE PARITY: the reference does not (yet) validate gossip-name characters here -- it trusts
1434
+ # the hash. We additionally reject names that are not valid resolved wire names (non-normalized,
1435
+ # non-verbatim by this implementation's rule, homeful, or pinned) so untrusted wire input cannot
1436
+ # create a local topic with such a name. Consistent with the documented whitespace-strip deviation;
1437
+ # the reference may adopt the same validation later.
1438
+ if not _is_valid_wire_name(name):
1439
+ _logger.debug("Gossip drop invalid wire name hash=%016x", topic_hash)
1440
+ return None
1377
1441
  # Validate that the hash matches the name to prevent corrupt gossip from creating inconsistencies.
1378
1442
  if rapidhash(name) != topic_hash:
1379
1443
  _logger.debug("Gossip hash mismatch for '%s': got %016x, expected %016x", name, topic_hash, rapidhash(name))
@@ -1398,12 +1462,15 @@ class NodeImpl(Node):
1398
1462
  def on_scout(self, arrival: TransportArrival, hdr: ScoutHeader, payload: bytes) -> None:
1399
1463
  if hdr.pattern_len == 0 or hdr.pattern_len > TOPIC_NAME_MAX or len(payload) < hdr.pattern_len:
1400
1464
  return
1465
+ # Best-effort decode; an invalid pattern simply matches no local topic names.
1401
1466
  pattern = payload[: hdr.pattern_len].decode("utf-8", errors="replace")
1402
1467
  _logger.debug("Scout received pattern='%s' from %016x", pattern, arrival.remote_id)
1403
1468
  for topic in list(self.topics_by_name.values()):
1404
1469
  subs = match_pattern(pattern, topic.name)
1405
1470
  if subs is not None:
1406
- self.loop.create_task(self.send_gossip_unicast(topic, arrival.remote_id, arrival.priority))
1471
+ self._spawn_detached(
1472
+ self.send_gossip_unicast(topic, arrival.remote_id, arrival.priority), "gossip unicast"
1473
+ )
1407
1474
 
1408
1475
  # -- Implicit Topic GC --
1409
1476
 
@@ -1474,6 +1541,12 @@ class NodeImpl(Node):
1474
1541
  return
1475
1542
  self._closed = True
1476
1543
  _logger.info("Node closing home='%s'", self._home)
1544
+ # Unblock anything awaiting on a subscriber (`async for`): closing each enqueues StopAsyncIteration,
1545
+ # otherwise a default (no-liveness-timeout) subscriber would wait on its queue forever. (Reliable
1546
+ # publishes / response streams are deadline-bounded and resolve on their own.)
1547
+ for root in list(self.sub_roots_verbatim.values()) + list(self.sub_roots_pattern.values()):
1548
+ for sub in list(root.subscribers):
1549
+ sub.close()
1477
1550
  self._gc_task.cancel()
1478
1551
  for root in list(self.sub_roots_pattern.values()):
1479
1552
  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())