reactor-runtime 2.7.6__tar.gz → 2.7.7__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 (139) hide show
  1. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/PKG-INFO +1 -1
  2. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/pyproject.toml +1 -1
  3. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/internal/output_buffer.py +46 -87
  4. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/session_recorder.py +14 -2
  5. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/runtime_api.py +10 -13
  6. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/headless/headless_runtime.py +35 -17
  7. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/PKG-INFO +1 -1
  8. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/README.md +0 -0
  9. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/setup.cfg +0 -0
  10. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/api/__init__.py +0 -0
  11. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/__init__.py +0 -0
  12. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/config.py +0 -0
  13. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/experiment/__init__.py +0 -0
  14. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/experiment/session.py +0 -0
  15. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/__init__.py +0 -0
  16. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/defaults.py +0 -0
  17. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/driver/__init__.py +0 -0
  18. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/driver/pipeline_executor.py +0 -0
  19. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/driver/step_result.py +0 -0
  20. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/__init__.py +0 -0
  21. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/connected.py +0 -0
  22. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/event.py +0 -0
  23. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/messages.py +0 -0
  24. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/upload.py +0 -0
  25. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  26. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  27. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  28. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/model/__init__.py +0 -0
  29. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/model/decorators.py +0 -0
  30. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/model/handlers.py +0 -0
  31. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  32. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  33. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  34. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  35. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  36. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  37. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  38. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/tracks/input.py +0 -0
  39. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/tracks/output.py +0 -0
  40. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/upload.py +0 -0
  41. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/model_state.py +0 -0
  42. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/__init__.py +0 -0
  43. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/backends/__init__.py +0 -0
  44. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/backends/base.py +0 -0
  45. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/backends/file.py +0 -0
  46. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/backends/otlp.py +0 -0
  47. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/helpers.py +0 -0
  48. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/nvml_sampler.py +0 -0
  49. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/plotting/__init__.py +0 -0
  50. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/plotting/plot_profiling.py +0 -0
  51. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/profiler.py +0 -0
  52. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/singleton.py +0 -0
  53. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/torch_chunk_profiler.py +0 -0
  54. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/__init__.py +0 -0
  55. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  56. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/chunk_uploader.py +0 -0
  57. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/config.py +0 -0
  58. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/markers.py +0 -0
  59. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/sinks.py +0 -0
  60. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/track_resolver.py +0 -0
  61. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/headless/config.py +0 -0
  62. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/headless/input_feeder.py +0 -0
  63. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/http/config.py +0 -0
  64. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/http/http_runtime.py +0 -0
  65. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/http/types.py +0 -0
  66. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/schema.py +0 -0
  67. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/schema_validator.py +0 -0
  68. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/__init__.py +0 -0
  69. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/__main__.py +0 -0
  70. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/commands/__init__.py +0 -0
  71. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/commands/run.py +0 -0
  72. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/commands/schema.py +0 -0
  73. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/main.py +0 -0
  74. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/utils/__init__.py +0 -0
  75. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/utils/config.py +0 -0
  76. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/utils/runtime.py +0 -0
  77. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/__init__.py +0 -0
  78. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/__init__.py +0 -0
  79. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/audio_track.py +0 -0
  80. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/client.py +0 -0
  81. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/frame_conversion.py +0 -0
  82. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/ice_connection.py +0 -0
  83. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/video_track.py +0 -0
  84. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/config.py +0 -0
  85. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/events.py +0 -0
  86. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/__init__.py +0 -0
  87. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/client.py +0 -0
  88. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/__init__.py +0 -0
  89. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/av1.py +0 -0
  90. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/base.py +0 -0
  91. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/factory.py +0 -0
  92. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/h264.py +0 -0
  93. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/h265.py +0 -0
  94. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/opus.py +0 -0
  95. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/vp8.py +0 -0
  96. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/vp9.py +0 -0
  97. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/__init__.py +0 -0
  98. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/av1.py +0 -0
  99. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/base.py +0 -0
  100. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/factory.py +0 -0
  101. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/h264.py +0 -0
  102. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/h265.py +0 -0
  103. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/opus.py +0 -0
  104. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/vp8.py +0 -0
  105. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/vp9.py +0 -0
  106. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/gst.py +0 -0
  107. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/gst_helpers.py +0 -0
  108. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/probes/__init__.py +0 -0
  109. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/probes/fps_probe.py +0 -0
  110. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/receiver/__init__.py +0 -0
  111. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/receiver/audio.py +0 -0
  112. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/receiver/base.py +0 -0
  113. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/receiver/video.py +0 -0
  114. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/__init__.py +0 -0
  115. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/bundle.py +0 -0
  116. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/codec.py +0 -0
  117. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/extmap.py +0 -0
  118. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/ice.py +0 -0
  119. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sender/__init__.py +0 -0
  120. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sender/audio.py +0 -0
  121. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sender/base.py +0 -0
  122. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sender/video.py +0 -0
  123. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/settings.py +0 -0
  124. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/signals.py +0 -0
  125. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/ice_uris.py +0 -0
  126. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/interface.py +0 -0
  127. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/media.py +0 -0
  128. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/types.py +0 -0
  129. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/launch.py +0 -0
  130. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/loader.py +0 -0
  131. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/log.py +0 -0
  132. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/messages.py +0 -0
  133. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/paths.py +0 -0
  134. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/ports.py +0 -0
  135. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/typing.py +0 -0
  136. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/SOURCES.txt +0 -0
  137. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/dependency_links.txt +0 -0
  138. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/requires.txt +0 -0
  139. {reactor_runtime-2.7.6 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactor_runtime
3
- Version: 2.7.6
3
+ Version: 2.7.7
4
4
  Summary: Reactor runtime with public model API
5
5
  Author-email: Reactor <team@reactor.inc>
6
6
  Requires-Python: >=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactor_runtime"
7
- version = "2.7.6"
7
+ version = "2.7.7"
8
8
  description = "Reactor runtime with public model API"
9
9
  authors = [
10
10
  { name = "Reactor", email = "team@reactor.inc" }
@@ -105,25 +105,9 @@ def split_batch(bundle: MediaBundle) -> List[MediaBundle]:
105
105
  class _FlushMarker:
106
106
  """Sentinel placed in :class:`OutputBuffer`'s queue by :meth:`flush`.
107
107
 
108
- Carries no payload. When the emission loop dequeues an instance, it
109
- resets ``_last_emitted`` *in-thread* and falls through to this
110
- tick's empty-queue fallback (which dispatches a fresh
111
- ``_create_black_bundle()`` with ``duplicate=True``).
112
-
113
- The point of routing the session-boundary reset through the queue
114
- rather than writing ``_last_emitted = None`` directly from
115
- :meth:`flush` is to make the reset atomic with respect to the
116
- emission thread: only the emission thread reads or writes
117
- ``_last_emitted``, so the loop can never observe a half-applied
118
- "queue drained but cached frame not yet cleared" state. Down-stream
119
- callbacks already drop ``duplicate=True`` bundles (the wire
120
- callback in ``_send_out_app_bundle_sync`` short-circuits, and the
121
- :class:`~reactor_runtime.recording.session_recorder.SessionRecorder`
122
- skips them when ``skip_leading_black`` is on and
123
- ``recording_started`` is still False), so no stale frame would
124
- reach the client or recording even without this guarantee — but
125
- closing the race architecturally is cheaper than re-deriving that
126
- safety argument for every callback added in the future.
108
+ Consumed by the emission thread, which resets ``_last_emitted`` and
109
+ dispatches a fresh black bundle with ``is_fresh_black=True``. Doing
110
+ the reset in-thread keeps it atomic with the per-tick state read.
127
111
  """
128
112
 
129
113
 
@@ -152,13 +136,11 @@ class OutputBuffer:
152
136
  # Ordered list of per-tick observers. Callbacks fire in
153
137
  # registration order on every emission tick; an exception in
154
138
  # one callback is logged and does not block the others.
155
- self._callbacks: List[Callable[[MediaBundle, bool], None]] = []
139
+ self._callbacks: List[Callable[[MediaBundle, bool, bool], None]] = []
156
140
  self._callbacks_lock: threading.Lock = threading.Lock()
157
141
 
158
- # Queue items are normally MediaBundle, but flush() also puts a
159
- # _FlushMarker sentinel here. We type the queue as ``object`` so
160
- # both shapes are valid; the emission loop discriminates with
161
- # ``isinstance``.
142
+ # Holds MediaBundle plus the _FlushMarker sentinel; the
143
+ # emission loop discriminates by isinstance.
162
144
  self._q: queue.Queue[object] = queue.Queue(maxsize=queue_depth)
163
145
 
164
146
  # FPS control — store both rate and period to avoid 1/fps on every tick
@@ -182,29 +164,18 @@ class OutputBuffer:
182
164
  # Callbacks
183
165
  # ------------------------------------------------------------------
184
166
 
185
- def add_callback(self, fn: Callable[[MediaBundle, bool], None]) -> None:
186
- """Register a per-tick callback.
167
+ def add_callback(self, fn: Callable[[MediaBundle, bool, bool], None]) -> None:
168
+ """Register a per-tick callback ``(bundle, duplicate, is_fresh_black)``.
187
169
 
188
- Idempotent: registering the same ``fn`` twice leaves the
189
- callback list with a single entry. This mirrors
190
- :meth:`remove_callback` which has always been a no-op on
191
- already-unregistered functions — callers shouldn't have to
192
- track registration state.
193
-
194
- The callback receives ``(bundle, duplicate)`` on every emission
195
- tick. Callbacks fire in registration order and are isolated
196
- from each other: an exception raised by one is logged and the
197
- remaining callbacks still fire.
170
+ Idempotent — re-registering the same ``fn`` is a no-op.
171
+ Callbacks fire in registration order with exceptions isolated.
198
172
  """
199
173
  with self._callbacks_lock:
200
174
  if fn not in self._callbacks:
201
175
  self._callbacks.append(fn)
202
176
 
203
- def remove_callback(self, fn: Callable[[MediaBundle, bool], None]) -> None:
204
- """Deregister a previously added callback.
205
-
206
- No-op if *fn* was never registered.
207
- """
177
+ def remove_callback(self, fn: Callable[[MediaBundle, bool, bool], None]) -> None:
178
+ """Deregister a previously added callback (no-op if not registered)."""
208
179
  with self._callbacks_lock:
209
180
  try:
210
181
  self._callbacks.remove(fn)
@@ -320,27 +291,30 @@ class OutputBuffer:
320
291
  # Stage 3: Emission loop
321
292
  # ------------------------------------------------------------------
322
293
 
323
- def _dispatch(self, bundle: MediaBundle, duplicate: bool) -> None:
294
+ def _dispatch(
295
+ self,
296
+ bundle: MediaBundle,
297
+ duplicate: bool,
298
+ *,
299
+ is_fresh_black: bool = False,
300
+ ) -> None:
324
301
  """Fire every registered callback in order with error isolation.
325
302
 
326
- We snapshot the callback list under the lock so callbacks that
327
- register/deregister siblings during dispatch don't perturb the
328
- current tick. Each callback is wrapped so an exception in one
329
- does not block the others.
303
+ Callbacks receive ``(bundle, duplicate, is_fresh_black)``.
304
+ ``is_fresh_black`` is ``True`` only on the synthesised black
305
+ the sentinel path emits at session boundaries; callbacks
306
+ choose how to interpret that separately from ``duplicate``.
307
+ Exceptions in one callback are logged and don't block the
308
+ rest.
330
309
  """
331
310
  with self._callbacks_lock:
332
311
  callbacks = list(self._callbacks)
333
312
  for fn in callbacks:
334
313
  try:
335
- fn(bundle, duplicate)
314
+ fn(bundle, duplicate, is_fresh_black)
336
315
  except Exception as ex:
337
- # ``logger.exception`` already attaches sys.exc_info()'s
338
- # traceback; we also surface the type + message as
339
- # structured fields so log queries on ``exc_type`` /
340
- # ``exc_msg`` / ``callback`` work without traceback
341
- # parsing. Without these explicit fields a silent
342
- # malfunction in a downstream callback looks like
343
- # "feature X didn't fire" with no obvious cause.
316
+ # Structured fields so log queries on exc_type /
317
+ # exc_msg / callback work without traceback parsing.
344
318
  logger.exception(
345
319
  "OutputBuffer callback raised; continuing with remaining callbacks",
346
320
  callback=repr(fn),
@@ -361,19 +335,24 @@ class OutputBuffer:
361
335
  item = None
362
336
 
363
337
  bundle: Optional[MediaBundle] = None
338
+ sentinel_consumed = False
364
339
  if isinstance(item, _FlushMarker):
365
- # Session-boundary reset, processed in the emission
366
- # thread so there is no cross-thread race with
367
- # flush(): clear the cached frame and fall through
368
- # to the empty-queue fallback below, which
369
- # dispatches duplicate=True black. mark_first_real_frame()
370
- # is gated on duplicate=False, so the recorder's
371
- # latch (REA-2323 / #2325) stays unset.
372
340
  self._last_emitted = None
341
+ sentinel_consumed = True
373
342
  elif isinstance(item, MediaBundle):
374
343
  bundle = item
375
344
 
376
- if bundle is not None:
345
+ if sentinel_consumed:
346
+ # Session-boundary black: tagged so the wire
347
+ # forwards it (otherwise the client would stay
348
+ # frozen on the previous frame), the recorder
349
+ # ignores it.
350
+ self._dispatch(
351
+ self._create_black_bundle(),
352
+ True,
353
+ is_fresh_black=True,
354
+ )
355
+ elif bundle is not None:
377
356
  vtracks = bundle.get_tracks_by_kind(TrackKind.VIDEO)
378
357
  if vtracks:
379
358
  vd = vtracks[0].data
@@ -511,36 +490,16 @@ class OutputBuffer:
511
490
  def flush(self) -> None:
512
491
  """Drop pending bundles and request a session-boundary reset.
513
492
 
514
- The reset of ``_last_emitted`` is performed by the emission
515
- thread when it dequeues the :class:`_FlushMarker` sentinel,
516
- making the operation race-free with the per-tick
517
- "what do I emit?" decision in :meth:`_emission_loop`. After the
518
- sentinel is consumed, the next emission tick synthesises
519
- ``_create_black_bundle()`` with ``duplicate=True`` — the
520
- correct pre-roll behaviour for a session boundary:
521
-
522
- * the wire callback (``_send_out_app_bundle_sync``) drops
523
- ``duplicate=True`` outright;
524
- * the recorder's ``mark_first_real_frame()`` latch is not
525
- tripped (REA-2323 / #2325), so the recording timeline only
526
- starts at the next real model frame.
527
-
528
- Safe to call from any thread; in practice ``flush()`` is
529
- called from the model thread (``model-run``), the same thread
530
- that calls :meth:`submit`, so there is no submit/flush race
531
- on the producer side either.
493
+ The emission thread performs the actual reset of
494
+ ``_last_emitted`` when it dequeues the sentinel. Then the
495
+ next tick emits a fresh black bundle tagged ``is_fresh_black``
496
+ forwarded by the wire, ignored by the recorder.
532
497
  """
533
498
  self._drain_queue()
534
499
  try:
535
500
  self._q.put_nowait(_FLUSH_MARKER)
536
501
  except queue.Full:
537
- # Unreachable in practice: the queue is bounded and the
538
- # only producer (submit) runs on the same thread as
539
- # flush, so nothing can refill the queue between drain
540
- # and put. Log loudly if it ever happens and continue —
541
- # the next real frame from the new session will set
542
- # _last_emitted correctly even if the sentinel never
543
- # lands.
502
+ # Same thread as submit(), so unreachable in practice.
544
503
  logger.warning(
545
504
  "OutputBuffer.flush: queue full immediately after drain; "
546
505
  "reset sentinel dropped"
@@ -250,10 +250,22 @@ class SessionRecorder:
250
250
  # OutputBuffer integration
251
251
  # ------------------------------------------------------------------
252
252
 
253
- def _on_bundle_sync(self, bundle: MediaBundle, duplicate: bool) -> None:
254
- """Enqueue a bundle for the feed worker; non-blocking."""
253
+ def _on_bundle_sync(
254
+ self,
255
+ bundle: MediaBundle,
256
+ duplicate: bool,
257
+ is_fresh_black: bool = False,
258
+ ) -> None:
259
+ """Enqueue a bundle for the feed worker; non-blocking.
260
+
261
+ Session-boundary synthesised black (``is_fresh_black``) is not
262
+ real model output and is invisible to the recording timeline:
263
+ no feed-queue write, no ``mark_first_real_frame`` consideration.
264
+ """
255
265
  if self._disabled or not self._started:
256
266
  return
267
+ if is_fresh_black:
268
+ return
257
269
  now = time.monotonic()
258
270
  if duplicate:
259
271
  if self._config.skip_leading_black and not self._markers.recording_started:
@@ -350,22 +350,19 @@ class Runtime(ModelStateMachine, ABC):
350
350
  logger.warning("Failed to send app message", error=e)
351
351
 
352
352
  def _send_out_app_bundle_sync(
353
- self, media_bundle: MediaBundle, duplicate: bool
353
+ self,
354
+ media_bundle: MediaBundle,
355
+ duplicate: bool,
356
+ is_fresh_black: bool = False,
354
357
  ) -> None:
355
- """Callback from the :class:`FrameBuffer` emission loop.
356
-
357
- Silently fails if the event loop is closed (expected during shutdown).
358
-
359
- When *duplicate* is ``True`` the bundle is a re-emission of the
360
- last frame (queue was empty, video-only). We skip forwarding
361
- entirely: the video track already serves the last pushed frame,
362
- and pushing the same frame again only wastes event-loop time.
358
+ """OutputBuffer callback forward real frames + session-boundary black.
363
359
 
364
- Args:
365
- media_bundle: The :class:`MediaBundle` to send.
366
- duplicate: True if this is a re-emission of the last frame.
360
+ Drops keepalive duplicates (the wire track already shows that
361
+ frame) but forwards ``is_fresh_black`` bundles so the client
362
+ transitions off the previous session's last frame on flush.
363
+ Silently fails if the event loop is closed (shutdown).
367
364
  """
368
- if duplicate:
365
+ if duplicate and not is_fresh_black:
369
366
  return
370
367
  if self.loop.is_closed():
371
368
  return
@@ -70,19 +70,26 @@ class HeadlessRuntime(Runtime):
70
70
  # Runtime API implementation - MODEL -> CLIENT (stdout)
71
71
  # ===============================
72
72
 
73
- def _send_out_app_bundle_sync(self, media_bundle, duplicate: bool) -> None:
74
- """Override base-class sync wrapper to forward duplicates.
75
-
76
- The base :class:`Runtime` drops duplicates because re-sending
77
- the same frame over WebRTC is wasteful. The headless runtime
78
- needs to see them so that ``send_out_app_bundle`` can count
79
- skipped duplicates for the ``ignore_duplicates`` feature.
73
+ def _send_out_app_bundle_sync(
74
+ self,
75
+ media_bundle,
76
+ duplicate: bool,
77
+ is_fresh_black: bool = False,
78
+ ) -> None:
79
+ """Forward every bundle to :meth:`send_out_app_bundle`.
80
+
81
+ Unlike the base class, headless does not drop duplicates here
82
+ — the async path counts them for the ``ignore_duplicates``
83
+ feature. Silently fails if the event loop is closed.
80
84
  """
81
85
  if self.loop.is_closed():
82
86
  return
83
87
  try:
84
88
  asyncio.run_coroutine_threadsafe(
85
- self.send_out_app_bundle(media_bundle, duplicate), self.loop
89
+ self.send_out_app_bundle(
90
+ media_bundle, duplicate, is_fresh_black=is_fresh_black
91
+ ),
92
+ self.loop,
86
93
  )
87
94
  except RuntimeError:
88
95
  pass
@@ -105,19 +112,30 @@ class HeadlessRuntime(Runtime):
105
112
  output = json.dumps(data, indent=2)
106
113
  print(f"\n[RUNTIME] {output}", flush=True)
107
114
 
108
- async def send_out_app_bundle(self, media_bundle, duplicate: bool) -> None:
109
- """
110
- Handle an output media bundle from the model.
111
- Extracts the video frame and writes it as a PNG file.
112
-
113
- Args:
114
- media_bundle: A :class:`MediaBundle` (video track extracted).
115
- duplicate: True if this is a re-emission of the last frame.
115
+ async def send_out_app_bundle(
116
+ self,
117
+ media_bundle,
118
+ duplicate: bool,
119
+ *,
120
+ is_fresh_black: bool = False,
121
+ ) -> None:
122
+ """Extract the video frame from ``media_bundle`` and write as PNG.
123
+
124
+ Skips ``is_fresh_black`` bundles (synthesised session-boundary
125
+ black, not model output) and — when ``ignore_duplicates`` is
126
+ set — ``duplicate`` keepalives.
116
127
  """
117
128
  if self.file_path is None:
118
129
  return
119
130
 
120
- # Skip duplicate frames (re-emissions from the frame buffer when it's empty)
131
+ if is_fresh_black:
132
+ self._duplicates_skipped += 1
133
+ logger.debug(
134
+ "Skipped fresh-black session-boundary frame",
135
+ total_skipped=self._duplicates_skipped,
136
+ )
137
+ return
138
+
121
139
  if self.config.ignore_duplicates and duplicate:
122
140
  self._duplicates_skipped += 1
123
141
  logger.debug(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactor_runtime
3
- Version: 2.7.6
3
+ Version: 2.7.7
4
4
  Summary: Reactor runtime with public model API
5
5
  Author-email: Reactor <team@reactor.inc>
6
6
  Requires-Python: >=3.9