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.
Files changed (98) hide show
  1. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/PKG-INFO +1 -1
  2. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/pyproject.toml +1 -1
  3. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/pyproject.toml.orig +1 -1
  4. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/recording/chunk_encoder.py +6 -9
  5. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/recording/recorder.py +20 -2
  6. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/runner.py +26 -9
  7. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/__init__.py +9 -2
  8. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/acceptor.py +136 -9
  9. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/config.py +32 -1
  10. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/peer.py +8 -0
  11. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/router.py +105 -14
  12. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/LICENSE +0 -0
  13. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/NOTICE +0 -0
  14. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/README.md +0 -0
  15. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/__init__.py +0 -0
  16. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/codes.py +0 -0
  17. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/__init__.py +0 -0
  18. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/fields.py +0 -0
  19. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/model.py +0 -0
  20. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/naming.py +0 -0
  21. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/service.py +0 -0
  22. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/session.py +0 -0
  23. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/transport.py +0 -0
  24. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/typespec.py +0 -0
  25. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/core/values.py +0 -0
  26. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/event_stream.py +0 -0
  27. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/__init__.py +0 -0
  28. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/events.py +0 -0
  29. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/routes.py +0 -0
  30. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/server.py +0 -0
  31. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/http/spec.py +0 -0
  32. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/__init__.py +0 -0
  33. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/client.py +0 -0
  34. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/__init__.py +0 -0
  35. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/decorators.py +0 -0
  36. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/errors.py +0 -0
  37. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/events/messages.py +0 -0
  38. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  39. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/bridge.py +0 -0
  40. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  41. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  42. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/__init__.py +0 -0
  43. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/contract.py +0 -0
  44. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  45. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/model/schema.py +0 -0
  46. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  47. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  48. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  49. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  50. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  51. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  52. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/input.py +0 -0
  53. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/interface/tracks/output.py +0 -0
  54. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/log.py +0 -0
  55. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/manifest.py +0 -0
  56. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/message_gateway.py +0 -0
  57. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/metrics.py +0 -0
  58. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/paths.py +0 -0
  59. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/__init__.py +0 -0
  60. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/base.py +0 -0
  61. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/common.py +0 -0
  62. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  63. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/v0/codec.py +0 -0
  64. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  65. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  66. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/py.typed +0 -0
  67. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/recording/__init__.py +0 -0
  68. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/recording/markers.py +0 -0
  69. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/__init__.py +0 -0
  70. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/connection_manager.py +0 -0
  71. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/offer_epochs.py +0 -0
  72. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/runner/state_machine.py +0 -0
  73. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/schema.py +0 -0
  74. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/serve.py +0 -0
  75. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/service.py +0 -0
  76. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/__init__.py +0 -0
  77. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/acceptor.py +0 -0
  78. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/router.py +0 -0
  79. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/connection.py +0 -0
  80. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  81. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
  82. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
  83. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
  84. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/stats.py +0 -0
  85. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  86. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_runtime/upload_store.py +0 -0
  87. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/common_pb2.py +0 -0
  88. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  89. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/control_pb2.py +0 -0
  90. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/control_pb2.pyi +0 -0
  91. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/data_pb2.py +0 -0
  92. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  93. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/model_pb2.py +0 -0
  94. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  95. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/platform_pb2.py +0 -0
  96. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
  97. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/track_pb2.py +0 -0
  98. {reactor_runtime-3.2.6 → reactor_runtime-3.3.0}/src/reactor_wire/v1/track_pb2.pyi +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactor-runtime
3
- Version: 3.2.6
3
+ Version: 3.3.0
4
4
  Summary: A Python framework for building real-time, interactive video models
5
5
  Author: Reactor
6
6
  Author-email: Reactor <team@reactor.inc>
@@ -92,7 +92,7 @@ pythonpath = ["."]
92
92
 
93
93
  [project]
94
94
  name = "reactor-runtime"
95
- version = "3.2.6"
95
+ version = "3.3.0"
96
96
  description = "A Python framework for building real-time, interactive video models"
97
97
  readme = "README.md"
98
98
  license = "Apache-2.0"
@@ -20,7 +20,7 @@ version = "1.20260814.7"
20
20
 
21
21
  [project]
22
22
  name = "reactor-runtime"
23
- version = "3.2.6"
23
+ version = "3.3.0"
24
24
  description = "A Python framework for building real-time, interactive video models"
25
25
  readme = "README.md"
26
26
  license = "Apache-2.0"
@@ -232,15 +232,10 @@ class ChunkEncoder:
232
232
  "hls_segment_filename": str(self._output_dir / _SEGMENT_PATTERN),
233
233
  },
234
234
  )
235
- # ``add_stream`` is overloaded on a literal set of codec names, so the
236
- # configured codec resolves to the catch-all return type.
237
- video = cast(
238
- "av.VideoStream",
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, capped at one second.
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
- cap = self._audio_sample_rate
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, set per session in start_session: the id a
225
- # recording is stored and addressed under, and the id stamped on the
226
- # session's log records. Separate from the fixed transport session id so a
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
- transport session id is unaffected — it is always :data:`SESSION_ID`.
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 also where the log's session context binds, so
1111
- every record written while a session is live names it, the opening move
1112
- included. The release travels differently: it rides the ``SessionEnded``
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)
@@ -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 IceServer, IceTransportPolicy, WebRtcConfig
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",
@@ -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(conn_id, sdp_offer, tracks, version, ice_servers, offered_at=offered_at)
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 configured STUN/TURN servers for this
254
- connection when given; ``None`` uses the acceptor's configuration.
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
- config = (
257
- self._config
258
- if ice_servers is None
259
- else dataclasses.replace(self._config, ice_servers=ice_servers)
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:
@@ -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
@@ -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
@@ -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 ICE servers.
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
- acceptor.start_offer(
249
- conn_id,
250
- SdpOffer(req.sdp_offer),
251
- tracks,
252
- protocol_for_transport(webrtc_version),
253
- ice_servers=_ice_servers_from_request(req.ice_servers),
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=_CONNECT_RESPONSES)(offer)
259
- app.put(offer_path, status_code=202, responses=_CONNECT_RESPONSES)(offer)
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