Skip to main content

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}