reactor-runtime 3.2.4__tar.gz → 3.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/PKG-INFO +15 -2
  2. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/README.md +13 -0
  3. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/pyproject.toml +2 -2
  4. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/pyproject.toml.orig +2 -2
  5. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/__init__.py +2 -0
  6. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/core/model.py +6 -0
  7. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/core/session.py +7 -0
  8. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/model/reactor_model.py +5 -1
  9. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/log.py +148 -3
  10. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/recording/recorder.py +16 -4
  11. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/runner/runner.py +64 -15
  12. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/runner/state_machine.py +5 -0
  13. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/serve.py +39 -3
  14. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/config.py +18 -0
  15. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/peer.py +47 -4
  16. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/signaling.py +2 -1
  17. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/LICENSE +0 -0
  18. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/NOTICE +0 -0
  19. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/codes.py +0 -0
  20. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/core/__init__.py +0 -0
  21. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/core/fields.py +0 -0
  22. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/core/naming.py +0 -0
  23. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/core/service.py +0 -0
  24. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/core/transport.py +0 -0
  25. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/core/typespec.py +0 -0
  26. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/core/values.py +0 -0
  27. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/event_stream.py +0 -0
  28. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/http/__init__.py +0 -0
  29. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/http/events.py +0 -0
  30. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/http/routes.py +0 -0
  31. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/http/server.py +0 -0
  32. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/http/spec.py +0 -0
  33. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/__init__.py +0 -0
  34. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/client.py +0 -0
  35. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/events/__init__.py +0 -0
  36. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/events/decorators.py +0 -0
  37. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/events/errors.py +0 -0
  38. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/events/messages.py +0 -0
  39. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  40. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/internal/bridge.py +0 -0
  41. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  42. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  43. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/model/__init__.py +0 -0
  44. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/model/contract.py +0 -0
  45. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/model/schema.py +0 -0
  46. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  47. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  48. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  49. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  50. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  51. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  52. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/tracks/input.py +0 -0
  53. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/interface/tracks/output.py +0 -0
  54. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/manifest.py +0 -0
  55. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/message_gateway.py +0 -0
  56. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/metrics.py +0 -0
  57. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/paths.py +0 -0
  58. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/protocol/__init__.py +0 -0
  59. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/protocol/base.py +0 -0
  60. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/protocol/common.py +0 -0
  61. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  62. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/protocol/v0/codec.py +0 -0
  63. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  64. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  65. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/py.typed +0 -0
  66. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/recording/__init__.py +0 -0
  67. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  68. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/recording/markers.py +0 -0
  69. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/runner/__init__.py +0 -0
  70. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/runner/connection_manager.py +0 -0
  71. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/runner/offer_epochs.py +0 -0
  72. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/schema.py +0 -0
  73. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/service.py +0 -0
  74. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/__init__.py +0 -0
  75. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/acceptor.py +0 -0
  76. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/router.py +0 -0
  77. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/__init__.py +0 -0
  78. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/acceptor.py +0 -0
  79. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/connection.py +0 -0
  80. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  81. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
  82. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/router.py +0 -0
  83. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
  84. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/stats.py +0 -0
  85. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  86. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_runtime/upload_store.py +0 -0
  87. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/common_pb2.py +0 -0
  88. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  89. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/control_pb2.py +0 -0
  90. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/control_pb2.pyi +0 -0
  91. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/data_pb2.py +0 -0
  92. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  93. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/model_pb2.py +0 -0
  94. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  95. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/platform_pb2.py +0 -0
  96. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
  97. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/src/reactor_wire/v1/track_pb2.py +0 -0
  98. {reactor_runtime-3.2.4 → reactor_runtime-3.2.6}/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.4
3
+ Version: 3.2.6
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.12.0
21
+ Requires-Dist: reactor-webrtc==0.14.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
@@ -44,6 +44,7 @@ Reactor Runtime turns an inference pipeline into a real-time, interactive media
44
44
  - 🎮 **Live interaction.** Clients send commands mid-generation: change a prompt, move a camera, adjust a parameter. The next frame reflects it.
45
45
  - 🔌 **No transport code.** You never import a WebRTC library, manage a WebSocket, or encode video. The runtime ships its own media engine as a wheel, so a plain Python container is all a model needs.
46
46
  - ✅ **Typed, validated commands.** Declare the commands your model accepts with standard Python types and constraints. The runtime validates every payload before your handler runs and compiles the surface into an OpenAPI schema that drives typed client SDKs.
47
+ - 🔎 **Traceable logs.** `get_logger()` writes structured records — readable `key=value` in a terminal, JSON for a log pipeline. Every record a session writes carries that session's id automatically, so one filter recovers everything a single run logged.
47
48
  - 📦 **One container, anywhere.** The `reactor` CLI scaffolds a workspace, builds a small image, and runs it locally. The same image deploys to [Reactor](https://reactor.inc)'s GPU cloud unchanged.
48
49
 
49
50
  ## How it works
@@ -93,6 +94,18 @@ reactor run
93
94
 
94
95
  `reactor run` builds a container with the runtime inside and serves WebRTC signaling on port 8080. Point a browser at it with the [JS SDK](https://docs.reactor.inc), or connect from the [Reactor Sandbox](https://reactor-sandbox.vercel.app/) and watch frames stream immediately.
95
96
 
97
+ Log from the same import, passing context as keyword arguments:
98
+
99
+ ```python
100
+ from reactor_runtime import get_logger
101
+
102
+ logger = get_logger(__name__)
103
+
104
+ logger.info("scene changed", prompt=self.prompt)
105
+ ```
106
+
107
+ Records render as `key=value` text by default, or as one JSON object per line under `REACTOR_LOG_FORMAT=json`. While a session is live, its id is stamped on every record, so tracing one run's logs never requires threading an id through your call sites. Every record also carries the lifecycle phase it was written in, at both granularities: `state`, the session state machine's word, and `runtime_state`, the coarse word the health endpoint serves — so the logs of one phase — loading weights, a live session, teardown — are filterable by whichever vocabulary you are reading off another surface. The stamp is applied where records are written rather than where they are made, so a plain `logging.getLogger(__name__)` and the libraries your model imports are covered too.
108
+
96
109
  ## Install
97
110
 
98
111
  Everything runs through the [`reactor` CLI](https://docs.reactor.inc/deploy/platform/installation). There is nothing to install on your host but the CLI and Docker; the runtime ships inside the image the CLI builds for your workspace.
@@ -18,6 +18,7 @@ Reactor Runtime turns an inference pipeline into a real-time, interactive media
18
18
  - 🎮 **Live interaction.** Clients send commands mid-generation: change a prompt, move a camera, adjust a parameter. The next frame reflects it.
19
19
  - 🔌 **No transport code.** You never import a WebRTC library, manage a WebSocket, or encode video. The runtime ships its own media engine as a wheel, so a plain Python container is all a model needs.
20
20
  - ✅ **Typed, validated commands.** Declare the commands your model accepts with standard Python types and constraints. The runtime validates every payload before your handler runs and compiles the surface into an OpenAPI schema that drives typed client SDKs.
21
+ - 🔎 **Traceable logs.** `get_logger()` writes structured records — readable `key=value` in a terminal, JSON for a log pipeline. Every record a session writes carries that session's id automatically, so one filter recovers everything a single run logged.
21
22
  - 📦 **One container, anywhere.** The `reactor` CLI scaffolds a workspace, builds a small image, and runs it locally. The same image deploys to [Reactor](https://reactor.inc)'s GPU cloud unchanged.
22
23
 
23
24
  ## How it works
@@ -67,6 +68,18 @@ reactor run
67
68
 
68
69
  `reactor run` builds a container with the runtime inside and serves WebRTC signaling on port 8080. Point a browser at it with the [JS SDK](https://docs.reactor.inc), or connect from the [Reactor Sandbox](https://reactor-sandbox.vercel.app/) and watch frames stream immediately.
69
70
 
71
+ Log from the same import, passing context as keyword arguments:
72
+
73
+ ```python
74
+ from reactor_runtime import get_logger
75
+
76
+ logger = get_logger(__name__)
77
+
78
+ logger.info("scene changed", prompt=self.prompt)
79
+ ```
80
+
81
+ Records render as `key=value` text by default, or as one JSON object per line under `REACTOR_LOG_FORMAT=json`. While a session is live, its id is stamped on every record, so tracing one run's logs never requires threading an id through your call sites. Every record also carries the lifecycle phase it was written in, at both granularities: `state`, the session state machine's word, and `runtime_state`, the coarse word the health endpoint serves — so the logs of one phase — loading weights, a live session, teardown — are filterable by whichever vocabulary you are reading off another surface. The stamp is applied where records are written rather than where they are made, so a plain `logging.getLogger(__name__)` and the libraries your model imports are covered too.
82
+
70
83
  ## Install
71
84
 
72
85
  Everything runs through the [`reactor` CLI](https://docs.reactor.inc/deploy/platform/installation). There is nothing to install on your host but the CLI and Docker; the runtime ships inside the image the CLI builds for your workspace.
@@ -92,7 +92,7 @@ pythonpath = ["."]
92
92
 
93
93
  [project]
94
94
  name = "reactor-runtime"
95
- version = "3.2.4"
95
+ version = "3.2.6"
96
96
  description = "A Python framework for building real-time, interactive video models"
97
97
  readme = "README.md"
98
98
  license = "Apache-2.0"
@@ -115,7 +115,7 @@ dependencies = [
115
115
  "prometheus-client>=0.26.0",
116
116
  "protobuf>=7.35.1",
117
117
  "pyyaml>=6.0.3",
118
- "reactor-webrtc==0.12.0",
118
+ "reactor-webrtc==0.14.0",
119
119
  "uvicorn>=0.49.0",
120
120
  ]
121
121
 
@@ -20,7 +20,7 @@ version = "1.20260814.7"
20
20
 
21
21
  [project]
22
22
  name = "reactor-runtime"
23
- version = "3.2.4"
23
+ version = "3.2.6"
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.12.0",
44
+ "reactor-webrtc==0.14.0",
45
45
  "uvicorn>=0.49.0",
46
46
  ]
47
47
 
@@ -49,6 +49,7 @@ from reactor_runtime.interface import (
49
49
  session_ended,
50
50
  session_started,
51
51
  )
52
+ from reactor_runtime.log import get_logger
52
53
  from reactor_runtime.paths import get_weights_path
53
54
 
54
55
  __version__ = version("reactor-runtime")
@@ -89,6 +90,7 @@ __all__ = [
89
90
  "disconnected",
90
91
  "event",
91
92
  "file_uploaded",
93
+ "get_logger",
92
94
  "get_weights_path",
93
95
  "session_ended",
94
96
  "session_started",
@@ -238,10 +238,16 @@ class SessionEnded(ReactorEvent):
238
238
  Attributes:
239
239
  session_id: Identifier for the session that ended.
240
240
  reason: Why the session ended.
241
+ _log_binding: The log session binding to retire once the
242
+ ``@session_ended`` hook has run. Internal plumbing between the
243
+ runner and the reactor loop, never surfaced to a hook: dispatch is
244
+ what proves the hook's records were written while the binding was
245
+ live, so dispatch is where it is released.
241
246
  """
242
247
 
243
248
  session_id: str
244
249
  reason: EndReason
250
+ _log_binding: int = 0
245
251
 
246
252
 
247
253
  @dataclass(frozen=True)
@@ -69,6 +69,12 @@ class SessionEvent(Enum):
69
69
  ``CLOSING``, and carries an :class:`~reactor_runtime.core.model.EndReason`
70
70
  in ``detail.reason`` (and, for a crash, the error) so a consumer learns why.
71
71
 
72
+ ``INITIALIZING`` records that the runtime is still loading its weights. It is
73
+ a self-loop legal only in ``CREATED``, emitted once at boot before the load
74
+ blocks, so a consumer replaying the journal observes the loading phase — the
75
+ one phase otherwise silent, since the runner emits nothing until it leaves
76
+ ``CREATED`` on ``INITIALIZATION_SUCCESS``.
77
+
72
78
  ``CHUNK_READY``, ``CLIP_READY``, ``COMMAND``, ``ERROR``, and ``METRIC`` are
73
79
  the journal-only events (:data:`JOURNAL_EVENTS`): facts recorded for an
74
80
  external consumer rather than moves of the lifecycle. Each is a pure
@@ -81,6 +87,7 @@ class SessionEvent(Enum):
81
87
 
82
88
  INITIALIZATION_SUCCESS = auto()
83
89
  INITIALIZATION_FAIL = auto()
90
+ INITIALIZING = auto()
84
91
  START_SESSION = auto()
85
92
  STOP_SESSION = auto()
86
93
  TIMEOUT = auto()
@@ -43,7 +43,7 @@ from reactor_runtime.interface.internal.reactor_core import (
43
43
  RequestId,
44
44
  )
45
45
  from reactor_runtime.interface.model.contract import ModelContract
46
- from reactor_runtime.log import get_logger
46
+ from reactor_runtime.log import get_logger, release_session_id
47
47
 
48
48
  logger = get_logger(__name__)
49
49
 
@@ -188,6 +188,10 @@ class ReactorModel(ReactorCore):
188
188
  self._set_connected(0)
189
189
  await self._invoke_hook(hooks.session_ended, None)
190
190
  self._clients.clear()
191
+ # The hook has returned, so its records were written while the
192
+ # session's log binding was live; the session's last ambient writer
193
+ # is done and the binding retires here, on the model thread.
194
+ release_session_id(event._log_binding)
191
195
  elif isinstance(event, FileUploaded):
192
196
  await self._invoke_hook(hooks.file_uploaded, event.conn_id, uploaded_file=event.file)
193
197
 
@@ -8,10 +8,25 @@ of two shapes, chosen by the ``REACTOR_LOG_FORMAT`` environment variable:
8
8
  - ``json``: one JSON object per line, ready for a log pipeline to parse.
9
9
 
10
10
  Call sites pass structured context as keyword arguments —
11
- ``log.info("session started", session_id=sid)`` — and the active formatter
12
- renders them; the wire shape is the formatter's concern, not the call site's.
11
+ ``log.info("chunk encoded", chunk_idx=idx)`` — and the active formatter renders
12
+ them; the wire shape is the formatter's concern, not the call site's.
13
13
  ``configure`` installs the chosen formatter on the root logger, and
14
14
  ``get_logger`` returns a logger to write through.
15
+
16
+ Three fields arrive without a call site naming them, stamped by
17
+ :class:`SessionContextFilter` on the handler ``configure`` installs. While a
18
+ session is live, every record carries its ``session_id``. From the moment the
19
+ runtime boots, every record carries the lifecycle it was written in, at both
20
+ granularities: ``state``, the session state machine's word — the same words the
21
+ session descriptor's ``state`` field serves — and ``runtime_state``, its coarse
22
+ projection, the words the health route serves (``loading`` / ``available`` /
23
+ ``serving`` / ``terminated``). Carrying both means a reader can filter by
24
+ whichever vocabulary they read off a surface: the model-load window is
25
+ ``state="created"`` and equally ``runtime_state="loading"``. Because the stamp
26
+ happens where records are written rather than where they are made, it reaches a
27
+ model's own ``logging.getLogger(__name__)`` and any third-party library that
28
+ propagates to root, so a line can be traced to the session and phase that
29
+ produced it without model code threading any of it through its call sites.
15
30
  """
16
31
 
17
32
  from __future__ import annotations
@@ -35,6 +50,94 @@ _JSON_RESERVED = frozenset({"ts", "level", "logger", "msg", "exc_info"})
35
50
 
36
51
  _QUOTE_TRIGGERS = (" ", "=", '"', "\n", "\r", "\t")
37
52
 
53
+ # The live session's id, stamped on every record while it is set. A module global
54
+ # rather than a ContextVar because a session fans its work across plain worker
55
+ # threads, which do not inherit context; the runtime hosts one session at a time,
56
+ # so a single value is unambiguous.
57
+ _session_id: str | None = None
58
+
59
+ # Counts bindings, so a release can name the one it retires. Two sessions may
60
+ # carry the same id — nothing stops a caller reusing one — and a release that
61
+ # matched on the id alone would unbind the session that reused it.
62
+ _session_binding = 0
63
+
64
+
65
+ def set_session_id(session_id: str | None) -> int:
66
+ """Stamp *session_id* on every record written from now on.
67
+
68
+ Args:
69
+ session_id: The live session's id, or ``None`` to stamp nothing.
70
+
71
+ Returns:
72
+ A token naming this binding, which :func:`release_session_id` takes to
73
+ retire it.
74
+ """
75
+ global _session_id, _session_binding
76
+ _session_id = session_id
77
+ _session_binding += 1
78
+ return _session_binding
79
+
80
+
81
+ def clear_session_id() -> None:
82
+ """Stop stamping a session id, for the window between sessions."""
83
+ set_session_id(None)
84
+
85
+
86
+ def release_session_id(binding: int) -> None:
87
+ """Retire the binding *binding* names, leaving a later one in place.
88
+
89
+ A session's teardown outlives the move that ends it, so the release that
90
+ follows one is deferred until that work has finished. By then the next
91
+ session may already have bound its own id — the same id, even, since nothing
92
+ stops a caller reusing one — so a release names the binding it retires rather
93
+ than the value that binding held.
94
+
95
+ Args:
96
+ binding: The token :func:`set_session_id` returned, ignored once a later
97
+ binding has replaced the one it names.
98
+ """
99
+ global _session_id
100
+ if _session_binding == binding:
101
+ _session_id = None
102
+
103
+
104
+ def get_session_id() -> str | None:
105
+ """Return the id currently being stamped, or ``None`` between sessions."""
106
+ return _session_id
107
+
108
+
109
+ # The runtime's lifecycle, stamped on every record while set: the state
110
+ # machine's own word and its coarse projection. One fact at two granularities,
111
+ # so one setter binds both and they cannot drift apart. Unlike the session id
112
+ # they need no binding token: there is always exactly one current state and the
113
+ # latest write is by definition the truth, so last-write-wins is the correct
114
+ # semantics rather than a race to guard against.
115
+ _state: str | None = None
116
+ _runtime_state: str | None = None
117
+
118
+
119
+ def set_state(state: str | None, runtime_state: str | None) -> None:
120
+ """Stamp *state* and *runtime_state* on every record written from now on.
121
+
122
+ Args:
123
+ state: The session state machine's word, or ``None`` to stamp nothing.
124
+ runtime_state: Its coarse lifecycle projection, the health route's
125
+ vocabulary, or ``None`` to stamp nothing.
126
+ """
127
+ global _state, _runtime_state
128
+ _state = state
129
+ _runtime_state = runtime_state
130
+
131
+
132
+ def get_state() -> str | None:
133
+ """Return the machine word currently being stamped, or ``None`` before boot."""
134
+ return _state
135
+
136
+
137
+ def get_runtime_state() -> str | None:
138
+ """Return the coarse word currently being stamped, or ``None`` before boot."""
139
+ return _runtime_state
140
+
38
141
 
39
142
  def _logfmt_value(value: Any) -> str:
40
143
  """Render *value* as a logfmt-safe token.
@@ -63,6 +166,37 @@ def _record_fields(record: logging.LogRecord) -> dict[str, Any]:
63
166
  return {key: value for key, value in raw.items() if value is not None}
64
167
 
65
168
 
169
+ class SessionContextFilter(logging.Filter):
170
+ """Stamp the live session's id and the runtime's state on every record.
171
+
172
+ Sits on the handler rather than on one logger, so it sees every record a
173
+ handler writes: the runtime's own, a model's ``logging.getLogger(__name__)``,
174
+ and a third-party library's that propagates to root. A call site that names
175
+ ``session_id``, ``state``, or ``runtime_state`` itself keeps its own value —
176
+ a model logging its own ``state`` claims that record's field, deliberately —
177
+ and a field with nothing bound, the session id between sessions or the
178
+ states before boot, is absent rather than empty.
179
+ """
180
+
181
+ def filter(self, record: logging.LogRecord) -> bool:
182
+ """Merge the ambient context into *record*'s structured fields."""
183
+ stamped = {
184
+ "session_id": _session_id,
185
+ "state": _state,
186
+ "runtime_state": _runtime_state,
187
+ }
188
+ context = {key: value for key, value in stamped.items() if value is not None}
189
+ if not context:
190
+ return True
191
+ fields = getattr(record, _REACTOR_FIELDS_ATTR, None)
192
+ if not isinstance(fields, dict):
193
+ setattr(record, _REACTOR_FIELDS_ATTR, context)
194
+ return True
195
+ for key, value in context.items():
196
+ fields.setdefault(key, value)
197
+ return True
198
+
199
+
66
200
  class TextFormatter(logging.Formatter):
67
201
  """Render a record as its message followed by ``key=value`` field tokens."""
68
202
 
@@ -159,7 +293,9 @@ def configure(*, level: int = logging.INFO, stream: IO[str] | None = None) -> No
159
293
  The shape is chosen by ``REACTOR_LOG_FORMAT``: ``json`` for one JSON object
160
294
  per line, anything else (the default) for human-readable ``key=value`` text.
161
295
  Replaces any handlers already on the root logger so output has a single,
162
- predictable shape.
296
+ predictable shape. The handler carries a :class:`SessionContextFilter`, so
297
+ every record written through it is stamped with the live session's id and
298
+ the runtime's lifecycle state at both granularities.
163
299
 
164
300
  Args:
165
301
  level: The level the root logger is set to.
@@ -171,6 +307,7 @@ def configure(*, level: int = logging.INFO, stream: IO[str] | None = None) -> No
171
307
  )
172
308
  handler = logging.StreamHandler(stream)
173
309
  handler.setFormatter(formatter)
310
+ handler.addFilter(SessionContextFilter())
174
311
  root = logging.getLogger()
175
312
  for existing in root.handlers[:]:
176
313
  root.removeHandler(existing)
@@ -180,8 +317,16 @@ def configure(*, level: int = logging.INFO, stream: IO[str] | None = None) -> No
180
317
 
181
318
  __all__ = [
182
319
  "JsonFormatter",
320
+ "SessionContextFilter",
183
321
  "StructuredLogger",
184
322
  "TextFormatter",
323
+ "clear_session_id",
185
324
  "configure",
186
325
  "get_logger",
326
+ "get_runtime_state",
327
+ "get_session_id",
328
+ "get_state",
329
+ "release_session_id",
330
+ "set_session_id",
331
+ "set_state",
187
332
  ]
@@ -369,7 +369,10 @@ class Recorder:
369
369
  try:
370
370
  (self._session_dir / _COMPLETE_MARKER).write_text("")
371
371
  except OSError:
372
- logger.exception("failed to write recording completion marker")
372
+ logger.exception(
373
+ "failed to write recording completion marker",
374
+ session_id=self._session_id,
375
+ )
373
376
  self._fire_ready_chunks()
374
377
  self._fire_ready_clips()
375
378
  self._watch_stop.set()
@@ -378,9 +381,15 @@ class Recorder:
378
381
  if watch_thread is not None:
379
382
  watch_thread.join(timeout=2.0)
380
383
  self._started = False
384
+ # session_id is named explicitly, like the start's: the recorder outlives
385
+ # the session's ambient log context, which the model retires at its own
386
+ # session-ended dispatch, so a record written on the way out — this one,
387
+ # the marker and callback failures, the feed thread's exit — attributes
388
+ # itself.
381
389
  logger.info(
382
390
  "recorder stopped",
383
391
  recording_id=self._session_id,
392
+ session_id=self._session_id,
384
393
  dropped=self._dropped_frames,
385
394
  )
386
395
 
@@ -651,7 +660,10 @@ class Recorder:
651
660
  if self._has_audio and audio is not None:
652
661
  encoder.feed_audio(audio)
653
662
  except Exception:
654
- logger.exception("recorder encoder feed failed; disabling recording")
663
+ logger.exception(
664
+ "recorder encoder feed failed; disabling recording",
665
+ session_id=self._session_id,
666
+ )
655
667
  self._disabled = True
656
668
  self._drain_feed_queue()
657
669
  return
@@ -812,7 +824,7 @@ class Recorder:
812
824
  try:
813
825
  callback(clip)
814
826
  except Exception:
815
- logger.exception("clip-ready callback raised")
827
+ logger.exception("clip-ready callback raised", session_id=self._session_id)
816
828
 
817
829
  def _fire_ready_chunks(self) -> None:
818
830
  """Announce every recording segment that has closed since the last poll.
@@ -839,7 +851,7 @@ class Recorder:
839
851
  try:
840
852
  callback(recording_id, idx)
841
853
  except Exception:
842
- logger.exception("chunk-ready callback raised")
854
+ logger.exception("chunk-ready callback raised", session_id=recording_id)
843
855
 
844
856
  # -- HTTP serving ---------------------------------------------------------
845
857
 
@@ -54,7 +54,7 @@ from reactor_runtime.interface.events.messages import ModelMessage
54
54
  from reactor_runtime.interface.internal.bridge import ModelBridge
55
55
  from reactor_runtime.interface.internal.reactor_core import MediaOps
56
56
  from reactor_runtime.interface.model.contract import ModelContract
57
- from reactor_runtime.log import get_logger
57
+ from reactor_runtime.log import get_logger, set_session_id, set_state
58
58
  from reactor_runtime.manifest import import_model_class
59
59
  from reactor_runtime.message_gateway import InboundCommand, MessageGateway
60
60
  from reactor_runtime.metrics import (
@@ -98,6 +98,17 @@ _RUNTIME_STATES: dict[SessionState, RuntimeState] = {
98
98
  SessionState.TERMINATED: RuntimeState.TERMINATED,
99
99
  }
100
100
 
101
+
102
+ def _stamp_log_state(state: SessionState) -> None:
103
+ """Bind the log's state context to *state*, at both granularities.
104
+
105
+ Records carry the machine's own word and the coarse word the health route
106
+ serves, so a reader can filter by whichever vocabulary the surface they are
107
+ looking at showed them.
108
+ """
109
+ set_state(state.name.lower(), _RUNTIME_STATES[state].value)
110
+
111
+
101
112
  # How long to wait for an upload's bytes to arrive when a command or notification
102
113
  # references it before they are written. A client references an upload over the
103
114
  # data channel while its bytes are still being delivered on a separate request,
@@ -169,6 +180,10 @@ class Runner(ServiceComponent, ConnectionSink):
169
180
  self._cfg = cfg
170
181
  self._metrics = metrics or RuntimeMetrics(version=_server_version(), model=cfg.model_ref)
171
182
  self._sm = SessionStateMachine()
183
+ # The log's state context starts at the machine's starting state, so the
184
+ # model-load window — records written before any transition — is already
185
+ # stamped; every later move re-stamps in _dispatch_transition.
186
+ _stamp_log_state(self._sm.current_state)
172
187
  self._sm.on_transition(self._dispatch_transition)
173
188
  # The session surface of the metrics is one listener over the same moves
174
189
  # the journal carries, so no session code below calls an instrument.
@@ -206,12 +221,18 @@ class Runner(ServiceComponent, ConnectionSink):
206
221
  self._teardown: set[asyncio.Task[None]] = set()
207
222
  self._orphan_task: asyncio.Task[None] | None = None
208
223
  self._session_id = SESSION_ID
209
- # The id a recording is stored and addressed under, set per session in
210
- # start_session. Separate from the fixed transport session id so a director
211
- # can align a recording with the platform's session id; a session started
212
- # without one mints a fresh id, so sequential recordings in a reused process
213
- # never share a directory. The construction value is an unused placeholder.
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
227
+ # caller can align both with the id it knows the session by; a session
228
+ # started without one mints a fresh id, so sequential recordings in a
229
+ # reused process never share a directory and the logs of one session are
230
+ # never read as another's. The construction value is an unused placeholder.
214
231
  self._recording_id = SESSION_ID
232
+ # Names the log's current session binding, so the release that follows a
233
+ # session retires that binding and not a later session's. Zero until the
234
+ # first session binds one.
235
+ self._log_binding = 0
215
236
  self._accepting = True
216
237
  # The process-shutdown hook, wired by the assembly so the runner can ask
217
238
  # the service to bring the process down when the session is terminated
@@ -237,11 +258,17 @@ class Runner(ServiceComponent, ConnectionSink):
237
258
 
238
259
  The model load runs off the event loop (it may block while it reads
239
260
  weights), so the HTTP surface — already up by the time this runs — stays
240
- responsive throughout, and a client subscribed to ``/events`` observes
241
- the ``initialization_success``/``initialization_fail`` transition live.
261
+ responsive throughout: a client subscribed to ``/events`` observes the
262
+ ``initializing`` self-loop journalled before the load, then the
263
+ ``initialization_success``/``initialization_fail`` transition when it ends.
242
264
  """
243
265
  self._loop = asyncio.get_running_loop()
244
266
  logger.info("loading model", model=self._cfg.model_ref)
267
+ # Journal the loading phase before the (blocking) load, so a consumer
268
+ # replaying /events sees the runtime is initializing during the load
269
+ # window rather than nothing until READY. A self-loop on CREATED: no
270
+ # state change, no side effect (the bridge is not built yet).
271
+ self._sm.send(SessionEvent.INITIALIZING)
245
272
  started_at = time.monotonic()
246
273
  try:
247
274
  model_cls = import_model_class(self._cfg.model_ref)
@@ -580,11 +607,12 @@ class Runner(ServiceComponent, ConnectionSink):
580
607
  The rejection surfaces the current state so the caller can report the
581
608
  precise reason. The parameters seed the session's initial state.
582
609
 
583
- A ``session_id`` in *params* is adopted as the id this session's recording
584
- is stored and addressed under, so a director can align clips with the
585
- platform's session id; absent one, a fresh id is minted per session so
586
- sequential recordings never overwrite each other. The transport session id
587
- is unaffected — it is always :data:`SESSION_ID`.
610
+ A ``session_id`` in *params* is adopted as this session's own id: the id
611
+ its recording is stored and addressed under, and the id stamped on every
612
+ log record the session writes. A caller can therefore align both clips and
613
+ 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`.
588
616
 
589
617
  Args:
590
618
  params: The initial session parameters supplied by the caller.
@@ -1078,11 +1106,27 @@ class Runner(ServiceComponent, ConnectionSink):
1078
1106
  declare a dead model ready again. Real moves log at info; journal
1079
1107
  self-loops log at debug so a per-segment ``chunk_ready`` does not flood
1080
1108
  the log.
1109
+
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``
1113
+ event into the model, whose dispatch retires the binding once the
1114
+ ``@session_ended`` hook has returned. A terminal move dispatches no
1115
+ ``SessionEnded`` and releases nothing — the process is exiting, and its
1116
+ last records belong to the session that brought it down. The log's state
1117
+ context re-stamps here too, before the move's own line, so a record
1118
+ reads the state the process was in when it was written.
1081
1119
  """
1120
+ if transition.is_session_start:
1121
+ self._log_binding = set_session_id(self._recording_id)
1122
+ if transition.from_state is not transition.to_state:
1123
+ _stamp_log_state(transition.to_state)
1082
1124
  log = logger.debug if transition.event in JOURNAL_EVENTS else logger.info
1125
+ # The fixed transport id (SESSION_ID) is deliberately not a field here:
1126
+ # one constant value per process carries nothing, and squatting on
1127
+ # session_id would mask the id the session is known by.
1083
1128
  log(
1084
1129
  "session transition",
1085
- session_id=self._session_id,
1086
1130
  event=transition.event.name.lower(),
1087
1131
  from_state=transition.from_state.name.lower(),
1088
1132
  to_state=transition.to_state.name.lower(),
@@ -1138,7 +1182,12 @@ class Runner(ServiceComponent, ConnectionSink):
1138
1182
  bridge.dispatch_reactor_event(SessionStarted(self._session_id))
1139
1183
  if transition.is_session_end:
1140
1184
  reason = transition.detail.get("reason", EndReason.STOPPED)
1141
- bridge.dispatch_reactor_event(SessionEnded(self._session_id, reason))
1185
+ bridge.dispatch_reactor_event(
1186
+ # The event carries the log binding so its dispatch — the point
1187
+ # where the @session_ended hook has provably returned — is what
1188
+ # retires it, on the model thread.
1189
+ SessionEnded(self._session_id, reason, self._log_binding)
1190
+ )
1142
1191
  if transition.event is SessionEvent.CONNECTION_OPENED:
1143
1192
  bridge.dispatch_reactor_event(
1144
1193
  ClientConnected(transition.detail["conn_id"], self._connections.count)
@@ -42,6 +42,11 @@ from reactor_runtime.core import JOURNAL_EVENTS, SessionEvent, SessionState, Tra
42
42
  _TRANSITIONS: dict[SessionEvent, dict[SessionState, SessionState]] = {
43
43
  SessionEvent.INITIALIZATION_SUCCESS: {SessionState.CREATED: SessionState.READY},
44
44
  SessionEvent.INITIALIZATION_FAIL: {SessionState.CREATED: SessionState.TERMINATED},
45
+ # INITIALIZING is the runtime's "still loading" fact: a self-loop legal only
46
+ # in CREATED, emitted once at boot so a journal consumer sees the loading
47
+ # phase that is otherwise silent until INITIALIZATION_SUCCESS leaves CREATED.
48
+ # It changes no state and leaves the connection count alone (see _update_count).
49
+ SessionEvent.INITIALIZING: {SessionState.CREATED: SessionState.CREATED},
45
50
  SessionEvent.START_SESSION: {SessionState.READY: SessionState.WAITING},
46
51
  SessionEvent.STOP_SESSION: {
47
52
  SessionState.STREAMING: SessionState.CLOSING,
@@ -8,8 +8,9 @@ platform.
8
8
 
9
9
  This is also the runtime's one configuration boundary: the manifest names the
10
10
  model, and the surrounding deployment names everything else (bind address, the
11
- ICE servers and port range, the congestion-control bitrate limits, the video
12
- codec preference order, the lifecycle timeouts) through environment variables.
11
+ ICE servers and port range, the congestion-control and per-sender bitrate
12
+ limits, the video codec preference order, the lifecycle timeouts) through
13
+ environment variables.
13
14
  The transport and lifecycle config objects stay free of environment reads; the
14
15
  small adapter here is the only place that translates the outside world into
15
16
  them.
@@ -26,7 +27,7 @@ import sys
26
27
  from pathlib import Path
27
28
 
28
29
  from reactor_runtime import log
29
- from reactor_runtime.core import RuntimeConfig
30
+ from reactor_runtime.core import RuntimeConfig, RuntimeState, SessionState
30
31
  from reactor_runtime.http import HttpServer
31
32
  from reactor_runtime.manifest import MANIFEST, load_config
32
33
  from reactor_runtime.metrics import RuntimeMetrics
@@ -200,6 +201,32 @@ def _bwe_limits_from_env() -> tuple[int, int, int]:
200
201
  return min_kbps, max_kbps, initial_kbps
201
202
 
202
203
 
204
+ def _sender_limits_from_env() -> tuple[int, int]:
205
+ """Read the per-sender bitrate bounds, checked at boot like the BWE ones.
206
+
207
+ These bound one track's encoder, where the BWE limits bound the whole
208
+ connection's estimate. Both must allow a rate for a stream to reach it, and
209
+ the per-sender ceiling is the one that lifts libwebrtc's resolution-keyed
210
+ default of 2500 kbps.
211
+
212
+ ``0`` or less means "leave this bound at the libwebrtc default", matching how
213
+ the rest of this config spells an absent limit. A negative is not an error
214
+ here for that reason, but ``min`` above ``max`` is: libwebrtc refuses the
215
+ pair, and finding out at boot beats finding out on every negotiation.
216
+
217
+ Raises:
218
+ SystemExit: If the ordering does not hold.
219
+ """
220
+ max_kbps = _int_env("WEBRTC_SENDER_MAX_KBPS", WebRtcConfig.sender_max_kbps)
221
+ min_kbps = _int_env("WEBRTC_SENDER_MIN_KBPS", WebRtcConfig.sender_min_kbps)
222
+ if min_kbps > 0 < max_kbps and min_kbps > max_kbps:
223
+ raise SystemExit(
224
+ "WEBRTC_SENDER_MIN_KBPS must not exceed WEBRTC_SENDER_MAX_KBPS; "
225
+ f"got {min_kbps} > {max_kbps}"
226
+ )
227
+ return max_kbps, min_kbps
228
+
229
+
203
230
  def _webrtc_config_from_env() -> WebRtcConfig:
204
231
  """Build the WebRTC transport config from the environment.
205
232
 
@@ -207,6 +234,7 @@ def _webrtc_config_from_env() -> WebRtcConfig:
207
234
  single place the outside world is translated into it.
208
235
  """
209
236
  bwe_min_kbps, bwe_max_kbps, bwe_initial_kbps = _bwe_limits_from_env()
237
+ sender_max_kbps, sender_min_kbps = _sender_limits_from_env()
210
238
  return WebRtcConfig(
211
239
  ice_servers=_ice_servers_from_env(),
212
240
  port_range=_port_range_from_env(),
@@ -216,6 +244,8 @@ def _webrtc_config_from_env() -> WebRtcConfig:
216
244
  bwe_min_kbps=bwe_min_kbps,
217
245
  bwe_max_kbps=bwe_max_kbps,
218
246
  bwe_initial_kbps=bwe_initial_kbps,
247
+ sender_max_kbps=sender_max_kbps,
248
+ sender_min_kbps=sender_min_kbps,
219
249
  )
220
250
 
221
251
 
@@ -342,6 +372,12 @@ def main() -> None:
342
372
  from reactor_runtime.transport.webrtc.peer import libwebrtc_peer_factory
343
373
 
344
374
  log.configure(level=_log_level_from_env())
375
+ # The runner re-stamps this at construction and on every move; stamping here
376
+ # too covers the lines written before it exists — the process is up and the
377
+ # model is not loaded, which is exactly what CREATED names and what LOADING
378
+ # projects it to — so every record the process writes carries its lifecycle,
379
+ # the first one included.
380
+ log.set_state(SessionState.CREATED.name.lower(), RuntimeState.LOADING.value)
345
381
  manifest = Path.cwd() / MANIFEST
346
382
  if not manifest.is_file():
347
383
  raise SystemExit(f"no {MANIFEST} found in {Path.cwd()}")
@@ -97,6 +97,22 @@ class WebRtcConfig:
97
97
  bwe_initial_kbps: Starting bitrate before estimates arrive.
98
98
  bwe_target_update_threshold: Relative change below which a new bitrate
99
99
  estimate is ignored rather than re-applied to the encoders.
100
+ sender_max_kbps: Ceiling for each sendonly *video* track's own encoder,
101
+ which is
102
+ a different limit from ``bwe_max_kbps`` and the one that actually
103
+ caps a video stream. The two are conjunctive — the lower wins — and
104
+ without this a sender's maximum comes from libwebrtc's
105
+ resolution-keyed default, which is 2500 kbps for anything above
106
+ 960x540. Every frame size we send at 720p or larger would cap at
107
+ 2.5 Mbps no matter how much headroom the estimate had. Audio senders
108
+ are left alone: that default is keyed on frame size, so there is no
109
+ equivalent for them to clear. ``0`` or less leaves the libwebrtc
110
+ default in place.
111
+ sender_min_kbps: Floor for each sendonly video track's own encoder. ``0`` or
112
+ less leaves it unset, which is the default: a floor stops the
113
+ encoder degrading gracefully, so on a link that cannot sustain it
114
+ the trade is lower quality for packet loss. Useful mainly when
115
+ several tracks compete and one must be preserved.
100
116
  rtx_max_size_packets: Retransmission history depth, in packets.
101
117
  rtx_max_size_time_ms: Retransmission history depth, in milliseconds;
102
118
  ``0`` means no time limit.
@@ -134,6 +150,8 @@ class WebRtcConfig:
134
150
  bwe_max_kbps: int = 10000
135
151
  bwe_initial_kbps: int = 4000
136
152
  bwe_target_update_threshold: float = 0.05
153
+ sender_max_kbps: int = 10000
154
+ sender_min_kbps: int = 0
137
155
  rtx_max_size_packets: int = 512
138
156
  rtx_max_size_time_ms: int = 200
139
157
  rtp_payload_mtu: int = 1200
@@ -17,8 +17,8 @@ garble.
17
17
  Per-peer audio isolation
18
18
  ------------------------
19
19
  Each outbound audio track is created with
20
- ``PeerConnectionFactory.create_audio_track_with_local_source()``, which backs the
21
- track with a ``LocalAudioSource`` — a custom ``AudioSourceInterface`` that
20
+ ``PeerConnectionFactory.create_audio_track_with_options(source=LocalPush)``,
21
+ which backs the track with a ``LocalAudioSource`` — a custom ``AudioSourceInterface`` that
22
22
  maintains the list of sinks registered by each peer connection's voice send
23
23
  channel. When ``track.push_pcm()`` is called, it delivers PCM directly to that
24
24
  track's encoder via ``AudioTrackSinkInterface::OnData``, bypassing the shared
@@ -272,6 +272,36 @@ async def _apply_bitrate_limits(pc: rw.PeerConnection, config: WebRtcConfig) ->
272
272
  )
273
273
 
274
274
 
275
+ async def _apply_sender_bitrate(transceiver: rw.Transceiver, config: WebRtcConfig) -> None:
276
+ """Apply the configured per-sender bitrate bounds to one sendonly video transceiver.
277
+
278
+ A different limit from ``_apply_bitrate_limits``: that one bounds the whole
279
+ connection's congestion-control estimate, this one bounds what a single
280
+ track's encoder may spend of it. They are conjunctive, so a stream runs fast
281
+ only when both allow it — and this is the one that has to be raised, because
282
+ with nothing set libwebrtc derives a sender's ceiling from the frame size
283
+ alone and it is 2500 kbps for anything above 960x540.
284
+
285
+ **Video only**, for two reasons that agree. The default being lifted is
286
+ ``GetMaxDefaultVideoBitrateKbps``, keyed on frame size, so there is no
287
+ equivalent for an audio sender to clear — the config's 10 Mbps ceiling would
288
+ say nothing to a 64 kbps Opus stream. (The bounds themselves do apply to
289
+ audio and would cap its allocation; we simply have no reason to.) And an
290
+ audio transceiver materialised from a remote offer has no encodings to write
291
+ until the answer is applied, where a video one has them as soon as the
292
+ transceiver exists, so calling this here would fail on every audio track.
293
+
294
+ ``0`` or less in the config leaves that bound at the libwebrtc default, which
295
+ is how the rest of this config spells an absent limit; the binding takes
296
+ ``None`` for it. Applied per negotiation, since every offer rebuilds the peer
297
+ and its transceivers come up on library defaults.
298
+ """
299
+ await transceiver.set_send_bitrate(
300
+ min_bps=config.sender_min_kbps * 1000 if config.sender_min_kbps > 0 else None,
301
+ max_bps=config.sender_max_kbps * 1000 if config.sender_max_kbps > 0 else None,
302
+ )
303
+
304
+
275
305
  def _is_terminal_state(state: rw.PeerConnectionState) -> bool:
276
306
  """Return whether a peer-connection state means the wire is gone.
277
307
 
@@ -513,9 +543,16 @@ class WebRTCPeer:
513
543
  if info.kind is TrackKind.VIDEO:
514
544
  track = factory.create_video_track(info.name)
515
545
  else:
516
- track = factory.create_audio_track_with_local_source(info.name)
546
+ # LocalPush, not the factory ADM: each outbound audio track
547
+ # gets its own source, so one peer's audio cannot reach
548
+ # another's encoder. See the module docstring.
549
+ track = factory.create_audio_track_with_options(
550
+ info.name, source=rw.AudioTrackSource.LocalPush
551
+ )
517
552
  await transceiver.set_track(track)
518
553
  await transceiver.set_direction(rw.TransceiverDirection.SendOnly)
554
+ if info.kind is TrackKind.VIDEO:
555
+ await _apply_sender_bitrate(transceiver, self._config)
519
556
  self._out_tracks[info.name] = track
520
557
  if codec_preferences and transceiver.kind() == rw.MediaKind.Video:
521
558
  await transceiver.set_codec_preferences(codec_preferences)
@@ -873,7 +910,11 @@ class WebRTCPeer:
873
910
  logger.debug("data-channel send failed", exc_info=True)
874
911
 
875
912
  async def add_ice(self, candidate: IceCandidate) -> None:
876
- """Add a trickle-ICE candidate; valid before and after the wire connects."""
913
+ """Add a trickle-ICE candidate; valid before and after the wire connects.
914
+
915
+ An empty candidate string is the end-of-candidates marker (RFC 8838);
916
+ the native binding accepts it as a no-op.
917
+ """
877
918
  pc = self._pc
878
919
  if self._stop_event.is_set() or pc is None:
879
920
  return
@@ -1003,6 +1044,8 @@ class WebRTCPeer:
1003
1044
  if info.name in paused
1004
1045
  else rw.TransceiverDirection.SendOnly
1005
1046
  )
1047
+ if info.kind is TrackKind.VIDEO:
1048
+ await _apply_sender_bitrate(transceiver, self._config)
1006
1049
 
1007
1050
  # =========================================================================
1008
1051
  # Seam: stats and teardown
@@ -46,7 +46,8 @@ class IceCandidate:
46
46
  """One trickle-ICE candidate from the client.
47
47
 
48
48
  Attributes:
49
- candidate: The candidate string (SDP ``a=candidate`` form).
49
+ candidate: The candidate string (SDP ``a=candidate`` form); an empty
50
+ string is the end-of-candidates marker (RFC 8838).
50
51
  sdp_mid: The media-stream identifier the candidate belongs to, or
51
52
  ``None`` when addressed by m-line index instead.
52
53
  sdp_mline_index: The index of the m-line the candidate belongs to, or
File without changes
File without changes