PyAgoraRTC 0.2.0__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,166 @@
1
+ """Timing policies the session runtime applies: peer recovery, keep-alive cadence and renew debounce.
2
+
3
+ Pure: each policy reads an injected monotonic clock (or a ``now`` the caller passes) and owns no task;
4
+ the runtime owns every loop and timer (D13).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ import logging
11
+ from typing import TYPE_CHECKING
12
+
13
+ from pyagorartc.const import (
14
+ KEEPALIVE_INTERVAL_S,
15
+ PEER_RECOVER_COOLDOWN_S,
16
+ PEER_RECOVER_MAX_ATTEMPTS,
17
+ PEER_RECOVER_RESET_S,
18
+ PEER_REJOIN_DEBOUNCE_S,
19
+ RENEW_TOKEN_DEBOUNCE_S,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable
24
+
25
+ _LOGGER = logging.getLogger(__name__)
26
+
27
+
28
+ class PeerRecovery:
29
+ """When a publisher that left the channel should be recovered (D14; HA-Luba ``_peer_recovery``).
30
+
31
+ The runtime, on ``on_user_offline`` for a uid other than its own and only when ``on_peer_left``
32
+ is set: cancels any pending recovery timer (only the latest peer-left counts), then calls
33
+ ``peer_left``; if that returns a delay, it sleeps that long and calls ``should_recover`` with
34
+ whether the uid is back online, invoking ``on_peer_left(uid)`` when it returns True. It skips
35
+ all of this once the session is no longer joined, and calls ``reset`` after a successful join.
36
+
37
+ One budget covers every uid, as shipped: at most ``max_attempts`` recoveries, ``cooldown_s``
38
+ apart; a stream that runs longer than ``reset_after_s`` since the last recovery earns a fresh budget.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ debounce_s: float = PEER_REJOIN_DEBOUNCE_S,
45
+ cooldown_s: float = PEER_RECOVER_COOLDOWN_S,
46
+ max_attempts: int = PEER_RECOVER_MAX_ATTEMPTS,
47
+ reset_after_s: float = PEER_RECOVER_RESET_S,
48
+ clock: Callable[[], float],
49
+ ) -> None:
50
+ self.debounce_s = debounce_s
51
+ self.cooldown_s = cooldown_s
52
+ self.max_attempts = max_attempts
53
+ self.reset_after_s = reset_after_s
54
+ self._clock = clock
55
+ self._attempts = 0
56
+ self._last_recovered_at: float | None = None
57
+
58
+ @property
59
+ def attempts(self) -> int:
60
+ """Recoveries counted against the current budget."""
61
+ return self._attempts
62
+
63
+ def peer_left(self, uid: int, now: float | None = None) -> float | None:
64
+ """Seconds to wait for ``uid`` to rejoin, or ``None`` when no recovery could follow.
65
+
66
+ ``None`` means ``should_recover`` would refuse at the end of the debounce (attempt cap or
67
+ cooldown), so the runtime need not arm a timer. Records nothing.
68
+ """
69
+ due = (self._clock() if now is None else now) + self.debounce_s
70
+ if (reason := self._refusal(due)) is not None:
71
+ _LOGGER.debug("Peer %s left; no recovery can follow (%s)", uid, reason)
72
+ return None
73
+ return self.debounce_s
74
+
75
+ def should_recover(self, uid: int, now: float | None = None, *, peer_present: bool) -> bool:
76
+ """Whether to recover ``uid`` now, after the debounce; records the attempt when True."""
77
+ if peer_present:
78
+ return False
79
+ now = self._clock() if now is None else now
80
+ self._apply_reset(now)
81
+ if (reason := self._refusal(now)) is not None:
82
+ if self._attempts >= self.max_attempts:
83
+ _LOGGER.warning(
84
+ "Peer %s left the channel %s times without the stream settling; not recovering it again",
85
+ uid,
86
+ self._attempts,
87
+ )
88
+ else:
89
+ _LOGGER.debug("Peer %s still gone; recovery skipped (%s)", uid, reason)
90
+ return False
91
+ self._last_recovered_at = now
92
+ self._attempts += 1
93
+ _LOGGER.debug("Recovering peer %s (attempt %s/%s)", uid, self._attempts, self.max_attempts)
94
+ return True
95
+
96
+ def reset(self) -> None:
97
+ """Forget every attempt and the cooldown (a fresh session)."""
98
+ self._attempts = 0
99
+ self._last_recovered_at = None
100
+
101
+ def _apply_reset(self, now: float) -> None:
102
+ if self._attempts and self._since_last(now) > self.reset_after_s:
103
+ self._attempts = 0
104
+
105
+ def _refusal(self, now: float) -> str | None:
106
+ attempts = self._attempts
107
+ if attempts and self._since_last(now) > self.reset_after_s:
108
+ attempts = 0
109
+ if attempts >= self.max_attempts:
110
+ return "attempt cap reached"
111
+ if self._since_last(now) < self.cooldown_s:
112
+ return "inside cooldown"
113
+ return None
114
+
115
+ def _since_last(self, now: float) -> float:
116
+ return float("inf") if self._last_recovered_at is None else now - self._last_recovered_at
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Keepalive:
121
+ """Cadence and deadline for the host's periodic keep-alive callback (D15).
122
+
123
+ ``deadline`` is absolute monotonic seconds; ``None`` means none. The runtime's loop: close with
124
+ ``CloseReason.DEADLINE`` once ``deadline_reached``; otherwise run the callback (stopping the
125
+ callback, not the deadline, when it returns False) and sleep ``delay``.
126
+ """
127
+
128
+ interval_s: float = KEEPALIVE_INTERVAL_S
129
+ deadline: float | None = None
130
+
131
+ def next_due(self, now: float) -> float:
132
+ """The monotonic instant of the next tick: one interval on, but never past the deadline."""
133
+ due = now + self.interval_s
134
+ return due if self.deadline is None else min(due, self.deadline)
135
+
136
+ def delay(self, now: float) -> float:
137
+ """Seconds from ``now`` until ``next_due``; 0 once the deadline has passed."""
138
+ return max(0.0, self.next_due(now) - now)
139
+
140
+ def deadline_reached(self, now: float) -> bool:
141
+ """Whether the session's budget is spent."""
142
+ return self.deadline is not None and now >= self.deadline
143
+
144
+
145
+ class RenewDebounce:
146
+ """At most one ``renew_token`` per window (D8; ``will_expire`` repeats about once a second).
147
+
148
+ ``clear`` on ``on_token_privilege_did_expire``, or when a renew failed to send, so the next goes out.
149
+ """
150
+
151
+ def __init__(self, *, window_s: float = RENEW_TOKEN_DEBOUNCE_S, clock: Callable[[], float]) -> None:
152
+ self.window_s = window_s
153
+ self._clock = clock
154
+ self._last_sent_at: float | None = None
155
+
156
+ def should_send(self, now: float | None = None) -> bool:
157
+ """Whether to send a renew now; records the send when True."""
158
+ now = self._clock() if now is None else now
159
+ if self._last_sent_at is not None and now - self._last_sent_at < self.window_s:
160
+ return False
161
+ self._last_sent_at = now
162
+ return True
163
+
164
+ def clear(self) -> None:
165
+ """Let the next renew through regardless of the window."""
166
+ self._last_sent_at = None