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.
- {pycyphal2-2.0.0.dev3/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev5}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/__init__.py +1 -1
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_node.py +92 -19
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_publisher.py +7 -21
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_subscriber.py +3 -19
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_interface.py +5 -4
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_media_slcan.py +3 -4
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_wire.py +1 -2
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/pythoncan.py +21 -16
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/socketcan.py +30 -14
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/udp.py +40 -16
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5/src/pycyphal2.egg-info}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_integration.py +2 -2
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_monitor.py +27 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_parity.py +0 -2
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_parity_coverage.py +3 -3
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_pubsub.py +60 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_reliable.py +0 -3
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_topic.py +37 -10
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_udp.py +131 -10
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/LICENSE +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/README.md +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/pyproject.toml +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/setup.cfg +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_api.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_hash.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_header.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/_transport.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/__init__.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_reassembly.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/_transport.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/can/webserial.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2/py.typed +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2.egg-info/SOURCES.txt +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2.egg-info/requires.txt +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/src/pycyphal2.egg-info/top_level.txt +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_gossip.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_hash.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_header.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_names.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_reorder.py +0 -0
- {pycyphal2-2.0.0.dev3 → pycyphal2-2.0.0.dev5}/tests/test_rpc.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 <=
|
|
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 <=
|
|
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 <=
|
|
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
|
|
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 >
|
|
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
|
-
|
|
31
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 &
|
|
266
|
+
return TimestampedFrame(id=msg.arbitration_id & CAN_EXT_ID_MASK, data=bytes(msg.data), timestamp=Instant.now())
|