arcly_stream/bus/handle.rs
1//! The live stream handle — a lock-free, zero-copy broadcast fan-out bus.
2
3use crate::observe::{NoopObserver, Observer};
4use crate::{frame::FrameFlags, AppName, MediaFrame, Result, StreamId, StreamKey};
5use arc_swap::{ArcSwap, ArcSwapOption};
6use std::net::SocketAddr;
7use std::ops::ControlFlow;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex as StdMutex, OnceLock};
10use std::time::{Instant, SystemTime, UNIX_EPOCH};
11use tokio::sync::broadcast::error::RecvError;
12use tokio::sync::{broadcast, RwLock};
13use tokio_util::sync::CancellationToken;
14
15/// A per-frame egress sink driven by [`StreamHandle::drive_to`].
16///
17/// Implementors send one frame to a single downstream peer (an RTSP player, an
18/// upstream RTMP server, …). Returning [`ControlFlow::Break`] stops the drive
19/// cleanly — typically because the peer disconnected — while `Err` aborts it.
20#[async_trait::async_trait]
21pub trait FrameSink: Send {
22 /// Forward one frame; `Break` ends the session gracefully.
23 async fn send(&mut self, frame: Arc<MediaFrame>) -> Result<ControlFlow<()>>;
24}
25
26/// Current wall-clock time in Unix milliseconds (saturating to 0 pre-epoch).
27///
28/// Use this only for *displayed* timestamps (e.g. `started_at_ms`); it is
29/// subject to NTP steps and operator clock changes, so it must never drive
30/// elapsed-time decisions. For those, use [`mono_ms`].
31pub(crate) fn now_ms() -> u64 {
32 SystemTime::now()
33 .duration_since(UNIX_EPOCH)
34 .map(|d| d.as_millis() as u64)
35 .unwrap_or(0)
36}
37
38/// Milliseconds elapsed on a process-local **monotonic** clock since the first
39/// call. Unlike [`now_ms`], this never jumps backward (or forward) when the wall
40/// clock is adjusted, so it is the correct basis for QoS windows and the idle
41/// reaper: a leap-second or NTP correction can't spuriously reap a live stream
42/// or distort a measured bitrate.
43pub(crate) fn mono_ms() -> u64 {
44 static EPOCH: OnceLock<Instant> = OnceLock::new();
45 EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
46}
47
48/// Current lifecycle state of a stream.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum StreamState {
51 /// No publisher yet.
52 Idle,
53 /// A publisher is connected and sending data.
54 Publishing,
55 /// The stream is being transcoded into one or more renditions.
56 Transcoding,
57 /// The stream is being recorded.
58 Recording,
59 /// The publisher has disconnected; the stream has ended.
60 Ended,
61}
62
63impl std::fmt::Display for StreamState {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.write_str(match self {
66 StreamState::Idle => "idle",
67 StreamState::Publishing => "publishing",
68 StreamState::Transcoding => "transcoding",
69 StreamState::Recording => "recording",
70 StreamState::Ended => "ended",
71 })
72 }
73}
74
75/// Runtime metadata about a stream, updated continuously while publishing.
76///
77/// Resolution and the ingest protocol are set by the protocol handler (e.g. via
78/// [`StreamHandle::update_metadata`] after parsing the codec config), while the
79/// `fps` and `*_bitrate_bps` fields are overlaid live from measured throughput
80/// by [`StreamHandle::metadata_snapshot`].
81#[derive(Debug, Clone)]
82pub struct StreamMetadata {
83 /// The `(app, stream_id)` this metadata describes.
84 pub key: StreamKey,
85 /// Publisher remote address.
86 pub publisher_addr: Option<SocketAddr>,
87 /// Video width in pixels (0 = unknown).
88 pub width: u32,
89 /// Video height in pixels (0 = unknown).
90 pub height: u32,
91 /// Video frames per second (0 = unknown). Overlaid from measured throughput.
92 pub fps: f64,
93 /// Measured video ingest bitrate in bits-per-second.
94 pub video_bitrate_bps: u64,
95 /// Measured audio ingest bitrate in bits-per-second.
96 pub audio_bitrate_bps: u64,
97 /// Timestamp of the first frame received (Unix ms).
98 pub started_at_ms: u64,
99 /// Protocol used for ingest (e.g. `"rtmp"`).
100 pub ingest_protocol: String,
101}
102
103impl StreamMetadata {
104 /// Create zeroed metadata for `(app, stream_id)`.
105 pub fn new(app: AppName, stream_id: StreamId) -> Self {
106 Self {
107 key: StreamKey::new(app, stream_id),
108 publisher_addr: None,
109 width: 0,
110 height: 0,
111 fps: 0.0,
112 video_bitrate_bps: 0,
113 audio_bitrate_bps: 0,
114 started_at_ms: 0,
115 ingest_protocol: String::new(),
116 }
117 }
118}
119
120/// A point-in-time snapshot of a stream's measured quality of service.
121#[derive(Debug, Clone, Copy, Default)]
122pub struct Qos {
123 /// Video bitrate over the last ~1s window (bits/sec).
124 pub video_bitrate_bps: u64,
125 /// Audio bitrate over the last ~1s window (bits/sec).
126 pub audio_bitrate_bps: u64,
127 /// Video frames per second over the last ~1s window.
128 pub fps: f64,
129 /// Cumulative frames published on this stream.
130 pub total_frames: u64,
131 /// Cumulative payload bytes published on this stream.
132 pub total_bytes: u64,
133}
134
135/// Lock-free throughput counters folded into [`Qos`] / [`StreamMetadata`].
136///
137/// A stream has a single publisher (enforced by `start_publish`), so the
138/// read-modify-write here is effectively single-writer and `Relaxed` is sound.
139#[derive(Default)]
140struct QosCounters {
141 total_frames: AtomicU64,
142 total_bytes: AtomicU64,
143 window_start_ms: AtomicU64,
144 window_video_bytes: AtomicU64,
145 window_audio_bytes: AtomicU64,
146 window_video_frames: AtomicU64,
147 cur_video_bitrate: AtomicU64,
148 cur_audio_bitrate: AtomicU64,
149 cur_fps_milli: AtomicU64, // fps × 1000, integer-encoded
150 last_frame_ms: AtomicU64,
151}
152
153/// Keyframe-anchored replay buffer for instant playback start.
154struct GopBuffer {
155 /// Frames since (and including) the most recent keyframe.
156 frames: Vec<Arc<MediaFrame>>,
157 /// Hard cap on buffered frames (frame-count bound between keyframes).
158 capacity: usize,
159 /// Hard cap on buffered payload **bytes** between keyframes (0 = unbounded).
160 /// Frame count alone does not bound memory — one 4K keyframe can be megabytes
161 /// — so a high-bitrate stream can be capped by size independently of count.
162 byte_capacity: usize,
163 /// Payload bytes currently buffered in `frames` (kept in sync on push/clear).
164 bytes: usize,
165 /// Whether the current GOP already overflowed `capacity`/`byte_capacity` (so
166 /// the truncation signal fires once per GOP, not once per dropped frame).
167 overflowed: bool,
168}
169
170/// A live handle to a single active stream.
171///
172/// Multiple subscribers (HLS packager, DASH packager, WebRTC SFU, recorders …)
173/// call [`StreamHandle::subscribe_resilient`] to receive every [`MediaFrame`]
174/// cheaply via a `broadcast` channel (zero-copy `Bytes` cloning).
175///
176/// Each broadcast slot holds one `Arc<MediaFrame>` pointer (8 bytes), so e.g.
177/// 4096 slots ≈ 32 KB per stream.
178///
179/// # Backpressure model
180///
181/// Fan-out uses a **single, fixed-capacity ring buffer per stream** (a
182/// `tokio::broadcast` channel sized by `AppSpec::broadcast_capacity`). This is a
183/// deliberate design choice, with consequences worth understanding:
184///
185/// - **The publisher never blocks on a slow subscriber.** Publishing is a
186/// non-awaiting pointer write; one subscriber falling behind can never apply
187/// backpressure to the publisher or to its peers. This is what keeps the hot
188/// path lock-free and the fast publisher isolated from the slow viewer.
189/// - **Backpressure is resolved by dropping, not stalling.** A subscriber that
190/// can't keep up overruns the ring and observes lag.
191/// [`Subscription::recv`] resynchronizes to the oldest still-buffered frame
192/// and reports the gap via [`Observer::on_subscriber_lagged`]; with
193/// [`Subscription::max_lag`] a chronically slow consumer is evicted
194/// ([`Observer::on_subscriber_evicted`]) rather than churning forever.
195/// - **Capacity is the tuning knob**, traded per stream: larger capacity
196/// tolerates burstier consumers at higher per-stream memory, smaller capacity
197/// sheds laggards sooner. There is intentionally no per-subscriber queue —
198/// that would reintroduce unbounded memory growth and per-consumer locking,
199/// the very things this design avoids.
200///
201/// In short: a slow subscriber degrades only *its own* view (lag, then
202/// eviction), never the publisher's or another subscriber's. Wire an
203/// [`Observer`] to see lag and eviction as they happen.
204#[derive(Clone)]
205pub struct StreamHandle {
206 metadata: Arc<RwLock<StreamMetadata>>,
207 state: Arc<RwLock<StreamState>>,
208 key: StreamKey,
209 /// The frame-bus sender, held *indirectly* so its lifetime is owned by the
210 /// stream's lifecycle — not by how many `StreamHandle` clones happen to be
211 /// alive. Cloning a handle shares this cell; it does not mint a new sender.
212 ///
213 /// This is the structural fix for a sharp edge: previously the handle stored
214 /// the `broadcast::Sender` by value, so any consumer that merely retained a
215 /// handle (to subscribe, read metadata, request a keyframe) silently pinned
216 /// the channel open and defeated the `Closed` shutdown signal. Now
217 /// [`close`](Self::close) — called by the registry when a publish ends — empties
218 /// this cell, dropping the sole sender and closing the channel regardless of
219 /// any lingering handle clones.
220 tx: Arc<ArcSwapOption<broadcast::Sender<Arc<MediaFrame>>>>,
221 /// Latest video CONFIG (AVCDecoderConfigurationRecord) frame, if seen.
222 /// Uses `ArcSwap` for lock-free reads from multiple subscriber tasks.
223 video_config: Arc<ArcSwap<Option<Arc<MediaFrame>>>>,
224 /// Latest audio CONFIG (AudioSpecificConfig) frame, if seen.
225 audio_config: Arc<ArcSwap<Option<Arc<MediaFrame>>>>,
226 /// Rolling GOP buffer for instant-start (empty when `gop_capacity == 0`).
227 gop: Arc<StdMutex<GopBuffer>>,
228 gop_capacity: usize,
229 /// Default cumulative-lag budget applied to [`subscribe_resilient`]
230 /// subscriptions: a consumer that drops more than this many frames is evicted.
231 /// `None` = no default bound (a caller may still set one per subscription).
232 default_max_lag: Option<u64>,
233 /// Live throughput counters.
234 qos: Arc<QosCounters>,
235 /// Injected telemetry hook (no-op by default).
236 observer: Arc<dyn Observer>,
237}
238
239impl StreamHandle {
240 /// Create a handle with the no-op observer and no GOP cache.
241 pub fn new(app: AppName, stream_id: StreamId, capacity: usize) -> Self {
242 Self::with_observer(app, stream_id, capacity, 0, Arc::new(NoopObserver))
243 }
244
245 /// Create a handle wired to a host-supplied observer.
246 ///
247 /// `gop_capacity` bounds the keyframe-anchored replay buffer (0 disables it).
248 pub fn with_observer(
249 app: AppName,
250 stream_id: StreamId,
251 capacity: usize,
252 gop_capacity: usize,
253 observer: Arc<dyn Observer>,
254 ) -> Self {
255 Self::with_config(app, stream_id, capacity, gop_capacity, 0, None, observer)
256 }
257
258 /// Create a handle with the full per-stream policy: an optional GOP byte cap
259 /// (`gop_byte_capacity`, 0 = unbounded) and an optional default subscriber
260 /// lag budget (`default_max_lag`). The engine threads these from
261 /// [`AppSpec`](crate::AppSpec); [`with_observer`](Self::with_observer) is the
262 /// shorthand with both disabled.
263 #[allow(clippy::too_many_arguments)]
264 pub fn with_config(
265 app: AppName,
266 stream_id: StreamId,
267 capacity: usize,
268 gop_capacity: usize,
269 gop_byte_capacity: usize,
270 default_max_lag: Option<u64>,
271 observer: Arc<dyn Observer>,
272 ) -> Self {
273 let (tx, _) = broadcast::channel(capacity);
274 let qos = QosCounters::default();
275 // Treat creation as the last activity so a just-claimed stream is not
276 // instantly considered idle before its first frame arrives. Monotonic, to
277 // match the idle reaper (see `mono_ms`).
278 qos.last_frame_ms.store(mono_ms(), Ordering::Relaxed);
279 Self {
280 metadata: Arc::new(RwLock::new(StreamMetadata::new(
281 app.clone(),
282 stream_id.clone(),
283 ))),
284 state: Arc::new(RwLock::new(StreamState::Idle)),
285 key: StreamKey::new(app, stream_id),
286 tx: Arc::new(ArcSwapOption::new(Some(Arc::new(tx)))),
287 video_config: Arc::new(ArcSwap::new(Arc::new(None))),
288 audio_config: Arc::new(ArcSwap::new(Arc::new(None))),
289 gop: Arc::new(StdMutex::new(GopBuffer {
290 frames: Vec::new(),
291 capacity: gop_capacity,
292 byte_capacity: gop_byte_capacity,
293 bytes: 0,
294 overflowed: false,
295 })),
296 gop_capacity,
297 default_max_lag,
298 qos: Arc::new(qos),
299 observer,
300 }
301 }
302
303 /// The `(app, stream_id)` this handle belongs to.
304 pub fn key(&self) -> &StreamKey {
305 &self.key
306 }
307
308 /// Publish a frame to all current subscribers. Returns the number of
309 /// active receivers; returns `Ok(0)` when there are no subscribers.
310 pub fn publish_frame(&self, frame: MediaFrame) -> crate::Result<usize> {
311 self.observer.on_frame(&self.key, &frame);
312 let len = frame.data.len() as u64;
313 let is_audio = frame.is_audio();
314 let is_key = frame.is_keyframe();
315 let is_config = frame.flags.contains(FrameFlags::CONFIG);
316 let arc = Arc::new(frame);
317
318 // Cache the latest CONFIG frame for late-joining subscribers.
319 if is_config {
320 if is_audio {
321 self.audio_config.store(Arc::new(Some(Arc::clone(&arc))));
322 } else {
323 self.video_config.store(Arc::new(Some(Arc::clone(&arc))));
324 }
325 }
326
327 // Maintain the keyframe-anchored GOP replay buffer.
328 let mut gop_truncated = false;
329 if self.gop_capacity > 0 {
330 if let Ok(mut g) = self.gop.lock() {
331 if is_key {
332 g.frames.clear();
333 g.bytes = len as usize;
334 g.overflowed = false;
335 g.frames.push(Arc::clone(&arc));
336 } else if !is_config && !g.frames.is_empty() {
337 // Only buffer once a keyframe anchors the GOP; CONFIG frames
338 // are replayed separately via `cached_configs`.
339 //
340 // Admit the frame only if it stays within *both* the frame
341 // count and the byte budget (0 = byte cap disabled). Frame
342 // count alone does not bound memory, so the byte cap keeps a
343 // high-bitrate GOP from growing without limit.
344 let fits_count = g.frames.len() < g.capacity;
345 let fits_bytes =
346 g.byte_capacity == 0 || g.bytes + len as usize <= g.byte_capacity;
347 if fits_count && fits_bytes {
348 g.bytes += len as usize;
349 g.frames.push(Arc::clone(&arc));
350 } else if !g.overflowed {
351 // First overflow of this GOP: the replay will be truncated.
352 // Latch it so the signal fires once, not per dropped frame.
353 g.overflowed = true;
354 gop_truncated = true;
355 }
356 }
357 }
358 }
359 if gop_truncated {
360 self.observer.on_gop_truncated(&self.key, self.gop_capacity);
361 }
362
363 self.record_qos(len, is_audio, is_key);
364
365 // The sender is gone once the stream is closed; publishing then is a
366 // no-op (Ok(0)) rather than an error, so a publisher racing teardown
367 // winds down cleanly.
368 let count = match self.tx.load_full() {
369 Some(tx) => tx.send(arc).unwrap_or(0),
370 None => 0,
371 };
372 Ok(count)
373 }
374
375 /// Fold one frame into the rolling throughput window.
376 fn record_qos(&self, len: u64, is_audio: bool, _is_key: bool) {
377 let q = &self.qos;
378 let now = mono_ms();
379 q.total_frames.fetch_add(1, Ordering::Relaxed);
380 q.total_bytes.fetch_add(len, Ordering::Relaxed);
381 q.last_frame_ms.store(now, Ordering::Relaxed);
382 if is_audio {
383 q.window_audio_bytes.fetch_add(len, Ordering::Relaxed);
384 } else {
385 q.window_video_bytes.fetch_add(len, Ordering::Relaxed);
386 q.window_video_frames.fetch_add(1, Ordering::Relaxed);
387 }
388
389 let ws = q.window_start_ms.load(Ordering::Relaxed);
390 if ws == 0 {
391 q.window_start_ms.store(now, Ordering::Relaxed);
392 } else if now.saturating_sub(ws) >= 1000 {
393 let elapsed = (now - ws) as f64 / 1000.0;
394 let vbytes = q.window_video_bytes.swap(0, Ordering::Relaxed);
395 let abytes = q.window_audio_bytes.swap(0, Ordering::Relaxed);
396 let vframes = q.window_video_frames.swap(0, Ordering::Relaxed);
397 q.cur_video_bitrate
398 .store((vbytes as f64 * 8.0 / elapsed) as u64, Ordering::Relaxed);
399 q.cur_audio_bitrate
400 .store((abytes as f64 * 8.0 / elapsed) as u64, Ordering::Relaxed);
401 q.cur_fps_milli.store(
402 (vframes as f64 / elapsed * 1000.0) as u64,
403 Ordering::Relaxed,
404 );
405 q.window_start_ms.store(now, Ordering::Relaxed);
406 }
407 }
408
409 /// A snapshot of measured throughput (bitrate, fps, totals).
410 pub fn qos(&self) -> Qos {
411 let q = &self.qos;
412 Qos {
413 video_bitrate_bps: q.cur_video_bitrate.load(Ordering::Relaxed),
414 audio_bitrate_bps: q.cur_audio_bitrate.load(Ordering::Relaxed),
415 fps: q.cur_fps_milli.load(Ordering::Relaxed) as f64 / 1000.0,
416 total_frames: q.total_frames.load(Ordering::Relaxed),
417 total_bytes: q.total_bytes.load(Ordering::Relaxed),
418 }
419 }
420
421 /// Monotonic-clock timestamp (process-local milliseconds) of the most
422 /// recently published frame (or stream creation if none yet). Used by the
423 /// engine's idle reaper; this is elapsed monotonic time, not wall-clock time,
424 /// so compare it only against other readings of the same monotonic clock.
425 pub fn last_frame_ms(&self) -> u64 {
426 self.qos.last_frame_ms.load(Ordering::Relaxed)
427 }
428
429 /// Returns the most recently seen video and audio CONFIG frames,
430 /// for replaying to late-joining subscribers.
431 pub fn cached_configs(&self) -> (Option<Arc<MediaFrame>>, Option<Arc<MediaFrame>>) {
432 let video = (**self.video_config.load()).clone();
433 let audio = (**self.audio_config.load()).clone();
434 (video, audio)
435 }
436
437 /// The frames a late joiner should be handed before going live: cached
438 /// decoder configs followed by the current GOP (keyframe + trailing deltas).
439 ///
440 /// Replaying these lets a new subscriber start decoding immediately rather
441 /// than waiting for the next keyframe — sub-second join times at scale.
442 /// Requires the app to have enabled a GOP cache; otherwise only the cached
443 /// configs are returned.
444 pub fn replay_buffer(&self) -> Vec<Arc<MediaFrame>> {
445 let (vcfg, acfg) = self.cached_configs();
446 let mut out = Vec::new();
447 out.extend(vcfg.clone());
448 out.extend(acfg.clone());
449 if self.gop_capacity > 0 {
450 if let Ok(g) = self.gop.lock() {
451 out.reserve(g.frames.len());
452 for f in &g.frames {
453 // Avoid duplicating a config frame already pushed above. Only
454 // the (≤2) cached configs can collide with a GOP frame — GOP
455 // frames are themselves distinct — so compare against just
456 // those, keeping this O(n) rather than O(n²) over the GOP.
457 let dup = vcfg.as_ref().is_some_and(|c| Arc::ptr_eq(c, f))
458 || acfg.as_ref().is_some_and(|c| Arc::ptr_eq(c, f));
459 if !dup {
460 out.push(Arc::clone(f));
461 }
462 }
463 }
464 }
465 out
466 }
467
468 /// Subscribe to this stream's frame bus.
469 ///
470 /// The returned raw [`broadcast::Receiver`] surfaces [`RecvError::Lagged`]
471 /// when a slow consumer falls behind the channel capacity — callers that
472 /// `while let Ok(_) = rx.recv().await` will silently terminate on the first
473 /// lag. Prefer [`subscribe_resilient`](Self::subscribe_resilient) unless you
474 /// are deliberately handling lag yourself.
475 pub fn subscribe(&self) -> broadcast::Receiver<Arc<MediaFrame>> {
476 match self.tx.load_full() {
477 Some(tx) => tx.subscribe(),
478 // The stream has already closed: hand back a receiver on a spent
479 // channel so the caller's `recv` loop terminates immediately with
480 // `Closed` rather than blocking forever.
481 None => {
482 let (_, rx) = broadcast::channel(1);
483 rx
484 }
485 }
486 }
487
488 /// Subscribe with a [`Subscription`] that resynchronizes after lag instead
489 /// of terminating, reporting each gap to the installed [`Observer`] via
490 /// [`Observer::on_subscriber_lagged`].
491 ///
492 /// If the app configured [`AppSpec::subscriber_max_lag`](crate::AppSpec::subscriber_max_lag),
493 /// that bound is applied by default, so a chronically slow consumer is evicted
494 /// automatically; a caller may still tighten it with [`Subscription::max_lag`].
495 pub fn subscribe_resilient(&self) -> Subscription {
496 Subscription {
497 rx: self.subscribe(),
498 key: self.key.clone(),
499 observer: Arc::clone(&self.observer),
500 max_lag: self.default_max_lag,
501 skipped: 0,
502 }
503 }
504
505 /// Drive this stream to a [`FrameSink`]: replay the instant-start buffer
506 /// (cached configs + cached GOP) so the consumer can decode immediately,
507 /// then forward live frames until the stream ends or `shutdown` fires.
508 ///
509 /// This is the playback loop every egress that pulls from a single stream
510 /// shares (RTSP server, RTMP relay, …); each supplies only its per-frame
511 /// [`FrameSink::send`].
512 pub async fn drive_to(
513 &self,
514 shutdown: &CancellationToken,
515 sink: &mut dyn FrameSink,
516 ) -> Result<()> {
517 for frame in self.replay_buffer() {
518 if sink.send(frame).await?.is_break() {
519 return Ok(());
520 }
521 }
522 let mut sub = self.subscribe_resilient();
523 loop {
524 tokio::select! {
525 _ = shutdown.cancelled() => break,
526 next = sub.recv() => match next {
527 Some(frame) => {
528 if sink.send(frame).await?.is_break() {
529 break;
530 }
531 }
532 None => break, // stream ended
533 }
534 }
535 }
536 Ok(())
537 }
538
539 /// Drive this stream into a [`Packager`](crate::packager::Packager) (HLS
540 /// segmenter, DASH packager, …): replay the instant-start buffer — **which
541 /// carries the cached CONFIG access unit (SPS/PPS)** — then forward live
542 /// frames until the stream ends or `shutdown` fires, and `finish`.
543 ///
544 /// Priming with [`replay_buffer`](Self::replay_buffer) is essential, not just
545 /// an optimization: the CONFIG frame is published once at stream start, so a
546 /// packager that only `subscribe_resilient`s (subscribing *after* the event
547 /// that spawned it) never sees it — and then a fragmented-MP4 muxer never
548 /// builds its `init.m4s` and the master playlist never learns its codec
549 /// string. Replaying configs first makes both deterministic.
550 #[cfg(feature = "hls")]
551 pub async fn package_to(
552 &self,
553 shutdown: &CancellationToken,
554 packager: &mut dyn crate::packager::Packager,
555 ) -> Result<()> {
556 for frame in self.replay_buffer() {
557 packager.push(&frame).await?;
558 }
559 let mut sub = self.subscribe_resilient();
560 loop {
561 tokio::select! {
562 _ = shutdown.cancelled() => break,
563 next = sub.recv() => match next {
564 Some(frame) => packager.push(&frame).await?,
565 None => break, // stream ended
566 }
567 }
568 }
569 packager.finish().await
570 }
571
572 /// Number of active subscribers (0 once the stream is closed).
573 pub fn subscriber_count(&self) -> usize {
574 self.tx
575 .load_full()
576 .map(|tx| tx.receiver_count())
577 .unwrap_or(0)
578 }
579
580 /// Close the frame bus: drop the sole sender so every subscriber's `recv`
581 /// observes `Closed` and terminates, *regardless* of how many `StreamHandle`
582 /// clones are still alive.
583 ///
584 /// Called by the registry when a publish ends (see
585 /// `Application::end_publish`). Idempotent. This is what makes the channel's
586 /// lifetime track the stream's lifecycle rather than handle reachability.
587 pub fn close(&self) {
588 self.tx.store(None);
589 }
590
591 /// Transition to a new state.
592 pub async fn set_state(&self, state: StreamState) {
593 let mut guard = self.state.write().await;
594 *guard = state;
595 }
596
597 /// The current lifecycle state.
598 pub async fn current_state(&self) -> StreamState {
599 self.state.read().await.clone()
600 }
601
602 /// A consistent point-in-time copy of this stream's [`StreamMetadata`], with
603 /// the live measured `fps`/bitrate overlaid from [`qos`](Self::qos).
604 ///
605 /// Cloning the snapshot releases the lock immediately, so callers never hold
606 /// the metadata `RwLock` across an `.await`.
607 pub async fn metadata_snapshot(&self) -> StreamMetadata {
608 let mut m = self.metadata.read().await.clone();
609 let q = self.qos();
610 m.video_bitrate_bps = q.video_bitrate_bps;
611 m.audio_bitrate_bps = q.audio_bitrate_bps;
612 if q.fps > 0.0 {
613 m.fps = q.fps;
614 }
615 m
616 }
617
618 /// Mutate this stream's [`StreamMetadata`] under the write lock.
619 ///
620 /// Ingest handlers call this as they parse the stream — e.g. on the first
621 /// keyframe to record resolution from the codec config, or to set the
622 /// publisher address — so the metadata exposed to operators and the control
623 /// plane stays live rather than frozen at its zeroed defaults.
624 ///
625 /// ```no_run
626 /// # use arcly_stream::StreamHandle;
627 /// # async fn demo(handle: &StreamHandle, addr: std::net::SocketAddr) {
628 /// handle
629 /// .update_metadata(|m| {
630 /// m.publisher_addr = Some(addr);
631 /// m.width = 1920;
632 /// m.height = 1080;
633 /// m.ingest_protocol = "rtmp".to_string();
634 /// })
635 /// .await;
636 /// # }
637 /// ```
638 pub async fn update_metadata(&self, f: impl FnOnce(&mut StreamMetadata)) {
639 let mut guard = self.metadata.write().await;
640 f(&mut guard);
641 }
642}
643
644impl std::fmt::Debug for StreamHandle {
645 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646 f.debug_struct("StreamHandle")
647 .field("key", &self.key)
648 .field("subscribers", &self.subscriber_count())
649 .finish()
650 }
651}
652
653/// A lag-tolerant subscription to a stream's frame bus.
654///
655/// Returned by [`StreamHandle::subscribe_resilient`]. Unlike a raw
656/// [`broadcast::Receiver`], [`recv`](Self::recv) does not terminate when the
657/// consumer falls behind: the dropped span is reported to the [`Observer`] as
658/// [`on_subscriber_lagged`](Observer::on_subscriber_lagged) and reception
659/// continues from the oldest still-buffered frame. This is the recommended
660/// consumer loop for packagers, recorders, and SFUs.
661///
662/// An optional [`max_lag`](Self::max_lag) bound turns chronic lag into
663/// eviction: once cumulative dropped frames exceed the bound, `recv` returns
664/// `None` (after an [`on_subscriber_evicted`](Observer::on_subscriber_evicted)
665/// notification) so a hopelessly slow consumer is shed rather than wasting
666/// buffer churn forever.
667pub struct Subscription {
668 rx: broadcast::Receiver<Arc<MediaFrame>>,
669 key: StreamKey,
670 observer: Arc<dyn Observer>,
671 max_lag: Option<u64>,
672 skipped: u64,
673}
674
675impl Subscription {
676 /// Evict this subscriber once cumulative dropped frames exceed `max`.
677 ///
678 /// ```no_run
679 /// # use arcly_stream::StreamHandle;
680 /// # fn demo(handle: &StreamHandle) {
681 /// let sub = handle.subscribe_resilient().max_lag(10_000);
682 /// # let _ = sub;
683 /// # }
684 /// ```
685 pub fn max_lag(mut self, max: u64) -> Self {
686 self.max_lag = Some(max);
687 self
688 }
689
690 /// Total frames dropped from this subscriber's view so far.
691 pub fn dropped(&self) -> u64 {
692 self.skipped
693 }
694
695 /// Receive the next frame, resynchronizing past any lag.
696 ///
697 /// Returns `None` when the stream's sender is dropped (the publisher ended)
698 /// or when the `max_lag` eviction threshold is crossed:
699 ///
700 /// ```no_run
701 /// # async fn run(sub: &mut arcly_stream::bus::Subscription) {
702 /// while let Some(frame) = sub.recv().await {
703 /// // packetize `frame` …
704 /// }
705 /// # }
706 /// ```
707 pub async fn recv(&mut self) -> Option<Arc<MediaFrame>> {
708 loop {
709 match self.rx.recv().await {
710 Ok(frame) => return Some(frame),
711 Err(RecvError::Lagged(skipped)) => {
712 self.skipped = self.skipped.saturating_add(skipped);
713 self.observer.on_subscriber_lagged(&self.key, skipped);
714 if let Some(max) = self.max_lag {
715 if self.skipped > max {
716 self.observer.on_subscriber_evicted(&self.key);
717 return None;
718 }
719 }
720 continue;
721 }
722 Err(RecvError::Closed) => return None,
723 }
724 }
725 }
726
727 /// Borrow the underlying raw receiver, for callers that need
728 /// [`broadcast::Receiver`] APIs directly.
729 pub fn raw(&mut self) -> &mut broadcast::Receiver<Arc<MediaFrame>> {
730 &mut self.rx
731 }
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 use crate::CodecId;
738
739 fn video(pts: i64, key: bool) -> MediaFrame {
740 MediaFrame::new_video(
741 pts,
742 pts,
743 bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65]),
744 CodecId::H264,
745 key,
746 )
747 }
748
749 /// Regression guard for the sender-lifetime fix: `close()` must terminate a
750 /// subscriber's `recv` even while a `StreamHandle` clone is still held — the
751 /// exact shape that previously hung WHEP egress forever.
752 #[tokio::test]
753 async fn close_terminates_recv_while_a_handle_clone_is_held() {
754 let handle = StreamHandle::new("live".into(), "show".into(), 16);
755 let mut sub = handle.subscribe_resilient();
756
757 // A retained clone (e.g. an egress pump) must NOT pin the channel open.
758 let retained = handle.clone();
759 handle.publish_frame(video(0, true)).unwrap();
760 assert!(sub.recv().await.is_some(), "frame delivered before close");
761
762 retained.close();
763
764 // Without the registry-owned sender this would block forever; bound it so
765 // a regression fails loudly instead of hanging the suite.
766 let got = tokio::time::timeout(std::time::Duration::from_secs(5), sub.recv())
767 .await
768 .expect("recv resolved after close (no hang)");
769 assert!(got.is_none(), "recv returns None once the stream is closed");
770
771 // Publishing post-close is a clean no-op, not a panic.
772 assert_eq!(retained.publish_frame(video(1, false)).unwrap(), 0);
773 assert_eq!(retained.subscriber_count(), 0);
774 }
775
776 /// `package_to` must replay the cached CONFIG access unit even though it was
777 /// published *before* the packager subscribed — the regression behind DASH's
778 /// missing `init.m4s` and the absent master playlist.
779 #[cfg(feature = "hls")]
780 #[tokio::test]
781 async fn package_to_replays_config_published_before_subscribe() {
782 use crate::packager::Packager;
783 use tokio_util::sync::CancellationToken;
784
785 // A trivial packager that records the flags of every frame it is pushed.
786 struct RecordingPackager(std::sync::Arc<std::sync::Mutex<Vec<FrameFlags>>>);
787 #[async_trait::async_trait]
788 impl Packager for RecordingPackager {
789 async fn push(&mut self, frame: &MediaFrame) -> crate::Result<()> {
790 self.0.lock().unwrap().push(frame.flags);
791 Ok(())
792 }
793 async fn finish(&mut self) -> crate::Result<()> {
794 Ok(())
795 }
796 }
797
798 let handle = StreamHandle::new("live".into(), "cam".into(), 16);
799
800 // The one-shot CONFIG frame arrives BEFORE any packager subscribes.
801 let mut cfg = video(0, true);
802 cfg.flags |= FrameFlags::CONFIG;
803 handle.publish_frame(cfg).unwrap();
804
805 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
806 let mut pkg = RecordingPackager(seen.clone());
807 let h = handle.clone();
808 let shutdown = CancellationToken::new();
809 let task = tokio::spawn(async move { h.package_to(&shutdown, &mut pkg).await });
810
811 // Give the replay + subscribe a moment, then end the stream.
812 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
813 handle.publish_frame(video(1, false)).unwrap();
814 handle.close();
815 task.await.unwrap().unwrap();
816
817 assert!(
818 seen.lock()
819 .unwrap()
820 .iter()
821 .any(|f| f.contains(FrameFlags::CONFIG)),
822 "package_to must replay the cached CONFIG frame to the packager"
823 );
824 }
825
826 /// The GOP-overflow signal fires once per GOP (not per dropped frame) and
827 /// resets on the next keyframe so a fresh GOP can report again.
828 #[test]
829 fn gop_truncation_signals_once_per_gop() {
830 use crate::StreamKey;
831 use std::sync::atomic::{AtomicUsize, Ordering};
832
833 #[derive(Default)]
834 struct CountingObserver {
835 truncations: AtomicUsize,
836 }
837 impl Observer for CountingObserver {
838 fn on_gop_truncated(&self, _key: &StreamKey, _capacity: usize) {
839 self.truncations.fetch_add(1, Ordering::Relaxed);
840 }
841 }
842
843 let obs = Arc::new(CountingObserver::default());
844 // Capacity 2: keyframe + 1 delta fit; the 2nd delta onward overflows.
845 let handle = StreamHandle::with_observer(
846 "live".into(),
847 "g".into(),
848 64,
849 2,
850 Arc::clone(&obs) as Arc<dyn Observer>,
851 );
852
853 handle.publish_frame(video(0, true)).unwrap(); // keyframe anchors GOP
854 handle.publish_frame(video(1, false)).unwrap(); // fills to capacity
855 handle.publish_frame(video(2, false)).unwrap(); // first overflow → signal
856 handle.publish_frame(video(3, false)).unwrap(); // still overflowing → silent
857 assert_eq!(
858 obs.truncations.load(Ordering::Relaxed),
859 1,
860 "one signal per GOP"
861 );
862
863 // A new keyframe resets the latch; overflowing again signals once more.
864 handle.publish_frame(video(4, true)).unwrap();
865 handle.publish_frame(video(5, false)).unwrap();
866 handle.publish_frame(video(6, false)).unwrap();
867 handle.publish_frame(video(7, false)).unwrap();
868 assert_eq!(
869 obs.truncations.load(Ordering::Relaxed),
870 2,
871 "next GOP signals again"
872 );
873 }
874
875 /// The GOP byte cap truncates a high-bitrate GOP independently of the frame
876 /// count: a buffer with a generous frame count but a tight byte budget stops
877 /// admitting frames once the budget is reached.
878 #[test]
879 fn gop_byte_cap_bounds_replay_buffer_memory() {
880 use crate::StreamKey;
881 use std::sync::atomic::{AtomicUsize, Ordering};
882
883 #[derive(Default)]
884 struct CountingObserver {
885 truncations: AtomicUsize,
886 }
887 impl Observer for CountingObserver {
888 fn on_gop_truncated(&self, _key: &StreamKey, _capacity: usize) {
889 self.truncations.fetch_add(1, Ordering::Relaxed);
890 }
891 }
892
893 fn frame(pts: i64, key: bool, len: usize) -> MediaFrame {
894 MediaFrame::new_video(
895 pts,
896 pts,
897 bytes::Bytes::from(vec![0u8; len]),
898 CodecId::H264,
899 key,
900 )
901 }
902
903 let obs = Arc::new(CountingObserver::default());
904 // Frame count is generous (100) but the byte budget admits only ~250 B.
905 let handle = StreamHandle::with_config(
906 "live".into(),
907 "hb".into(),
908 64,
909 100,
910 250,
911 None,
912 Arc::clone(&obs) as Arc<dyn Observer>,
913 );
914
915 handle.publish_frame(frame(0, true, 100)).unwrap(); // keyframe: 100 B
916 handle.publish_frame(frame(1, false, 100)).unwrap(); // +100 → 200 B (fits)
917 handle.publish_frame(frame(2, false, 100)).unwrap(); // +100 → 300 B > 250 → drop
918 handle.publish_frame(frame(3, false, 100)).unwrap(); // still over → silent
919
920 // Only keyframe + one delta were buffered, despite the 100-frame count cap.
921 assert_eq!(handle.replay_buffer().len(), 2);
922 assert_eq!(
923 obs.truncations.load(Ordering::Relaxed),
924 1,
925 "byte-cap overflow signals once per GOP"
926 );
927
928 // A new keyframe resets the byte accounting so the next GOP buffers again.
929 handle.publish_frame(frame(4, true, 100)).unwrap();
930 handle.publish_frame(frame(5, false, 100)).unwrap();
931 assert_eq!(handle.replay_buffer().len(), 2, "fresh GOP buffers anew");
932 }
933
934 /// A default `max_lag` configured on the handle is applied to every resilient
935 /// subscription, so a chronically slow consumer is evicted without the caller
936 /// setting a per-subscription bound.
937 #[tokio::test]
938 async fn default_max_lag_evicts_slow_subscriber() {
939 // Capacity 4, default lag budget 4 frames.
940 let handle = StreamHandle::with_config(
941 "live".into(),
942 "slow".into(),
943 4,
944 0,
945 0,
946 Some(4),
947 Arc::new(NoopObserver),
948 );
949 let mut sub = handle.subscribe_resilient();
950
951 // Overrun the ring far past the budget without ever receiving.
952 for i in 0..50 {
953 handle.publish_frame(video(i, i == 0)).unwrap();
954 }
955 // The first recv observes lag beyond the default budget → eviction (None).
956 assert!(
957 sub.recv().await.is_none(),
958 "subscriber evicted by the app default max_lag"
959 );
960 }
961
962 /// `mono_ms` is monotonic and drives `last_frame_ms`, so the idle reaper's
963 /// elapsed-time math is independent of the wall clock.
964 #[test]
965 fn mono_clock_is_monotonic_and_drives_last_frame() {
966 let a = mono_ms();
967 let b = mono_ms();
968 assert!(b >= a, "monotonic clock never goes backward");
969
970 let handle = StreamHandle::new("live".into(), "m".into(), 4);
971 let before = mono_ms();
972 handle.publish_frame(video(0, true)).unwrap();
973 assert!(
974 handle.last_frame_ms() >= before,
975 "last_frame_ms advances on the monotonic clock"
976 );
977 }
978}