reactor-runtime 2.7.0__tar.gz → 2.7.2__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 (134) hide show
  1. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/PKG-INFO +1 -1
  2. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/pyproject.toml +1 -1
  3. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/recording/chunk_uploader.py +37 -8
  4. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/recording/config.py +8 -0
  5. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/recording/markers.py +51 -5
  6. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/recording/session_recorder.py +53 -9
  7. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/utils/launch.py +4 -0
  8. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/utils/log.py +14 -4
  9. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime.egg-info/PKG-INFO +1 -1
  10. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/README.md +0 -0
  11. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/setup.cfg +0 -0
  12. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/api/__init__.py +0 -0
  13. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/__init__.py +0 -0
  14. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/config.py +0 -0
  15. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/__init__.py +0 -0
  16. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/defaults.py +0 -0
  17. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/driver/__init__.py +0 -0
  18. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/driver/pipeline_executor.py +0 -0
  19. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/driver/step_result.py +0 -0
  20. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/events/__init__.py +0 -0
  21. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/events/connected.py +0 -0
  22. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/events/event.py +0 -0
  23. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/events/messages.py +0 -0
  24. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/events/upload.py +0 -0
  25. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  26. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  27. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/internal/output_buffer.py +0 -0
  28. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  29. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/model/__init__.py +0 -0
  30. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/model/decorators.py +0 -0
  31. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/model/handlers.py +0 -0
  32. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  33. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  34. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  35. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  36. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  37. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  38. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  39. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/tracks/input.py +0 -0
  40. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/tracks/output.py +0 -0
  41. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/interface/upload.py +0 -0
  42. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/model_state.py +0 -0
  43. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/__init__.py +0 -0
  44. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/backends/__init__.py +0 -0
  45. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/backends/base.py +0 -0
  46. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/backends/file.py +0 -0
  47. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/backends/otlp.py +0 -0
  48. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/helpers.py +0 -0
  49. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/plotting/__init__.py +0 -0
  50. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/plotting/plot_profiling.py +0 -0
  51. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/profiler.py +0 -0
  52. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/profiling/singleton.py +0 -0
  53. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/recording/__init__.py +0 -0
  54. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  55. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/recording/sinks.py +0 -0
  56. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/recording/track_resolver.py +0 -0
  57. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/runtime_api.py +0 -0
  58. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/runtimes/headless/config.py +0 -0
  59. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/runtimes/headless/headless_runtime.py +0 -0
  60. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/runtimes/headless/input_feeder.py +0 -0
  61. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/runtimes/http/config.py +0 -0
  62. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/runtimes/http/http_runtime.py +0 -0
  63. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/runtimes/http/types.py +0 -0
  64. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/schema.py +0 -0
  65. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/schema_validator.py +0 -0
  66. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/serve/__init__.py +0 -0
  67. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/serve/commands/__init__.py +0 -0
  68. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/serve/commands/run.py +0 -0
  69. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/serve/commands/schema.py +0 -0
  70. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/serve/main.py +0 -0
  71. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/serve/utils/__init__.py +0 -0
  72. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/serve/utils/config.py +0 -0
  73. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/serve/utils/runtime.py +0 -0
  74. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/__init__.py +0 -0
  75. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/aiortc/__init__.py +0 -0
  76. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/aiortc/audio_track.py +0 -0
  77. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/aiortc/client.py +0 -0
  78. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/aiortc/frame_conversion.py +0 -0
  79. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/aiortc/ice_connection.py +0 -0
  80. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/aiortc/video_track.py +0 -0
  81. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/config.py +0 -0
  82. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/events.py +0 -0
  83. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/__init__.py +0 -0
  84. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/client.py +0 -0
  85. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/decoders/__init__.py +0 -0
  86. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/decoders/av1.py +0 -0
  87. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/decoders/base.py +0 -0
  88. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/decoders/factory.py +0 -0
  89. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/decoders/h264.py +0 -0
  90. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/decoders/h265.py +0 -0
  91. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/decoders/opus.py +0 -0
  92. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/decoders/vp8.py +0 -0
  93. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/decoders/vp9.py +0 -0
  94. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/encoders/__init__.py +0 -0
  95. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/encoders/av1.py +0 -0
  96. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/encoders/base.py +0 -0
  97. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/encoders/factory.py +0 -0
  98. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/encoders/h264.py +0 -0
  99. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/encoders/h265.py +0 -0
  100. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/encoders/opus.py +0 -0
  101. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/encoders/vp8.py +0 -0
  102. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/encoders/vp9.py +0 -0
  103. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/gst.py +0 -0
  104. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/gst_helpers.py +0 -0
  105. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/probes/__init__.py +0 -0
  106. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/probes/fps_probe.py +0 -0
  107. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/receiver/__init__.py +0 -0
  108. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/receiver/audio.py +0 -0
  109. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/receiver/base.py +0 -0
  110. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/receiver/video.py +0 -0
  111. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/sdp/__init__.py +0 -0
  112. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/sdp/bundle.py +0 -0
  113. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/sdp/codec.py +0 -0
  114. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/sdp/extmap.py +0 -0
  115. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/sdp/ice.py +0 -0
  116. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/sender/__init__.py +0 -0
  117. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/sender/audio.py +0 -0
  118. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/sender/base.py +0 -0
  119. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/sender/video.py +0 -0
  120. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/settings.py +0 -0
  121. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/gstreamer/signals.py +0 -0
  122. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/ice_uris.py +0 -0
  123. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/interface.py +0 -0
  124. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/media.py +0 -0
  125. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/transports/types.py +0 -0
  126. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/utils/loader.py +0 -0
  127. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/utils/messages.py +0 -0
  128. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/utils/paths.py +0 -0
  129. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/utils/ports.py +0 -0
  130. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime/utils/typing.py +0 -0
  131. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime.egg-info/SOURCES.txt +0 -0
  132. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime.egg-info/dependency_links.txt +0 -0
  133. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/src/reactor_runtime.egg-info/requires.txt +0 -0
  134. {reactor_runtime-2.7.0 → reactor_runtime-2.7.2}/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.0
3
+ Version: 2.7.2
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.0"
7
+ version = "2.7.2"
8
8
  description = "Reactor runtime with public model API"
9
9
  authors = [
10
10
  { name = "Reactor", email = "team@reactor.inc" }
@@ -66,6 +66,13 @@ class ChunkUploader:
66
66
  # math (``chunk_idx * chunk_seconds``) and the ``/clips``
67
67
  # manifest endpoint already assume. No translation needed.
68
68
  self._next_idx = 0
69
+ # Read by ``_run`` after the stop signal to decide whether the
70
+ # worker should drain leftover chunks before exiting. Set by
71
+ # ``stop()`` *before* signalling stop so the worker reads it
72
+ # consistently on its way out. See ``stop()`` docstring for
73
+ # why the drain has to happen on this thread rather than the
74
+ # caller's.
75
+ self._flush_on_exit = False
69
76
 
70
77
  def start(self) -> None:
71
78
  if self._thread is not None:
@@ -76,22 +83,38 @@ class ChunkUploader:
76
83
  )
77
84
  self._thread.start()
78
85
 
79
- def stop(self, *, flush_remaining: bool = True, timeout: float = 5.0) -> None:
86
+ def stop(self, *, flush_remaining: bool = True, timeout: float = 30.0) -> bool:
80
87
  """Signal the watcher to exit; optionally drain final chunks.
81
88
 
82
- Always invokes ``sink.close()`` after the drain so sinks that
83
- own external resources (httpx clients, connection pools, file
84
- handles) release them at the right point in the lifecycle.
85
- ``close`` is invoked even when ``flush_remaining=False`` so a
86
- caller that opts out of draining still releases the sink.
89
+ Returns ``True`` when the worker joined cleanly, ``False`` when
90
+ both join attempts timed out. Always closes the sink before
91
+ returning.
92
+
93
+ The drain runs on the worker thread; see REA-2268.
87
94
  """
95
+ self._flush_on_exit = flush_remaining
88
96
  self._stop.set()
89
97
  thread = self._thread
90
98
  self._thread = None
99
+ clean = True
91
100
  if thread is not None:
92
101
  thread.join(timeout=timeout)
93
- if flush_remaining:
94
- self._drain_final()
102
+ if thread.is_alive():
103
+ # Close the sink to cancel any in-flight presign future,
104
+ # then give the worker a short grace window to exit.
105
+ self._safe_close_sink()
106
+ thread.join(timeout=2.0)
107
+ if thread.is_alive():
108
+ clean = False
109
+ logger.error(
110
+ "ChunkUploader.stop join timed out; thread leaked",
111
+ timeout_s=timeout,
112
+ next_idx=self._next_idx,
113
+ )
114
+ self._safe_close_sink()
115
+ return clean
116
+
117
+ def _safe_close_sink(self) -> None:
95
118
  try:
96
119
  self._sink.close()
97
120
  except Exception:
@@ -103,6 +126,12 @@ class ChunkUploader:
103
126
  progress = self._tick()
104
127
  if not progress:
105
128
  self._stop.wait(self._poll_interval)
129
+ # Final drain happens here, in the worker thread, so the
130
+ # caller-side ``stop()`` doesn't accidentally drive
131
+ # ``run_coroutine_threadsafe(...).result()`` from the
132
+ # asyncio loop thread. See ``stop()`` docstring.
133
+ if self._flush_on_exit:
134
+ self._drain_final()
106
135
  except Exception:
107
136
  logger.exception("ChunkUploader thread crashed")
108
137
 
@@ -91,6 +91,11 @@ class RecordingConfig:
91
91
  # ``reactor.yaml``.
92
92
  chunk_seconds: int = 4
93
93
  clip_max_seconds: int = 300
94
+ # Trim the session-start pre-roll from clip playlists by clamping
95
+ # ``start_marker`` forward to the first real (non-duplicate) frame
96
+ # the recorder observes. Set to ``False`` to keep that pre-roll
97
+ # in clips.
98
+ skip_leading_black: bool = True
94
99
  video_track: Optional[str] = None
95
100
  audio_track: Optional[str] = None
96
101
  video: VideoEncoderConfig = field(default_factory=VideoEncoderConfig)
@@ -105,6 +110,9 @@ class RecordingConfig:
105
110
  enabled=bool(raw.get("enabled", cls.enabled)),
106
111
  chunk_seconds=int(raw.get("chunk_seconds", cls.chunk_seconds)),
107
112
  clip_max_seconds=int(raw.get("clip_max_seconds", cls.clip_max_seconds)),
113
+ skip_leading_black=bool(
114
+ raw.get("skip_leading_black", cls.skip_leading_black)
115
+ ),
108
116
  video_track=raw.get("video_track"),
109
117
  audio_track=raw.get("audio_track"),
110
118
  video=VideoEncoderConfig.from_dict(raw.get("video") or {}),
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import threading
14
14
  import time
15
- from typing import Tuple
15
+ from typing import Optional, Tuple
16
16
 
17
17
 
18
18
  class MarkerBookkeeper:
@@ -26,6 +26,10 @@ class MarkerBookkeeper:
26
26
  def __init__(self) -> None:
27
27
  self._session_start = time.monotonic()
28
28
  self._last_uploaded_chunk_idx: int = -1
29
+ # Sticky marker latched by :meth:`mark_first_real_frame`;
30
+ # ``None`` until the recorder sees its first non-duplicate
31
+ # bundle.
32
+ self._first_real_frame_marker: Optional[float] = None
29
33
  self._lock = threading.Lock()
30
34
 
31
35
  def now_marker(self) -> float:
@@ -38,6 +42,13 @@ class MarkerBookkeeper:
38
42
  with self._lock:
39
43
  return self._last_uploaded_chunk_idx
40
44
 
45
+ @property
46
+ def first_real_frame_marker(self) -> Optional[float]:
47
+ """Wall-clock of the first real (non-duplicate) bundle, or
48
+ ``None`` if no real frame has landed yet."""
49
+ with self._lock:
50
+ return self._first_real_frame_marker
51
+
41
52
  def mark_chunk_uploaded(self, idx: int) -> None:
42
53
  """Advance ``last_uploaded_chunk_idx`` monotonically.
43
54
 
@@ -48,7 +59,20 @@ class MarkerBookkeeper:
48
59
  if idx > self._last_uploaded_chunk_idx:
49
60
  self._last_uploaded_chunk_idx = idx
50
61
 
51
- def compute_clip_range(self, duration_seconds: float) -> Tuple[float, float]:
62
+ def mark_first_real_frame(self) -> None:
63
+ """Latch the wall-clock at which the first real frame was seen.
64
+
65
+ Idempotent and one-shot — only the first call has an effect,
66
+ so the marker pins the session-start pre-roll and is never
67
+ moved by later idle periods.
68
+ """
69
+ with self._lock:
70
+ if self._first_real_frame_marker is None:
71
+ self._first_real_frame_marker = time.monotonic() - self._session_start
72
+
73
+ def compute_clip_range(
74
+ self, duration_seconds: float, *, skip_leading_black: bool = False
75
+ ) -> Tuple[float, float]:
52
76
  """Resolve ``requestClip`` to a ``(start_marker, end_marker)`` pair.
53
77
 
54
78
  ``end_marker`` is the wall-clock at request time (``now_marker``),
@@ -59,11 +83,33 @@ class MarkerBookkeeper:
59
83
  polls until ``200``. This keeps the runtime's response
60
84
  latency at one round-trip regardless of where in the chunk
61
85
  cycle the client asks.
86
+
87
+ When ``skip_leading_black`` is true and the first-real-frame
88
+ marker has been latched, ``start`` is clamped forward to that
89
+ marker. Without the latch the clamp is skipped so callers
90
+ always receive a non-empty range.
62
91
  """
63
92
  end_marker = self.now_marker()
64
93
  start_marker = max(0.0, end_marker - duration_seconds)
94
+ if skip_leading_black:
95
+ with self._lock:
96
+ first_real = self._first_real_frame_marker
97
+ if first_real is not None:
98
+ start_marker = max(start_marker, first_real)
65
99
  return start_marker, end_marker
66
100
 
67
- def compute_recording_range(self) -> Tuple[float, float]:
68
- """``requestRecording`` is just a clip with ``start = 0``."""
69
- return 0.0, self.now_marker()
101
+ def compute_recording_range(
102
+ self, *, skip_leading_black: bool = False
103
+ ) -> Tuple[float, float]:
104
+ """``requestRecording`` is just a clip from session start to
105
+ now. Honours ``skip_leading_black`` with the same fallback
106
+ as :meth:`compute_clip_range`.
107
+ """
108
+ end_marker = self.now_marker()
109
+ start_marker = 0.0
110
+ if skip_leading_black:
111
+ with self._lock:
112
+ first_real = self._first_real_frame_marker
113
+ if first_real is not None:
114
+ start_marker = first_real
115
+ return start_marker, end_marker
@@ -86,6 +86,11 @@ class ClipResult:
86
86
  now_marker: float
87
87
  predicted_ready_at_ms: int
88
88
  playlist_url: str
89
+ # Wall-clock of the first real (non-duplicate) frame the recorder
90
+ # observed, or ``None`` if none has landed yet. Surfaced for
91
+ # observability; ``start_marker`` already uses it when
92
+ # ``RecordingConfig.skip_leading_black`` is on.
93
+ first_real_frame_marker: Optional[float] = None
89
94
 
90
95
  def to_dict(self) -> dict:
91
96
  return {
@@ -96,6 +101,7 @@ class ClipResult:
96
101
  "now_marker": self.now_marker,
97
102
  "predicted_ready_at_ms": self.predicted_ready_at_ms,
98
103
  "playlist_url": self.playlist_url,
104
+ "first_real_frame_marker": self.first_real_frame_marker,
99
105
  }
100
106
 
101
107
 
@@ -215,9 +221,18 @@ class SessionRecorder:
215
221
  )
216
222
 
217
223
  def stop(self) -> None:
218
- """Cleanly tear down feed worker, encoder, and uploader."""
224
+ """Tear down feed worker, encoder, and uploader.
225
+
226
+ Emits a structured ``SessionRecorder stopped`` log with
227
+ ``clean=…`` and per-thread leak flags so failed joins surface
228
+ in monitoring instead of leaking ffmpeg + uploader threads
229
+ silently.
230
+ """
219
231
  if not self._started:
220
232
  return
233
+ # Disable callbacks first so a racing _on_bundle_sync becomes
234
+ # a no-op even before its OutputBuffer subscription is removed.
235
+ self._disabled = True
221
236
  # Signal the feed loop to drain and exit; sentinel unblocks .get().
222
237
  self._feed_stop.set()
223
238
  try:
@@ -227,10 +242,14 @@ class SessionRecorder:
227
242
  pass
228
243
  feed_thread = self._feed_thread
229
244
  self._feed_thread = None
245
+ # Stop the encoder before joining the feed worker: the worker's
246
+ # blocking call is ``os.write()`` into the ffmpeg pipe and only
247
+ # unblocks when the pipe is closed.
248
+ self._encoder.stop()
230
249
  if feed_thread is not None:
231
250
  feed_thread.join(timeout=2.0)
232
- self._encoder.stop()
233
- self._uploader.stop(flush_remaining=True)
251
+ feed_leaked = feed_thread is not None and feed_thread.is_alive()
252
+ uploader_clean = self._uploader.stop(flush_remaining=True)
234
253
  self._started = False
235
254
  # Cleanup the encoder's transient working dir. Chunks that
236
255
  # made it to the sink are already copied to the served dir;
@@ -244,11 +263,16 @@ class SessionRecorder:
244
263
  "Failed to clean encoder work dir; leaking",
245
264
  work_dir=str(self._work_dir),
246
265
  )
247
- logger.info(
266
+ clean = not feed_leaked and uploader_clean
267
+ log = logger.info if clean else logger.error
268
+ log(
248
269
  "SessionRecorder stopped",
249
270
  session_id=self._session_id,
250
271
  last_uploaded_chunk_idx=self._markers.last_uploaded_chunk_idx,
251
272
  dropped=self._dropped_frames,
273
+ clean=clean,
274
+ feed_leaked=feed_leaked,
275
+ uploader_leaked=not uploader_clean,
252
276
  )
253
277
 
254
278
  # ------------------------------------------------------------------
@@ -276,6 +300,9 @@ class SessionRecorder:
276
300
  if self._disabled or not self._started:
277
301
  return
278
302
  now = time.monotonic()
303
+ if not duplicate:
304
+ # Idempotent latch; only the first call takes effect.
305
+ self._markers.mark_first_real_frame()
279
306
  if duplicate:
280
307
  if (
281
308
  self._keepalive_interval <= 0.0
@@ -295,8 +322,17 @@ class SessionRecorder:
295
322
  audio_td = bundle.tracks.get(self._audio_track)
296
323
  if audio_td is not None:
297
324
  audio_data = audio_td.data
325
+ # The OutputBuffer's cache is per-model, so the first
326
+ # duplicates of a new session can carry the previous session's
327
+ # last frame. Black-fill them until a real frame lands so the
328
+ # leading pre-roll is genuinely black rather than stale content.
329
+ video_data = video_td.data
330
+ if duplicate and self._markers.first_real_frame_marker is None:
331
+ video_data = np.zeros_like(video_data)
332
+ if audio_data is not None:
333
+ audio_data = np.zeros_like(audio_data)
298
334
  try:
299
- self._feed_queue.put_nowait((video_td.data, audio_data))
335
+ self._feed_queue.put_nowait((video_data, audio_data))
300
336
  except queue.Full:
301
337
  self._dropped_frames += 1
302
338
  # Cold-start can fill the queue for a few hundred frames;
@@ -428,23 +464,30 @@ class SessionRecorder:
428
464
  promise includes the chunk ffmpeg is currently writing. The
429
465
  ``/clips`` manifest endpoint will ``202 Retry-After`` until
430
466
  that chunk lands; the SDK polls until ``200``.
467
+
468
+ Honours :attr:`RecordingConfig.skip_leading_black`.
431
469
  """
432
470
  if self.disabled:
433
471
  raise RecorderDisabledError("recorder disabled or encoder crashed")
434
472
  capped = min(float(duration_seconds), float(self._config.clip_max_seconds))
435
473
  if capped <= 0:
436
474
  raise ValueError("duration_seconds must be positive")
437
- start, end = self._markers.compute_clip_range(capped)
475
+ start, end = self._markers.compute_clip_range(
476
+ capped, skip_leading_black=self._config.skip_leading_black
477
+ )
438
478
  return self._build_result(kind=kind, start=start, end=end)
439
479
 
440
480
  def request_recording(self) -> ClipResult:
441
- """Resolve a full-recording request; ``start = 0``, ``end = now``.
481
+ """Resolve a full-recording request; ``end = now``.
442
482
 
443
- Same promise-then-poll semantics as :meth:`request_clip`.
483
+ Same promise-then-poll semantics as :meth:`request_clip` and
484
+ honours :attr:`RecordingConfig.skip_leading_black`.
444
485
  """
445
486
  if self.disabled:
446
487
  raise RecorderDisabledError("recorder disabled or encoder crashed")
447
- start, end = self._markers.compute_recording_range()
488
+ start, end = self._markers.compute_recording_range(
489
+ skip_leading_black=self._config.skip_leading_black
490
+ )
448
491
  return self._build_result(kind="recording", start=start, end=end)
449
492
 
450
493
  def _build_result(self, kind: str, start: float, end: float) -> ClipResult:
@@ -481,4 +524,5 @@ class SessionRecorder:
481
524
  now_marker=now,
482
525
  predicted_ready_at_ms=predicted_ready_at_ms,
483
526
  playlist_url=playlist_url,
527
+ first_real_frame_marker=self._markers.first_real_frame_marker,
484
528
  )
@@ -42,6 +42,10 @@ def configure_logging(level: str, show_timestamps: bool = True) -> None:
42
42
  logging.getLogger("uvicorn").setLevel(logging.WARNING)
43
43
  logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
44
44
  logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
45
+ # Per-request httpx INFO logs would emit one line per recorded
46
+ # chunk and embed presigned credentials in the URL.
47
+ logging.getLogger("httpx").setLevel(logging.WARNING)
48
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
45
49
 
46
50
 
47
51
  def _infer_runtime_name(serve_fn: Callable) -> str:
@@ -35,9 +35,19 @@ _LEVEL_COLORS: dict[int, str] = {
35
35
  }
36
36
 
37
37
 
38
- # `reactor_runtime.serve.*` is matched by the `reactor_runtime.` prefix, so
39
- # a single entry covers both the library and the serve entrypoint subpackage.
40
- _RUNTIME_PREFIXES = ("reactor_runtime.",)
38
+ # Loggers under these prefixes are not model code and render without the
39
+ # cyan ``MDL`` tag. Update when adopting a new dependency that emits
40
+ # through stdlib ``logging``.
41
+ _NON_MODEL_PREFIXES = (
42
+ "reactor_runtime.",
43
+ "opentelemetry.",
44
+ "aiortc.",
45
+ "aioice.",
46
+ "av.",
47
+ "httpx",
48
+ "httpcore",
49
+ "uvicorn",
50
+ )
41
51
  _MODEL_COLOR = "\033[36m"
42
52
 
43
53
 
@@ -50,7 +60,7 @@ class ColorFormatter(logging.Formatter):
50
60
  orig = record.levelname
51
61
  color = _LEVEL_COLORS.get(record.levelno, "")
52
62
 
53
- if record.name.startswith(_RUNTIME_PREFIXES):
63
+ if record.name.startswith(_NON_MODEL_PREFIXES):
54
64
  pad = self._COL - len(orig)
55
65
  record.levelname = f"{color}{orig}{_RESET}{' ' * pad}"
56
66
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactor_runtime
3
- Version: 2.7.0
3
+ Version: 2.7.2
4
4
  Summary: Reactor runtime with public model API
5
5
  Author-email: Reactor <team@reactor.inc>
6
6
  Requires-Python: >=3.9