caprid-relay 0.1.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.
- caprid_relay/__init__.py +133 -0
- caprid_relay/_awaitable.py +59 -0
- caprid_relay/_bounded_events.py +166 -0
- caprid_relay/_frame_reader.py +179 -0
- caprid_relay/_settling_groups.py +136 -0
- caprid_relay/_subscription.py +515 -0
- caprid_relay/_supervision.py +302 -0
- caprid_relay/_validation.py +168 -0
- caprid_relay/_write_slot.py +927 -0
- caprid_relay/cancellation.py +68 -0
- caprid_relay/connect.py +449 -0
- caprid_relay/connect_session.py +563 -0
- caprid_relay/errors.py +153 -0
- caprid_relay/media.py +1076 -0
- caprid_relay/namespace_events.py +218 -0
- caprid_relay/payload.py +30 -0
- caprid_relay/publish.py +349 -0
- caprid_relay/py.typed +0 -0
- caprid_relay/quic/__init__.py +22 -0
- caprid_relay/quic/_aioquic.py +160 -0
- caprid_relay/quic/_client.py +332 -0
- caprid_relay/quic/_errors.py +116 -0
- caprid_relay/quic/_fin_retry.py +105 -0
- caprid_relay/quic/_protocol.py +337 -0
- caprid_relay/quic/_session.py +400 -0
- caprid_relay/quic/_streams.py +373 -0
- caprid_relay/quic/_udp.py +114 -0
- caprid_relay/room_endpoint.py +62 -0
- caprid_relay/subscription_events.py +87 -0
- caprid_relay/supervised_publisher.py +1097 -0
- caprid_relay/supervised_subscriber.py +1817 -0
- caprid_relay/switchable.py +566 -0
- caprid_relay-0.1.0.dist-info/METADATA +90 -0
- caprid_relay-0.1.0.dist-info/RECORD +36 -0
- caprid_relay-0.1.0.dist-info/WHEEL +4 -0
- caprid_relay-0.1.0.dist-info/licenses/LICENSE +202 -0
caprid_relay/__init__.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Relay Python SDK.
|
|
2
|
+
|
|
3
|
+
Owns the Relay product API and authentication convention, built on ``moq_core``. The default
|
|
4
|
+
transport is native QUIC through aioquic (``caprid_relay.quic``), which reaches a relay natively with no
|
|
5
|
+
browser; ``create_transport`` swaps in another implementation of the same seam.
|
|
6
|
+
|
|
7
|
+
The public names are the rows of ``spec/vocabulary.md``, pinned by ``tests/test_exports.py``. From
|
|
8
|
+
the core this package re-exports exactly what ``@caprid/relay`` re-exports, plus ``Closed``; every
|
|
9
|
+
other protocol type (location filters, fetch, track status, the constants) is imported from
|
|
10
|
+
``moq_core``, a declared dependency.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from moq_core import (
|
|
14
|
+
Closed,
|
|
15
|
+
Goaway,
|
|
16
|
+
KeyValuePair,
|
|
17
|
+
LocationFilter,
|
|
18
|
+
MessageParameters,
|
|
19
|
+
MoqtObject,
|
|
20
|
+
MoqTrackSource,
|
|
21
|
+
MoqtTransport,
|
|
22
|
+
PublishDone,
|
|
23
|
+
RequestError,
|
|
24
|
+
SubscribeOk,
|
|
25
|
+
Subscription,
|
|
26
|
+
SubscriptionHandlers,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ._awaitable import RelayAwaitable
|
|
30
|
+
from ._subscription import RelaySubscription, SubscriptionEnd, SubscriptionEndKind
|
|
31
|
+
from ._supervision import SupervisedStatus, SupervisionBackoff
|
|
32
|
+
from .cancellation import CancellationToken, Cancelled
|
|
33
|
+
from .connect import RelaySubscriber, RelaySubscriptionHandlers, connect_relay_subscriber
|
|
34
|
+
from .connect_session import ConnectingTransport, RelayCredential, RelayTransportFactory
|
|
35
|
+
from .errors import RelayError, RelayErrorCode, RelayErrorPhase
|
|
36
|
+
from .namespace_events import NamespaceEvent, NamespaceEvents
|
|
37
|
+
from .payload import read_json, read_text
|
|
38
|
+
from .publish import RelayGroupWriter, RelayPublisher, RelayTrackWriter, connect_relay_publisher
|
|
39
|
+
from .room_endpoint import room_endpoint
|
|
40
|
+
from .subscription_events import (
|
|
41
|
+
GoawayEvent,
|
|
42
|
+
ObjectEvent,
|
|
43
|
+
PublisherDoneEvent,
|
|
44
|
+
StateNotifyEvent,
|
|
45
|
+
SubgroupEndEvent,
|
|
46
|
+
SubgroupResetEvent,
|
|
47
|
+
SubscribedEvent,
|
|
48
|
+
SubscriptionConnectionClosedError,
|
|
49
|
+
SubscriptionEvent,
|
|
50
|
+
SubscriptionEvents,
|
|
51
|
+
SubscriptionOverflowError,
|
|
52
|
+
SubscriptionRequestError,
|
|
53
|
+
UpdateOkEvent,
|
|
54
|
+
)
|
|
55
|
+
from .supervised_publisher import PublisherGeneration, SupervisedPublisher, connect_supervised_publisher
|
|
56
|
+
from .supervised_subscriber import (
|
|
57
|
+
RestartReason,
|
|
58
|
+
SupervisedSubscriber,
|
|
59
|
+
WantHandlers,
|
|
60
|
+
WantSwitchedInfo,
|
|
61
|
+
connect_supervised_subscriber,
|
|
62
|
+
)
|
|
63
|
+
from .switchable import SwitchableHandlers, SwitchableSubscription, SwitchedInfo
|
|
64
|
+
|
|
65
|
+
DEFAULT_ENDPOINT = "https://relay.mux.global"
|
|
66
|
+
"""The default Relay endpoint pinned in ``spec/protocol.json``."""
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"DEFAULT_ENDPOINT",
|
|
70
|
+
"CancellationToken",
|
|
71
|
+
"Cancelled",
|
|
72
|
+
"Closed",
|
|
73
|
+
"ConnectingTransport",
|
|
74
|
+
"Goaway",
|
|
75
|
+
"GoawayEvent",
|
|
76
|
+
"KeyValuePair",
|
|
77
|
+
"LocationFilter",
|
|
78
|
+
"MessageParameters",
|
|
79
|
+
"MoqTrackSource",
|
|
80
|
+
"MoqtObject",
|
|
81
|
+
"MoqtTransport",
|
|
82
|
+
"NamespaceEvent",
|
|
83
|
+
"NamespaceEvents",
|
|
84
|
+
"ObjectEvent",
|
|
85
|
+
"PublishDone",
|
|
86
|
+
"PublisherDoneEvent",
|
|
87
|
+
"PublisherGeneration",
|
|
88
|
+
"RelayAwaitable",
|
|
89
|
+
"RelayCredential",
|
|
90
|
+
"RelayError",
|
|
91
|
+
"RelayErrorCode",
|
|
92
|
+
"RelayErrorPhase",
|
|
93
|
+
"RelayGroupWriter",
|
|
94
|
+
"RelayPublisher",
|
|
95
|
+
"RelaySubscriber",
|
|
96
|
+
"RelaySubscription",
|
|
97
|
+
"RelaySubscriptionHandlers",
|
|
98
|
+
"RelayTrackWriter",
|
|
99
|
+
"RelayTransportFactory",
|
|
100
|
+
"RequestError",
|
|
101
|
+
"RestartReason",
|
|
102
|
+
"StateNotifyEvent",
|
|
103
|
+
"SubgroupEndEvent",
|
|
104
|
+
"SubgroupResetEvent",
|
|
105
|
+
"SubscribeOk",
|
|
106
|
+
"SubscribedEvent",
|
|
107
|
+
"Subscription",
|
|
108
|
+
"SubscriptionConnectionClosedError",
|
|
109
|
+
"SubscriptionEnd",
|
|
110
|
+
"SubscriptionEndKind",
|
|
111
|
+
"SubscriptionEvent",
|
|
112
|
+
"SubscriptionEvents",
|
|
113
|
+
"SubscriptionHandlers",
|
|
114
|
+
"SubscriptionOverflowError",
|
|
115
|
+
"SubscriptionRequestError",
|
|
116
|
+
"SupervisedPublisher",
|
|
117
|
+
"SupervisedStatus",
|
|
118
|
+
"SupervisedSubscriber",
|
|
119
|
+
"SupervisionBackoff",
|
|
120
|
+
"SwitchableHandlers",
|
|
121
|
+
"SwitchableSubscription",
|
|
122
|
+
"SwitchedInfo",
|
|
123
|
+
"UpdateOkEvent",
|
|
124
|
+
"WantHandlers",
|
|
125
|
+
"WantSwitchedInfo",
|
|
126
|
+
"connect_relay_publisher",
|
|
127
|
+
"connect_relay_subscriber",
|
|
128
|
+
"connect_supervised_publisher",
|
|
129
|
+
"connect_supervised_subscriber",
|
|
130
|
+
"read_json",
|
|
131
|
+
"read_text",
|
|
132
|
+
"room_endpoint",
|
|
133
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The type of every fact the SDK settles once and lets any number of tasks await."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextvars
|
|
7
|
+
from collections.abc import Callable, Generator
|
|
8
|
+
from typing import Any, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
_T = TypeVar("_T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RelayAwaitable(Generic[_T]):
|
|
14
|
+
"""A fact the SDK settles once: a subscription's ``subscribed`` or ``ended``, a publisher's
|
|
15
|
+
``announced``, or a connection's ``closed``.
|
|
16
|
+
|
|
17
|
+
Await it as often as needed. Each waiter is isolated: a task cancelled while awaiting, or an
|
|
18
|
+
``asyncio.wait_for`` that times out, never touches the underlying result, where handing out the
|
|
19
|
+
future itself would let one impatient waiter destroy the signal for every later one. ``done()``,
|
|
20
|
+
``result()``, ``exception()`` and ``add_done_callback()`` read the result directly and allocate
|
|
21
|
+
nothing, so polling is free. It is not an :class:`asyncio.Future`: it cannot be cancelled or
|
|
22
|
+
set. Wrap it with :func:`asyncio.ensure_future` for an API that needs a future.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
__slots__ = ("_future",)
|
|
26
|
+
|
|
27
|
+
def __init__(self, future: asyncio.Future[_T]) -> None:
|
|
28
|
+
self._future = future
|
|
29
|
+
|
|
30
|
+
def __await__(self) -> Generator[Any, None, _T]:
|
|
31
|
+
future = self._future
|
|
32
|
+
if not future.done():
|
|
33
|
+
future = asyncio.shield(future)
|
|
34
|
+
return future.__await__()
|
|
35
|
+
|
|
36
|
+
def done(self) -> bool:
|
|
37
|
+
"""Whether the result is in."""
|
|
38
|
+
return self._future.done()
|
|
39
|
+
|
|
40
|
+
def result(self) -> _T:
|
|
41
|
+
"""The result; raises what awaiting would, or ``asyncio.InvalidStateError`` while pending."""
|
|
42
|
+
return self._future.result()
|
|
43
|
+
|
|
44
|
+
def exception(self) -> BaseException | None:
|
|
45
|
+
"""The exception awaiting would raise, ``None`` for a plain result."""
|
|
46
|
+
return self._future.exception()
|
|
47
|
+
|
|
48
|
+
def add_done_callback(
|
|
49
|
+
self, callback: Callable[[asyncio.Future[_T]], object], *, context: contextvars.Context | None = None
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Call ``callback`` with the settled future once the result is in."""
|
|
52
|
+
self._future.add_done_callback(callback, context=context)
|
|
53
|
+
|
|
54
|
+
def remove_done_callback(self, callback: Callable[[asyncio.Future[_T]], object]) -> int:
|
|
55
|
+
"""Forget a callback; returns how many registrations were removed."""
|
|
56
|
+
return self._future.remove_done_callback(callback)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["RelayAwaitable"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The bounded events iterator both events forms share: one owned request, a queue bound and a
|
|
2
|
+
cancellation token. ``subscription_events.py`` and ``namespace_events.py`` each supply the request
|
|
3
|
+
and the events; this module owns the queue, the single pending read and the ending.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Generic, Protocol, Self, TypeVar
|
|
13
|
+
|
|
14
|
+
from ._subscription import SubscriptionOverflowError
|
|
15
|
+
from .cancellation import CancellationToken
|
|
16
|
+
|
|
17
|
+
E = TypeVar("E")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OwnedRequest(Protocol):
|
|
21
|
+
"""The request an iterator owns: all it needs is the cancel."""
|
|
22
|
+
|
|
23
|
+
def cancel(self, reason: object = None) -> None:
|
|
24
|
+
"""Tear the request down; its echo through the sink is ignored."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BoundedEventSink(Generic[E]):
|
|
28
|
+
"""Where a producer delivers into a bounded iterator."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("_events",)
|
|
31
|
+
|
|
32
|
+
def __init__(self, events: BoundedEvents[E]) -> None:
|
|
33
|
+
self._events = events
|
|
34
|
+
|
|
35
|
+
def event(self, event: E) -> None:
|
|
36
|
+
"""One event; past the capacity the iterator ends with ``SubscriptionOverflowError``."""
|
|
37
|
+
self._events._push(event)
|
|
38
|
+
|
|
39
|
+
def ended(self, error: BaseException | None = None) -> None:
|
|
40
|
+
"""The producer ended: ``None`` finishes the iterator cleanly, anything else is raised from reads.
|
|
41
|
+
|
|
42
|
+
Either way the queue is discarded.
|
|
43
|
+
"""
|
|
44
|
+
# An ending the iterator started (close, cancellation, overflow) echoes back here.
|
|
45
|
+
self._events._finish(error)
|
|
46
|
+
|
|
47
|
+
def complete(self) -> None:
|
|
48
|
+
"""The producer finished cleanly: queued events are still read, then the iterator is done."""
|
|
49
|
+
self._events._finish(None, keep_queue=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BoundedEvents(Generic[E]):
|
|
53
|
+
"""Single-consumer iterator over one owned request. Use ``async with`` for deterministic cleanup.
|
|
54
|
+
|
|
55
|
+
At most ``capacity`` events are retained. Overflow discards the queue, cancels only this request
|
|
56
|
+
and raises on reads. No transport backpressure is implied. Explicit close produces EOF; failures
|
|
57
|
+
raise.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
start: Callable[[BoundedEventSink[E]], OwnedRequest],
|
|
63
|
+
*,
|
|
64
|
+
capacity: int = 64,
|
|
65
|
+
cancellation: CancellationToken | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
if isinstance(capacity, bool) or not isinstance(capacity, int | float):
|
|
68
|
+
raise TypeError("capacity must be a number")
|
|
69
|
+
if not isinstance(capacity, int) or capacity <= 0:
|
|
70
|
+
raise ValueError("capacity must be a positive integer")
|
|
71
|
+
self._capacity = capacity
|
|
72
|
+
self._queue: deque[E] = deque()
|
|
73
|
+
# A one-element tuple, so an event that is itself ``None`` stays distinguishable from a wake.
|
|
74
|
+
self._pending: asyncio.Future[tuple[E] | None] | None = None
|
|
75
|
+
self._ended = False
|
|
76
|
+
self._failure: BaseException | None = None
|
|
77
|
+
self._request: OwnedRequest | None = None
|
|
78
|
+
self._cancellation = cancellation
|
|
79
|
+
if cancellation is not None and cancellation.cancelled:
|
|
80
|
+
self._finish(cancellation.reason)
|
|
81
|
+
return
|
|
82
|
+
if cancellation is not None:
|
|
83
|
+
cancellation.add_callback(self._finish)
|
|
84
|
+
try:
|
|
85
|
+
self._request = start(BoundedEventSink(self))
|
|
86
|
+
except BaseException:
|
|
87
|
+
self.close()
|
|
88
|
+
raise
|
|
89
|
+
# Events can overflow the queue before start() returns. The ending recorded then had no
|
|
90
|
+
# request to cancel, so cancel it now; the echo of that cancel is ignored.
|
|
91
|
+
if self._ended:
|
|
92
|
+
self._request.cancel(self._failure)
|
|
93
|
+
|
|
94
|
+
def _push(self, event: E) -> None:
|
|
95
|
+
if self._ended:
|
|
96
|
+
return
|
|
97
|
+
if self._pending is not None and not self._pending.done():
|
|
98
|
+
self._pending.set_result((event,))
|
|
99
|
+
return
|
|
100
|
+
if len(self._queue) == self._capacity:
|
|
101
|
+
self._finish(SubscriptionOverflowError("Subscription event buffer overflow"))
|
|
102
|
+
return
|
|
103
|
+
self._queue.append(event)
|
|
104
|
+
|
|
105
|
+
def _finish(self, error: BaseException | None = None, *, keep_queue: bool = False) -> None:
|
|
106
|
+
if self._ended:
|
|
107
|
+
return
|
|
108
|
+
self._ended = True
|
|
109
|
+
self._failure = error
|
|
110
|
+
# A pending read only exists while the queue is empty, so keeping it still settles that read as done.
|
|
111
|
+
if not keep_queue:
|
|
112
|
+
self._queue.clear()
|
|
113
|
+
if self._cancellation is not None:
|
|
114
|
+
self._cancellation.remove_callback(self._finish)
|
|
115
|
+
if self._request is not None:
|
|
116
|
+
self._request.cancel(error)
|
|
117
|
+
# Wake by value, not exception: abandoned reads cannot leak an unretrieved exception.
|
|
118
|
+
if self._pending is not None and not self._pending.done():
|
|
119
|
+
self._pending.set_result(None)
|
|
120
|
+
|
|
121
|
+
def __aiter__(self) -> Self:
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
async def __anext__(self) -> E:
|
|
125
|
+
if self._pending is not None:
|
|
126
|
+
raise RuntimeError("Only one pending subscription read is supported")
|
|
127
|
+
try:
|
|
128
|
+
if not self._ended and not self._queue:
|
|
129
|
+
self._pending = asyncio.get_running_loop().create_future()
|
|
130
|
+
handed = await self._pending
|
|
131
|
+
# A handoff already completed before teardown belongs to the consumer, like TS.
|
|
132
|
+
if handed is not None:
|
|
133
|
+
return handed[0]
|
|
134
|
+
# Events left by a clean completion are read before the end.
|
|
135
|
+
if self._queue:
|
|
136
|
+
return self._queue.popleft()
|
|
137
|
+
if self._ended:
|
|
138
|
+
if self._failure is not None:
|
|
139
|
+
raise self._failure
|
|
140
|
+
raise StopAsyncIteration
|
|
141
|
+
raise RuntimeError("unreachable: an empty queue on a live iterator is awaited")
|
|
142
|
+
except asyncio.CancelledError:
|
|
143
|
+
self.close()
|
|
144
|
+
raise
|
|
145
|
+
finally:
|
|
146
|
+
self._pending = None
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
"""Discard queued events and settle pending reads as EOF. Idempotent."""
|
|
150
|
+
self._finish()
|
|
151
|
+
self._queue.clear()
|
|
152
|
+
|
|
153
|
+
async def aclose(self) -> None:
|
|
154
|
+
""":meth:`close` as a coroutine, for ``contextlib.aclosing`` and other async-iterator cleanup."""
|
|
155
|
+
self.close()
|
|
156
|
+
|
|
157
|
+
async def __aenter__(self) -> Self:
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
async def __aexit__(
|
|
161
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
162
|
+
) -> None:
|
|
163
|
+
self.close()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = ["BoundedEventSink", "BoundedEvents", "OwnedRequest"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""The frame reader: a subscription's or a want's handlers that read its track as frames in decode order.
|
|
2
|
+
|
|
3
|
+
It drives a sequencer it is given (``moq_media.FrameSequencer`` qualifies), which owns the ordering, the deadlines
|
|
4
|
+
and the LOC format, so this package stays free of the format package. See the "Media subscribing" section of
|
|
5
|
+
``spec/behavior.md``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from typing import Generic, Protocol, TypeVar
|
|
13
|
+
|
|
14
|
+
from moq_core import KeyValuePair, MoqtObject, PublishDone, RequestError, SubgroupEnd, SubgroupReset, SubscribeOk
|
|
15
|
+
|
|
16
|
+
from .connect import RelaySubscriptionHandlers
|
|
17
|
+
from .supervised_subscriber import RestartReason, WantHandlers, WantSwitchedInfo
|
|
18
|
+
|
|
19
|
+
F = TypeVar("F")
|
|
20
|
+
F_co = TypeVar("F_co", covariant=True)
|
|
21
|
+
|
|
22
|
+
# Poll at least this often while a wait runs: another track may reset or re-anchor the shared clock meanwhile.
|
|
23
|
+
_CLOCK_RECHECK_US = 50_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FrameReaderPoll(Protocol[F_co]):
|
|
27
|
+
"""What a sequencer's ``poll()`` returns: the frames ready now, and when to poll again in microseconds."""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def frames(self) -> Sequence[F_co]: ...
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def wait_us(self) -> int | None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FrameReaderSequencer(Protocol[F_co]):
|
|
37
|
+
"""What a :class:`FrameReader` drives: ``moq_media.FrameSequencer`` qualifies."""
|
|
38
|
+
|
|
39
|
+
track_properties: Sequence[KeyValuePair]
|
|
40
|
+
|
|
41
|
+
def object(self, obj: MoqtObject) -> None: ...
|
|
42
|
+
|
|
43
|
+
def group_ended(self, group_id: int) -> None: ...
|
|
44
|
+
|
|
45
|
+
def group_reset(self, group_id: int) -> None: ...
|
|
46
|
+
|
|
47
|
+
def restart(self) -> None: ...
|
|
48
|
+
|
|
49
|
+
def poll(self, max_frames: int | None = None) -> FrameReaderPoll[F_co]: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FrameReader(Generic[F]):
|
|
53
|
+
"""Read a track as frames: feed the sequencer every event, and poll it as the application reads, or when a wait it
|
|
54
|
+
reports ends (at most 50 ms on, since another track may move a shared clock). Iterate it for the frames, in decode
|
|
55
|
+
order; :meth:`close` ends the iteration.
|
|
56
|
+
|
|
57
|
+
Pass :meth:`subscription_handlers` to ``RelaySubscriber.subscribe`` or :meth:`want_handlers` to
|
|
58
|
+
``SupervisedSubscriber.want``. Each group is one subgroup, as the frame writer sends it, so a subgroup's end is its
|
|
59
|
+
group's. A want read through a reader keeps ``drain`` at 0, the default: a drained old subscription's groups would
|
|
60
|
+
arrive after the Move, indistinguishable from the new one's. Frames wait in the sequencer until they are read, and
|
|
61
|
+
lateness is judged then, so a slow reader gets fresh frames rather than a backlog.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, sequencer: FrameReaderSequencer[F]) -> None:
|
|
65
|
+
self._sequencer = sequencer
|
|
66
|
+
self._changed: asyncio.Event | None = None
|
|
67
|
+
self._closed = False
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def closed(self) -> bool:
|
|
71
|
+
"""Whether :meth:`close` was called."""
|
|
72
|
+
return self._closed
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""Stop delivering and drop frames not yet read. Later events are ignored."""
|
|
76
|
+
self._closed = True
|
|
77
|
+
self._wake()
|
|
78
|
+
|
|
79
|
+
def __aiter__(self) -> FrameReader[F]:
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
async def __anext__(self) -> F:
|
|
83
|
+
while not self._closed:
|
|
84
|
+
# One frame per read: whatever waits stays in the sequencer, judged late when it is taken.
|
|
85
|
+
result = self._sequencer.poll(1)
|
|
86
|
+
if result.frames:
|
|
87
|
+
return result.frames[0]
|
|
88
|
+
changed = self._changed = self._changed or asyncio.Event()
|
|
89
|
+
changed.clear()
|
|
90
|
+
timeout = None if result.wait_us is None else min(result.wait_us, _CLOCK_RECHECK_US) / 1_000_000
|
|
91
|
+
try:
|
|
92
|
+
await asyncio.wait_for(changed.wait(), timeout)
|
|
93
|
+
except TimeoutError:
|
|
94
|
+
pass
|
|
95
|
+
raise StopAsyncIteration
|
|
96
|
+
|
|
97
|
+
# --- handlers
|
|
98
|
+
|
|
99
|
+
def subscription_handlers(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
on_error: Callable[[RequestError], None] | None = None,
|
|
103
|
+
on_publisher_done: Callable[[PublishDone], None] | None = None,
|
|
104
|
+
) -> RelaySubscriptionHandlers:
|
|
105
|
+
"""Handlers for one subscription, with the application's own error and completion callbacks."""
|
|
106
|
+
return RelaySubscriptionHandlers(
|
|
107
|
+
on_subscribed=self._subscribed,
|
|
108
|
+
on_error=on_error,
|
|
109
|
+
on_object=self._object,
|
|
110
|
+
on_subgroup_end=self._subgroup_end,
|
|
111
|
+
on_subgroup_reset=self._subgroup_reset,
|
|
112
|
+
on_publisher_done=on_publisher_done,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def want_handlers(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
on_subscribed: Callable[[SubscribeOk, tuple[str, ...]], None] | None = None,
|
|
119
|
+
on_publisher_done: Callable[[PublishDone], None] | None = None,
|
|
120
|
+
on_restarted: Callable[[RestartReason, tuple[str, ...]], None] | None = None,
|
|
121
|
+
on_switched: Callable[[WantSwitchedInfo], None] | None = None,
|
|
122
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
123
|
+
) -> WantHandlers:
|
|
124
|
+
"""Handlers for a want. A Restart and a Move start the sequencer over; the application's callbacks run after."""
|
|
125
|
+
|
|
126
|
+
def subscribed(info: SubscribeOk, namespace: tuple[str, ...]) -> None:
|
|
127
|
+
self._subscribed(info)
|
|
128
|
+
if on_subscribed is not None:
|
|
129
|
+
on_subscribed(info, namespace)
|
|
130
|
+
|
|
131
|
+
def restarted(reason: RestartReason, namespace: tuple[str, ...]) -> None:
|
|
132
|
+
self._event(self._sequencer.restart)
|
|
133
|
+
if on_restarted is not None:
|
|
134
|
+
on_restarted(reason, namespace)
|
|
135
|
+
|
|
136
|
+
def switched(info: WantSwitchedInfo) -> None:
|
|
137
|
+
def move() -> None:
|
|
138
|
+
self._sequencer.restart()
|
|
139
|
+
self._sequencer.track_properties = info.subscribed.track_properties
|
|
140
|
+
|
|
141
|
+
self._event(move)
|
|
142
|
+
if on_switched is not None:
|
|
143
|
+
on_switched(info)
|
|
144
|
+
|
|
145
|
+
return WantHandlers(
|
|
146
|
+
on_subscribed=subscribed,
|
|
147
|
+
on_object=self._object,
|
|
148
|
+
on_subgroup_end=self._subgroup_end,
|
|
149
|
+
on_subgroup_reset=self._subgroup_reset,
|
|
150
|
+
on_publisher_done=on_publisher_done,
|
|
151
|
+
on_restarted=restarted,
|
|
152
|
+
on_switched=switched,
|
|
153
|
+
on_error=on_error,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _subscribed(self, info: SubscribeOk) -> None:
|
|
157
|
+
if not self._closed:
|
|
158
|
+
self._sequencer.track_properties = info.track_properties
|
|
159
|
+
|
|
160
|
+
def _object(self, obj: MoqtObject) -> None:
|
|
161
|
+
self._event(lambda: self._sequencer.object(obj))
|
|
162
|
+
|
|
163
|
+
def _subgroup_end(self, end: SubgroupEnd) -> None:
|
|
164
|
+
self._event(lambda: self._sequencer.group_ended(end.group_id))
|
|
165
|
+
|
|
166
|
+
def _subgroup_reset(self, reset: SubgroupReset) -> None:
|
|
167
|
+
self._event(lambda: self._sequencer.group_reset(reset.group_id))
|
|
168
|
+
|
|
169
|
+
# --- delivery
|
|
170
|
+
|
|
171
|
+
def _event(self, apply: Callable[[], None]) -> None:
|
|
172
|
+
if self._closed:
|
|
173
|
+
return
|
|
174
|
+
apply()
|
|
175
|
+
self._wake()
|
|
176
|
+
|
|
177
|
+
def _wake(self) -> None:
|
|
178
|
+
if self._changed is not None:
|
|
179
|
+
self._changed.set()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""A publisher connection's settling groups (see ``CONTEXT.md``).
|
|
2
|
+
|
|
3
|
+
A settling group is one whose operation completed once its FIN was handed to the transport, but whose
|
|
4
|
+
FIN the relay has not acknowledged yet. aioquic queues without limit, so without a bound a dead uplink
|
|
5
|
+
keeps accepting groups until the congestion window fills (about 190 one-kilobyte groups). Settling
|
|
6
|
+
groups are what can pile up on a connection: open streams are already bounded by one per track. So
|
|
7
|
+
before an operation opens its stream it waits while the connection has :data:`SETTLING_GROUP_LIMIT`
|
|
8
|
+
settling groups, first come first served; it holds nothing while its stream is open, and its group
|
|
9
|
+
joins the set once its FIN is handed over. The count can briefly exceed the limit by the number of
|
|
10
|
+
groups open at that moment. The TypeScript SDK keeps the same set (``ts/src/settling-groups.ts``).
|
|
11
|
+
|
|
12
|
+
A settling group that fails later (STOP_SENDING, a reset, session teardown) leaves the set like one
|
|
13
|
+
that settles cleanly, and nobody hears of it: the cause surfaces as demand, a rebind, or the
|
|
14
|
+
connection's ``closed``. A group with a SUBGROUP_DELIVERY_TIMEOUT (draft-ietf-moq-transport-22 section
|
|
15
|
+
5.2) carries its timer here: started when the group joins, it resets the stream if the acknowledgement
|
|
16
|
+
has not come by then, which fails the acknowledgement wait, so the group leaves the set the same
|
|
17
|
+
silent way and frees its room. The timer belongs to the group: a subscription change or end and the
|
|
18
|
+
track's close leave it running, and only the acknowledgement or the connection's end settles the
|
|
19
|
+
group first. ``RelayPublisher.close()`` drains the set before it ends the session, because closing
|
|
20
|
+
the connection discards whatever the relay has not acknowledged; delivery timeouts only end some of
|
|
21
|
+
the drained groups sooner.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
from collections import deque
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from moq_core._tasks import observe, spawn
|
|
32
|
+
|
|
33
|
+
SETTLING_GROUP_LIMIT = 64
|
|
34
|
+
"""Settling groups per publisher connection before new streams wait. Internal, not an option."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _settled(acknowledged: Awaitable[None]) -> None:
|
|
38
|
+
try:
|
|
39
|
+
await acknowledged
|
|
40
|
+
except Exception: # a late failure is dropped: it surfaces elsewhere (see the module doc)
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SettlingGroups:
|
|
45
|
+
"""The settling groups of one publisher connection, shared by every track on it."""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_limit", "_settling", "_waiting")
|
|
48
|
+
|
|
49
|
+
def __init__(self, limit: int = SETTLING_GROUP_LIMIT) -> None:
|
|
50
|
+
self._limit = limit
|
|
51
|
+
self._settling: set[asyncio.Task[None]] = set()
|
|
52
|
+
self._waiting: deque[asyncio.Future[None]] = deque()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def size(self) -> int:
|
|
56
|
+
"""How many groups are settling now."""
|
|
57
|
+
return len(self._settling)
|
|
58
|
+
|
|
59
|
+
def has_room(self) -> bool:
|
|
60
|
+
"""Whether a new stream may open now: the connection is below the limit."""
|
|
61
|
+
return len(self._settling) < self._limit
|
|
62
|
+
|
|
63
|
+
async def wait_for_room(self, interrupted: asyncio.Future[None]) -> None:
|
|
64
|
+
"""Wait until the connection is below the limit, in arrival order.
|
|
65
|
+
|
|
66
|
+
Raises the exception ``interrupted`` settles with, if that comes first; the wait then gives up
|
|
67
|
+
its place in the queue.
|
|
68
|
+
"""
|
|
69
|
+
if self.has_room():
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
waiter: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
73
|
+
self._waiting.append(waiter)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
await asyncio.wait({waiter, interrupted}, return_when=asyncio.FIRST_COMPLETED)
|
|
77
|
+
finally:
|
|
78
|
+
if not waiter.done():
|
|
79
|
+
self._waiting.remove(waiter)
|
|
80
|
+
waiter.cancel()
|
|
81
|
+
|
|
82
|
+
if not waiter.done() or waiter.cancelled():
|
|
83
|
+
interrupted.result()
|
|
84
|
+
|
|
85
|
+
def add(
|
|
86
|
+
self,
|
|
87
|
+
acknowledged: Awaitable[None],
|
|
88
|
+
*,
|
|
89
|
+
timeout: float | None = None,
|
|
90
|
+
on_timeout: Callable[[], None] | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""A group's FIN was handed over: count it until ``acknowledged`` settles, either way.
|
|
93
|
+
|
|
94
|
+
With a ``timeout`` (seconds, the group's SUBGROUP_DELIVERY_TIMEOUT), ``on_timeout`` runs if
|
|
95
|
+
``acknowledged`` is still pending by then; it resets the group's stream, which settles
|
|
96
|
+
``acknowledged`` with the reset.
|
|
97
|
+
"""
|
|
98
|
+
tracked = spawn(_settled(acknowledged))
|
|
99
|
+
self._settling.add(tracked)
|
|
100
|
+
tracked.add_done_callback(self._settle)
|
|
101
|
+
|
|
102
|
+
if timeout is not None and on_timeout is not None:
|
|
103
|
+
timer = asyncio.get_running_loop().call_later(timeout, on_timeout)
|
|
104
|
+
tracked.add_done_callback(lambda _: timer.cancel())
|
|
105
|
+
|
|
106
|
+
async def drain(self, seconds: float, stop: asyncio.Future[Any], *also: Awaitable[None]) -> None:
|
|
107
|
+
"""Wait for every settling group and each of ``also``, for at most ``seconds`` or until ``stop`` settles.
|
|
108
|
+
|
|
109
|
+
Never raises; whatever is still unsettled afterwards is left to the transport.
|
|
110
|
+
"""
|
|
111
|
+
# Futures at once, so a wait cancelled before it starts still counts as awaited.
|
|
112
|
+
extra = [asyncio.ensure_future(awaitable) for awaitable in also]
|
|
113
|
+
waits = [*self._settling, *extra]
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
if waits and not stop.done():
|
|
117
|
+
everything = asyncio.gather(*waits, return_exceptions=True)
|
|
118
|
+
observe(everything)
|
|
119
|
+
await asyncio.wait({everything, stop}, timeout=seconds, return_when=asyncio.FIRST_COMPLETED)
|
|
120
|
+
finally:
|
|
121
|
+
# Only the extra waits are ours to stop; the settling groups keep counting.
|
|
122
|
+
for wait in extra:
|
|
123
|
+
wait.cancel()
|
|
124
|
+
|
|
125
|
+
def _settle(self, tracked: asyncio.Task[None]) -> None:
|
|
126
|
+
self._settling.discard(tracked)
|
|
127
|
+
|
|
128
|
+
# Every waiter goes on, in arrival order, once the connection is below the limit again.
|
|
129
|
+
while self._waiting and self.has_room():
|
|
130
|
+
waiter = self._waiting.popleft()
|
|
131
|
+
|
|
132
|
+
if not waiter.done():
|
|
133
|
+
waiter.set_result(None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
__all__ = ["SETTLING_GROUP_LIMIT", "SettlingGroups"]
|