pycyphal2 2.0.0.dev0__py3-none-any.whl

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.
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import math
6
+ from dataclasses import dataclass
7
+
8
+ from ._api import DeliveryError, Instant, LivenessError, Priority, SendError
9
+ from ._api import Publisher, Topic, ResponseStream, Response
10
+ from ._header import MsgBeHeader, MsgRelHeader, RspBeHeader, RspRelHeader
11
+ from ._node import ACK_BASELINE_DEFAULT_TIMEOUT, NodeImpl, PublishTracker, SESSION_LIFETIME, TopicImpl
12
+ from ._transport import TransportArrival
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+ REQUEST_FUTURE_HISTORY = 192
17
+ REQUEST_FUTURE_HISTORY_MASK = (1 << REQUEST_FUTURE_HISTORY) - 1
18
+ ACK_TIMEOUT_MIN = 1e-6
19
+
20
+
21
+ @dataclass
22
+ class ResponseRemoteState:
23
+ seqno_top: int
24
+ seqno_acked: int = 1
25
+
26
+ def accept(self, seqno: int) -> tuple[bool, bool]:
27
+ if seqno > self.seqno_top:
28
+ shift = seqno - self.seqno_top
29
+ self.seqno_acked = (
30
+ 1
31
+ if shift >= REQUEST_FUTURE_HISTORY
32
+ else (((self.seqno_acked << shift) & REQUEST_FUTURE_HISTORY_MASK) | 1)
33
+ )
34
+ self.seqno_top = seqno
35
+ return True, True
36
+ dist = self.seqno_top - seqno
37
+ if dist >= REQUEST_FUTURE_HISTORY:
38
+ return False, False
39
+ mask = 1 << dist
40
+ if self.seqno_acked & mask:
41
+ return True, False
42
+ self.seqno_acked |= mask
43
+ return True, True
44
+
45
+ def accepted_earlier(self, seqno: int) -> bool:
46
+ if seqno > self.seqno_top:
47
+ return False
48
+ dist = self.seqno_top - seqno
49
+ return dist < REQUEST_FUTURE_HISTORY and bool(self.seqno_acked & (1 << dist))
50
+
51
+
52
+ class PublisherImpl(Publisher):
53
+ def __init__(self, node: NodeImpl, topic: TopicImpl) -> None:
54
+ self._node = node
55
+ self._topic = topic
56
+ self._priority = Priority.NOMINAL
57
+ self._ack_timeout_baseline = ACK_BASELINE_DEFAULT_TIMEOUT
58
+ self.closed = False
59
+
60
+ @property
61
+ def topic(self) -> Topic:
62
+ return self._topic
63
+
64
+ @property
65
+ def priority(self) -> Priority:
66
+ return self._priority
67
+
68
+ @priority.setter
69
+ def priority(self, priority: Priority) -> None:
70
+ self._priority = priority
71
+
72
+ @property
73
+ def ack_timeout(self) -> float:
74
+ return self._ack_timeout_baseline * (1 << int(self._priority))
75
+
76
+ @ack_timeout.setter
77
+ def ack_timeout(self, duration: float) -> None:
78
+ duration = float(duration)
79
+ if duration < ACK_TIMEOUT_MIN or not math.isfinite(duration):
80
+ raise ValueError("ACK timeout must be a positive finite duration")
81
+ if duration > SESSION_LIFETIME:
82
+ raise ValueError(f"ACK timeout must be less than session lifetime")
83
+ self._ack_timeout_baseline = duration / (1 << int(self._priority))
84
+
85
+ async def __call__(
86
+ self,
87
+ deadline: Instant,
88
+ message: memoryview | bytes,
89
+ *,
90
+ reliable: bool = False,
91
+ ) -> None:
92
+ if self.closed:
93
+ raise SendError("Publisher closed")
94
+
95
+ tag = self._topic.next_tag()
96
+ payload = bytes(message)
97
+
98
+ if not reliable:
99
+ writer = self._topic.ensure_writer()
100
+ await writer(deadline, self._priority, self._serialize_message(tag, payload, reliable=False))
101
+ _logger.debug("Published BE tag=%d topic='%s'", tag, self._topic.name)
102
+ return
103
+
104
+ await self._reliable_publish(deadline, tag, payload)
105
+
106
+ async def request(
107
+ self,
108
+ delivery_deadline: Instant,
109
+ response_timeout: float,
110
+ message: memoryview | bytes,
111
+ ) -> ResponseStream:
112
+ if self.closed:
113
+ raise SendError("Publisher closed")
114
+
115
+ tag = self._topic.next_tag()
116
+ payload = bytes(message)
117
+
118
+ # Create response stream before publishing so it's ready to receive.
119
+ stream = ResponseStreamImpl(
120
+ node=self._node,
121
+ topic=self._topic,
122
+ message_tag=tag,
123
+ response_timeout=response_timeout,
124
+ )
125
+ self._topic.request_futures[tag] = stream
126
+
127
+ tracker = self._prepare_reliable_publish_tracker(tag, delivery_deadline.ns, payload)
128
+ try:
129
+ initial_window = await self._reliable_publish_start(delivery_deadline, tag, payload, tracker)
130
+ except asyncio.CancelledError:
131
+ tracker.compromised = True
132
+ self._topic.request_futures.pop(tag, None)
133
+ self._release_reliable_publish_tracker(tag, tracker)
134
+ raise
135
+ except BaseException:
136
+ self._topic.request_futures.pop(tag, None)
137
+ self._release_reliable_publish_tracker(tag, tracker)
138
+ raise
139
+
140
+ task = self._node.loop.create_task(
141
+ self._request_publish(delivery_deadline, tag, payload, stream, tracker, initial_window)
142
+ )
143
+
144
+ def on_done(done_task: asyncio.Task[None]) -> None:
145
+ if done_task.cancelled() and self._topic.publish_futures.get(tag) is tracker:
146
+ tracker.compromised = True
147
+ self._release_reliable_publish_tracker(tag, tracker)
148
+
149
+ task.add_done_callback(on_done)
150
+ stream.set_publish_task(task)
151
+ return stream
152
+
153
+ async def _request_publish(
154
+ self,
155
+ deadline: Instant,
156
+ tag: int,
157
+ payload: bytes,
158
+ stream: ResponseStreamImpl,
159
+ tracker: PublishTracker,
160
+ initial_window: tuple[int, bool],
161
+ ) -> None:
162
+ try:
163
+ await self._reliable_publish_continue(deadline, tag, payload, tracker, initial_window)
164
+ except asyncio.CancelledError:
165
+ tracker.compromised = True
166
+ raise
167
+ except BaseException as ex:
168
+ stream.on_publish_error(ex)
169
+ finally:
170
+ self._release_reliable_publish_tracker(tag, tracker)
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
+ @staticmethod
179
+ def _ack_window_is_compromised(deadline_ns: int, current_ack_timeout: float) -> bool:
180
+ return Instant.now().ns >= (deadline_ns - round(current_ack_timeout * 1e9))
181
+
182
+ def _serialize_message(self, tag: int, payload: bytes, *, reliable: bool) -> bytes:
183
+ lage = self._topic.lage(Instant.now().s)
184
+ hdr = (MsgRelHeader if reliable else MsgBeHeader)(
185
+ topic_log_age=lage,
186
+ topic_evictions=self._topic.evictions,
187
+ topic_hash=self._topic.hash,
188
+ tag=tag,
189
+ )
190
+ return hdr.serialize() + payload
191
+
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)
202
+ tracker.ack_timeout = self.ack_timeout
203
+ self._topic.publish_futures[tag] = tracker
204
+ return tracker
205
+
206
+ def _release_reliable_publish_tracker(self, tag: int, tracker: PublishTracker) -> None:
207
+ self._topic.publish_futures.pop(tag, None)
208
+ self._node.publish_tracker_release(self._topic, tracker)
209
+
210
+ async def _send_reliable_publish(
211
+ self,
212
+ deadline: Instant,
213
+ tag: int,
214
+ payload: bytes,
215
+ tracker: PublishTracker,
216
+ *,
217
+ first_attempt: bool,
218
+ ) -> None:
219
+ data = self._serialize_message(tag, payload, reliable=True)
220
+ if (not first_attempt) and (len(tracker.remaining) == 1):
221
+ remote_id = next(iter(tracker.remaining))
222
+ await self._node.transport.unicast(deadline, self._priority, remote_id, data)
223
+ else:
224
+ writer = self._topic.ensure_writer()
225
+ await writer(deadline, self._priority, data)
226
+
227
+ async def _reliable_publish_start(
228
+ self,
229
+ deadline: Instant,
230
+ tag: int,
231
+ payload: bytes,
232
+ tracker: PublishTracker,
233
+ ) -> tuple[int, bool]:
234
+ initial_window = self._reliable_publish_window(deadline.ns, tracker.ack_timeout)
235
+ if initial_window is None:
236
+ raise DeliveryError("Reliable publish not acknowledged before deadline")
237
+ ack_deadline_ns, _ = initial_window
238
+ tracker.ack_event.clear()
239
+ try:
240
+ await self._send_reliable_publish(Instant(ns=ack_deadline_ns), tag, payload, tracker, first_attempt=True)
241
+ except SendError:
242
+ tracker.compromised = True
243
+ raise
244
+ except OSError as ex:
245
+ tracker.compromised = True
246
+ raise SendError("Reliable publish initial send failed") from ex
247
+ return initial_window
248
+
249
+ async def _reliable_publish_continue(
250
+ self,
251
+ deadline: Instant,
252
+ tag: int,
253
+ payload: bytes,
254
+ tracker: PublishTracker,
255
+ initial_window: tuple[int, bool],
256
+ ) -> None:
257
+ ack_deadline_ns, last_attempt = initial_window
258
+ while True:
259
+ if tracker.acknowledged and not tracker.remaining:
260
+ _logger.debug("Reliable publish ACKed tag=%d topic='%s'", tag, self._topic.name)
261
+ return
262
+
263
+ wait_until_ns = deadline.ns if last_attempt else ack_deadline_ns
264
+ wait_timeout = max(0.0, (wait_until_ns - Instant.now().ns) * 1e-9)
265
+ if wait_timeout > 0:
266
+ try:
267
+ await asyncio.wait_for(tracker.ack_event.wait(), timeout=wait_timeout)
268
+ except asyncio.TimeoutError:
269
+ pass
270
+
271
+ if (not last_attempt) and self._ack_window_is_compromised(deadline.ns, tracker.ack_timeout):
272
+ tracker.compromised = True
273
+
274
+ if tracker.acknowledged and not tracker.remaining:
275
+ _logger.debug("Reliable publish ACKed tag=%d topic='%s'", tag, self._topic.name)
276
+ return
277
+ if last_attempt:
278
+ break
279
+ tracker.ack_timeout *= 2
280
+ next_window = self._reliable_publish_window(deadline.ns, tracker.ack_timeout)
281
+ if next_window is None:
282
+ break
283
+ ack_deadline_ns, last_attempt = next_window
284
+ tracker.ack_event.clear()
285
+ try:
286
+ await self._send_reliable_publish(
287
+ Instant(ns=ack_deadline_ns), tag, payload, tracker, first_attempt=False
288
+ )
289
+ except (SendError, OSError):
290
+ tracker.compromised = True
291
+
292
+ raise DeliveryError("Reliable publish not acknowledged before deadline")
293
+
294
+ async def _reliable_publish(self, deadline: Instant, tag: int, payload: bytes) -> None:
295
+ tracker = self._prepare_reliable_publish_tracker(tag, deadline.ns, payload)
296
+ try:
297
+ initial_window = await self._reliable_publish_start(deadline, tag, payload, tracker)
298
+ await self._reliable_publish_continue(deadline, tag, payload, tracker, initial_window)
299
+ except asyncio.CancelledError:
300
+ tracker.compromised = True
301
+ raise
302
+ finally:
303
+ self._release_reliable_publish_tracker(tag, tracker)
304
+
305
+ def close(self) -> None:
306
+ if self.closed:
307
+ return
308
+ self.closed = True
309
+ self._topic.pub_count -= 1
310
+ self._topic.sync_implicit()
311
+ _logger.info("Publisher closed for '%s'", self._topic.name)
312
+
313
+
314
+ # =====================================================================================================================
315
+ # Response Stream
316
+ # =====================================================================================================================
317
+
318
+
319
+ class ResponseStreamImpl(ResponseStream):
320
+ def __init__(
321
+ self,
322
+ node: NodeImpl,
323
+ topic: TopicImpl,
324
+ message_tag: int,
325
+ response_timeout: float,
326
+ ) -> None:
327
+ self._node = node
328
+ self._topic = topic
329
+ self._message_tag = message_tag
330
+ self._response_timeout = response_timeout
331
+ self.queue: asyncio.Queue[Response | BaseException] = asyncio.Queue()
332
+ self.closed = False
333
+ self._reliable_remote_by_id: dict[int, ResponseRemoteState] = {}
334
+ self._publish_task: asyncio.Task[None] | None = None
335
+ self._cleanup_handle: asyncio.TimerHandle | None = None
336
+
337
+ def __aiter__(self) -> ResponseStreamImpl:
338
+ return self
339
+
340
+ async def __anext__(self) -> Response:
341
+ if self.closed:
342
+ raise StopAsyncIteration
343
+ try:
344
+ item = await asyncio.wait_for(self.queue.get(), timeout=self._response_timeout)
345
+ except asyncio.TimeoutError:
346
+ raise LivenessError("Response timeout")
347
+ if isinstance(item, StopAsyncIteration):
348
+ raise item
349
+ if isinstance(item, BaseException):
350
+ raise item
351
+ return item
352
+
353
+ def set_publish_task(self, task: asyncio.Task[None]) -> None:
354
+ self._publish_task = task
355
+
356
+ def on_publish_error(self, ex: BaseException) -> None:
357
+ if self.closed or isinstance(ex, asyncio.CancelledError):
358
+ return
359
+ self.queue.put_nowait(ex)
360
+
361
+ def _remove_from_topic(self) -> None:
362
+ if self._cleanup_handle is not None:
363
+ self._cleanup_handle.cancel()
364
+ self._cleanup_handle = None
365
+ if self._topic.request_futures.get(self._message_tag) is self:
366
+ del self._topic.request_futures[self._message_tag]
367
+
368
+ def _schedule_cleanup(self) -> None:
369
+ if self._cleanup_handle is not None:
370
+ return
371
+
372
+ def cleanup() -> None:
373
+ self._cleanup_handle = None
374
+ self._remove_from_topic()
375
+
376
+ self._cleanup_handle = self._node.loop.call_later(SESSION_LIFETIME / 2, cleanup)
377
+
378
+ def on_response(
379
+ self,
380
+ arrival: TransportArrival,
381
+ hdr: RspBeHeader | RspRelHeader,
382
+ payload: bytes,
383
+ ) -> bool:
384
+ """Called by the node when a response arrives matching our message_tag."""
385
+ reliable = isinstance(hdr, RspRelHeader)
386
+ if self.closed:
387
+ if not reliable:
388
+ return False
389
+ remote = self._reliable_remote_by_id.get(arrival.remote_id)
390
+ return (remote is not None) and remote.accepted_earlier(hdr.seqno)
391
+
392
+ if reliable:
393
+ remote = self._reliable_remote_by_id.get(arrival.remote_id)
394
+ if remote is None:
395
+ remote = ResponseRemoteState(seqno_top=hdr.seqno)
396
+ self._reliable_remote_by_id[arrival.remote_id] = remote
397
+ unique = True
398
+ else:
399
+ accepted, unique = remote.accept(hdr.seqno)
400
+ if not accepted:
401
+ return False
402
+ if not unique:
403
+ _logger.debug("RSP dedup drop remote=%016x seqno=%d", arrival.remote_id, hdr.seqno)
404
+ return True
405
+
406
+ response = Response(
407
+ timestamp=arrival.timestamp,
408
+ remote_id=arrival.remote_id,
409
+ seqno=hdr.seqno,
410
+ message=payload,
411
+ )
412
+ self.queue.put_nowait(response)
413
+ return True
414
+
415
+ def close(self) -> None:
416
+ if self.closed:
417
+ return
418
+ self.closed = True
419
+ if self._publish_task is not None:
420
+ self._publish_task.cancel()
421
+ self._publish_task = None
422
+ if self._reliable_remote_by_id:
423
+ self._schedule_cleanup()
424
+ else:
425
+ self._remove_from_topic()
426
+ self.queue.put_nowait(StopAsyncIteration())
427
+ _logger.debug("Response stream closed for tag=%d", self._message_tag)