reactor-runtime 3.2.6__tar.gz → 3.3.0__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.
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/PKG-INFO +1 -1
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/pyproject.toml +1 -1
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/pyproject.toml.orig +1 -1
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/recording/chunk_encoder.py +6 -9
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/recording/recorder.py +20 -2
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/runner.py +26 -9
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/__init__.py +9 -2
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/acceptor.py +136 -9
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/config.py +32 -1
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/peer.py +8 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/router.py +105 -14
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/LICENSE +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/NOTICE +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/README.md +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/codes.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/fields.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/model.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/naming.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/service.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/session.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/transport.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/typespec.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/values.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/event_stream.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/events.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/routes.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/server.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/spec.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/client.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/decorators.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/errors.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/messages.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/bridge.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/contract.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/schema.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/input.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/output.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/log.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/manifest.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/message_gateway.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/metrics.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/paths.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/base.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/common.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/v0/codec.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/v1/codec.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/py.typed +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/recording/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/recording/markers.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/connection_manager.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/offer_epochs.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/state_machine.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/schema.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/serve.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/service.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/__init__.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/acceptor.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/router.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/connection.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/stats.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/version.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/upload_store.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/common_pb2.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/common_pb2.pyi +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/control_pb2.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/control_pb2.pyi +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/data_pb2.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/data_pb2.pyi +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/model_pb2.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/model_pb2.pyi +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/platform_pb2.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/track_pb2.py +0 -0
- {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/track_pb2.pyi +0 -0
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/recording/chunk_encoder.py
RENAMED
|
@@ -232,15 +232,10 @@ class ChunkEncoder:
|
|
|
232
232
|
"hls_segment_filename": str(self._output_dir / _SEGMENT_PATTERN),
|
|
233
233
|
},
|
|
234
234
|
)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
container.add_stream(
|
|
240
|
-
"libx264" if config.video_codec == "h264" else "libx265",
|
|
241
|
-
rate=Fraction(self._frame_rate, 1),
|
|
242
|
-
options=_video_options(config, self._frame_rate * config.chunk_seconds),
|
|
243
|
-
),
|
|
235
|
+
video = container.add_stream(
|
|
236
|
+
"libx264" if config.video_codec == "h264" else "libx265",
|
|
237
|
+
rate=Fraction(self._frame_rate, 1),
|
|
238
|
+
options=_video_options(config, self._frame_rate * config.chunk_seconds),
|
|
244
239
|
)
|
|
245
240
|
video.width = width
|
|
246
241
|
video.height = height
|
|
@@ -248,6 +243,8 @@ class ChunkEncoder:
|
|
|
248
243
|
video.profile = _PROFILE
|
|
249
244
|
video.time_base = Fraction(1, self._frame_rate)
|
|
250
245
|
|
|
246
|
+
# ``add_stream`` is overloaded on a literal set of codec names, so the
|
|
247
|
+
# configured codec resolves to the catch-all return type.
|
|
251
248
|
audio = cast(
|
|
252
249
|
"av.AudioStream",
|
|
253
250
|
container.add_stream(config.audio_codec, rate=self._audio_sample_rate, layout="mono"),
|
|
@@ -59,6 +59,14 @@ _FEED_WAIT_SECONDS = 1.0
|
|
|
59
59
|
# frames on every emission, so the count is carried on every recording's summary
|
|
60
60
|
# and only the periodic warning is rate-limited.
|
|
61
61
|
_DROP_LOG_INTERVAL_SECONDS = 5.0
|
|
62
|
+
# How much audio may sit in the jitter buffer on top of the emission just handed
|
|
63
|
+
# over, in grid slots. A model's audio is framed on its own boundaries rather
|
|
64
|
+
# than the video's, so an emission carries a little more or less than its slots
|
|
65
|
+
# drain; two slots absorb that wobble without the buffer running dry on the
|
|
66
|
+
# short ones. Past that the audio belongs to frames the encoder was too far
|
|
67
|
+
# behind to take — their video is not in the recording, so pairing it with later
|
|
68
|
+
# video would hold the rest of the session that far out of sync.
|
|
69
|
+
_AUDIO_BACKLOG_FRAMES = 2
|
|
62
70
|
|
|
63
71
|
_INIT_FILENAME = "init.mp4"
|
|
64
72
|
# Written into a recording's directory once it is finished, so its final segment
|
|
@@ -669,11 +677,20 @@ class Recorder:
|
|
|
669
677
|
return
|
|
670
678
|
|
|
671
679
|
def _buffer_audio(self, bundle: MediaBundle) -> None:
|
|
672
|
-
"""Append a chunk's audio to the jitter buffer,
|
|
680
|
+
"""Append a chunk's audio to the jitter buffer, keeping the newest samples.
|
|
673
681
|
|
|
674
682
|
The whole chunk's audio is buffered once; :meth:`_take_audio` then pulls a
|
|
675
683
|
grid slot's worth per recorded frame, so the audio DTS tracks the video
|
|
676
684
|
PTS regardless of how the chunk's frames map onto the grid.
|
|
685
|
+
|
|
686
|
+
The bound is one authoritative limit, never applied below the emission
|
|
687
|
+
just appended, so however much audio a model hands over at once, all of
|
|
688
|
+
it is kept and the grid slots that drain it find real samples rather than
|
|
689
|
+
silence. Above the emission sits :data:`_AUDIO_BACKLOG_FRAMES` of slack
|
|
690
|
+
and no more, which keeps each emission's video paired with its own audio:
|
|
691
|
+
trimming takes the oldest samples, so audio orphaned by frames the
|
|
692
|
+
encoder never took is discarded with them instead of displacing the
|
|
693
|
+
audio that belongs to the video still to be fed.
|
|
677
694
|
"""
|
|
678
695
|
if self._audio_track is None:
|
|
679
696
|
return
|
|
@@ -683,7 +700,8 @@ class Recorder:
|
|
|
683
700
|
flat = np.ascontiguousarray(track.data, dtype=np.int16).reshape(-1)
|
|
684
701
|
self._audio_jitter_buf.append(flat)
|
|
685
702
|
self._audio_buffered_samples += int(flat.size)
|
|
686
|
-
|
|
703
|
+
slack = _AUDIO_BACKLOG_FRAMES * round(self._audio_sample_rate / RECORDING_FPS)
|
|
704
|
+
cap = int(flat.size) + slack
|
|
687
705
|
while self._audio_buffered_samples > cap:
|
|
688
706
|
head = self._audio_jitter_buf[0]
|
|
689
707
|
drop = self._audio_buffered_samples - cap
|
|
@@ -109,6 +109,17 @@ def _stamp_log_state(state: SessionState) -> None:
|
|
|
109
109
|
set_state(state.name.lower(), _RUNTIME_STATES[state].value)
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def _recording_id_from(params: Mapping[str, Any]) -> str:
|
|
113
|
+
"""Resolve a session's recording id from its start parameters.
|
|
114
|
+
|
|
115
|
+
A ``session_id`` in *params* is adopted as the recording id, so a caller can
|
|
116
|
+
align both clips and logs with the id it knows the session by. Absent one, a
|
|
117
|
+
fresh id is minted per session so sequential recordings in a reused process
|
|
118
|
+
never overwrite each other.
|
|
119
|
+
"""
|
|
120
|
+
return str(params.get("session_id") or uuid.uuid4())
|
|
121
|
+
|
|
122
|
+
|
|
112
123
|
# How long to wait for an upload's bytes to arrive when a command or notification
|
|
113
124
|
# references it before they are written. A client references an upload over the
|
|
114
125
|
# data channel while its bytes are still being delivered on a separate request,
|
|
@@ -221,9 +232,10 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
221
232
|
self._teardown: set[asyncio.Task[None]] = set()
|
|
222
233
|
self._orphan_task: asyncio.Task[None] | None = None
|
|
223
234
|
self._session_id = SESSION_ID
|
|
224
|
-
# The session's own id,
|
|
225
|
-
#
|
|
226
|
-
#
|
|
235
|
+
# The session's own id, resolved per session as the start transition is
|
|
236
|
+
# applied (see _dispatch_transition): the id a recording is stored and
|
|
237
|
+
# addressed under, and the id stamped on the session's log records.
|
|
238
|
+
# Separate from the fixed transport session id so a
|
|
227
239
|
# caller can align both with the id it knows the session by; a session
|
|
228
240
|
# started without one mints a fresh id, so sequential recordings in a
|
|
229
241
|
# reused process never share a directory and the logs of one session are
|
|
@@ -611,8 +623,10 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
611
623
|
its recording is stored and addressed under, and the id stamped on every
|
|
612
624
|
log record the session writes. A caller can therefore align both clips and
|
|
613
625
|
logs with the id it knows the session by. Absent one, a fresh id is minted
|
|
614
|
-
per session so sequential recordings never overwrite each other. The
|
|
615
|
-
|
|
626
|
+
per session so sequential recordings never overwrite each other. The id is
|
|
627
|
+
resolved as the machine accepts the start, so a rejected request leaves a
|
|
628
|
+
live session's id untouched. The transport session id is unaffected: it is
|
|
629
|
+
always :data:`SESSION_ID`.
|
|
616
630
|
|
|
617
631
|
Args:
|
|
618
632
|
params: The initial session parameters supplied by the caller.
|
|
@@ -620,7 +634,6 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
620
634
|
Raises:
|
|
621
635
|
SessionTransitionError: If the session is not in a startable state.
|
|
622
636
|
"""
|
|
623
|
-
self._recording_id = str(params.get("session_id") or uuid.uuid4())
|
|
624
637
|
if not self._sm.send(SessionEvent.START_SESSION, params=dict(params)):
|
|
625
638
|
raise SessionTransitionError("start", self._sm.current_state)
|
|
626
639
|
self._offer_epochs.session_started()
|
|
@@ -1107,9 +1120,12 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
1107
1120
|
self-loops log at debug so a per-segment ``chunk_ready`` does not flood
|
|
1108
1121
|
the log.
|
|
1109
1122
|
|
|
1110
|
-
The session boundary is
|
|
1111
|
-
|
|
1112
|
-
|
|
1123
|
+
The session boundary is where the session's recording id resolves, off
|
|
1124
|
+
the start parameters, so a rejected start cannot touch it; both the log's
|
|
1125
|
+
session context and the recorder's directory read it from there. Binding
|
|
1126
|
+
the log context is also part of this boundary, so every record written
|
|
1127
|
+
while a session is live names it, the opening move included. The release
|
|
1128
|
+
travels differently: it rides the ``SessionEnded``
|
|
1113
1129
|
event into the model, whose dispatch retires the binding once the
|
|
1114
1130
|
``@session_ended`` hook has returned. A terminal move dispatches no
|
|
1115
1131
|
``SessionEnded`` and releases nothing — the process is exiting, and its
|
|
@@ -1118,6 +1134,7 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
1118
1134
|
reads the state the process was in when it was written.
|
|
1119
1135
|
"""
|
|
1120
1136
|
if transition.is_session_start:
|
|
1137
|
+
self._recording_id = _recording_id_from(transition.detail.get("params", {}))
|
|
1121
1138
|
self._log_binding = set_session_id(self._recording_id)
|
|
1122
1139
|
if transition.from_state is not transition.to_state:
|
|
1123
1140
|
_stamp_log_state(transition.to_state)
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/__init__.py
RENAMED
|
@@ -7,8 +7,13 @@ WebRTC reshaped as a connection: :class:`WebRTCConnection` is the wire,
|
|
|
7
7
|
during offer negotiation by a :data:`WebRtcPeerFactory`.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from reactor_runtime.transport.webrtc.acceptor import WebRTCAcceptor
|
|
11
|
-
from reactor_runtime.transport.webrtc.config import
|
|
10
|
+
from reactor_runtime.transport.webrtc.acceptor import PortRangeUnavailableError, WebRTCAcceptor
|
|
11
|
+
from reactor_runtime.transport.webrtc.config import (
|
|
12
|
+
IceCredentials,
|
|
13
|
+
IceServer,
|
|
14
|
+
IceTransportPolicy,
|
|
15
|
+
WebRtcConfig,
|
|
16
|
+
)
|
|
12
17
|
from reactor_runtime.transport.webrtc.connection import WebRTCConnection
|
|
13
18
|
from reactor_runtime.transport.webrtc.peer import WebRTCPeer, WebRtcPeerFactory
|
|
14
19
|
from reactor_runtime.transport.webrtc.router import WebRtcRouter
|
|
@@ -23,11 +28,13 @@ from reactor_runtime.transport.webrtc.stats import OutboundMediaHealth, PeerStat
|
|
|
23
28
|
|
|
24
29
|
__all__ = [
|
|
25
30
|
"IceCandidate",
|
|
31
|
+
"IceCredentials",
|
|
26
32
|
"IceServer",
|
|
27
33
|
"IceTransportPolicy",
|
|
28
34
|
"MappedTrack",
|
|
29
35
|
"OutboundMediaHealth",
|
|
30
36
|
"PeerStats",
|
|
37
|
+
"PortRangeUnavailableError",
|
|
31
38
|
"SdpAnswer",
|
|
32
39
|
"SdpOffer",
|
|
33
40
|
"TrackMap",
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/acceptor.py
RENAMED
|
@@ -28,7 +28,7 @@ from reactor_runtime.metrics import WebRtcMetrics
|
|
|
28
28
|
from reactor_runtime.protocol import ProtocolVersion
|
|
29
29
|
from reactor_runtime.transport.acceptor import ConnectionAcceptor
|
|
30
30
|
from reactor_runtime.transport.router import TooManyConnectionsError
|
|
31
|
-
from reactor_runtime.transport.webrtc.config import IceServer, WebRtcConfig
|
|
31
|
+
from reactor_runtime.transport.webrtc.config import IceCredentials, IceServer, WebRtcConfig
|
|
32
32
|
from reactor_runtime.transport.webrtc.connection import WebRTCConnection
|
|
33
33
|
from reactor_runtime.transport.webrtc.peer import WebRtcPeerFactory
|
|
34
34
|
from reactor_runtime.transport.webrtc.signaling import IceCandidate, SdpAnswer, SdpOffer, TrackMap
|
|
@@ -47,6 +47,46 @@ _MAX_PENDING_ICE_CONNS = 128
|
|
|
47
47
|
_MAX_PENDING_ICE_PER_CONN = 256
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
class PortRangeUnavailableError(RuntimeError):
|
|
51
|
+
"""Raised when a single-port ``port_range`` override names a port already held.
|
|
52
|
+
|
|
53
|
+
A caller pinning a connection to one port has no second port to fall back
|
|
54
|
+
on. Two connections pinned to the same one both try to bind it: the second
|
|
55
|
+
finds nothing free in its range, gathers no host candidate, and its
|
|
56
|
+
negotiation is logged and dropped — which reaches the caller only as its
|
|
57
|
+
answer poll timing out. Refused at the offer instead, it is an error the
|
|
58
|
+
caller can act on by pinning a different port.
|
|
59
|
+
|
|
60
|
+
Only a single-port range is measured. A wider range still has ports left to
|
|
61
|
+
pick, and two wide ranges overlapping is the ordinary case: the acceptor
|
|
62
|
+
knows which range a connection gathers in, never which port inside it the
|
|
63
|
+
ICE agent took.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
port: The pinned port that is already held.
|
|
67
|
+
held_by: The connection holding it.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, port: int, held_by: ConnId) -> None:
|
|
71
|
+
"""Record the pinned port and the connection already holding it."""
|
|
72
|
+
self.port = port
|
|
73
|
+
self.held_by = held_by
|
|
74
|
+
super().__init__(f"port {port} is already held by connection {held_by}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _pinned_port(port_range: tuple[int, int] | None) -> int | None:
|
|
78
|
+
"""Return the single port a range pins a connection to, or ``None``.
|
|
79
|
+
|
|
80
|
+
A range wider than one port leaves the ICE agent a choice, so there is
|
|
81
|
+
nothing to reserve; only ``min == max`` names a port the connection must
|
|
82
|
+
have.
|
|
83
|
+
"""
|
|
84
|
+
if port_range is None:
|
|
85
|
+
return None
|
|
86
|
+
low, high = port_range
|
|
87
|
+
return low if low == high else None
|
|
88
|
+
|
|
89
|
+
|
|
50
90
|
class WebRTCAcceptor(ConnectionAcceptor):
|
|
51
91
|
"""Negotiate WebRTC handshakes and register connections once they are live.
|
|
52
92
|
|
|
@@ -100,6 +140,12 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
100
140
|
# teardown of the superseded connection runs while the new offer's
|
|
101
141
|
# deadline is already armed and must not carry it off.
|
|
102
142
|
self._deadlines: dict[ConnId, tuple[float, asyncio.Task[None]]] = {}
|
|
143
|
+
# The port each connection offering a single-port range is pinned to,
|
|
144
|
+
# keyed with the offer generation that claimed it: a reconnect claims
|
|
145
|
+
# its port and only then tears the superseded connection down, so a
|
|
146
|
+
# release by id alone would carry the new claim off. Wider ranges are
|
|
147
|
+
# absent — there is nothing to reserve when the agent has a choice.
|
|
148
|
+
self._pinned_ports: dict[ConnId, tuple[float, int]] = {}
|
|
103
149
|
|
|
104
150
|
def start_offer(
|
|
105
151
|
self,
|
|
@@ -108,6 +154,8 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
108
154
|
tracks: TrackMap,
|
|
109
155
|
version: ProtocolVersion,
|
|
110
156
|
ice_servers: tuple[IceServer, ...] | None = None,
|
|
157
|
+
ice_credentials: IceCredentials | None = None,
|
|
158
|
+
port_range: tuple[int, int] | None = None,
|
|
111
159
|
) -> None:
|
|
112
160
|
"""Begin negotiating *sdp_offer* in the background.
|
|
113
161
|
|
|
@@ -123,13 +171,30 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
123
171
|
``None`` falls back to the acceptor's configuration. Supplied per offer,
|
|
124
172
|
so a reconnect can carry fresh credentials.
|
|
125
173
|
|
|
174
|
+
*ice_credentials* and *port_range* override the same fields for this
|
|
175
|
+
connection only, on the same terms: ``None`` — the default for both —
|
|
176
|
+
uses the acceptor's configuration, which for credentials means the media
|
|
177
|
+
engine generates its own. They are here for a deployment that fronts the
|
|
178
|
+
runtime with a relaying layer and must know a connection's ICE
|
|
179
|
+
credentials and media port before the connection exists; a runtime
|
|
180
|
+
driven directly never sets them.
|
|
181
|
+
|
|
182
|
+
*port_range* replaces the configured range rather than narrowing it, so
|
|
183
|
+
a caller can name a port outside it. A single-port range is reserved for
|
|
184
|
+
the connection: a second connection pinned to the same port is refused
|
|
185
|
+
here rather than left to fail gathering in the background.
|
|
186
|
+
|
|
126
187
|
Raises:
|
|
127
188
|
TooManyConnectionsError: If *conn_id* is a new connection and the
|
|
128
189
|
acceptor already holds its configured maximum. A re-offer on a
|
|
129
190
|
connection already negotiating or live is a reconnect and is
|
|
130
191
|
admitted regardless of the ceiling.
|
|
192
|
+
PortRangeUnavailableError: If *port_range* pins a single port that
|
|
193
|
+
another connection is already pinned to. A re-offer on this same
|
|
194
|
+
connection keeps its own port.
|
|
131
195
|
"""
|
|
132
196
|
self._guard_capacity(conn_id)
|
|
197
|
+
self._guard_pinned_port(conn_id, port_range)
|
|
133
198
|
in_flight = self._negotiating.pop(conn_id, None)
|
|
134
199
|
if in_flight is not None:
|
|
135
200
|
in_flight.cancel()
|
|
@@ -137,8 +202,18 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
137
202
|
self._answers.pop(conn_id, None)
|
|
138
203
|
offered_at = time.monotonic()
|
|
139
204
|
self._offered_at[conn_id] = offered_at
|
|
205
|
+
self._claim_pinned_port(conn_id, port_range, offered_at)
|
|
140
206
|
self._negotiating[conn_id] = asyncio.create_task(
|
|
141
|
-
self._negotiate(
|
|
207
|
+
self._negotiate(
|
|
208
|
+
conn_id,
|
|
209
|
+
sdp_offer,
|
|
210
|
+
tracks,
|
|
211
|
+
version,
|
|
212
|
+
ice_servers,
|
|
213
|
+
ice_credentials,
|
|
214
|
+
port_range,
|
|
215
|
+
offered_at=offered_at,
|
|
216
|
+
)
|
|
142
217
|
)
|
|
143
218
|
self._arm_deadline(conn_id, offered_at)
|
|
144
219
|
|
|
@@ -156,6 +231,48 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
156
231
|
if not known and len(set(self._conns) | set(self._negotiating)) >= limit:
|
|
157
232
|
raise TooManyConnectionsError(limit)
|
|
158
233
|
|
|
234
|
+
def _guard_pinned_port(self, conn_id: ConnId, port_range: tuple[int, int] | None) -> None:
|
|
235
|
+
"""Refuse an offer pinned to a port another connection already holds.
|
|
236
|
+
|
|
237
|
+
The one bad ``port_range`` the request boundary cannot catch: it is
|
|
238
|
+
structurally valid and only conflicts with what this acceptor is
|
|
239
|
+
currently negotiating. A re-offer on the connection already holding the
|
|
240
|
+
port is a reconnect — its own peer is closed before the new one is
|
|
241
|
+
built, so the port is free for it.
|
|
242
|
+
"""
|
|
243
|
+
port = _pinned_port(port_range)
|
|
244
|
+
if port is None:
|
|
245
|
+
return
|
|
246
|
+
for other, (_, held) in self._pinned_ports.items():
|
|
247
|
+
if other != conn_id and held == port:
|
|
248
|
+
raise PortRangeUnavailableError(port, other)
|
|
249
|
+
|
|
250
|
+
def _claim_pinned_port(
|
|
251
|
+
self, conn_id: ConnId, port_range: tuple[int, int] | None, offered_at: float
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Record the port this offer pins the connection to, if it pins one.
|
|
254
|
+
|
|
255
|
+
A re-offer that supplies a wider range, or none at all, releases the
|
|
256
|
+
port its predecessor claimed: the new peer gathers wherever its range
|
|
257
|
+
allows, so the old claim describes nothing.
|
|
258
|
+
"""
|
|
259
|
+
port = _pinned_port(port_range)
|
|
260
|
+
if port is None:
|
|
261
|
+
self._pinned_ports.pop(conn_id, None)
|
|
262
|
+
return
|
|
263
|
+
self._pinned_ports[conn_id] = (offered_at, port)
|
|
264
|
+
|
|
265
|
+
def _release_pinned_port(self, conn_id: ConnId, offered_at: float) -> None:
|
|
266
|
+
"""Free a claimed port, unless a newer offer on the id already holds it.
|
|
267
|
+
|
|
268
|
+
The ordering :meth:`_drop_offer` guards against: a reconnect claims its
|
|
269
|
+
port and then tears the superseded connection down, and that teardown
|
|
270
|
+
must not release the claim the reconnect is negotiating with.
|
|
271
|
+
"""
|
|
272
|
+
entry = self._pinned_ports.get(conn_id)
|
|
273
|
+
if entry is not None and entry[0] == offered_at:
|
|
274
|
+
del self._pinned_ports[conn_id]
|
|
275
|
+
|
|
159
276
|
def _arm_deadline(self, conn_id: ConnId, offered_at: float) -> None:
|
|
160
277
|
"""Start the reaper that frees this connection's slot if it never goes live."""
|
|
161
278
|
if self._config.negotiation_timeout <= 0:
|
|
@@ -222,6 +339,7 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
222
339
|
self._pending_ice.pop(conn_id, None)
|
|
223
340
|
self._answers.pop(conn_id, None)
|
|
224
341
|
self._drop_offer(conn_id, offered_at)
|
|
342
|
+
self._release_pinned_port(conn_id, offered_at)
|
|
225
343
|
finally:
|
|
226
344
|
entry = self._deadlines.get(conn_id)
|
|
227
345
|
if entry is not None and entry[1] is asyncio.current_task():
|
|
@@ -238,6 +356,8 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
238
356
|
tracks: TrackMap,
|
|
239
357
|
version: ProtocolVersion,
|
|
240
358
|
ice_servers: tuple[IceServer, ...] | None = None,
|
|
359
|
+
ice_credentials: IceCredentials | None = None,
|
|
360
|
+
port_range: tuple[int, int] | None = None,
|
|
241
361
|
*,
|
|
242
362
|
offered_at: float,
|
|
243
363
|
) -> None:
|
|
@@ -250,14 +370,18 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
250
370
|
in the gap. A negotiation that fails is logged and dropped — the client's
|
|
251
371
|
poll for the answer times out — rather than left as an unhandled task.
|
|
252
372
|
|
|
253
|
-
*ice_servers* override the
|
|
254
|
-
connection when given; ``None``
|
|
373
|
+
*ice_servers*, *ice_credentials* and *port_range* override the
|
|
374
|
+
corresponding configured values for this connection when given; ``None``
|
|
375
|
+
uses the acceptor's configuration for each independently.
|
|
255
376
|
"""
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
377
|
+
overrides: dict[str, object] = {}
|
|
378
|
+
if ice_servers is not None:
|
|
379
|
+
overrides["ice_servers"] = ice_servers
|
|
380
|
+
if ice_credentials is not None:
|
|
381
|
+
overrides["ice_credentials"] = ice_credentials
|
|
382
|
+
if port_range is not None:
|
|
383
|
+
overrides["port_range"] = port_range
|
|
384
|
+
config = self._config if not overrides else dataclasses.replace(self._config, **overrides)
|
|
261
385
|
try:
|
|
262
386
|
previous = self._conns.pop(conn_id, None)
|
|
263
387
|
if previous is not None:
|
|
@@ -295,6 +419,7 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
295
419
|
# its deadline has nothing left to guard either.
|
|
296
420
|
self._drop_offer(conn_id, offered_at)
|
|
297
421
|
self._cancel_deadline(conn_id, offered_at)
|
|
422
|
+
self._release_pinned_port(conn_id, offered_at)
|
|
298
423
|
logger.exception("WebRTC negotiation failed for connection %s", conn_id)
|
|
299
424
|
finally:
|
|
300
425
|
# Only clear our own entry: a superseding offer may have already
|
|
@@ -361,6 +486,7 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
361
486
|
self._pending_ice.pop(conn_id, None)
|
|
362
487
|
self._answers.pop(conn_id, None)
|
|
363
488
|
self._drop_offer(conn_id, offered_at)
|
|
489
|
+
self._release_pinned_port(conn_id, offered_at)
|
|
364
490
|
if conn_id in self._live:
|
|
365
491
|
self._live.discard(conn_id)
|
|
366
492
|
self._sink.connection_closed(conn_id)
|
|
@@ -379,6 +505,7 @@ class WebRTCAcceptor(ConnectionAcceptor):
|
|
|
379
505
|
self._pending_ice.pop(conn_id, None)
|
|
380
506
|
self._answers.pop(conn_id, None)
|
|
381
507
|
self._drop_offer(conn_id, offered_at)
|
|
508
|
+
self._release_pinned_port(conn_id, offered_at)
|
|
382
509
|
self._live.discard(conn_id)
|
|
383
510
|
|
|
384
511
|
def _drop_offer(self, conn_id: ConnId, offered_at: float) -> None:
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/config.py
RENAMED
|
@@ -40,6 +40,30 @@ class IceServer:
|
|
|
40
40
|
credential: str | None = None
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class IceCredentials:
|
|
45
|
+
"""The ICE username fragment and password a connection answers with.
|
|
46
|
+
|
|
47
|
+
Normally the media engine generates these itself, and nothing needs to
|
|
48
|
+
supply them. They exist as a configurable value for deployments that front
|
|
49
|
+
the runtime with a relaying layer that has to recognise a connection from
|
|
50
|
+
its ICE credentials alone — the credentials are the only field an ICE agent
|
|
51
|
+
echoes on every connectivity check, so a fronting layer can route on them
|
|
52
|
+
without inspecting media.
|
|
53
|
+
|
|
54
|
+
Both values must satisfy RFC 8445's ``ice-char`` alphabet and length
|
|
55
|
+
ranges; :meth:`reactor_webrtc.SessionDescription.with_ice_credentials`
|
|
56
|
+
rejects anything outside them.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
ufrag: The ``a=ice-ufrag`` value to answer with.
|
|
60
|
+
pwd: The ``a=ice-pwd`` value to answer with.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
ufrag: str
|
|
64
|
+
pwd: str
|
|
65
|
+
|
|
66
|
+
|
|
43
67
|
class CodecEntry(TypedDict):
|
|
44
68
|
"""A supported codec for SDP negotiation and encoder/decoder selection.
|
|
45
69
|
|
|
@@ -81,8 +105,14 @@ class WebRtcConfig:
|
|
|
81
105
|
|
|
82
106
|
Attributes:
|
|
83
107
|
ice_servers: The STUN/TURN servers offered for candidate gathering.
|
|
108
|
+
ice_credentials: The ICE credentials to answer with, or ``None`` — the
|
|
109
|
+
default — to let the media engine generate its own. Setting these
|
|
110
|
+
is only useful to a deployment whose fronting layer routes on them;
|
|
111
|
+
see :class:`IceCredentials`.
|
|
84
112
|
port_range: An inclusive ``(min, max)`` UDP port range to confine ICE
|
|
85
|
-
to, or ``None`` to let the stack choose.
|
|
113
|
+
to, or ``None`` to let the stack choose. A single-port range pins
|
|
114
|
+
the connection to one port, which is what a fronting layer needs if
|
|
115
|
+
it must know the media address before the connection exists.
|
|
86
116
|
transport_policy: Which candidate types to gather.
|
|
87
117
|
ping_timeout: Seconds without a client ping before the connection's
|
|
88
118
|
watchdog declares it lost. ``0`` or less disables the watchdog.
|
|
@@ -139,6 +169,7 @@ class WebRtcConfig:
|
|
|
139
169
|
"""
|
|
140
170
|
|
|
141
171
|
ice_servers: tuple[IceServer, ...] = ()
|
|
172
|
+
ice_credentials: IceCredentials | None = None
|
|
142
173
|
port_range: tuple[int, int] | None = None
|
|
143
174
|
transport_policy: IceTransportPolicy = IceTransportPolicy.ALL
|
|
144
175
|
ping_timeout: float = 20.0
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/peer.py
RENAMED
|
@@ -479,6 +479,14 @@ class WebRTCPeer:
|
|
|
479
479
|
await self._attach_out_tracks(pc, factory)
|
|
480
480
|
|
|
481
481
|
answer = await pc.create_answer()
|
|
482
|
+
# Substituting the ICE credentials has to happen HERE: setting the local
|
|
483
|
+
# description is what creates the transport and starts gathering, so a
|
|
484
|
+
# substitution after it has nothing left to act on. Nothing is
|
|
485
|
+
# substituted unless the configuration asks for it, which is the usual
|
|
486
|
+
# case -- the media engine's own credentials are used.
|
|
487
|
+
credentials = self._config.ice_credentials
|
|
488
|
+
if credentials is not None:
|
|
489
|
+
answer = answer.with_ice_credentials(credentials.ufrag, credentials.pwd)
|
|
482
490
|
self._answer_sdp = answer.sdp
|
|
483
491
|
await pc.set_local_description(answer)
|
|
484
492
|
# The answer describes every outbound track as sending, which is the
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/router.py
RENAMED
|
@@ -15,9 +15,9 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
from typing import Annotated, Any
|
|
17
17
|
|
|
18
|
-
from fastapi import FastAPI, Header, Request, Response
|
|
18
|
+
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
|
19
19
|
from fastapi.responses import JSONResponse
|
|
20
|
-
from pydantic import BaseModel, Field
|
|
20
|
+
from pydantic import BaseModel, Field, model_validator
|
|
21
21
|
|
|
22
22
|
from reactor_runtime.core import ConnId
|
|
23
23
|
from reactor_runtime.metrics import RuntimeMetrics, WebRtcMetrics
|
|
@@ -30,8 +30,8 @@ from reactor_runtime.transport.router import (
|
|
|
30
30
|
TransportRouter,
|
|
31
31
|
UnknownSessionError,
|
|
32
32
|
)
|
|
33
|
-
from reactor_runtime.transport.webrtc.acceptor import WebRTCAcceptor
|
|
34
|
-
from reactor_runtime.transport.webrtc.config import IceServer, WebRtcConfig
|
|
33
|
+
from reactor_runtime.transport.webrtc.acceptor import PortRangeUnavailableError, WebRTCAcceptor
|
|
34
|
+
from reactor_runtime.transport.webrtc.config import IceCredentials, IceServer, WebRtcConfig
|
|
35
35
|
from reactor_runtime.transport.webrtc.peer import WebRtcPeerFactory
|
|
36
36
|
from reactor_runtime.transport.webrtc.signaling import IceCandidate, SdpOffer, TrackMap
|
|
37
37
|
from reactor_runtime.transport.webrtc.version import protocol_for_transport
|
|
@@ -55,6 +55,16 @@ _CONNECT_RESPONSES: dict[int | str, dict[str, Any]] = {
|
|
|
55
55
|
503: {"model": ErrorDetail},
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
# Offering adds a 409 on top: a ``port_range`` pinned to a single port another
|
|
59
|
+
# connection already holds is refused, since a pinned caller has no second port
|
|
60
|
+
# to fall back on. Unlike the 503s this is not transient — retrying the same
|
|
61
|
+
# port changes nothing, so it is a distinct code the caller branches on to pin
|
|
62
|
+
# another port.
|
|
63
|
+
_OFFER_RESPONSES: dict[int | str, dict[str, Any]] = {
|
|
64
|
+
**_CONNECT_RESPONSES,
|
|
65
|
+
409: {"model": ErrorDetail},
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
|
|
59
69
|
class RegisterConnectionResponse(BaseModel):
|
|
60
70
|
"""A minted connection id and the model's track manifest for transceiver setup."""
|
|
@@ -104,18 +114,78 @@ class IceServerEntry(BaseModel):
|
|
|
104
114
|
credentials: TurnCredentials | None = None
|
|
105
115
|
|
|
106
116
|
|
|
117
|
+
# RFC 8445 §15.4: ice-char = ALPHA / DIGIT / "+" / "/". Anchored, so a value
|
|
118
|
+
# is rejected for containing anything else rather than for merely starting with
|
|
119
|
+
# something valid.
|
|
120
|
+
_ICE_CHAR = r"^[A-Za-z0-9+/]+$"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class IceCredentialsEntry(BaseModel):
|
|
124
|
+
"""The ICE credentials a connection should answer with.
|
|
125
|
+
|
|
126
|
+
The constraints are RFC 8445 §15.4: both values are ``ice-char``
|
|
127
|
+
(ALPHA / DIGIT / "+" / "/"), a ufrag is 4..256 of them and a password
|
|
128
|
+
22..256.
|
|
129
|
+
|
|
130
|
+
They are enforced here rather than left to the media engine because of where
|
|
131
|
+
each failure surfaces. Registering an offer answers 202 and the negotiation
|
|
132
|
+
runs in the background, so a malformed value rejected downstream reaches the
|
|
133
|
+
caller only as its answer poll timing out, with the reason in the runtime's
|
|
134
|
+
logs. Checked here it is a 422 naming the field.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
ufrag: str = Field(min_length=4, max_length=256, pattern=_ICE_CHAR)
|
|
138
|
+
pwd: str = Field(min_length=22, max_length=256, pattern=_ICE_CHAR)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# A UDP port. Zero is excluded: it asks the kernel for an ephemeral port, which
|
|
142
|
+
# is the one thing a caller pinning a range cannot mean.
|
|
143
|
+
_Port = Annotated[int, Field(ge=1, le=65535)]
|
|
144
|
+
|
|
145
|
+
|
|
107
146
|
class SdpParamsRequest(BaseModel):
|
|
108
|
-
"""A client's SDP offer, the tracks it declares, and optional
|
|
147
|
+
"""A client's SDP offer, the tracks it declares, and optional overrides.
|
|
109
148
|
|
|
110
149
|
``ice_servers`` lets the caller supply the STUN/TURN servers this connection
|
|
111
150
|
gathers against. Absent, the runtime uses its own configured servers; present
|
|
112
151
|
(even empty), it is authoritative for the connection — so a reconnect can
|
|
113
152
|
carry fresh credentials.
|
|
153
|
+
|
|
154
|
+
``ice_credentials`` and ``port_range`` follow the same rule and are likewise
|
|
155
|
+
optional: absent — the usual case — the media engine generates its own
|
|
156
|
+
credentials and the configured port range applies. They exist for a
|
|
157
|
+
deployment that fronts the runtime with a relaying layer, which must know a
|
|
158
|
+
connection's ICE credentials and media address before the connection exists.
|
|
159
|
+
``port_range`` is an inclusive ``[min, max]``; a single-port range pins the
|
|
160
|
+
connection to one port. It replaces the configured range rather than
|
|
161
|
+
narrowing it, so a caller can name a port outside what the runtime is
|
|
162
|
+
configured with — an operator who set a range for an environmental reason (a
|
|
163
|
+
firewall rule, a container's published ports) cannot rely on it to bound a
|
|
164
|
+
caller. A port pinned this way is reserved for the connection: pinning one
|
|
165
|
+
another live connection already holds is a 409 rather than a negotiation
|
|
166
|
+
that fails out of sight.
|
|
114
167
|
"""
|
|
115
168
|
|
|
116
169
|
sdp_offer: str
|
|
117
170
|
track_mapping: list[TrackMappingEntry] = Field(default_factory=list)
|
|
118
171
|
ice_servers: list[IceServerEntry] | None = None
|
|
172
|
+
ice_credentials: IceCredentialsEntry | None = None
|
|
173
|
+
port_range: tuple[_Port, _Port] | None = None
|
|
174
|
+
|
|
175
|
+
@model_validator(mode="after")
|
|
176
|
+
def _port_range_is_ordered(self) -> SdpParamsRequest:
|
|
177
|
+
"""Reject an inverted range here rather than at gathering.
|
|
178
|
+
|
|
179
|
+
``(50000, 40000)`` is accepted by the type and then fails when the
|
|
180
|
+
engine gathers, which reaches the caller as an answer poll that times
|
|
181
|
+
out. This makes it a 422 that names the field.
|
|
182
|
+
"""
|
|
183
|
+
if self.port_range is not None:
|
|
184
|
+
low, high = self.port_range
|
|
185
|
+
if low > high:
|
|
186
|
+
msg = f"port_range min {low} is above max {high}"
|
|
187
|
+
raise ValueError(msg)
|
|
188
|
+
return self
|
|
119
189
|
|
|
120
190
|
|
|
121
191
|
class IceCandidateEntry(BaseModel):
|
|
@@ -133,6 +203,19 @@ class IceCandidatesRequest(BaseModel):
|
|
|
133
203
|
is_final: bool = False
|
|
134
204
|
|
|
135
205
|
|
|
206
|
+
def _ice_credentials_from_request(
|
|
207
|
+
entry: IceCredentialsEntry | None,
|
|
208
|
+
) -> IceCredentials | None:
|
|
209
|
+
"""Convert a connect request's ICE credentials to the transport's form.
|
|
210
|
+
|
|
211
|
+
``None`` (the field absent) means the media engine generates its own, which
|
|
212
|
+
is the ordinary case.
|
|
213
|
+
"""
|
|
214
|
+
if entry is None:
|
|
215
|
+
return None
|
|
216
|
+
return IceCredentials(ufrag=entry.ufrag, pwd=entry.pwd)
|
|
217
|
+
|
|
218
|
+
|
|
136
219
|
def _ice_servers_from_request(
|
|
137
220
|
entries: list[IceServerEntry] | None,
|
|
138
221
|
) -> tuple[IceServer, ...] | None:
|
|
@@ -245,18 +328,26 @@ class WebRtcRouter(TransportRouter):
|
|
|
245
328
|
tracks = TrackMap.from_client(entry.model_dump() for entry in req.track_mapping)
|
|
246
329
|
conn_id = ConnId(cid)
|
|
247
330
|
runner.offer_admitted(conn_id)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
331
|
+
try:
|
|
332
|
+
acceptor.start_offer(
|
|
333
|
+
conn_id,
|
|
334
|
+
SdpOffer(req.sdp_offer),
|
|
335
|
+
tracks,
|
|
336
|
+
protocol_for_transport(webrtc_version),
|
|
337
|
+
ice_servers=_ice_servers_from_request(req.ice_servers),
|
|
338
|
+
ice_credentials=_ice_credentials_from_request(req.ice_credentials),
|
|
339
|
+
port_range=req.port_range,
|
|
340
|
+
)
|
|
341
|
+
except PortRangeUnavailableError as taken:
|
|
342
|
+
raise HTTPException(
|
|
343
|
+
status_code=409,
|
|
344
|
+
detail=f"port {taken.port} is already held by another connection",
|
|
345
|
+
) from None
|
|
255
346
|
return OfferAccepted(connection_id=cid)
|
|
256
347
|
|
|
257
348
|
offer_path = f"{_PREFIX}/connections/{{cid}}/sdp_params"
|
|
258
|
-
app.post(offer_path, status_code=202, responses=
|
|
259
|
-
app.put(offer_path, status_code=202, responses=
|
|
349
|
+
app.post(offer_path, status_code=202, responses=_OFFER_RESPONSES)(offer)
|
|
350
|
+
app.put(offer_path, status_code=202, responses=_OFFER_RESPONSES)(offer)
|
|
260
351
|
|
|
261
352
|
@app.get(
|
|
262
353
|
f"{_PREFIX}/connections/{{cid}}/sdp_params",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/__init__.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/decorators.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/errors.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/messages.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/__init__.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/bridge.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/__init__.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/contract.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/reactor_model.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/schema.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/__init__.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/idle.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/__init__.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/descriptors.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/input.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/output.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/connection_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/connection.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/frames.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/pacer.py
RENAMED
|
File without changes
|
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/signaling.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/stats.py
RENAMED
|
File without changes
|
{reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/version.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|