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.
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/PKG-INFO +5 -3
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/README.md +3 -1
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/pyproject.toml +11 -3
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/pyproject.toml.orig +18 -3
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/fields.py +16 -8
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/routes.py +11 -3
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/model/schema.py +3 -2
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/base.py +15 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/v0/codec.py +8 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/runner/connection_manager.py +89 -22
- reactor_runtime-3.2.0/src/reactor_runtime/runner/offer_epochs.py +44 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/runner/runner.py +99 -5
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/serve.py +36 -5
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/router.py +8 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/__init__.py +2 -1
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/connection.py +38 -1
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/pacer.py +13 -1
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/peer.py +500 -82
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/router.py +3 -1
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/sdp.py +80 -0
- reactor_runtime-3.2.0/src/reactor_runtime/transport/webrtc/stats.py +80 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/control_pb2.py +2 -2
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/control_pb2.pyi +4 -2
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/platform_pb2.py +3 -1
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/platform_pb2.pyi +6 -0
- reactor_runtime-3.1.2/src/reactor_runtime/transport/webrtc/stats.py +0 -45
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/LICENSE +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/NOTICE +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/codes.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/model.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/naming.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/service.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/session.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/transport.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/typespec.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/core/values.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/event_stream.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/events.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/server.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/http/spec.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/client.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/events/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/events/decorators.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/events/errors.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/events/messages.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/internal/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/internal/bridge.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/model/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/model/contract.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/tracks/input.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/tracks/output.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/log.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/manifest.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/message_gateway.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/metrics.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/paths.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/common.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/protocol/v1/codec.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/py.typed +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/recording/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/recording/markers.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/recording/recorder.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/runner/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/runner/state_machine.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/schema.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/service.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/__init__.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/acceptor.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/acceptor.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/config.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/transport/webrtc/version.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/upload_store.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/common_pb2.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/common_pb2.pyi +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/data_pb2.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/data_pb2.pyi +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/model_pb2.py +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/model_pb2.pyi +0 -0
- {reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_wire/v1/track_pb2.py +0 -0
- {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.
|
|
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.
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
19
|
+
version = "1.20260814.7"
|
|
20
20
|
|
|
21
21
|
[project]
|
|
22
22
|
name = "reactor-runtime"
|
|
23
|
-
version = "3.
|
|
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.
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|
{reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/interface/model/schema.py
RENAMED
|
@@ -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
|
-
|
|
241
|
-
|
|
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
|
|
{reactor_runtime-3.1.2 → reactor_runtime-3.2.0}/src/reactor_runtime/runner/connection_manager.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|