reactor-runtime 3.1.2__tar.gz → 3.2.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 (99) hide show
  1. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/PKG-INFO +5 -3
  2. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/README.md +3 -1
  3. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/pyproject.toml +11 -3
  4. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/pyproject.toml.orig +18 -3
  5. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/fields.py +16 -8
  6. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/routes.py +11 -3
  7. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/model/schema.py +3 -2
  8. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/base.py +15 -0
  9. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/v0/codec.py +8 -0
  10. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/runner/connection_manager.py +89 -22
  11. reactor_runtime-3.2.0/src/reactor_runtime/runner/offer_epochs.py +44 -0
  12. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/runner/runner.py +99 -5
  13. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/serve.py +36 -5
  14. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/router.py +8 -0
  15. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/__init__.py +2 -1
  16. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/connection.py +38 -1
  17. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/pacer.py +13 -1
  18. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/peer.py +500 -82
  19. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/router.py +3 -1
  20. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/sdp.py +80 -0
  21. reactor_runtime-3.2.0/src/reactor_runtime/transport/webrtc/stats.py +80 -0
  22. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/control_pb2.py +2 -2
  23. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/control_pb2.pyi +4 -2
  24. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/platform_pb2.py +3 -1
  25. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/platform_pb2.pyi +6 -0
  26. reactor_runtime-3.1.2/src/reactor_runtime/transport/webrtc/stats.py +0 -45
  27. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/LICENSE +0 -0
  28. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/NOTICE +0 -0
  29. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/__init__.py +0 -0
  30. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/codes.py +0 -0
  31. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/__init__.py +0 -0
  32. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/model.py +0 -0
  33. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/naming.py +0 -0
  34. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/service.py +0 -0
  35. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/session.py +0 -0
  36. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/transport.py +0 -0
  37. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/typespec.py +0 -0
  38. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/values.py +0 -0
  39. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/event_stream.py +0 -0
  40. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/__init__.py +0 -0
  41. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/events.py +0 -0
  42. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/server.py +0 -0
  43. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/spec.py +0 -0
  44. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/__init__.py +0 -0
  45. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/client.py +0 -0
  46. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/events/__init__.py +0 -0
  47. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/events/decorators.py +0 -0
  48. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/events/errors.py +0 -0
  49. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/events/messages.py +0 -0
  50. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  51. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/internal/bridge.py +0 -0
  52. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  53. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  54. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/model/__init__.py +0 -0
  55. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/model/contract.py +0 -0
  56. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  57. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  58. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  59. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  60. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  61. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  62. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  63. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/tracks/input.py +0 -0
  64. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/tracks/output.py +0 -0
  65. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/log.py +0 -0
  66. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/manifest.py +0 -0
  67. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/message_gateway.py +0 -0
  68. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/metrics.py +0 -0
  69. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/paths.py +0 -0
  70. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/__init__.py +0 -0
  71. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/common.py +0 -0
  72. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  73. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  74. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  75. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/py.typed +0 -0
  76. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/recording/__init__.py +0 -0
  77. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  78. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/recording/markers.py +0 -0
  79. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/recording/recorder.py +0 -0
  80. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/runner/__init__.py +0 -0
  81. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/runner/state_machine.py +0 -0
  82. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/schema.py +0 -0
  83. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/service.py +0 -0
  84. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/__init__.py +0 -0
  85. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/acceptor.py +0 -0
  86. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/acceptor.py +0 -0
  87. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/config.py +0 -0
  88. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  89. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
  90. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  91. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/upload_store.py +0 -0
  92. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/common_pb2.py +0 -0
  93. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  94. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/data_pb2.py +0 -0
  95. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  96. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/model_pb2.py +0 -0
  97. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  98. {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/track_pb2.py +0 -0
  99. {reactor_runtime-3.1.2 → reactor_runtime-3.2.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.1.2
3
+ Version: 3.2.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>
@@ -18,7 +18,7 @@ Requires-Dist: numpy>=2.1
18
18
  Requires-Dist: prometheus-client>=0.26.0
19
19
  Requires-Dist: protobuf>=7.35.1
20
20
  Requires-Dist: pyyaml>=6.0.3
21
- Requires-Dist: reactor-webrtc==0.7.2
21
+ Requires-Dist: reactor-webrtc==0.10.0
22
22
  Requires-Dist: uvicorn>=0.49.0
23
23
  Requires-Python: >=3.12
24
24
  Project-URL: Source, https://github.com/reactor-team/reactor-runtime
@@ -68,7 +68,9 @@ class MyModel(ReactorModel):
68
68
  self.prompt = "a sunny meadow"
69
69
 
70
70
  @event(name="set_prompt", description="Scene the model renders")
71
- async def set_prompt(self, prompt: str = InputField(default="a sunny meadow")) -> None:
71
+ async def set_prompt(
72
+ self, prompt: str = InputField(default="a sunny meadow", moderate=True)
73
+ ) -> None:
72
74
  self.prompt = prompt
73
75
 
74
76
  async def run(self) -> None:
@@ -42,7 +42,9 @@ class MyModel(ReactorModel):
42
42
  self.prompt = "a sunny meadow"
43
43
 
44
44
  @event(name="set_prompt", description="Scene the model renders")
45
- async def set_prompt(self, prompt: str = InputField(default="a sunny meadow")) -> None:
45
+ async def set_prompt(
46
+ self, prompt: str = InputField(default="a sunny meadow", moderate=True)
47
+ ) -> None:
46
48
  self.prompt = prompt
47
49
 
48
50
  async def run(self) -> None:
@@ -10,7 +10,7 @@ module-name = [
10
10
  namespace = true
11
11
 
12
12
  [tool.reactor-wire]
13
- version = "1.20260722.6"
13
+ version = "1.20260814.7"
14
14
 
15
15
  [tool.ruff]
16
16
  line-length = 100
@@ -45,6 +45,14 @@ ignore = [
45
45
  "D107",
46
46
  ]
47
47
 
48
+ [tool.ruff.lint.flake8-bugbear]
49
+ extend-immutable-calls = [
50
+ "reactor_runtime.InputField",
51
+ "reactor_runtime.core.InputField",
52
+ "reactor_runtime.core.fields.InputField",
53
+ "reactor_runtime.interface.InputField",
54
+ ]
55
+
48
56
  [tool.ruff.lint.isort]
49
57
  known-first-party = [
50
58
  "examples",
@@ -84,7 +92,7 @@ pythonpath = ["."]
84
92
 
85
93
  [project]
86
94
  name = "reactor-runtime"
87
- version = "3.1.2"
95
+ version = "3.2.0"
88
96
  description = "A Python framework for building real-time, interactive video models"
89
97
  readme = "README.md"
90
98
  license = "Apache-2.0"
@@ -107,7 +115,7 @@ dependencies = [
107
115
  "prometheus-client>=0.26.0",
108
116
  "protobuf>=7.35.1",
109
117
  "pyyaml>=6.0.3",
110
- "reactor-webrtc==0.7.2",
118
+ "reactor-webrtc==0.10.0",
111
119
  "uvicorn>=0.49.0",
112
120
  ]
113
121
 
@@ -16,11 +16,11 @@ namespace = true
16
16
  # gates the pin against proto/, so a release cannot ship a schema older than the
17
17
  # sources it was built from.
18
18
  [tool.reactor-wire]
19
- version = "1.20260722.6"
19
+ version = "1.20260814.7"
20
20
 
21
21
  [project]
22
22
  name = "reactor-runtime"
23
- version = "3.1.2"
23
+ version = "3.2.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"
@@ -41,7 +41,7 @@ dependencies = [
41
41
  "prometheus-client>=0.26.0",
42
42
  "protobuf>=7.35.1",
43
43
  "pyyaml>=6.0.3",
44
- "reactor-webrtc==0.7.2",
44
+ "reactor-webrtc==0.10.0",
45
45
  "uvicorn>=0.49.0",
46
46
  ]
47
47
 
@@ -93,6 +93,21 @@ ignore = [
93
93
  "D107", # __init__ is documented on the class
94
94
  ]
95
95
 
96
+ [tool.ruff.lint.flake8-bugbear]
97
+ # `InputField(...)` is the declaration idiom for a field's default and
98
+ # constraints, and it returns a frozen dataclass, so the shared-mutable-default
99
+ # hazard B008 guards against cannot arise. Ruff exempts it already on fields
100
+ # annotated with an immutable builtin; this extends that to every annotation,
101
+ # an upload field included.
102
+ # Ruff resolves the name from the import statement, so every re-export path a
103
+ # caller can reach it by is listed, not the definition site alone.
104
+ extend-immutable-calls = [
105
+ "reactor_runtime.InputField",
106
+ "reactor_runtime.core.InputField",
107
+ "reactor_runtime.core.fields.InputField",
108
+ "reactor_runtime.interface.InputField",
109
+ ]
110
+
96
111
  [tool.ruff.lint.isort]
97
112
  # The example models are first-party even though they live outside the package.
98
113
  # The generated wire bindings (reactor_wire) are first-party too, but they are
@@ -42,7 +42,10 @@ class FieldInfo:
42
42
  min_length: Minimum length for a string or sequence value.
43
43
  max_length: Maximum length for a string or sequence value.
44
44
  choices: Exhaustive set of allowed values.
45
- moderate: Whether the field's value is eligible for content moderation.
45
+ moderate: Whether the field is marked for content moderation in the
46
+ rendered schema, which states either polarity. Defaults to
47
+ ``False``; see :func:`InputField` for what the mark does and which
48
+ fields it applies to.
46
49
  """
47
50
 
48
51
  default: Any = NO_DEFAULT
@@ -52,7 +55,7 @@ class FieldInfo:
52
55
  min_length: int | None = None
53
56
  max_length: int | None = None
54
57
  choices: list[Any] | None = None
55
- moderate: bool = True
58
+ moderate: bool = False
56
59
 
57
60
 
58
61
  def InputField( # noqa: N802 — a capitalised factory reads as a type in field declarations
@@ -65,7 +68,7 @@ def InputField( # noqa: N802 — a capitalised factory reads as a type in field
65
68
  min_length: int | None = None,
66
69
  max_length: int | None = None,
67
70
  choices: list[Any] | None = None,
68
- moderate: bool = True,
71
+ moderate: bool = False,
69
72
  ) -> Any:
70
73
  """Declare a default value and validation constraints for a field.
71
74
 
@@ -84,11 +87,16 @@ def InputField( # noqa: N802 — a capitalised factory reads as a type in field
84
87
  min_length: Minimum length for a string or sequence value.
85
88
  max_length: Maximum length for a string or sequence value.
86
89
  choices: Exhaustive set of allowed values.
87
- moderate: Whether the field's value is eligible for content moderation
88
- when moderation is enabled. Only free-text strings and uploaded
89
- files are ever moderated; typed, enum, and bounded numeric fields
90
- are rejected before a handler sees them, so there is nothing left to
91
- moderate.
90
+ moderate: Whether to mark the field for content moderation. ``False``
91
+ by default, so a field is marked only when you ask for it. Either
92
+ way the rendered schema states the answer: the field carries
93
+ ``x-reactor-moderate: true`` or ``x-reactor-moderate: false``. The
94
+ mark is a preference and nothing more — it starts no check, and the
95
+ runtime moderates nothing itself. Whether a check runs against a
96
+ marked field is a deployment decision taken from that schema. Only
97
+ free-text strings and uploaded files are ever eligible — typed,
98
+ enum, and bounded numeric fields carry no free text, so the mark
99
+ does nothing for them.
92
100
 
93
101
  Returns:
94
102
  A :class:`FieldInfo` carrying the supplied default and constraints.
@@ -13,7 +13,7 @@ from typing import Annotated, Any
13
13
 
14
14
  from fastapi import Body, FastAPI, Header, HTTPException, Request
15
15
  from fastapi.responses import FileResponse, Response, StreamingResponse
16
- from pydantic import BaseModel
16
+ from pydantic import BaseModel, Field
17
17
 
18
18
  from reactor_runtime.core import Health, HealthStatus, RuntimeState, SessionState
19
19
  from reactor_runtime.http.events import format_sse
@@ -40,10 +40,15 @@ class StopSessionRequest(BaseModel):
40
40
 
41
41
  ``moderate`` marks the stop as a content-moderation verdict: the session
42
42
  ends as moderated and clients are notified before their connections close.
43
- The body itself is optional — a bare ``POST /stop_session`` is a plain stop.
43
+ ``reason`` is the platform's human-readable description of why the session
44
+ is ending: when set, clients receive a session-ended notice carrying it
45
+ verbatim before their connections close. A moderated stop outranks it —
46
+ clients see only the moderation notice. The body itself is optional — a
47
+ bare ``POST /stop_session`` is a plain stop.
44
48
  """
45
49
 
46
50
  moderate: bool = False
51
+ reason: str = Field(default="", max_length=64)
47
52
 
48
53
 
49
54
  # The non-2xx statuses a route can answer, declared so the published contract
@@ -126,7 +131,10 @@ class SessionRoutes:
126
131
  req: Annotated[StopSessionRequest | None, Body()] = None,
127
132
  ) -> Response:
128
133
  try:
129
- runner.stop_session(moderated=req.moderate if req else False)
134
+ runner.stop_session(
135
+ moderated=req.moderate if req else False,
136
+ reason=req.reason if req else "",
137
+ )
130
138
  except SessionTransitionError as rejected:
131
139
  raise _transition_rejection(rejected) from None
132
140
  return Response(status_code=200)
@@ -237,8 +237,9 @@ def _merge_constraints(schema: dict[str, Any], info: FieldInfo) -> None:
237
237
  schema["maxLength"] = info.max_length
238
238
  if info.choices is not None:
239
239
  schema["enum"] = info.choices
240
- if not info.moderate:
241
- schema["x-reactor-moderate"] = False
240
+ # Unconditional, in both polarities: a consumer reads the preference off the
241
+ # document rather than inferring it from an absent key.
242
+ schema["x-reactor-moderate"] = info.moderate
242
243
 
243
244
 
244
245
  def _coerce_default(value: Any) -> Any:
@@ -265,6 +265,21 @@ class Codec(ABC):
265
265
  )
266
266
  return self.encode(wire)
267
267
 
268
+ def encode_session_ended(self, *, reason: str) -> tuple[Channel, bytes | str]:
269
+ """Encode the platform's session-ended notice for the client.
270
+
271
+ Like a moderation verdict, the notice is unsolicited, so it rides a
272
+ ``ControlServerMessage`` notification with no ``request_id``. *reason*
273
+ is the platform-authored, human-readable description of the cause,
274
+ delivered verbatim. The physical channel is version-dependent and
275
+ returned alongside the frame.
276
+ """
277
+ wire = control_pb2.ControlServerMessage(
278
+ kind=common_pb2.MessageKind.MESSAGE_KIND_NOTIFICATION,
279
+ session_ended=platform_pb2.SessionEnded(reason=reason),
280
+ )
281
+ return self.encode(wire)
282
+
268
283
 
269
284
  def select(version: ProtocolVersion) -> Codec:
270
285
  """Return the codec for a negotiated wire version."""
@@ -152,6 +152,9 @@ class V0Codec(Codec):
152
152
  return Channel.DATA, _runtime("clipFailed", {"reason": message.clip_failed.reason})
153
153
  if which == "moderation":
154
154
  return Channel.DATA, _runtime("moderation", _moderation_to_dict(message.moderation))
155
+ if which == "session_ended":
156
+ ended = message.session_ended
157
+ return Channel.DATA, _runtime("sessionEnded", {"reason": ended.reason})
155
158
  if which == "publish_track":
156
159
  return Channel.CONTROL, _dump(
157
160
  {
@@ -314,6 +317,11 @@ class V0Codec(Codec):
314
317
  kind=_KIND.MESSAGE_KIND_NOTIFICATION,
315
318
  moderation=_dict_to_moderation(data),
316
319
  )
320
+ if kind == "sessionEnded":
321
+ return control_pb2.ControlServerMessage(
322
+ kind=_KIND.MESSAGE_KIND_NOTIFICATION,
323
+ session_ended=platform_pb2.SessionEnded(reason=str(data.get("reason", ""))),
324
+ )
317
325
  raise UnsupportedMessageError(f"unrecognized v0 runtime server message: {kind!r}")
318
326
 
319
327
 
@@ -10,12 +10,37 @@ turns into a client event.
10
10
  It is deliberately wire-blind. Connections enter as the neutral ``Connection``
11
11
  shape and the manager only ever calls that shape — it never asks which transport
12
12
  produced a connection, which is exactly what lets one session mix transports.
13
+
14
+ Threading
15
+ ---------
16
+ Writers run on one thread and readers run on several, so the registry is
17
+ replaced rather than edited.
18
+
19
+ * Every write — a register, a drop, a teardown — arrives on the runtime's event
20
+ loop, because a transport reports a connection opening or closing through the
21
+ sink from that loop. Writers therefore never race each other, and the manager
22
+ holds no lock.
23
+ * Reads arrive from any thread. The model's own thread broadcasts messages and
24
+ drives the playout controls, and a worker thread off that loop fans each
25
+ emitted media chunk out, so a fan-out is iterating the registry while a
26
+ client's disconnect is landing on the loop.
27
+ * The registry is bound as an immutable ``Mapping`` and swapped for a fresh one
28
+ on every change. A reader takes one attribute load and iterates a mapping
29
+ nothing will ever mutate, so a disconnect mid-fan-out is invisible to it — the
30
+ chunk simply reaches a connection that has just gone, whose stopped pacer no
31
+ longer puts anything on the wire. A lock would be worse than unnecessary
32
+ here: a fan-out can block for seconds inside a connection's pacer waiting for
33
+ queue room, and a lock spanning that wait would hold the event loop out of
34
+ the very disconnect it needs to process.
35
+ * The publisher table and the used-id pool stay ordinary mutable containers.
36
+ Both are read and written only on the event loop, and nothing iterates them
37
+ from another thread.
13
38
  """
14
39
 
15
40
  from __future__ import annotations
16
41
 
17
42
  import random
18
- from collections.abc import Callable
43
+ from collections.abc import Callable, Mapping
19
44
 
20
45
  from reactor_runtime.core import (
21
46
  Connection,
@@ -23,10 +48,13 @@ from reactor_runtime.core import (
23
48
  MediaChunk,
24
49
  SessionEvent,
25
50
  )
51
+ from reactor_runtime.log import get_logger
26
52
  from reactor_runtime.protocol import Channel, ProtocolVersion
27
53
  from reactor_runtime.runner.state_machine import SessionStateMachine
28
54
  from reactor_runtime.transport.router import ConnectionsExhaustedError
29
55
 
56
+ logger = get_logger(__name__)
57
+
30
58
  # Connection ids are minted at random in this inclusive range, the same id space
31
59
  # a production director hands out. 1000 is invalid and 1001 is reserved for
32
60
  # legacy single-connection compatibility, so explicit ids start at 1002.
@@ -42,6 +70,31 @@ _MAX_CONN_ID = 9999
42
70
  _MAX_MINT_ATTEMPTS = 100
43
71
 
44
72
 
73
+ def _deliver(conn: Connection, operation: str, act: Callable[[Connection], None]) -> None:
74
+ """Hand one already-encoded frame to one wire, containing what the wire raises.
75
+
76
+ A fan-out reaches every client in the session, so a failure on one wire is
77
+ the fan-out's to absorb: the remaining connections are still owed the frame,
78
+ and the caller — often the model's own thread, mid-``emit`` — has no wire of
79
+ its own to fail. Left to propagate, one connection's exception would surface
80
+ as a crash of the model's run loop and end the session for everyone on it.
81
+ The failure is logged with its traceback, naming the wire, so one connection
82
+ that fails every time is visible rather than silently dark.
83
+
84
+ Only the delivery belongs in here. *act* must already hold its frame, because
85
+ encoding is the model's or the codec's to get right rather than any one
86
+ client's: a payload the codec cannot render fails identically for every
87
+ connection, and absorbing it would turn one authoring mistake into a message
88
+ that silently reaches nobody and a log line per client blaming their wires.
89
+ """
90
+ try:
91
+ act(conn)
92
+ except Exception:
93
+ logger.exception(
94
+ "connection rejected an outbound frame", operation=operation, conn_id=conn.id
95
+ )
96
+
97
+
45
98
  class ConnectionManager:
46
99
  """Registry and multiplexer of the live connections in one session.
47
100
 
@@ -49,12 +102,19 @@ class ConnectionManager:
49
102
  first and last connection, arbitrates publisher tracks first-come-first-served,
50
103
  and exposes the broadcast / addressed / media sends the model's outbound path
51
104
  binds to. Constructed with the machine it drives; nothing here is async.
105
+
106
+ Reads are safe from any thread and writes belong to the runtime's event
107
+ loop; see the module docstring for what that buys and what it costs.
52
108
  """
53
109
 
54
110
  def __init__(self, *, state_machine: SessionStateMachine) -> None:
55
111
  """Bind the manager to the session machine it advances."""
56
112
  self._sm = state_machine
57
- self._by_id: dict[ConnId, Connection] = {}
113
+ # Bound as a read-only mapping and swapped whole on every change, so a
114
+ # reader on another thread iterates a snapshot that cannot move under
115
+ # it. The type is what holds the invariant: an in-place write here is a
116
+ # type error rather than a race discovered in production.
117
+ self._by_id: Mapping[ConnId, Connection] = {}
58
118
  # The owner of each published track. First publisher wins: a track is held
59
119
  # by one connection until it releases or drops, and a later claim on a held
60
120
  # track is refused.
@@ -103,11 +163,10 @@ class ConnectionManager:
103
163
  replaces the handle without re-driving the session — connection identity
104
164
  across a reconnect is the transport's concern, not the manager's.
105
165
  """
106
- if conn.id in self._by_id:
107
- self._by_id[conn.id] = conn
108
- return
109
- self._by_id[conn.id] = conn
110
- self._sm.send(SessionEvent.CONNECTION_OPENED, conn_id=conn.id)
166
+ known = conn.id in self._by_id
167
+ self._by_id = {**self._by_id, conn.id: conn}
168
+ if not known:
169
+ self._sm.send(SessionEvent.CONNECTION_OPENED, conn_id=conn.id)
111
170
 
112
171
  def drop(self, cid: ConnId) -> None:
113
172
  """Remove a connection and advance the session for its loss.
@@ -121,7 +180,7 @@ class ConnectionManager:
121
180
  """
122
181
  if cid not in self._by_id:
123
182
  return
124
- del self._by_id[cid]
183
+ self._by_id = {other: conn for other, conn in self._by_id.items() if other != cid}
125
184
  self._sm.send(SessionEvent.CONNECTION_CLOSED, conn_id=cid)
126
185
  held = [name for name, owner in self._publishers.items() if owner == cid]
127
186
  for name in held:
@@ -141,7 +200,7 @@ class ConnectionManager:
141
200
  too, so the next session starts with the whole id range free again.
142
201
  """
143
202
  conns = list(self._by_id.values())
144
- self._by_id.clear()
203
+ self._by_id = {}
145
204
  self._publishers.clear()
146
205
  self._used_conn_ids.clear()
147
206
  for conn in conns:
@@ -205,9 +264,12 @@ class ConnectionManager:
205
264
  *encode* renders the outbound frame for a given wire version. Each
206
265
  connection is sent the frame encoded for the codec it negotiated, so a
207
266
  mixed-version session reaches every client in the version it speaks.
267
+ Encoding happens outside the per-wire containment, so a frame the codec
268
+ cannot render raises here rather than being absorbed once per client.
208
269
  """
209
270
  for conn in self._by_id.values():
210
- conn.send_message(encode(conn.protocol_version))
271
+ frame = encode(conn.protocol_version)
272
+ _deliver(conn, "broadcast", lambda c, payload=frame: c.send_message(payload))
211
273
 
212
274
  def send(self, cid: ConnId, encode: Callable[[ProtocolVersion], bytes | str]) -> None:
213
275
  """Encode and send a frame to one connection in its codec, if registered."""
@@ -253,7 +315,8 @@ class ConnectionManager:
253
315
  conn = self._by_id.get(cid)
254
316
  if conn is None:
255
317
  return
256
- self._send_on_channel(conn, encode)
318
+ channel, frame = encode(conn.protocol_version)
319
+ self._send_on_channel(conn, channel, frame)
257
320
 
258
321
  def broadcast_response(
259
322
  self, encode: Callable[[ProtocolVersion], tuple[Channel, bytes | str]]
@@ -263,17 +326,21 @@ class ConnectionManager:
263
326
  The all-connections analogue of :meth:`send_response`: each connection
264
327
  receives the frame encoded for its negotiated codec, on the physical
265
328
  channel that codec picks for it. A runtime-authored notice with no single
266
- addressee — a moderation verdict — rides this.
329
+ addressee — a moderation verdict — rides this. As in :meth:`broadcast`,
330
+ encoding happens outside the per-wire containment so a frame the codec
331
+ cannot render raises rather than being absorbed once per client.
267
332
  """
268
333
  for conn in self._by_id.values():
269
- self._send_on_channel(conn, encode)
334
+ channel, frame = encode(conn.protocol_version)
335
+ _deliver(
336
+ conn,
337
+ "broadcast_response",
338
+ lambda c, ch=channel, payload=frame: self._send_on_channel(c, ch, payload),
339
+ )
270
340
 
271
341
  @staticmethod
272
- def _send_on_channel(
273
- conn: Connection, encode: Callable[[ProtocolVersion], tuple[Channel, bytes | str]]
274
- ) -> None:
275
- """Encode a frame for one connection and route it to the channel picked."""
276
- channel, frame = encode(conn.protocol_version)
342
+ def _send_on_channel(conn: Connection, channel: Channel, frame: bytes | str) -> None:
343
+ """Send an already-encoded frame on the channel its codec picked for it."""
277
344
  if channel is Channel.CONTROL:
278
345
  conn.send_control(frame)
279
346
  else:
@@ -300,22 +367,22 @@ class ConnectionManager:
300
367
  return
301
368
  caps = conn.capabilities
302
369
  if caps.carries_video or caps.carries_audio:
303
- conn.send_media(chunk)
370
+ _deliver(conn, "broadcast_media", lambda c: c.send_media(chunk))
304
371
 
305
372
  def flush_media(self) -> None:
306
373
  """Drop every connection's queued media and cut playout to black."""
307
374
  for conn in self._by_id.values():
308
- conn.flush_media()
375
+ _deliver(conn, "flush_media", lambda c: c.flush_media())
309
376
 
310
377
  def set_media_rate(self, fps: float) -> None:
311
378
  """Re-pace every connection's queued media at *fps* immediately."""
312
379
  for conn in self._by_id.values():
313
- conn.set_media_rate(fps)
380
+ _deliver(conn, "set_media_rate", lambda c: c.set_media_rate(fps))
314
381
 
315
382
  def set_media_depth(self, depth: int) -> None:
316
383
  """Bound every connection's media queue at *depth* frames."""
317
384
  for conn in self._by_id.values():
318
- conn.set_media_depth(depth)
385
+ _deliver(conn, "set_media_depth", lambda c: c.set_media_depth(depth))
319
386
 
320
387
  def note_keepalive(self, cid: ConnId) -> None:
321
388
  """Record a per-connection liveness ping.
@@ -0,0 +1,44 @@
1
+ """Session epochs for admitted connection offers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from reactor_runtime.core import ConnId
6
+
7
+
8
+ class OfferEpochs:
9
+ """Track which session each admitted connection offer belongs to.
10
+
11
+ Negotiation is asynchronous, so a wire can reach its connected state after
12
+ the session that admitted its offer ended. While no session runs, the
13
+ session state exposes that; once the next session starts, the state looks
14
+ valid again and cannot. The epoch counts sessions: each admitted offer is
15
+ stamped with the live epoch, and the wire is checked against the current
16
+ one when it connects.
17
+
18
+ Bounded by the connection-id space: a re-offer on the same id restamps it,
19
+ and a checked stamp is dropped.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ """Start before any session, with no offers stamped."""
24
+ self._current = 0
25
+ self._stamps: dict[ConnId, int] = {}
26
+
27
+ def session_started(self) -> None:
28
+ """Move to the next epoch; offers stamped from now on belong to it."""
29
+ self._current += 1
30
+
31
+ def stamp(self, conn_id: ConnId) -> None:
32
+ """Stamp an admitted offer with the live epoch. A re-offer restamps."""
33
+ self._stamps[conn_id] = self._current
34
+
35
+ def consume(self, conn_id: ConnId) -> bool:
36
+ """Take the stamp for *conn_id* and report whether it is stale.
37
+
38
+ Returns ``True`` when the offer was stamped in an earlier epoch, so
39
+ its wire belongs to a session that ended. An unstamped id is not
40
+ stale: a transport that does not stamp offers is judged on session
41
+ state alone. The stamp is consumed either way.
42
+ """
43
+ stamped = self._stamps.pop(conn_id, None)
44
+ return stamped is not None and stamped != self._current