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.
Files changed (99) hide show
  1. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/PKG-INFO +1 -1
  2. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/pyproject.toml +1 -1
  3. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/pyproject.toml.orig +1 -1
  4. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/typespec.py +15 -0
  5. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/recording/chunk_encoder.py +6 -9
  6. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/runner.py +69 -19
  7. reactor_runtime-3.3.1/src/reactor_runtime/runner/upload_resolution.py +118 -0
  8. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/__init__.py +9 -2
  9. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/acceptor.py +136 -9
  10. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/config.py +32 -1
  11. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/peer.py +8 -0
  12. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/router.py +105 -14
  13. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/LICENSE +0 -0
  14. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/NOTICE +0 -0
  15. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/README.md +0 -0
  16. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/__init__.py +0 -0
  17. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/codes.py +0 -0
  18. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/__init__.py +0 -0
  19. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/fields.py +0 -0
  20. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/model.py +0 -0
  21. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/naming.py +0 -0
  22. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/service.py +0 -0
  23. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/session.py +0 -0
  24. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/transport.py +0 -0
  25. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/core/values.py +0 -0
  26. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/event_stream.py +0 -0
  27. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/__init__.py +0 -0
  28. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/events.py +0 -0
  29. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/routes.py +0 -0
  30. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/server.py +0 -0
  31. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/http/spec.py +0 -0
  32. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/__init__.py +0 -0
  33. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/client.py +0 -0
  34. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/events/__init__.py +0 -0
  35. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/events/decorators.py +0 -0
  36. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/events/errors.py +0 -0
  37. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/events/messages.py +0 -0
  38. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  39. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/internal/bridge.py +0 -0
  40. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  41. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  42. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/model/__init__.py +0 -0
  43. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/model/contract.py +0 -0
  44. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  45. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/model/schema.py +0 -0
  46. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  47. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  48. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  49. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  50. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  51. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  52. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/tracks/input.py +0 -0
  53. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/interface/tracks/output.py +0 -0
  54. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/log.py +0 -0
  55. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/manifest.py +0 -0
  56. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/message_gateway.py +0 -0
  57. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/metrics.py +0 -0
  58. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/paths.py +0 -0
  59. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/__init__.py +0 -0
  60. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/base.py +0 -0
  61. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/common.py +0 -0
  62. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  63. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/v0/codec.py +0 -0
  64. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  65. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  66. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/py.typed +0 -0
  67. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/recording/__init__.py +0 -0
  68. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/recording/markers.py +0 -0
  69. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/recording/recorder.py +0 -0
  70. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/__init__.py +0 -0
  71. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/connection_manager.py +0 -0
  72. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/offer_epochs.py +0 -0
  73. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/runner/state_machine.py +0 -0
  74. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/schema.py +0 -0
  75. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/serve.py +0 -0
  76. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/service.py +0 -0
  77. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/__init__.py +0 -0
  78. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/acceptor.py +0 -0
  79. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/router.py +0 -0
  80. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/connection.py +0 -0
  81. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  82. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
  83. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
  84. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
  85. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/stats.py +0 -0
  86. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  87. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_runtime/upload_store.py +0 -0
  88. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/common_pb2.py +0 -0
  89. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  90. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/control_pb2.py +0 -0
  91. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/control_pb2.pyi +0 -0
  92. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/data_pb2.py +0 -0
  93. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  94. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/model_pb2.py +0 -0
  95. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  96. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/platform_pb2.py +0 -0
  97. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
  98. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/src/reactor_wire/v1/track_pb2.py +0 -0
  99. {reactor_runtime-3.2.7 → reactor_runtime-3.3.1}/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.7
3
+ Version: 3.3.1
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.7"
95
+ version = "3.3.1"
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.7"
23
+ version = "3.3.1"
24
24
  description = "A Python framework for building real-time, interactive video models"
25
25
  readme = "README.md"
26
26
  license = "Apache-2.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."""
@@ -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"),
@@ -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, 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
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
- transport session id is unaffected — it is always :data:`SESSION_ID`.
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 command that references an
805
- upload the store cannot produce is journalled as an error and dropped
806
- rather than submitted half-resolved. An accepted command is journalled on
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._uploads.fetch(
827
- upload_id, wait_seconds=_UPLOAD_RESOLVE_TIMEOUT_SECONDS
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._uploads.fetch(
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 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``
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
@@ -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: