reactor-runtime 3.6.0__tar.gz → 3.7.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 (126) hide show
  1. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/PKG-INFO +23 -3
  2. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/README.md +20 -1
  3. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/pyproject.toml +4 -3
  4. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/pyproject.toml.orig +4 -3
  5. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/__init__.py +2 -0
  6. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/core/__init__.py +19 -1
  7. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/core/fields.py +70 -0
  8. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/core/model.py +48 -16
  9. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/core/service.py +35 -0
  10. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/core/session.py +8 -3
  11. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/core/transport.py +11 -0
  12. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/core/values.py +132 -1
  13. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/http/__init__.py +2 -0
  14. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/http/routes.py +148 -3
  15. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/http/server.py +14 -4
  16. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/__init__.py +2 -1
  17. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/app/__init__.py +3 -1
  18. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/app/input_state.py +49 -11
  19. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/app/reactor_app.py +132 -63
  20. reactor_runtime-3.7.0/src/reactor_runtime/interface/app/step_completed.py +59 -0
  21. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/client.py +6 -0
  22. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/events/decorators.py +22 -2
  23. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/events/messages.py +33 -14
  24. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/internal/bridge.py +28 -2
  25. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/internal/reactor_core.py +93 -20
  26. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/model/contract.py +74 -3
  27. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +1 -1
  28. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/manifest.py +51 -7
  29. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/message_gateway.py +5 -3
  30. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/metrics/session.py +6 -2
  31. reactor_runtime-3.7.0/src/reactor_runtime/runner/client_stats.py +110 -0
  32. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/runner/connection_manager.py +48 -12
  33. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/runner/runner.py +457 -23
  34. reactor_runtime-3.7.0/src/reactor_runtime/runner/session_start.py +162 -0
  35. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/runner/state_machine.py +39 -2
  36. reactor_runtime-3.7.0/src/reactor_runtime/runner/system_client.py +64 -0
  37. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/serve.py +52 -12
  38. reactor_runtime-3.7.0/src/reactor_runtime/service.py +291 -0
  39. reactor_runtime-3.7.0/src/reactor_runtime/step_results/__init__.py +12 -0
  40. reactor_runtime-3.7.0/src/reactor_runtime/step_results/mp4.py +182 -0
  41. reactor_runtime-3.7.0/src/reactor_runtime/step_results/store.py +445 -0
  42. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/acceptor.py +4 -1
  43. reactor_runtime-3.7.0/src/reactor_runtime/transport/webrtc/client_stats.py +114 -0
  44. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/config.py +16 -0
  45. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/connection.py +27 -5
  46. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/peer.py +117 -16
  47. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/control_pb2.py +4 -4
  48. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/control_pb2.pyi +4 -2
  49. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/platform_pb2.py +23 -1
  50. reactor_runtime-3.7.0/src/reactor_wire/v1/platform_pb2.pyi +179 -0
  51. reactor_runtime-3.6.0/src/reactor_runtime/service.py +0 -141
  52. reactor_runtime-3.6.0/src/reactor_wire/v1/platform_pb2.pyi +0 -88
  53. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/LICENSE +0 -0
  54. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/NOTICE +0 -0
  55. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/codes.py +0 -0
  56. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/core/naming.py +0 -0
  57. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/core/typespec.py +0 -0
  58. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/distributed/__init__.py +0 -0
  59. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/distributed/errors.py +0 -0
  60. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/distributed/ipc.py +0 -0
  61. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/distributed/protocol.py +0 -0
  62. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/distributed/rank.py +0 -0
  63. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/distributed/runner.py +0 -0
  64. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/distributed/worker.py +0 -0
  65. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/event_stream.py +0 -0
  66. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/http/events.py +0 -0
  67. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/http/spec.py +0 -0
  68. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/app/outcome.py +0 -0
  69. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/events/__init__.py +0 -0
  70. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/events/errors.py +0 -0
  71. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  72. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/internal/aliases.py +0 -0
  73. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  74. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/model/__init__.py +0 -0
  75. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  76. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/model/schema.py +0 -0
  77. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  78. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  79. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  80. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  81. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  82. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/tracks/input.py +0 -0
  83. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/interface/tracks/output.py +0 -0
  84. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/log.py +0 -0
  85. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/metrics/__init__.py +0 -0
  86. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/metrics/command.py +0 -0
  87. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/metrics/model.py +0 -0
  88. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/metrics/registry.py +0 -0
  89. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/paths.py +0 -0
  90. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/protocol/__init__.py +0 -0
  91. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/protocol/base.py +0 -0
  92. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/protocol/common.py +0 -0
  93. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  94. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/protocol/v0/codec.py +0 -0
  95. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  96. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  97. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/py.typed +0 -0
  98. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/recording/__init__.py +0 -0
  99. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  100. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/recording/markers.py +0 -0
  101. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/recording/recorder.py +0 -0
  102. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/runner/__init__.py +0 -0
  103. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/runner/offer_epochs.py +0 -0
  104. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/runner/upload_resolution.py +0 -0
  105. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/schema.py +0 -0
  106. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/__init__.py +0 -0
  107. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/acceptor.py +0 -0
  108. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/router.py +0 -0
  109. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/__init__.py +0 -0
  110. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  111. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/metrics.py +0 -0
  112. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
  113. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/router.py +0 -0
  114. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
  115. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
  116. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/stats.py +0 -0
  117. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  118. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_runtime/upload_store.py +0 -0
  119. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/common_pb2.py +0 -0
  120. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  121. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/data_pb2.py +0 -0
  122. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  123. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/model_pb2.py +0 -0
  124. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  125. {reactor_runtime-3.6.0 → reactor_runtime-3.7.0}/src/reactor_wire/v1/track_pb2.py +0 -0
  126. {reactor_runtime-3.6.0 → reactor_runtime-3.7.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.6.0
3
+ Version: 3.7.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>
@@ -11,6 +11,7 @@ Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
14
15
  Classifier: Typing :: Typed
15
16
  Requires-Dist: av>=18.0.0
16
17
  Requires-Dist: fastapi>=0.139.0
@@ -18,7 +19,7 @@ Requires-Dist: numpy>=2.1
18
19
  Requires-Dist: prometheus-client>=0.26.0
19
20
  Requires-Dist: protobuf>=7.35.1
20
21
  Requires-Dist: pyyaml>=6.0.3
21
- Requires-Dist: reactor-webrtc==0.18.0
22
+ Requires-Dist: reactor-webrtc==0.21.0
22
23
  Requires-Dist: uvicorn>=0.49.0
23
24
  Requires-Python: >=3.12
24
25
  Project-URL: Source, https://github.com/reactor-team/reactor-runtime
@@ -78,6 +79,8 @@ class MyModel(ReactorApp):
78
79
 
79
80
  That is a complete application. The runtime calls `generate()` in a loop for as long as someone is watching and emits what it returns. Every public field on `MyState` is a command the client can send: here `set_prompt` and `set_paused`, validated from the fields, and the next step reads the new values.
80
81
 
82
+ State and message classes can be subclassed. A subclass keeps every field of its parents and adds its own, so `class SeededState(MyState): seed: int = InputField(default=0)` exposes `set_prompt`, `set_paused`, and `set_seed`. A field declared again in the subclass replaces the parent's, default and constraints included. The fields a subclass adds are keyword-only in its constructor, which lets a required field follow an inherited one that has a default. A private field (leading underscore) always needs a default, because the state is built from defaults when a session starts.
83
+
81
84
  A step is three calls, and `generate()` is the one you must write. `process_input()` runs before it, reading `self.state` and the media tracks, and decides whether a step can happen: return the input `generate()` gets, or raise `ApplicationError("reason")` to skip the step without touching the model. `process_output(outcome)` runs after it, with the result or the error, and returns the media to emit; send a message from there with `await self.send()` and it reaches the client before the step's frames. Both have defaults, so the model above writes neither.
82
85
 
83
86
  ```python
@@ -119,7 +122,24 @@ class MyModel(ReactorApp):
119
122
  return outcome.to_output()
120
123
  ```
121
124
 
122
- Command handlers and lifecycle hooks run between steps, never during one. The default `run()` is the loop that drives the three hooks. Override it to write your own loop against `emit()`, `send()`, `@event`, `self.connected`, and the tracks; `process_input()`, `generate()`, and `process_output()` are then not called. Do that for a loop that is not one step per emit, such as a renderer that emits several times per step or a model that must block on an input.
125
+ Command handlers and lifecycle hooks run between steps, never during one. A model has one method per lifecycle hook kind (`@session_started`, `@session_ended`, `@connected`, `@disconnected`, `@file_uploaded`). A subclass extends an inherited hook by overriding that method and calling `super()`; the override runs whether or not it repeats the decorator. Marking a second method with the same kind, in one class or in a subclass, fails when the class is defined, so a hook is never dropped in silence. The default `run()` is the loop that drives the three hooks, and it reports each step that runs with `complete_step()`, which is what the runtime counts a session's steps by. To write that report yourself, for example to keep extra files with the step, return a `StepCompleted(output=..., files=...)` from `process_output()` instead of the `Output`. Override `run()` to write your own loop against `emit()`, `send()`, `complete_step()`, `@event`, `self.connected`, and the tracks; `process_input()`, `generate()`, and `process_output()` are then not called, and your loop calls `complete_step(StepCompleted(output=...))` each time it finishes a unit of work. Do that for a loop that is not one step per emit, such as a renderer that emits several times per step or a model that must block on an input.
126
+
127
+ A session can start with its setup already in hand: `POST /start_session` takes a `starting_input` of state values and commands, which the runtime applies on the normal command path before any client connects. A system client sends them, a connection the runtime opens itself, so a handler that takes `client` gets a `ClientInfo` whose `system` is true. The step loop takes its first step only once every starting command has landed, uploads included, so no step runs on the defaults the starting input replaces. In a live session the system client then leaves and the loop waits for a viewer, as it always does; a model with its own `run()` sees the system client connect like any client. A live session stays `waiting` while its starting input applies, because the system client does not occupy it, and `/events` journals the system client's `connection_opened` and `connection_closed` as `waiting -> waiting`. A session with `steps` is occupied by the system client until it ends, so it streams with no other client. The runtime ends it itself once the model has reported that many steps, with a normal stop whose close reason is "Session ended: the requested steps are complete."
128
+
129
+ A model can keep each step it reports as a folder of files, turned on in `reactor.yaml`:
130
+
131
+ ```yaml
132
+ runtime:
133
+ step_results:
134
+ enabled: true
135
+ video: {codec: h264, preset: veryfast, crf: 23} # or codec: h265
136
+ audio: {codec: aac, bitrate_kbps: 128}
137
+ queue: 8
138
+ ```
139
+
140
+ Every track of the step's output becomes its own stream in one `output.mp4`, beside the extra `files` the step carried and a `result.json` that lists them. `queue` bounds how many steps may wait to be saved; a step that finds it full is not saved, so saving never holds the model up. Each step is its own encode, so this suits a model that finishes a step every few seconds rather than every frame.
141
+
142
+ When step results are on, `GET /sessions/{id}/steps` lists a session's saved folders by the session's own id; each stays readable for five minutes after it is written, after the session ends too.
123
143
 
124
144
  Scaffold, build, and run it with the CLI:
125
145
 
@@ -52,6 +52,8 @@ class MyModel(ReactorApp):
52
52
 
53
53
  That is a complete application. The runtime calls `generate()` in a loop for as long as someone is watching and emits what it returns. Every public field on `MyState` is a command the client can send: here `set_prompt` and `set_paused`, validated from the fields, and the next step reads the new values.
54
54
 
55
+ State and message classes can be subclassed. A subclass keeps every field of its parents and adds its own, so `class SeededState(MyState): seed: int = InputField(default=0)` exposes `set_prompt`, `set_paused`, and `set_seed`. A field declared again in the subclass replaces the parent's, default and constraints included. The fields a subclass adds are keyword-only in its constructor, which lets a required field follow an inherited one that has a default. A private field (leading underscore) always needs a default, because the state is built from defaults when a session starts.
56
+
55
57
  A step is three calls, and `generate()` is the one you must write. `process_input()` runs before it, reading `self.state` and the media tracks, and decides whether a step can happen: return the input `generate()` gets, or raise `ApplicationError("reason")` to skip the step without touching the model. `process_output(outcome)` runs after it, with the result or the error, and returns the media to emit; send a message from there with `await self.send()` and it reaches the client before the step's frames. Both have defaults, so the model above writes neither.
56
58
 
57
59
  ```python
@@ -93,7 +95,24 @@ class MyModel(ReactorApp):
93
95
  return outcome.to_output()
94
96
  ```
95
97
 
96
- Command handlers and lifecycle hooks run between steps, never during one. The default `run()` is the loop that drives the three hooks. Override it to write your own loop against `emit()`, `send()`, `@event`, `self.connected`, and the tracks; `process_input()`, `generate()`, and `process_output()` are then not called. Do that for a loop that is not one step per emit, such as a renderer that emits several times per step or a model that must block on an input.
98
+ Command handlers and lifecycle hooks run between steps, never during one. A model has one method per lifecycle hook kind (`@session_started`, `@session_ended`, `@connected`, `@disconnected`, `@file_uploaded`). A subclass extends an inherited hook by overriding that method and calling `super()`; the override runs whether or not it repeats the decorator. Marking a second method with the same kind, in one class or in a subclass, fails when the class is defined, so a hook is never dropped in silence. The default `run()` is the loop that drives the three hooks, and it reports each step that runs with `complete_step()`, which is what the runtime counts a session's steps by. To write that report yourself, for example to keep extra files with the step, return a `StepCompleted(output=..., files=...)` from `process_output()` instead of the `Output`. Override `run()` to write your own loop against `emit()`, `send()`, `complete_step()`, `@event`, `self.connected`, and the tracks; `process_input()`, `generate()`, and `process_output()` are then not called, and your loop calls `complete_step(StepCompleted(output=...))` each time it finishes a unit of work. Do that for a loop that is not one step per emit, such as a renderer that emits several times per step or a model that must block on an input.
99
+
100
+ A session can start with its setup already in hand: `POST /start_session` takes a `starting_input` of state values and commands, which the runtime applies on the normal command path before any client connects. A system client sends them, a connection the runtime opens itself, so a handler that takes `client` gets a `ClientInfo` whose `system` is true. The step loop takes its first step only once every starting command has landed, uploads included, so no step runs on the defaults the starting input replaces. In a live session the system client then leaves and the loop waits for a viewer, as it always does; a model with its own `run()` sees the system client connect like any client. A live session stays `waiting` while its starting input applies, because the system client does not occupy it, and `/events` journals the system client's `connection_opened` and `connection_closed` as `waiting -> waiting`. A session with `steps` is occupied by the system client until it ends, so it streams with no other client. The runtime ends it itself once the model has reported that many steps, with a normal stop whose close reason is "Session ended: the requested steps are complete."
101
+
102
+ A model can keep each step it reports as a folder of files, turned on in `reactor.yaml`:
103
+
104
+ ```yaml
105
+ runtime:
106
+ step_results:
107
+ enabled: true
108
+ video: {codec: h264, preset: veryfast, crf: 23} # or codec: h265
109
+ audio: {codec: aac, bitrate_kbps: 128}
110
+ queue: 8
111
+ ```
112
+
113
+ Every track of the step's output becomes its own stream in one `output.mp4`, beside the extra `files` the step carried and a `result.json` that lists them. `queue` bounds how many steps may wait to be saved; a step that finds it full is not saved, so saving never holds the model up. Each step is its own encode, so this suits a model that finishes a step every few seconds rather than every frame.
114
+
115
+ When step results are on, `GET /sessions/{id}/steps` lists a session's saved folders by the session's own id; each stays readable for five minutes after it is written, after the session ends too.
97
116
 
98
117
  Scaffold, build, and run it with the CLI:
99
118
 
@@ -10,7 +10,7 @@ module-name = [
10
10
  namespace = true
11
11
 
12
12
  [tool.reactor-wire]
13
- version = "1.20260814.7"
13
+ version = "1.20260929.8"
14
14
 
15
15
  [tool.ruff]
16
16
  line-length = 100
@@ -95,7 +95,7 @@ pythonpath = ["."]
95
95
 
96
96
  [project]
97
97
  name = "reactor-runtime"
98
- version = "3.6.0"
98
+ version = "3.7.0"
99
99
  description = "A Python framework for building real-time, interactive video models"
100
100
  readme = "README.md"
101
101
  license = "Apache-2.0"
@@ -109,6 +109,7 @@ classifiers = [
109
109
  "Intended Audience :: Developers",
110
110
  "Programming Language :: Python :: 3.12",
111
111
  "Programming Language :: Python :: 3.13",
112
+ "Programming Language :: Python :: 3.14",
112
113
  "Typing :: Typed",
113
114
  ]
114
115
  dependencies = [
@@ -118,7 +119,7 @@ dependencies = [
118
119
  "prometheus-client>=0.26.0",
119
120
  "protobuf>=7.35.1",
120
121
  "pyyaml>=6.0.3",
121
- "reactor-webrtc==0.18.0",
122
+ "reactor-webrtc==0.21.0",
122
123
  "uvicorn>=0.49.0",
123
124
  ]
124
125
 
@@ -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.20260814.7"
19
+ version = "1.20260929.8"
20
20
 
21
21
  [project]
22
22
  name = "reactor-runtime"
23
- version = "3.6.0"
23
+ version = "3.7.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"
@@ -32,6 +32,7 @@ classifiers = [
32
32
  "Intended Audience :: Developers",
33
33
  "Programming Language :: Python :: 3.12",
34
34
  "Programming Language :: Python :: 3.13",
35
+ "Programming Language :: Python :: 3.14",
35
36
  "Typing :: Typed",
36
37
  ]
37
38
  dependencies = [
@@ -41,7 +42,7 @@ dependencies = [
41
42
  "prometheus-client>=0.26.0",
42
43
  "protobuf>=7.35.1",
43
44
  "pyyaml>=6.0.3",
44
- "reactor-webrtc==0.18.0",
45
+ "reactor-webrtc==0.21.0",
45
46
  "uvicorn>=0.49.0",
46
47
  ]
47
48
 
@@ -43,6 +43,7 @@ from reactor_runtime.interface import (
43
43
  OutputStream,
44
44
  ReactorApp,
45
45
  ReadMode,
46
+ StepCompleted,
46
47
  StepOutcome,
47
48
  Track,
48
49
  TrackPayload,
@@ -96,6 +97,7 @@ __all__ = [
96
97
  "OutputStream",
97
98
  "ReactorApp",
98
99
  "ReadMode",
100
+ "StepCompleted",
99
101
  "StepOutcome",
100
102
  "Track",
101
103
  "TrackPayload",
@@ -17,10 +17,16 @@ from reactor_runtime.core.model import (
17
17
  ReactorEvent,
18
18
  SessionEnded,
19
19
  SessionStarted,
20
+ StartingInputApplied,
20
21
  TransitionEvent,
21
22
  UploadedFile,
22
23
  )
23
- from reactor_runtime.core.service import RecordingConfig, RuntimeConfig, ServiceComponent
24
+ from reactor_runtime.core.service import (
25
+ RecordingConfig,
26
+ RuntimeConfig,
27
+ ServiceComponent,
28
+ StepResultsConfig,
29
+ )
24
30
  from reactor_runtime.core.session import (
25
31
  JOURNAL_EVENTS,
26
32
  SessionEvent,
@@ -30,7 +36,12 @@ from reactor_runtime.core.session import (
30
36
  from reactor_runtime.core.transport import Connection, ConnectionSink
31
37
  from reactor_runtime.core.typespec import TypeSpec
32
38
  from reactor_runtime.core.values import (
39
+ ClientConnectionStat,
40
+ ClientStatsBatch,
41
+ ClientTrackDirection,
42
+ ClientTrackStat,
33
43
  CommandFailure,
44
+ CompletedStep,
34
45
  ConnectionCapabilities,
35
46
  ConnId,
36
47
  Health,
@@ -48,10 +59,15 @@ from reactor_runtime.core.values import (
48
59
  __all__ = [
49
60
  "JOURNAL_EVENTS",
50
61
  "ClientConnected",
62
+ "ClientConnectionStat",
51
63
  "ClientDisconnected",
64
+ "ClientStatsBatch",
65
+ "ClientTrackDirection",
66
+ "ClientTrackStat",
52
67
  "Command",
53
68
  "CommandFailure",
54
69
  "CommandField",
70
+ "CompletedStep",
55
71
  "ConnId",
56
72
  "Connection",
57
73
  "ConnectionCapabilities",
@@ -74,6 +90,8 @@ __all__ = [
74
90
  "SessionEvent",
75
91
  "SessionStarted",
76
92
  "SessionState",
93
+ "StartingInputApplied",
94
+ "StepResultsConfig",
77
95
  "TrackData",
78
96
  "TrackDirection",
79
97
  "TrackInfo",
@@ -13,6 +13,8 @@ graph alongside the neutral value vocabulary.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import dataclasses
17
+ import inspect
16
18
  from dataclasses import dataclass
17
19
  from typing import Any, Final
18
20
 
@@ -121,6 +123,74 @@ def InputField( # noqa: N802 — a capitalised factory reads as a type in field
121
123
  )
122
124
 
123
125
 
126
+ def own_annotations(cls: type) -> dict[str, Any]:
127
+ """Return the annotations *cls* declares itself, none from its bases.
128
+
129
+ Goes through :func:`inspect.get_annotations` rather than the class
130
+ ``__dict__``. From Python 3.14 (PEP 649) a class stores its annotations
131
+ lazily, behind ``__annotate__``, and a direct dict read finds nothing; the
132
+ accessor materialises them on every version. Inherited annotations are
133
+ read from the bases' cached field records, never re-resolved.
134
+ """
135
+ return inspect.get_annotations(cls)
136
+
137
+
138
+ def inherited_record(cls: type, attribute: str) -> dict[str, Any]:
139
+ """Merge the field records the bases of *cls* store on themselves.
140
+
141
+ A declaration base (``InputState``, ``ModelMessage``, ``Command``) caches
142
+ the fields each subclass declares in a class-level dict. A subclass starts
143
+ from this merge, so the fields it inherits are part of its own record. Bases
144
+ are read from the most distant to the closest, so a nearer base wins a name
145
+ both declare, and a re-declared name keeps the position the first base gave
146
+ it, which is the order a dataclass lays inherited fields out in.
147
+
148
+ Args:
149
+ cls: The class being declared.
150
+ attribute: The name of the record attribute on each base.
151
+
152
+ Returns:
153
+ A new dict; the bases' records are not modified.
154
+ """
155
+ merged: dict[str, Any] = {}
156
+ for base in reversed(cls.__mro__[1:]):
157
+ merged.update(base.__dict__.get(attribute, {}))
158
+ return merged
159
+
160
+
161
+ def apply_dataclass(cls: type, *, required: list[str], inherits: bool) -> None:
162
+ """Turn a freshly declared subclass into a dataclass, once.
163
+
164
+ A class whose parent is already a dataclass is still converted, so the
165
+ fields it declares become fields of its own. Its fields are keyword-only
166
+ when it inherits any, because a dataclass refuses a required field placed
167
+ after an inherited field with a default, and keyword-only fields have no
168
+ such order.
169
+
170
+ A required field hides any class attribute a parent holds under the same
171
+ name for the duration of the conversion. The dataclass reads a field's
172
+ default with ``getattr``, so without this a field declared required again
173
+ would silently inherit the parent's default. Once the placeholder is
174
+ removed the parent's attribute is reachable through the subclass again, so
175
+ ``Child.x`` reads the parent's value while ``dataclasses.fields(Child)``
176
+ and the field record both say required. The runtime reads defaults from
177
+ the record, never from the class attribute.
178
+
179
+ Args:
180
+ cls: The class to convert. A class that already carries its own
181
+ ``__dataclass_fields__`` is left alone.
182
+ required: The names of the fields *cls* declares without a default.
183
+ inherits: Whether *cls* inherits fields from a declared base.
184
+ """
185
+ if "__dataclass_fields__" in cls.__dict__:
186
+ return
187
+ for name in required:
188
+ setattr(cls, name, dataclasses.MISSING)
189
+ dataclasses.dataclass(cls, kw_only=inherits)
190
+ for name in required:
191
+ delattr(cls, name)
192
+
193
+
124
194
  def raise_if_default_not_static(owner: str, field_name: str, default: Any) -> None:
125
195
  """Raise ``TypeError`` when *default* is a mutable container.
126
196
 
@@ -20,6 +20,9 @@ from reactor_runtime.core.fields import (
20
20
  NO_DEFAULT,
21
21
  FieldInfo,
22
22
  InputField,
23
+ apply_dataclass,
24
+ inherited_record,
25
+ own_annotations,
23
26
  raise_if_default_invalid,
24
27
  raise_if_default_not_static,
25
28
  )
@@ -62,6 +65,10 @@ class Command:
62
65
  invalid defaults at import), turns the class into a dataclass, and caches the
63
66
  resolved fields on the class. There is no global registry — each command
64
67
  carries its own contract.
68
+
69
+ A subclass of a command inherits every field of its parents, and a field it
70
+ declares again replaces the parent's. The fields a subclass adds are
71
+ keyword-only in its constructor.
65
72
  """
66
73
 
67
74
  name: ClassVar[str]
@@ -71,8 +78,6 @@ class Command:
71
78
  def __init_subclass__(cls, **kwargs: object) -> None:
72
79
  super().__init_subclass__(**kwargs)
73
80
  _build_command(cls)
74
- if not dataclasses.is_dataclass(cls):
75
- dataclasses.dataclass(cls)
76
81
  if "name" not in cls.__dict__:
77
82
  cls.name = pascal_to_snake(cls.__name__)
78
83
 
@@ -83,9 +88,9 @@ def _is_classvar(annotation: Any) -> bool:
83
88
 
84
89
 
85
90
  def _build_command(cls: type[Command]) -> None:
86
- """Resolve a command class's fields and cache them on it.
91
+ """Resolve a command class's fields, cache them on it, and make it a dataclass.
87
92
 
88
- Runs once per subclass, before ``@dataclass`` is applied. Unwraps
93
+ Runs once per subclass. Starts from the fields the parents declared, unwraps
89
94
  ``InputField`` defaults so the dataclass sees plain values, resolves each
90
95
  field's :class:`TypeSpec` (the support check), records which fields are
91
96
  uploads, and reorders so fields without a default come first — the order a
@@ -103,10 +108,18 @@ def _build_command(cls: type[Command]) -> None:
103
108
  # deferring the import until a command is actually declared.
104
109
  from reactor_runtime.core.typespec import OptionalSpec, TypeSpec, UploadSpec
105
110
 
106
- annotations: dict[str, Any] = getattr(cls, "__annotations__", {})
111
+ def is_upload(spec: TypeSpec) -> bool:
112
+ resolved = spec.inner if isinstance(spec, OptionalSpec) else spec
113
+ return isinstance(resolved, UploadSpec)
114
+
115
+ fields = inherited_record(cls, "__command_fields__")
116
+ inherits = bool(fields)
117
+
118
+ annotations = own_annotations(cls)
107
119
  if not annotations:
108
- cls.__command_fields__ = {}
109
- cls.__upload_fields__ = frozenset()
120
+ cls.__command_fields__ = fields
121
+ cls.__upload_fields__ = frozenset(n for n, f in fields.items() if is_upload(f.spec))
122
+ apply_dataclass(cls, required=[], inherits=inherits)
110
123
  return
111
124
 
112
125
  try:
@@ -115,14 +128,17 @@ def _build_command(cls: type[Command]) -> None:
115
128
  hints = {}
116
129
 
117
130
  owner = cls.__qualname__
118
- fields: dict[str, CommandField] = {}
119
- uploads: set[str] = set()
120
131
  no_default: list[str] = []
121
132
  has_default: list[str] = []
133
+ class_vars: list[str] = []
122
134
 
123
135
  for name in list(annotations):
124
136
  annotation = hints.get(name, annotations[name])
125
137
  if _is_classvar(annotation):
138
+ # A ClassVar is not a field. Redeclaring an inherited field as one
139
+ # takes it out of the dataclass, so it leaves the record too.
140
+ fields.pop(name, None)
141
+ class_vars.append(name)
126
142
  continue
127
143
 
128
144
  raw = cls.__dict__.get(name, NO_DEFAULT)
@@ -138,8 +154,6 @@ def _build_command(cls: type[Command]) -> None:
138
154
  info = raw
139
155
  raise_if_default_invalid(owner, name, info.default, info)
140
156
  if info.default is NO_DEFAULT:
141
- if hasattr(cls, name):
142
- delattr(cls, name)
143
157
  no_default.append(name)
144
158
  else:
145
159
  setattr(cls, name, info.default)
@@ -160,15 +174,18 @@ def _build_command(cls: type[Command]) -> None:
160
174
  )
161
175
 
162
176
  fields[name] = CommandField(spec=spec, info=info)
163
- resolved = spec.inner if isinstance(spec, OptionalSpec) else spec
164
- if isinstance(resolved, UploadSpec):
165
- uploads.add(name)
166
177
 
167
178
  if no_default and has_default:
168
- cls.__annotations__ = {key: annotations[key] for key in no_default + has_default}
179
+ cls.__annotations__ = {
180
+ key: annotations[key] for key in no_default + has_default + class_vars
181
+ }
169
182
 
170
183
  cls.__command_fields__ = fields
171
- cls.__upload_fields__ = frozenset(uploads)
184
+ # Read off the winning definition of every field, so a name one base
185
+ # declares as an upload and a nearer base as something else follows the
186
+ # nearer base.
187
+ cls.__upload_fields__ = frozenset(n for n, f in fields.items() if is_upload(f.spec))
188
+ apply_dataclass(cls, required=no_default, inherits=inherits)
172
189
 
173
190
 
174
191
  class EndReason(StrEnum):
@@ -226,9 +243,22 @@ class SessionStarted(ReactorEvent):
226
243
 
227
244
  Attributes:
228
245
  session_id: Identifier for the session that started.
246
+ starting_input: Whether a starting input follows. When one does, the
247
+ step loop takes no step until :class:`StartingInputApplied`
248
+ arrives, so its first step sees the state the starting input set.
229
249
  """
230
250
 
231
251
  session_id: str
252
+ starting_input: bool = False
253
+
254
+
255
+ @dataclass(frozen=True)
256
+ class StartingInputApplied(ReactorEvent):
257
+ """Every command of the session's starting input has been handed to the model.
258
+
259
+ It follows the last starting command on the same ordered queue, so every
260
+ starting handler has returned by the time the model handles it.
261
+ """
232
262
 
233
263
 
234
264
  @dataclass(frozen=True)
@@ -257,10 +287,12 @@ class ClientConnected(ReactorEvent):
257
287
  Attributes:
258
288
  conn_id: The connection that opened.
259
289
  total: Live connection count after the open.
290
+ system: Whether the connection is the runtime's own system client.
260
291
  """
261
292
 
262
293
  conn_id: ConnId
263
294
  total: int
295
+ system: bool = False
264
296
 
265
297
 
266
298
  @dataclass(frozen=True)
@@ -92,6 +92,36 @@ class RecordingConfig:
92
92
  recording_dir: str | None = None
93
93
 
94
94
 
95
+ @dataclass(frozen=True)
96
+ class StepResultsConfig:
97
+ """How each finished step is kept, drawn from the manifest's ``step_results:`` block.
98
+
99
+ A model with no ``step_results:`` block keeps nothing (``enabled`` is
100
+ ``False``). When enabled, every step the model reports is saved as a
101
+ folder: its output in one ``output.mp4``, with each track of the step's
102
+ ``Output`` as its own stream, beside any extra files and a ``result.json``.
103
+ Turning it on is the model's choice, not a session option.
104
+
105
+ Attributes:
106
+ enabled: Whether the runtime saves each finished step.
107
+ video_codec: ``"h264"`` or ``"h265"``.
108
+ video_preset: The libx264/libx265 preset.
109
+ video_crf: The constant-rate-factor quality target.
110
+ audio_codec: The audio codec, e.g. ``"aac"``.
111
+ audio_bitrate_kbps: The audio bitrate in kilobits per second.
112
+ queue: How many steps may wait to be saved. A step that finds the
113
+ queue full is not saved, so the model never waits on saving.
114
+ """
115
+
116
+ enabled: bool = False
117
+ video_codec: str = "h264"
118
+ video_preset: str = "veryfast"
119
+ video_crf: int = 23
120
+ audio_codec: str = "aac"
121
+ audio_bitrate_kbps: int = 128
122
+ queue: int = 8
123
+
124
+
95
125
  @dataclass(frozen=True)
96
126
  class RuntimeConfig:
97
127
  """The single configuration object threaded through ``serve``.
@@ -112,8 +142,11 @@ class RuntimeConfig:
112
142
  host: Address the HTTP ingress binds.
113
143
  port: Port the HTTP ingress binds.
114
144
  grace_period: Seconds a draining session is given to end before stop.
145
+ exit_timeout: Seconds the orderly shutdown may take, on top of any
146
+ session drain it includes, before the process is forced to exit.
115
147
  orphan_timeout: Seconds a session may stay client-less before it closes.
116
148
  recording: The recorder's configuration; disabled by default.
149
+ step_results: How each finished step is saved; disabled by default.
117
150
  """
118
151
 
119
152
  model_ref: str
@@ -122,5 +155,7 @@ class RuntimeConfig:
122
155
  host: str = "0.0.0.0"
123
156
  port: int = 8080
124
157
  grace_period: float = 30.0
158
+ exit_timeout: float = 10.0
125
159
  orphan_timeout: float = 60.0
126
160
  recording: RecordingConfig = field(default_factory=RecordingConfig)
161
+ step_results: StepResultsConfig = field(default_factory=StepResultsConfig)
@@ -75,9 +75,10 @@ class SessionEvent(Enum):
75
75
  one phase otherwise silent, since the runner emits nothing until it leaves
76
76
  ``CREATED`` on ``INITIALIZATION_SUCCESS``.
77
77
 
78
- ``CHUNK_READY``, ``CLIP_READY``, ``COMMAND``, ``ERROR``, and ``METRIC`` are
79
- the journal-only events (:data:`JOURNAL_EVENTS`): facts recorded for an
80
- external consumer rather than moves of the lifecycle. Each is a pure
78
+ ``CHUNK_READY``, ``CLIP_READY``, ``COMMAND``, ``ERROR``, ``METRIC``,
79
+ ``STEP_COMPLETED``, and ``STEP_RESULT_READY`` are the journal-only events
80
+ (:data:`JOURNAL_EVENTS`): facts recorded for an external consumer rather
81
+ than moves of the lifecycle. Each is a pure
81
82
  self-loop legal in **every** state — a journal fact must never be silently
82
83
  dropped by the transition table (the final ``CHUNK_READY`` fires during
83
84
  ``CLOSING``; an ``ERROR`` can fire while ``ORPHANED``) — that changes no
@@ -101,6 +102,8 @@ class SessionEvent(Enum):
101
102
  COMMAND = auto()
102
103
  ERROR = auto()
103
104
  METRIC = auto()
105
+ STEP_COMPLETED = auto()
106
+ STEP_RESULT_READY = auto()
104
107
 
105
108
 
106
109
  JOURNAL_EVENTS: frozenset[SessionEvent] = frozenset(
@@ -110,6 +113,8 @@ JOURNAL_EVENTS: frozenset[SessionEvent] = frozenset(
110
113
  SessionEvent.COMMAND,
111
114
  SessionEvent.ERROR,
112
115
  SessionEvent.METRIC,
116
+ SessionEvent.STEP_COMPLETED,
117
+ SessionEvent.STEP_RESULT_READY,
113
118
  }
114
119
  )
115
120
  """The journal-only events: pure self-loops in every state, payload in ``detail``."""
@@ -13,6 +13,7 @@ from collections.abc import Mapping
13
13
  from typing import Protocol, runtime_checkable
14
14
 
15
15
  from reactor_runtime.core.values import (
16
+ ClientStatsBatch,
16
17
  ConnectionCapabilities,
17
18
  ConnId,
18
19
  InputFrame,
@@ -193,6 +194,16 @@ class ConnectionSink(Protocol):
193
194
  timeline, correlated by *request_id*.
194
195
  """
195
196
 
197
+ def client_stats_received(self, conn_id: ConnId, batch: ClientStatsBatch) -> None:
198
+ """Hand up a batch of client-observed quality samples for one connection.
199
+
200
+ The client is the only vantage point onto its own receive-side
201
+ quality, so a transport reports the batch as decoded rather than
202
+ validating it. The connection identity comes from *conn_id*, which the
203
+ transport already holds — never from anything the payload itself
204
+ might claim, because the payload carries none.
205
+ """
206
+
196
207
  def connection_answered(self, conn_id: ConnId, answer: Mapping[str, str]) -> None:
197
208
  """Relay the transport's negotiation answer for one connection.
198
209