reactor-runtime 3.7.0__tar.gz → 3.8.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 (127) hide show
  1. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/PKG-INFO +1 -1
  2. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/pyproject.toml +1 -1
  3. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/pyproject.toml.orig +1 -1
  4. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/core/__init__.py +5 -1
  5. reactor_runtime-3.8.0/src/reactor_runtime/core/stats.py +55 -0
  6. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/core/transport.py +24 -1
  7. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/core/values.py +5 -0
  8. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/app/reactor_app.py +8 -1
  9. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/internal/reactor_core.py +20 -1
  10. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/runner/connection_manager.py +16 -0
  11. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/runner/runner.py +85 -4
  12. reactor_runtime-3.8.0/src/reactor_runtime/runner/runtime_stats.py +204 -0
  13. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/connection.py +17 -0
  14. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/peer.py +50 -13
  15. reactor_runtime-3.8.0/src/reactor_runtime/transport/webrtc/reading.py +124 -0
  16. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/stats.py +23 -1
  17. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/LICENSE +0 -0
  18. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/NOTICE +0 -0
  19. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/README.md +0 -0
  20. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/__init__.py +0 -0
  21. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/codes.py +0 -0
  22. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/core/fields.py +0 -0
  23. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/core/model.py +0 -0
  24. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/core/naming.py +0 -0
  25. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/core/service.py +0 -0
  26. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/core/session.py +0 -0
  27. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/core/typespec.py +0 -0
  28. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/distributed/__init__.py +0 -0
  29. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/distributed/errors.py +0 -0
  30. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/distributed/ipc.py +0 -0
  31. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/distributed/protocol.py +0 -0
  32. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/distributed/rank.py +0 -0
  33. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/distributed/runner.py +0 -0
  34. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/distributed/worker.py +0 -0
  35. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/event_stream.py +0 -0
  36. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/http/__init__.py +0 -0
  37. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/http/events.py +0 -0
  38. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/http/routes.py +0 -0
  39. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/http/server.py +0 -0
  40. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/http/spec.py +0 -0
  41. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/__init__.py +0 -0
  42. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/app/__init__.py +0 -0
  43. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/app/input_state.py +0 -0
  44. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/app/outcome.py +0 -0
  45. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/app/step_completed.py +0 -0
  46. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/client.py +0 -0
  47. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/events/__init__.py +0 -0
  48. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/events/decorators.py +0 -0
  49. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/events/errors.py +0 -0
  50. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/events/messages.py +0 -0
  51. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  52. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/internal/aliases.py +0 -0
  53. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/internal/bridge.py +0 -0
  54. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  55. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/model/__init__.py +0 -0
  56. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/model/contract.py +0 -0
  57. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  58. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/model/schema.py +0 -0
  59. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  60. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  61. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  62. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  63. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  64. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  65. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/tracks/input.py +0 -0
  66. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/interface/tracks/output.py +0 -0
  67. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/log.py +0 -0
  68. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/manifest.py +0 -0
  69. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/message_gateway.py +0 -0
  70. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/metrics/__init__.py +0 -0
  71. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/metrics/command.py +0 -0
  72. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/metrics/model.py +0 -0
  73. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/metrics/registry.py +0 -0
  74. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/metrics/session.py +0 -0
  75. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/paths.py +0 -0
  76. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/protocol/__init__.py +0 -0
  77. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/protocol/base.py +0 -0
  78. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/protocol/common.py +0 -0
  79. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  80. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/protocol/v0/codec.py +0 -0
  81. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  82. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  83. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/py.typed +0 -0
  84. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/recording/__init__.py +0 -0
  85. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  86. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/recording/markers.py +0 -0
  87. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/recording/recorder.py +0 -0
  88. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/runner/__init__.py +0 -0
  89. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/runner/client_stats.py +0 -0
  90. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/runner/offer_epochs.py +0 -0
  91. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/runner/session_start.py +0 -0
  92. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/runner/state_machine.py +0 -0
  93. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/runner/system_client.py +0 -0
  94. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/runner/upload_resolution.py +0 -0
  95. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/schema.py +0 -0
  96. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/serve.py +0 -0
  97. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/service.py +0 -0
  98. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/step_results/__init__.py +0 -0
  99. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/step_results/mp4.py +0 -0
  100. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/step_results/store.py +0 -0
  101. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/__init__.py +0 -0
  102. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/acceptor.py +0 -0
  103. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/router.py +0 -0
  104. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/__init__.py +0 -0
  105. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/acceptor.py +0 -0
  106. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/client_stats.py +0 -0
  107. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/config.py +0 -0
  108. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  109. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/metrics.py +0 -0
  110. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
  111. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/router.py +0 -0
  112. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
  113. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
  114. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  115. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_runtime/upload_store.py +0 -0
  116. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/common_pb2.py +0 -0
  117. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  118. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/control_pb2.py +0 -0
  119. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/control_pb2.pyi +0 -0
  120. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/data_pb2.py +0 -0
  121. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  122. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/model_pb2.py +0 -0
  123. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  124. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/platform_pb2.py +0 -0
  125. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
  126. {reactor_runtime-3.7.0 → reactor_runtime-3.8.0}/src/reactor_wire/v1/track_pb2.py +0 -0
  127. {reactor_runtime-3.7.0 → reactor_runtime-3.8.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.7.0
3
+ Version: 3.8.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>
@@ -95,7 +95,7 @@ pythonpath = ["."]
95
95
 
96
96
  [project]
97
97
  name = "reactor-runtime"
98
- version = "3.7.0"
98
+ version = "3.8.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"
@@ -20,7 +20,7 @@ version = "1.20260929.8"
20
20
 
21
21
  [project]
22
22
  name = "reactor-runtime"
23
- version = "3.7.0"
23
+ version = "3.8.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"
@@ -33,7 +33,8 @@ from reactor_runtime.core.session import (
33
33
  SessionState,
34
34
  Transition,
35
35
  )
36
- from reactor_runtime.core.transport import Connection, ConnectionSink
36
+ from reactor_runtime.core.stats import TransportReading, TransportTrackReading
37
+ from reactor_runtime.core.transport import Connection, ConnectionSink, TransportStatsSource
37
38
  from reactor_runtime.core.typespec import TypeSpec
38
39
  from reactor_runtime.core.values import (
39
40
  ClientConnectionStat,
@@ -98,6 +99,9 @@ __all__ = [
98
99
  "TrackKind",
99
100
  "Transition",
100
101
  "TransitionEvent",
102
+ "TransportReading",
103
+ "TransportStatsSource",
104
+ "TransportTrackReading",
101
105
  "TypeSpec",
102
106
  "UploadedFile",
103
107
  ]
@@ -0,0 +1,55 @@
1
+ """Readings a transport takes of its own wire.
2
+
3
+ A transport that measures its wire describes what it measured in these types,
4
+ which follow a client's own stats reading (:class:`~reactor_runtime.core.ClientTrackStat`):
5
+ what is fixed about a track stays a field, and the measurements go in an open
6
+ ``metrics`` mapping. Each transport names what it measures, so a new transport
7
+ reports its own measurements without a change here, and a reader holds the same
8
+ shape whichever transport took the reading.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass, field
15
+
16
+ from reactor_runtime.core.values import ClientTrackDirection, TrackKind
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TransportTrackReading:
21
+ """What a transport measured of one track on one connection.
22
+
23
+ Attributes:
24
+ track_name: The track the reading is for.
25
+ kind: The track's kind; ``None`` when the transport does not know it.
26
+ direction: The track's direction as the client sees it: a model
27
+ output track is one the client receives.
28
+ codec: The negotiated codec, e.g. ``"VP9"``, ``"opus"``; empty when
29
+ the transport has not reported one.
30
+ metrics: The measurements, by name. Names match a client reading's
31
+ where both measure the same thing (``"bitrate_bps"``,
32
+ ``"packets_lost"``, ``"frames_per_second"``), and a measurement the
33
+ transport has no value for is left out rather than reported as
34
+ zero.
35
+ """
36
+
37
+ track_name: str
38
+ kind: TrackKind | None
39
+ direction: ClientTrackDirection
40
+ codec: str = ""
41
+ metrics: Mapping[str, float] = field(default_factory=dict)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TransportReading:
46
+ """What a transport measured of one connection's wire at one moment.
47
+
48
+ Attributes:
49
+ metrics: Measurements of the connection as a whole, by name, e.g.
50
+ ``"connection_rtt_ms"`` or ``"available_outgoing_bitrate_bps"``.
51
+ tracks: One reading per track the transport measured.
52
+ """
53
+
54
+ metrics: Mapping[str, float] = field(default_factory=dict)
55
+ tracks: tuple[TransportTrackReading, ...] = ()
@@ -4,7 +4,8 @@ The two structural protocols that form the seam between a transport and the
4
4
  runner, pointing opposite ways. ``Connection`` carries commands down to one
5
5
  client; ``ConnectionSink`` carries facts up from the transport. Both are
6
6
  ``Protocol``s, so a transport author or a test fake conforms by shape and
7
- signaling never reaches the runner.
7
+ signaling never reaches the runner. ``TransportStatsSource`` is an optional
8
+ capability a connection adds when its transport measures its wire.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -12,6 +13,7 @@ from __future__ import annotations
12
13
  from collections.abc import Mapping
13
14
  from typing import Protocol, runtime_checkable
14
15
 
16
+ from reactor_runtime.core.stats import TransportReading
15
17
  from reactor_runtime.core.values import (
16
18
  ClientStatsBatch,
17
19
  ConnectionCapabilities,
@@ -95,6 +97,27 @@ class Connection(Protocol):
95
97
  """Tear the connection down."""
96
98
 
97
99
 
100
+ @runtime_checkable
101
+ class TransportStatsSource(Protocol):
102
+ """A connection whose transport samples statistics about its own wire.
103
+
104
+ Optional, beside :class:`Connection`: a transport that measures its wire
105
+ implements it on its connections, and a reader checks for it with
106
+ ``isinstance`` before reading. A connection without it is a connection
107
+ with nothing to report, not an error, so a transport that predates it
108
+ keeps working unchanged.
109
+ """
110
+
111
+ @property
112
+ def latest_reading(self) -> TransportReading | None:
113
+ """The most recent reading of this connection's wire.
114
+
115
+ ``None`` before the transport has taken its first. The reading is
116
+ replaced as the transport takes new ones, so reading it never waits on
117
+ the wire.
118
+ """
119
+
120
+
98
121
  @runtime_checkable
99
122
  class ConnectionSink(Protocol):
100
123
  """The transport-to-runner upward channel — the facts-up half.
@@ -410,12 +410,17 @@ class MediaChunk:
410
410
  wait: Whether a consumer with a bounded queue should make the producer
411
411
  wait for room (backpressure, throttling the model to the playout
412
412
  rate) instead of dropping the overflow.
413
+ session: The number of the session the model was in when it emitted
414
+ the chunk, counted on the model's side, or ``None`` when the
415
+ emitter does not count sessions. A consumer compares it with its
416
+ own count to tell a late chunk of an earlier session apart.
413
417
  """
414
418
 
415
419
  bundle: MediaBundle
416
420
  fps: float
417
421
  n_frames: int = 1
418
422
  wait: bool = False
423
+ session: int | None = None
419
424
 
420
425
  def frames(self) -> list[MediaBundle]:
421
426
  """Split the chunk into one single-frame bundle per carried frame."""
@@ -74,6 +74,7 @@ from reactor_runtime.interface.events.decorators import (
74
74
  from reactor_runtime.interface.events.errors import ApplicationError, CommandError
75
75
  from reactor_runtime.interface.events.messages import ModelMessage
76
76
  from reactor_runtime.interface.internal.reactor_core import (
77
+ STEP_SESSION,
77
78
  CommandEnvelope,
78
79
  ReactorCore,
79
80
  RequestId,
@@ -353,7 +354,13 @@ class ReactorApp(ReactorCore):
353
354
  rate = None
354
355
  if report.output is not None:
355
356
  pace = None if fps_pinned else outcome.elapsed
356
- await self.emit(report.output, compute_time=pace)
357
+ # The media is the step's, so it carries the
358
+ # session the step began in, as the report does.
359
+ token = STEP_SESSION.set(session)
360
+ try:
361
+ await self.emit(report.output, compute_time=pace)
362
+ finally:
363
+ STEP_SESSION.reset(token)
357
364
  frames = self._to_bundle(report.output).frame_count
358
365
  rate = self._playout_rate(frames, pace)
359
366
  self._report_step(report, session, rate)
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import asyncio
14
14
  import contextlib
15
+ import contextvars
15
16
  import json
16
17
  import logging
17
18
  import threading
@@ -50,6 +51,17 @@ RequestId = str
50
51
 
51
52
  _Holder = TypeVar("_Holder")
52
53
 
54
+ STEP_SESSION: contextvars.ContextVar[int | None] = contextvars.ContextVar(
55
+ "reactor_step_session", default=None
56
+ )
57
+ """The session the step being emitted began in, set by the step loop around its emit.
58
+
59
+ A step reports the session it began in, and its media is stamped with the
60
+ same number, so both count toward one session even when the session changes
61
+ while the step emits. An emit made outside a step leaves it unset and is
62
+ stamped with the session the model is in when it emits.
63
+ """
64
+
53
65
  BroadcastSink = Callable[[ModelMessage], None]
54
66
 
55
67
  AddressedSink = Callable[[ConnId, "ModelMessage | CommandFailure | None", RequestId | None], None]
@@ -167,7 +179,14 @@ class OutputStream:
167
179
  fps = core._playout_rate(n_frames, compute_time)
168
180
  core._last_emit_fps = fps
169
181
  if core._out_media is not None:
170
- chunk = MediaChunk(bundle=bundle, fps=fps, n_frames=n_frames, wait=not drop)
182
+ session = STEP_SESSION.get()
183
+ chunk = MediaChunk(
184
+ bundle=bundle,
185
+ fps=fps,
186
+ n_frames=n_frames,
187
+ wait=not drop,
188
+ session=core._sessions_started if session is None else session,
189
+ )
171
190
  await asyncio.to_thread(core._out_media, chunk)
172
191
  await asyncio.sleep(0)
173
192
 
@@ -48,6 +48,8 @@ from reactor_runtime.core import (
48
48
  ConnId,
49
49
  MediaChunk,
50
50
  SessionEvent,
51
+ TransportReading,
52
+ TransportStatsSource,
51
53
  )
52
54
  from reactor_runtime.log import get_logger
53
55
  from reactor_runtime.protocol import Channel, ProtocolVersion
@@ -139,6 +141,20 @@ class ConnectionManager:
139
141
  """The number of connections currently registered."""
140
142
  return len(self._by_id)
141
143
 
144
+ def transport_readings(self) -> list[tuple[ConnId, TransportReading]]:
145
+ """The latest transport reading of every connection that has one.
146
+
147
+ A connection whose transport has not taken a reading yet, or does not
148
+ measure its wire (it is not a :class:`TransportStatsSource`), is left
149
+ out.
150
+ """
151
+ return [
152
+ (cid, reading)
153
+ for cid, conn in self._by_id.items()
154
+ if isinstance(conn, TransportStatsSource)
155
+ and (reading := conn.latest_reading) is not None
156
+ ]
157
+
142
158
  def new_conn_id(self) -> ConnId:
143
159
  """Mint a fresh random connection id, unique within the session.
144
160
 
@@ -51,6 +51,7 @@ from reactor_runtime.core import (
51
51
  SessionState,
52
52
  StartingInputApplied,
53
53
  TrackDirection,
54
+ TrackKind,
54
55
  Transition,
55
56
  TransitionEvent,
56
57
  TypeSpec,
@@ -73,10 +74,11 @@ from reactor_runtime.metrics import (
73
74
  )
74
75
  from reactor_runtime.protocol import Channel, Codec, ProtocolVersion, select
75
76
  from reactor_runtime.recording import ClipResult, Recorder, RecorderError
76
- from reactor_runtime.runner import client_stats
77
+ from reactor_runtime.runner import client_stats, runtime_stats
77
78
  from reactor_runtime.runner.client_stats import ClientStatsGate
78
79
  from reactor_runtime.runner.connection_manager import ConnectionManager
79
80
  from reactor_runtime.runner.offer_epochs import OfferEpochs
81
+ from reactor_runtime.runner.runtime_stats import ModelOutput
80
82
  from reactor_runtime.runner.session_start import (
81
83
  InvalidSessionStartError,
82
84
  SessionStart,
@@ -241,6 +243,11 @@ class Runner(ServiceComponent, ConnectionSink):
241
243
  self._sm.on_transition(self._metrics_recorder.observe)
242
244
  self._command_metrics = CommandMetrics(self._metrics)
243
245
  self._model_metrics = ModelMetrics(self._metrics)
246
+ # The same emissions, counted per session for the runtime's own stats.
247
+ self._model_output = ModelOutput()
248
+ self._output_kinds: dict[str, TrackKind] = {}
249
+ self._runtime_stats_interval = runtime_stats.INTERVAL_SECONDS
250
+ self._runtime_stats_task: asyncio.Task[None] | None = None
244
251
  self._events = EventStream()
245
252
  self._uploads = UploadStore()
246
253
  self._recorder = Recorder(
@@ -377,9 +384,13 @@ class Runner(ServiceComponent, ConnectionSink):
377
384
  self._model_metrics.loaded(since=started_at)
378
385
  self._bridge = bridge
379
386
  self._command_metrics.declare(contract.commands)
380
- self._model_metrics.declare(
381
- name for name, info in contract.tracks.items() if info.direction is TrackDirection.OUT
382
- )
387
+ self._output_kinds = {
388
+ name: info.kind
389
+ for name, info in contract.tracks.items()
390
+ if info.direction is TrackDirection.OUT
391
+ }
392
+ self._model_metrics.declare(self._output_kinds.keys())
393
+ self._model_output.declare(self._output_kinds.keys())
383
394
  self._sm.send(SessionEvent.INITIALIZATION_SUCCESS)
384
395
  logger.info(
385
396
  "model loaded; session ready",
@@ -414,6 +425,11 @@ class Runner(ServiceComponent, ConnectionSink):
414
425
  thing released even when ``stop`` races a session that is still closing.
415
426
  """
416
427
  self._cancel_orphan_timeout()
428
+ task = self._cancel_runtime_stats()
429
+ if task is not None:
430
+ # Waits for the reporter to finish without raising its
431
+ # cancellation, which it never caught if it had not started yet.
432
+ await asyncio.wait([task])
417
433
  await self._drain_teardown()
418
434
  await asyncio.to_thread(self._recorder.close)
419
435
  if self._step_store is not None:
@@ -1295,6 +1311,10 @@ class Runner(ServiceComponent, ConnectionSink):
1295
1311
  """
1296
1312
  for track in chunk.bundle.tracks:
1297
1313
  self._model_metrics.emitted(track, chunk.n_frames)
1314
+ # A chunk the model emitted for an earlier session (from its
1315
+ # session-end hook, say, after the next session started) still
1316
+ # plays out, but it is not this session's output.
1317
+ self._model_output.emitted(track, chunk.n_frames, chunk.session)
1298
1318
  generation = self._media_generation
1299
1319
  self._connections.broadcast_media(chunk, abort=lambda: self._media_generation != generation)
1300
1320
  # The archive takes the whole chunk even when a flush cut the broadcast
@@ -1522,6 +1542,7 @@ class Runner(ServiceComponent, ConnectionSink):
1522
1542
  entered = transition.from_state is not transition.to_state
1523
1543
  if entered:
1524
1544
  self._reset_orphan_timeout(transition.to_state)
1545
+ self._sync_runtime_stats(transition.to_state)
1525
1546
  if entered and transition.to_state is SessionState.CLOSING and self._loop is not None:
1526
1547
  reason = transition.detail.get("reason", EndReason.STOPPED)
1527
1548
  close_reason = transition.detail.get("close_reason", "")
@@ -1654,6 +1675,9 @@ class Runner(ServiceComponent, ConnectionSink):
1654
1675
  """
1655
1676
  if transition.is_session_start:
1656
1677
  self._sessions_posted += 1
1678
+ # Before the model hears of the session: from then on its thread
1679
+ # can emit, and every frame it emits for this session counts.
1680
+ self._model_output.reset(self._sessions_posted)
1657
1681
  bridge.dispatch_reactor_event(
1658
1682
  SessionStarted(
1659
1683
  self._session_id,
@@ -1714,6 +1738,63 @@ class Runner(ServiceComponent, ConnectionSink):
1714
1738
  return
1715
1739
  self._sm.send(SessionEvent.TIMEOUT, reason=EndReason.TIMED_OUT)
1716
1740
 
1741
+ # -- runtime stats --------------------------------------------------------
1742
+
1743
+ def _sync_runtime_stats(self, state: SessionState) -> None:
1744
+ """Run the runtime stats reporter while a session runs, and only then."""
1745
+ if state in _RUNNING_STATES:
1746
+ if self._runtime_stats_task is None and self._loop is not None:
1747
+ self._runtime_stats_task = self._loop.create_task(self._report_runtime_stats())
1748
+ else:
1749
+ self._cancel_runtime_stats()
1750
+
1751
+ def _cancel_runtime_stats(self) -> asyncio.Task[None] | None:
1752
+ """Cancel the runtime stats reporter, returning its task to await, if one ran."""
1753
+ task, self._runtime_stats_task = self._runtime_stats_task, None
1754
+ if task is not None:
1755
+ task.cancel()
1756
+ return task
1757
+
1758
+ async def _report_runtime_stats(self) -> None:
1759
+ """Journal a ``runtime_stats`` reading on a fixed cadence until cancelled.
1760
+
1761
+ A reading that fails to build is logged and skipped rather than ending
1762
+ the reporter: stats are best-effort, so one bad sample must not stop
1763
+ them for the rest of the session.
1764
+ """
1765
+ try:
1766
+ while True:
1767
+ await asyncio.sleep(self._runtime_stats_interval)
1768
+ try:
1769
+ self._journal_runtime_stats()
1770
+ except Exception:
1771
+ logger.warning("runtime stats reading failed", exc_info=True)
1772
+ except asyncio.CancelledError:
1773
+ return
1774
+
1775
+ def _journal_runtime_stats(self) -> None:
1776
+ """Journal the runtime's own view of the session as one ``metric`` fact.
1777
+
1778
+ The fact is a self-loop named ``runtime_stats``, the runtime's
1779
+ counterpart to a client's ``client_stats``: what the model emitted on
1780
+ each output track over the last window, and each connection's latest
1781
+ transport reading, in the metrics its transport names. Shaped by
1782
+ :func:`runtime_stats.to_detail`.
1783
+ """
1784
+ if self._sm.current_state not in _RUNNING_STATES:
1785
+ return
1786
+ self._sm.send(
1787
+ SessionEvent.METRIC,
1788
+ name="runtime_stats",
1789
+ session_id=self._recording_id,
1790
+ **runtime_stats.to_detail(
1791
+ observed_at_ms=int(time.time() * 1000),
1792
+ model_output=self._model_output.take(),
1793
+ output_kinds=self._output_kinds,
1794
+ readings=self._connections.transport_readings(),
1795
+ ),
1796
+ )
1797
+
1717
1798
  async def _close_session(self, reason: EndReason) -> None:
1718
1799
  """Close the session's connections, then mark cleanup complete.
1719
1800
 
@@ -0,0 +1,204 @@
1
+ """Count what the model emits during a session, for the runtime's own stats.
2
+
3
+ The runtime reports its own view of a session alongside what clients report
4
+ about theirs. The model's output is the part of that view no transport sees:
5
+ how many frames it produced on each output track and how evenly it produced
6
+ them. :class:`ModelOutput` keeps those counts for the session that is running.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import threading
13
+ import time
14
+ from collections.abc import Callable, Iterable, Mapping
15
+ from typing import Any
16
+
17
+ from reactor_runtime.core import (
18
+ ClientTrackDirection,
19
+ ConnId,
20
+ TrackKind,
21
+ TransportReading,
22
+ TransportTrackReading,
23
+ )
24
+
25
+
26
+ class ModelOutput:
27
+ """Per-session counts of the frames the model emitted, by output track.
28
+
29
+ Emissions are counted from the thread the model emits on, and a reading is
30
+ taken from the runtime's loop, so every access holds a lock. A reading
31
+ covers the window since the previous one: the frame rate and the longest
32
+ gap are what that window saw, while the frame total runs for the whole
33
+ session. :meth:`reset` starts a new session from zero, and an emission
34
+ stamped with another session's number is left out of its count.
35
+ """
36
+
37
+ def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
38
+ self._clock = clock
39
+ self._lock = threading.Lock()
40
+ self._tracks: tuple[str, ...] = ()
41
+ self._frames: dict[str, int] = {}
42
+ self._window_frames: dict[str, int] = {}
43
+ self._last_emit: dict[str, float] = {}
44
+ self._longest_gap: dict[str, float] = {}
45
+ self._window_start = clock()
46
+ self._session: int | None = None
47
+
48
+ def declare(self, tracks: Iterable[str]) -> None:
49
+ """Name the output tracks the model declares.
50
+
51
+ A declared track the model has emitted nothing on reads zero frames
52
+ rather than being absent, which tells a silent track apart from a
53
+ track this model does not have.
54
+ """
55
+ with self._lock:
56
+ self._tracks = tuple(tracks)
57
+
58
+ def reset(self, session: int | None = None) -> None:
59
+ """Start counting *session* from zero.
60
+
61
+ The gap between the last frame of one session and the first of the
62
+ next is the model waiting for a client, so no gap crosses the reset.
63
+
64
+ Args:
65
+ session: The number of the session that starts, as the model counts
66
+ them. From here on, an emission stamped with any other number
67
+ belongs to another session and is not counted.
68
+ """
69
+ with self._lock:
70
+ self._session = session
71
+ self._frames.clear()
72
+ self._window_frames.clear()
73
+ self._last_emit.clear()
74
+ self._longest_gap.clear()
75
+ self._window_start = self._clock()
76
+
77
+ def emitted(self, track: str, frames: int, session: int | None = None) -> None:
78
+ """Count one emission of *frames* frames on *track*.
79
+
80
+ Counted in frames because one emission can carry a batch of them. The
81
+ gap is measured between emissions, undivided by the batch, so a model
82
+ that emits a batch at a time has the play-out length of a batch as its
83
+ normal gap and a stall shows above it.
84
+
85
+ Args:
86
+ track: The output track the frames were emitted on.
87
+ frames: How many frames the emission carried.
88
+ session: The number of the session the model emitted them in, or
89
+ ``None`` when the emitter does not count sessions. An emission
90
+ of a session other than the one :meth:`reset` started is not
91
+ counted. The check runs under the same lock as the reset, so
92
+ an emission is counted for the session that is current when
93
+ it lands.
94
+ """
95
+ with self._lock:
96
+ if session is not None and self._session is not None and session != self._session:
97
+ return
98
+ # Read under the lock, so concurrent emissions store their times
99
+ # in the order they were taken.
100
+ now = self._clock()
101
+ previous = self._last_emit.get(track)
102
+ if previous is not None:
103
+ gap = now - previous
104
+ if gap > self._longest_gap.get(track, 0.0):
105
+ self._longest_gap[track] = gap
106
+ self._last_emit[track] = now
107
+ self._frames[track] = self._frames.get(track, 0) + frames
108
+ self._window_frames[track] = self._window_frames.get(track, 0) + frames
109
+
110
+ def take(self) -> dict[str, dict[str, float]]:
111
+ """Read every output track's counts and start the next window.
112
+
113
+ Returns:
114
+ Per track name, its metrics: ``frames_emitted`` (the session's
115
+ total), ``frames_per_second`` (over the window), and, once the track
116
+ has emitted, ``max_emit_interval_ms`` (the longest gap the window
117
+ saw, when it saw two emissions) and ``ms_since_last_emit``, which
118
+ shows a stall that is still going before the next emission ends it.
119
+ """
120
+ with self._lock:
121
+ now = self._clock()
122
+ elapsed = now - self._window_start
123
+ names = dict.fromkeys((*self._tracks, *self._frames))
124
+ readings: dict[str, dict[str, float]] = {}
125
+ for name in names:
126
+ metrics: dict[str, float] = {
127
+ "frames_emitted": float(self._frames.get(name, 0)),
128
+ }
129
+ if elapsed > 0:
130
+ metrics["frames_per_second"] = self._window_frames.get(name, 0) / elapsed
131
+ if name in self._longest_gap:
132
+ metrics["max_emit_interval_ms"] = self._longest_gap[name] * 1000.0
133
+ last = self._last_emit.get(name)
134
+ if last is not None:
135
+ metrics["ms_since_last_emit"] = (now - last) * 1000.0
136
+ readings[name] = metrics
137
+ self._window_frames.clear()
138
+ self._longest_gap.clear()
139
+ self._window_start = now
140
+ return readings
141
+
142
+
143
+ INTERVAL_SECONDS = 5.0
144
+ """How often the runner journals a ``runtime_stats`` reading during a session."""
145
+
146
+
147
+ def to_detail(
148
+ *,
149
+ observed_at_ms: int,
150
+ model_output: Mapping[str, Mapping[str, float]],
151
+ output_kinds: Mapping[str, TrackKind],
152
+ readings: Iterable[tuple[ConnId, TransportReading]],
153
+ ) -> dict[str, Any]:
154
+ """Shape one ``runtime_stats`` reading as a ``metric`` fact's detail.
155
+
156
+ The shape follows a ``client_stats`` reading's, so a consumer reads both
157
+ the same way: tracks carry ``track_name``, ``kind``, ``direction`` (as the
158
+ client sees the track) and ``codec``, beside their ``metrics``. A metric
159
+ value that isn't a finite number is left out, since JSON has no way to
160
+ carry it.
161
+
162
+ Args:
163
+ observed_at_ms: When the reading was taken, in Unix milliseconds.
164
+ model_output: Per output track, the metrics :meth:`ModelOutput.take`
165
+ returned.
166
+ output_kinds: The kind of each output track the model declares.
167
+ readings: Each connection's latest transport reading.
168
+ """
169
+ return {
170
+ "observed_at": observed_at_ms,
171
+ "model_output": [
172
+ {
173
+ "track_name": name,
174
+ "kind": str(output_kinds[name]) if name in output_kinds else "",
175
+ # What the model sends, the client receives.
176
+ "direction": str(ClientTrackDirection.RECVONLY),
177
+ "metrics": _finite(metrics),
178
+ }
179
+ for name, metrics in model_output.items()
180
+ ],
181
+ "connection_stats": [
182
+ {
183
+ "conn_id": int(cid),
184
+ "metrics": _finite(reading.metrics),
185
+ "track_stats": [_track(track) for track in reading.tracks],
186
+ }
187
+ for cid, reading in readings
188
+ ],
189
+ }
190
+
191
+
192
+ def _track(track: TransportTrackReading) -> dict[str, Any]:
193
+ return {
194
+ "track_name": track.track_name,
195
+ "kind": str(track.kind) if track.kind is not None else "",
196
+ "direction": str(track.direction),
197
+ "codec": track.codec,
198
+ "metrics": _finite(track.metrics),
199
+ }
200
+
201
+
202
+ def _finite(metrics: Mapping[str, float]) -> dict[str, float]:
203
+ """Keep the values JSON can carry: finite numbers."""
204
+ return {name: float(value) for name, value in metrics.items() if math.isfinite(value)}
@@ -25,12 +25,14 @@ from reactor_runtime.core import (
25
25
  TrackDirection,
26
26
  TrackInfo,
27
27
  TrackKind,
28
+ TransportReading,
28
29
  )
29
30
  from reactor_runtime.protocol import Channel, ProtocolVersion
30
31
  from reactor_runtime.transport.webrtc.client_stats import read_client_stats
31
32
  from reactor_runtime.transport.webrtc.config import WebRtcConfig
32
33
  from reactor_runtime.transport.webrtc.pacer import MediaPacer
33
34
  from reactor_runtime.transport.webrtc.peer import WebRTCPeer, WebRtcPeerFactory
35
+ from reactor_runtime.transport.webrtc.reading import transport_reading
34
36
  from reactor_runtime.transport.webrtc.signaling import IceCandidate, SdpAnswer, SdpOffer, TrackMap
35
37
  from reactor_runtime.transport.webrtc.stats import OutboundMediaHealth, PeerStats
36
38
 
@@ -105,6 +107,8 @@ class WebRTCConnection:
105
107
  self._watchdog_task: asyncio.Task[None] | None = None
106
108
  self._stats_task: asyncio.Task[None] | None = None
107
109
  self._latest_stats: PeerStats | None = None
110
+ self._latest_reading: TransportReading | None = None
111
+ self._sampled_at: float | None = None
108
112
  # The counters as of the previous sample, so each report is what the
109
113
  # window cost rather than what the connection has cost since it opened.
110
114
  self._reported_media = OutboundMediaHealth()
@@ -152,6 +156,15 @@ class WebRTCConnection:
152
156
  """The most recent stats sample, or ``None`` before the first cycle."""
153
157
  return self._latest_stats
154
158
 
159
+ @property
160
+ def latest_reading(self) -> TransportReading | None:
161
+ """The most recent stats sample as a neutral reading, or ``None`` before the first.
162
+
163
+ Each track's bitrate is measured between this sample and the one before
164
+ it, so the reading of a connection's first sample has none.
165
+ """
166
+ return self._latest_reading
167
+
155
168
  @property
156
169
  def protocol_version(self) -> ProtocolVersion:
157
170
  """The wire codec the connection negotiated, held by its peer."""
@@ -365,7 +378,11 @@ class WebRTCConnection:
365
378
  media=replace(stats.media, dropped_frames=self._pacer.dropped_frames),
366
379
  )
367
380
  self._report_media_health(stats.media)
381
+ now = time.monotonic()
382
+ elapsed = now - self._sampled_at if self._sampled_at is not None else None
383
+ self._latest_reading = transport_reading(stats, self._latest_stats, elapsed)
368
384
  self._latest_stats = stats
385
+ self._sampled_at = now
369
386
  if self._on_stats is not None:
370
387
  self._on_stats(stats)
371
388
  except asyncio.CancelledError: