reactor-runtime 3.2.7__tar.gz → 3.3.1__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.7 → reactor_runtime-3.3.1}/PKG-INFO +1 -1
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/pyproject.toml +1 -1
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/pyproject.toml.orig +1 -1
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/typespec.py +15 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/recording/chunk_encoder.py +6 -9
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/runner.py +69 -19
- reactor_runtime-3.3.1/src/reactor_runtime/runner/upload_resolution.py +118 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/__init__.py +9 -2
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/acceptor.py +136 -9
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/config.py +32 -1
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/peer.py +8 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/router.py +105 -14
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/LICENSE +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/NOTICE +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/README.md +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/codes.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/fields.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/model.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/naming.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/service.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/session.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/transport.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/values.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/event_stream.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/events.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/routes.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/server.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/spec.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/client.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/events/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/events/decorators.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/events/errors.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/events/messages.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/internal/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/internal/bridge.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/model/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/model/contract.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/model/schema.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/tracks/input.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/tracks/output.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/log.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/manifest.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/message_gateway.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/metrics.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/paths.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/base.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/common.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/v0/codec.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/v1/codec.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/py.typed +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/recording/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/recording/markers.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/recording/recorder.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/connection_manager.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/offer_epochs.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/state_machine.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/schema.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/serve.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/service.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/__init__.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/acceptor.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/router.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/connection.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/stats.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/version.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/upload_store.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/common_pb2.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/common_pb2.pyi +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/control_pb2.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/control_pb2.pyi +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/data_pb2.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/data_pb2.pyi +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/model_pb2.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/model_pb2.pyi +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/platform_pb2.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/track_pb2.py +0 -0
- {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/track_pb2.pyi +0 -0
|
@@ -218,6 +218,11 @@ class ListSpec(TypeSpec):
|
|
|
218
218
|
def __init__(self, item: TypeSpec) -> None:
|
|
219
219
|
self._item = item
|
|
220
220
|
|
|
221
|
+
@property
|
|
222
|
+
def item(self) -> TypeSpec:
|
|
223
|
+
"""The type every element must fit."""
|
|
224
|
+
return self._item
|
|
225
|
+
|
|
221
226
|
def check(self, value: Any) -> str | None: # noqa: D102 — contract on the base
|
|
222
227
|
if not isinstance(value, list):
|
|
223
228
|
return f"expected array, got {type(value).__name__}"
|
|
@@ -240,6 +245,11 @@ class DictSpec(TypeSpec):
|
|
|
240
245
|
def __init__(self, value: TypeSpec) -> None:
|
|
241
246
|
self._value = value
|
|
242
247
|
|
|
248
|
+
@property
|
|
249
|
+
def value(self) -> TypeSpec:
|
|
250
|
+
"""The type every value must fit."""
|
|
251
|
+
return self._value
|
|
252
|
+
|
|
243
253
|
def check(self, value: Any) -> str | None: # noqa: D102 — contract on the base
|
|
244
254
|
if not isinstance(value, Mapping):
|
|
245
255
|
return f"expected object, got {type(value).__name__}"
|
|
@@ -266,6 +276,11 @@ class DataclassSpec(TypeSpec):
|
|
|
266
276
|
self._fields = fields
|
|
267
277
|
self._required = required
|
|
268
278
|
|
|
279
|
+
@property
|
|
280
|
+
def fields(self) -> Mapping[str, TypeSpec]:
|
|
281
|
+
"""Each field's type, by field name."""
|
|
282
|
+
return self._fields
|
|
283
|
+
|
|
269
284
|
@classmethod
|
|
270
285
|
def build(cls, dataclass_type: type) -> DataclassSpec:
|
|
271
286
|
"""Resolve a dataclass into a spec over its fields."""
|
{reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/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"),
|
|
@@ -48,6 +48,8 @@ from reactor_runtime.core import (
|
|
|
48
48
|
TrackDirection,
|
|
49
49
|
Transition,
|
|
50
50
|
TransitionEvent,
|
|
51
|
+
TypeSpec,
|
|
52
|
+
UploadedFile,
|
|
51
53
|
)
|
|
52
54
|
from reactor_runtime.event_stream import EventStream
|
|
53
55
|
from reactor_runtime.interface.events.messages import ModelMessage
|
|
@@ -69,6 +71,7 @@ from reactor_runtime.recording import ClipResult, Recorder, RecorderError
|
|
|
69
71
|
from reactor_runtime.runner.connection_manager import ConnectionManager
|
|
70
72
|
from reactor_runtime.runner.offer_epochs import OfferEpochs
|
|
71
73
|
from reactor_runtime.runner.state_machine import SessionStateMachine
|
|
74
|
+
from reactor_runtime.runner.upload_resolution import declares_upload, resolve_uploads
|
|
72
75
|
from reactor_runtime.transport.router import (
|
|
73
76
|
SessionNotRunningError,
|
|
74
77
|
SessionTransitionError,
|
|
@@ -109,6 +112,17 @@ def _stamp_log_state(state: SessionState) -> None:
|
|
|
109
112
|
set_state(state.name.lower(), _RUNTIME_STATES[state].value)
|
|
110
113
|
|
|
111
114
|
|
|
115
|
+
def _recording_id_from(params: Mapping[str, Any]) -> str:
|
|
116
|
+
"""Resolve a session's recording id from its start parameters.
|
|
117
|
+
|
|
118
|
+
A ``session_id`` in *params* is adopted as the recording id, so a caller can
|
|
119
|
+
align both clips and logs with the id it knows the session by. Absent one, a
|
|
120
|
+
fresh id is minted per session so sequential recordings in a reused process
|
|
121
|
+
never overwrite each other.
|
|
122
|
+
"""
|
|
123
|
+
return str(params.get("session_id") or uuid.uuid4())
|
|
124
|
+
|
|
125
|
+
|
|
112
126
|
# How long to wait for an upload's bytes to arrive when a command or notification
|
|
113
127
|
# references it before they are written. A client references an upload over the
|
|
114
128
|
# data channel while its bytes are still being delivered on a separate request,
|
|
@@ -221,9 +235,10 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
221
235
|
self._teardown: set[asyncio.Task[None]] = set()
|
|
222
236
|
self._orphan_task: asyncio.Task[None] | None = None
|
|
223
237
|
self._session_id = SESSION_ID
|
|
224
|
-
# The session's own id,
|
|
225
|
-
#
|
|
226
|
-
#
|
|
238
|
+
# The session's own id, resolved per session as the start transition is
|
|
239
|
+
# applied (see _dispatch_transition): the id a recording is stored and
|
|
240
|
+
# addressed under, and the id stamped on the session's log records.
|
|
241
|
+
# Separate from the fixed transport session id so a
|
|
227
242
|
# caller can align both with the id it knows the session by; a session
|
|
228
243
|
# started without one mints a fresh id, so sequential recordings in a
|
|
229
244
|
# reused process never share a directory and the logs of one session are
|
|
@@ -611,8 +626,10 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
611
626
|
its recording is stored and addressed under, and the id stamped on every
|
|
612
627
|
log record the session writes. A caller can therefore align both clips and
|
|
613
628
|
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
|
-
|
|
629
|
+
per session so sequential recordings never overwrite each other. The id is
|
|
630
|
+
resolved as the machine accepts the start, so a rejected request leaves a
|
|
631
|
+
live session's id untouched. The transport session id is unaffected: it is
|
|
632
|
+
always :data:`SESSION_ID`.
|
|
616
633
|
|
|
617
634
|
Args:
|
|
618
635
|
params: The initial session parameters supplied by the caller.
|
|
@@ -620,7 +637,6 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
620
637
|
Raises:
|
|
621
638
|
SessionTransitionError: If the session is not in a startable state.
|
|
622
639
|
"""
|
|
623
|
-
self._recording_id = str(params.get("session_id") or uuid.uuid4())
|
|
624
640
|
if not self._sm.send(SessionEvent.START_SESSION, params=dict(params)):
|
|
625
641
|
raise SessionTransitionError("start", self._sm.current_state)
|
|
626
642
|
self._offer_epochs.session_started()
|
|
@@ -801,9 +817,17 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
801
817
|
|
|
802
818
|
Each upload the command references is resolved to its bytes through the
|
|
803
819
|
store and merged into the arguments before validation, so the model
|
|
804
|
-
receives a file rather than a reference. A
|
|
805
|
-
|
|
806
|
-
|
|
820
|
+
receives a file rather than a reference. A reference arrives one of two
|
|
821
|
+
ways: beside the arguments, keyed by parameter name, which is how a
|
|
822
|
+
single top-level file travels; or inline in an argument as a mapping
|
|
823
|
+
with an ``upload_id``, which is the only way a file nested in a list,
|
|
824
|
+
dict, or dataclass can travel and is accepted for a single file too.
|
|
825
|
+
Inline references are found by
|
|
826
|
+
walking the command's declared types, never by inspecting values, so a
|
|
827
|
+
mapping of the model's own that carries an ``upload_id`` key is left
|
|
828
|
+
alone. A command that references an upload the store cannot produce,
|
|
829
|
+
in either form, is journalled as an error and dropped rather than
|
|
830
|
+
submitted half-resolved. An accepted command is journalled on
|
|
807
831
|
the egress stream so a consumer can audit or moderate it; a command the
|
|
808
832
|
contract rejects is journalled as an error instead and never reaches the
|
|
809
833
|
model. The journalled argument record carries the scalar arguments, never
|
|
@@ -820,12 +844,13 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
820
844
|
return
|
|
821
845
|
label = self._command_label(command.name)
|
|
822
846
|
args = dict(command.args)
|
|
847
|
+
inline = self._inline_upload_fields(command.name, args)
|
|
823
848
|
resolve_started = time.monotonic()
|
|
824
849
|
try:
|
|
825
850
|
for param, upload_id in command.uploads.items():
|
|
826
|
-
args[param] = await self.
|
|
827
|
-
|
|
828
|
-
)
|
|
851
|
+
args[param] = await self._fetch_upload(upload_id)
|
|
852
|
+
for param, spec in inline.items():
|
|
853
|
+
args[param] = await resolve_uploads(spec, args[param], self._fetch_upload)
|
|
829
854
|
except UnknownUploadError:
|
|
830
855
|
self._command_metrics.unresolved_upload(label)
|
|
831
856
|
self._sm.send(
|
|
@@ -844,7 +869,7 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
844
869
|
# again where that wait ended. Counted whole, one command with a file
|
|
845
870
|
# parameter reports the upload and hides the runtime's own cost.
|
|
846
871
|
started_at = command.received_at
|
|
847
|
-
if command.uploads:
|
|
872
|
+
if command.uploads or inline:
|
|
848
873
|
started_at += time.monotonic() - resolve_started
|
|
849
874
|
outcome = await self._bridge.submit_command(
|
|
850
875
|
command.name,
|
|
@@ -874,6 +899,29 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
874
899
|
outcome.reason or "command rejected",
|
|
875
900
|
)
|
|
876
901
|
|
|
902
|
+
def _inline_upload_fields(self, name: str, args: Mapping[str, Any]) -> dict[str, TypeSpec]:
|
|
903
|
+
"""Return the present arguments of command *name* whose type carries an upload.
|
|
904
|
+
|
|
905
|
+
The contract decides which fields to walk: a field typed as an upload,
|
|
906
|
+
or as a container holding one, may hold inline references and is
|
|
907
|
+
returned with its declared type; every other field is not. An unknown
|
|
908
|
+
command has no fields to walk and is left for the bridge to reject.
|
|
909
|
+
"""
|
|
910
|
+
if self._bridge is None:
|
|
911
|
+
return {}
|
|
912
|
+
spec = self._bridge.contract.commands.get(name)
|
|
913
|
+
if spec is None:
|
|
914
|
+
return {}
|
|
915
|
+
return {
|
|
916
|
+
param: field.spec
|
|
917
|
+
for param, field in spec.command.__command_fields__.items()
|
|
918
|
+
if param in args and declares_upload(field.spec)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async def _fetch_upload(self, upload_id: str) -> UploadedFile:
|
|
922
|
+
"""Read one upload from the store, waiting the standard grace for its bytes."""
|
|
923
|
+
return await self._uploads.fetch(upload_id, wait_seconds=_UPLOAD_RESOLVE_TIMEOUT_SECONDS)
|
|
924
|
+
|
|
877
925
|
def _command_label(self, name: str) -> str:
|
|
878
926
|
"""Return a command name that is safe to label a metric with.
|
|
879
927
|
|
|
@@ -895,9 +943,7 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
895
943
|
if self._bridge is None:
|
|
896
944
|
return
|
|
897
945
|
try:
|
|
898
|
-
file = await self.
|
|
899
|
-
upload_id, wait_seconds=_UPLOAD_RESOLVE_TIMEOUT_SECONDS
|
|
900
|
-
)
|
|
946
|
+
file = await self._fetch_upload(upload_id)
|
|
901
947
|
except UnknownUploadError:
|
|
902
948
|
self._sm.send(
|
|
903
949
|
SessionEvent.ERROR, message=f"file upload {upload_id!r} could not be resolved"
|
|
@@ -1107,9 +1153,12 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
1107
1153
|
self-loops log at debug so a per-segment ``chunk_ready`` does not flood
|
|
1108
1154
|
the log.
|
|
1109
1155
|
|
|
1110
|
-
The session boundary is
|
|
1111
|
-
|
|
1112
|
-
|
|
1156
|
+
The session boundary is where the session's recording id resolves, off
|
|
1157
|
+
the start parameters, so a rejected start cannot touch it; both the log's
|
|
1158
|
+
session context and the recorder's directory read it from there. Binding
|
|
1159
|
+
the log context is also part of this boundary, so every record written
|
|
1160
|
+
while a session is live names it, the opening move included. The release
|
|
1161
|
+
travels differently: it rides the ``SessionEnded``
|
|
1113
1162
|
event into the model, whose dispatch retires the binding once the
|
|
1114
1163
|
``@session_ended`` hook has returned. A terminal move dispatches no
|
|
1115
1164
|
``SessionEnded`` and releases nothing — the process is exiting, and its
|
|
@@ -1118,6 +1167,7 @@ class Runner(ServiceComponent, ConnectionSink):
|
|
|
1118
1167
|
reads the state the process was in when it was written.
|
|
1119
1168
|
"""
|
|
1120
1169
|
if transition.is_session_start:
|
|
1170
|
+
self._recording_id = _recording_id_from(transition.detail.get("params", {}))
|
|
1121
1171
|
self._log_binding = set_session_id(self._recording_id)
|
|
1122
1172
|
if transition.from_state is not transition.to_state:
|
|
1123
1173
|
_stamp_log_state(transition.to_state)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Resolve the upload references a command's contract declares.
|
|
2
|
+
|
|
3
|
+
A client refers to an uploaded file by its ``upload_id`` and the runtime swaps
|
|
4
|
+
the reference for the bytes before the model sees the command. A single
|
|
5
|
+
top-level file travels beside the arguments, keyed by parameter name; a file
|
|
6
|
+
nested in a container has no such slot and travels inline, as a
|
|
7
|
+
``{"upload_id": ...}`` mapping inside the argument itself. Both forms are
|
|
8
|
+
resolved here.
|
|
9
|
+
|
|
10
|
+
The walk follows the command's declared :class:`~reactor_runtime.core.TypeSpec`
|
|
11
|
+
rather than the shape of the arguments: only a value the contract types as an
|
|
12
|
+
upload is fetched, so a mapping field of the model's own that happens to carry
|
|
13
|
+
an ``upload_id`` key is left exactly as the client sent it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from reactor_runtime.core import UploadedFile
|
|
23
|
+
from reactor_runtime.core.typespec import (
|
|
24
|
+
DataclassSpec,
|
|
25
|
+
DictSpec,
|
|
26
|
+
ListSpec,
|
|
27
|
+
OptionalSpec,
|
|
28
|
+
TypeSpec,
|
|
29
|
+
UploadSpec,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
Fetch = Callable[[str], Awaitable[UploadedFile]]
|
|
33
|
+
"""Turn an ``upload_id`` into the uploaded file, or raise when it cannot."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def declares_upload(spec: TypeSpec) -> bool:
|
|
37
|
+
"""Return whether a value of *spec* can carry an upload reference.
|
|
38
|
+
|
|
39
|
+
True for an upload itself and for any container the contract supports
|
|
40
|
+
around one — optional, list, dict, or dataclass field — however deep they
|
|
41
|
+
nest. Every other type is False, so a caller can skip the walk for the
|
|
42
|
+
fields that never need it.
|
|
43
|
+
"""
|
|
44
|
+
if isinstance(spec, UploadSpec):
|
|
45
|
+
return True
|
|
46
|
+
if isinstance(spec, OptionalSpec):
|
|
47
|
+
return declares_upload(spec.inner)
|
|
48
|
+
if isinstance(spec, ListSpec):
|
|
49
|
+
return declares_upload(spec.item)
|
|
50
|
+
if isinstance(spec, DictSpec):
|
|
51
|
+
return declares_upload(spec.value)
|
|
52
|
+
if isinstance(spec, DataclassSpec):
|
|
53
|
+
return any(declares_upload(field) for field in spec.fields.values())
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def resolve_uploads(spec: TypeSpec, value: Any, fetch: Fetch) -> Any:
|
|
58
|
+
"""Return *value* with every upload reference *spec* declares fetched.
|
|
59
|
+
|
|
60
|
+
A reference is a mapping with a string ``upload_id`` in a position the spec
|
|
61
|
+
types as an upload. An :class:`~reactor_runtime.core.UploadedFile` already
|
|
62
|
+
in that position passes through, as does any value the spec does not type
|
|
63
|
+
as an upload, so contract validation still sees whatever the client sent
|
|
64
|
+
where it sent something else. The entries of a container are fetched
|
|
65
|
+
together and returned under their original positions and keys, so the
|
|
66
|
+
container as a whole waits no longer than its slowest entry.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
spec: The declared type of the field holding *value*.
|
|
70
|
+
value: The field's raw argument, as decoded from the wire.
|
|
71
|
+
fetch: Resolves one ``upload_id`` to its file.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The value with each declared reference replaced by its file.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
Exception: Whatever *fetch* raises for a reference it cannot resolve.
|
|
78
|
+
"""
|
|
79
|
+
if isinstance(spec, OptionalSpec):
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
return await resolve_uploads(spec.inner, value, fetch)
|
|
83
|
+
if isinstance(spec, ListSpec):
|
|
84
|
+
if not isinstance(value, list) or not declares_upload(spec.item):
|
|
85
|
+
return value
|
|
86
|
+
return list(
|
|
87
|
+
await asyncio.gather(*(resolve_uploads(spec.item, element, fetch) for element in value))
|
|
88
|
+
)
|
|
89
|
+
if isinstance(spec, DictSpec):
|
|
90
|
+
if not isinstance(value, Mapping) or not declares_upload(spec.value):
|
|
91
|
+
return value
|
|
92
|
+
return await _resolve_entries(value, dict.fromkeys(value, spec.value), fetch)
|
|
93
|
+
if isinstance(spec, DataclassSpec):
|
|
94
|
+
if not isinstance(value, Mapping):
|
|
95
|
+
return value
|
|
96
|
+
# Fields the dataclass does not declare are left as sent; the contract
|
|
97
|
+
# decides what to do with them.
|
|
98
|
+
specs = {name: field for name, field in spec.fields.items() if name in value}
|
|
99
|
+
if not any(declares_upload(field) for field in specs.values()):
|
|
100
|
+
return value
|
|
101
|
+
return await _resolve_entries(value, specs, fetch)
|
|
102
|
+
if isinstance(spec, UploadSpec):
|
|
103
|
+
if isinstance(value, Mapping) and isinstance(value.get("upload_id"), str):
|
|
104
|
+
return await fetch(value["upload_id"])
|
|
105
|
+
return value
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _resolve_entries(
|
|
110
|
+
value: Mapping[Any, Any], specs: Mapping[Any, TypeSpec], fetch: Fetch
|
|
111
|
+
) -> dict[Any, Any]:
|
|
112
|
+
"""Resolve the entries of *value* named in *specs* together, keeping every key in place."""
|
|
113
|
+
resolved = dict(value)
|
|
114
|
+
keys = list(specs)
|
|
115
|
+
files = await asyncio.gather(*(resolve_uploads(specs[key], value[key], fetch) for key in keys))
|
|
116
|
+
for key, file in zip(keys, files, strict=True):
|
|
117
|
+
resolved[key] = file
|
|
118
|
+
return resolved
|
{reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/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.7 → reactor_runtime-3.3.1}/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:
|