mod egress;
mod rtmp;
mod rtsp;
mod srt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use media_plane::egress::{NegotiationOutcome, PushEgress, TrackSelection};
use media_plane::trunk::{SampleCursorItem, Trunk};
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
pub use egress::PushTransportEgress;
use crate::config::PushFormat;
use broadcast_common::Package;
use transmux::TsMux;
use transmux::ir::{Media, TrackSpec};
pub use rtmp::{RtmpTransport, RtmpTransportConfig};
pub use rtsp::{RtspTransport, RtspTransportConfig};
pub use srt::{SrtTransport, SrtTransportConfig};
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum SendMediaError {
#[error("push mux failed: {0}")]
Mux(String),
#[error("push send failed: {0}")]
Transport(Box<dyn std::error::Error + Send + Sync>),
}
#[async_trait::async_trait]
pub trait PushTransport: Send + 'static {
type Config: Send + Sync + Clone;
type Error: std::error::Error + Send + Sync + 'static;
async fn connect(url: &str, config: &Self::Config) -> Result<Self, Self::Error>
where
Self: Sized;
async fn send(&mut self, data: &[u8]) -> Result<(), Self::Error>;
async fn write_message(&mut self, message: &[u8]) -> Result<(), Self::Error> {
self.send(message).await
}
async fn setup(&mut self, _tracks: &[TrackSpec]) -> Result<(), Self::Error> {
Ok(())
}
fn supports_codec(&self, _config: &transmux::CodecConfig) -> bool {
true
}
fn encode_media(&mut self, media: &Media) -> Result<Vec<bytes::Bytes>, SendMediaError> {
let bytes = TsMux::new()
.package(media)
.map_err(|e| SendMediaError::Mux(e.to_string()))?;
Ok(vec![bytes::Bytes::from(bytes)])
}
async fn send_media(&mut self, media: &Media) -> Result<u64, SendMediaError> {
let messages = self.encode_media(media)?;
let mut total = 0u64;
for message in &messages {
self.send(message)
.await
.map_err(|e| SendMediaError::Transport(Box::new(e)))?;
total += message.len() as u64;
}
Ok(total)
}
fn close(&mut self);
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReconnectState {
Ready,
Backoff { resume_at: Instant },
Failed,
}
#[derive(Debug, Clone)]
pub struct ReconnectEngine {
state: ReconnectState,
attempt: u32,
policy: crate::config::ReconnectPolicy,
}
impl ReconnectEngine {
pub fn new(policy: crate::config::ReconnectPolicy) -> Self {
Self {
state: ReconnectState::Ready,
attempt: 0,
policy,
}
}
pub fn should_connect(&self) -> bool {
match self.state {
ReconnectState::Ready => true,
ReconnectState::Failed => false,
ReconnectState::Backoff { resume_at } => Instant::now() >= resume_at,
}
}
pub fn on_connect(&mut self) {
self.attempt = 0;
self.state = ReconnectState::Ready;
}
pub fn on_disconnect(&mut self) {
self.attempt = self.attempt.saturating_add(1);
if let Some(max) = self.policy.max_attempts
&& self.attempt >= max
{
self.state = ReconnectState::Failed;
return;
}
let backoff = self.policy.backoff_for(self.attempt.saturating_sub(1));
self.state = ReconnectState::Backoff {
resume_at: Instant::now() + backoff,
};
}
pub fn is_failed(&self) -> bool {
self.state == ReconnectState::Failed
}
pub fn time_until_retry(&self) -> Duration {
match self.state {
ReconnectState::Ready | ReconnectState::Failed => Duration::ZERO,
ReconnectState::Backoff { resume_at } => {
resume_at.saturating_duration_since(Instant::now())
}
}
}
pub fn state(&self) -> &ReconnectState {
&self.state
}
}
#[derive(Debug, Default)]
pub struct PushMetrics {
samples_sent: AtomicU64,
samples_dropped: AtomicU64,
bytes_sent: AtomicU64,
reconnect_count: AtomicU32,
}
impl PushMetrics {
pub fn samples_sent(&self) -> u64 {
self.samples_sent.load(Ordering::Relaxed)
}
pub fn samples_dropped(&self) -> u64 {
self.samples_dropped.load(Ordering::Relaxed)
}
pub fn bytes_sent(&self) -> u64 {
self.bytes_sent.load(Ordering::Relaxed)
}
pub fn reconnect_count(&self) -> u32 {
self.reconnect_count.load(Ordering::Relaxed)
}
}
fn log_partial_selection(url: &str, proposed: usize, selection: &TrackSelection) {
if selection.track_ids.len() < proposed {
tracing::warn!(
url,
carried = selection.track_ids.len(),
proposed,
"push output cannot carry every track in this program; excluded tracks are \
dropped from this push"
);
}
}
pub async fn drive_push<T: PushTransport>(
trunk: Arc<Trunk>,
url: String,
config: T::Config,
_format: PushFormat,
reconnect: crate::config::ReconnectPolicy,
cancel: CancellationToken,
) -> PushMetrics {
const UNSATISFIABLE: &str = "no track this output's container format can carry";
let metrics = PushMetrics::default();
let mut egress: Option<PushTransportEgress<T>> = None;
let mut engine = ReconnectEngine::new(reconnect);
let mut cursor = trunk.subscribe();
let mut negotiated_generation: Option<u64> = None;
let _ = _format;
loop {
if cancel.is_cancelled() {
if let Some(e) = egress.as_mut() {
e.transport_mut().close();
}
return metrics;
}
if let Some(listener) = trunk.listen() {
listener.wait_deadline(Instant::now() + Duration::from_millis(250));
}
if let Some(e) = egress.as_mut() {
let generation = trunk.track_generation();
if negotiated_generation != Some(generation) {
let tracks_now = trunk.tracks();
match e.renegotiate(&tracks_now) {
NegotiationOutcome::Accepted(sel) => {
log_partial_selection(&url, tracks_now.len(), &sel);
negotiated_generation = Some(generation);
}
NegotiationOutcome::Refused { reason } => {
tracing::warn!(
url,
reason,
"push renegotiate refused; continuing on the previous track \
selection"
);
negotiated_generation = Some(generation);
}
NegotiationOutcome::Error(err) => {
tracing::warn!(url, error = %err, "push renegotiate failed; closing");
e.transport_mut().close();
egress = None;
negotiated_generation = None;
engine.on_disconnect();
}
_ => {}
}
}
}
let mut dropped_while_disconnected = 0u64;
while let Some(item) = cursor.poll() {
if let SampleCursorItem::Lagged { skipped } | SampleCursorItem::Degraded { skipped } =
&item
{
metrics
.samples_dropped
.fetch_add(*skipped, Ordering::Relaxed);
}
let Some(e) = egress.as_mut() else {
if matches!(
item,
SampleCursorItem::Timed { .. } | SampleCursorItem::Sparse { .. }
) {
dropped_while_disconnected += 1;
}
continue;
};
match e.send(&item) {
Ok(()) => {
if matches!(
item,
SampleCursorItem::Timed { .. } | SampleCursorItem::Sparse { .. }
) {
metrics.samples_sent.fetch_add(1, Ordering::Relaxed);
}
}
Err(SendMediaError::Mux(msg)) => {
tracing::error!(error = %msg, "push mux failed; dropping sample");
metrics.samples_dropped.fetch_add(1, Ordering::Relaxed);
}
Err(SendMediaError::Transport(err)) => {
tracing::warn!(url, error = %err, "push send failed; reconnecting");
e.transport_mut().close();
egress = None;
negotiated_generation = None;
engine.on_disconnect();
}
}
}
if dropped_while_disconnected > 0 {
metrics
.samples_dropped
.fetch_add(dropped_while_disconnected, Ordering::Relaxed);
}
if engine.is_failed() {
return metrics;
}
if engine.should_connect() && egress.is_none() {
match T::connect(&url, &config).await {
Ok(conn) => {
let mut e = PushTransportEgress::new(conn, UNSATISFIABLE);
let tracks_now = trunk.tracks();
let generation = trunk.track_generation();
match e.negotiate(&tracks_now) {
NegotiationOutcome::Accepted(sel) => {
log_partial_selection(&url, tracks_now.len(), &sel);
let selected = e.selected_tracks().to_vec();
if let Err(err) = e.transport_mut().setup(&selected).await {
tracing::warn!(url, error = %err, "push setup failed; closing");
e.transport_mut().close();
engine.on_disconnect();
} else {
engine.on_connect();
metrics.reconnect_count.fetch_add(1, Ordering::Relaxed);
negotiated_generation = Some(generation);
egress = Some(e);
}
}
NegotiationOutcome::Error(err) => {
tracing::warn!(url, error = %err, "push negotiate failed; backing off");
engine.on_disconnect();
}
_ => {
engine.on_disconnect();
}
}
}
Err(e) => {
tracing::warn!(url, error = %e, "push connect failed; backing off");
engine.on_disconnect();
}
}
}
if let Some(e) = egress.as_mut() {
if let Err(SendMediaError::Transport(err)) = e.flush_transmit().await {
tracing::warn!(url, error = %err, "push send failed while flushing; reconnecting");
e.transport_mut().close();
egress = None;
negotiated_generation = None;
engine.on_disconnect();
}
} else {
let wait = engine.time_until_retry();
if !wait.is_zero() {
sleep(wait).await;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ReconnectPolicy;
fn policy(max_attempts: Option<u32>) -> ReconnectPolicy {
ReconnectPolicy {
initial_backoff_ms: 1_000,
max_backoff_ms: 30_000,
max_attempts,
}
}
#[test]
fn backoff_doubles_each_attempt_capped_at_max() {
let eng = ReconnectEngine::new(policy(None));
assert_eq!(eng.policy.backoff_for(0), Duration::from_millis(1_000));
assert_eq!(eng.policy.backoff_for(1), Duration::from_millis(2_000));
assert_eq!(eng.policy.backoff_for(2), Duration::from_millis(4_000));
assert_eq!(eng.policy.backoff_for(20), Duration::from_millis(30_000));
assert_eq!(eng.policy.backoff_for(30), Duration::from_millis(30_000));
}
#[test]
fn max_attempts_triggers_failed() {
let mut eng = ReconnectEngine::new(policy(Some(3)));
assert!(!eng.is_failed());
for _ in 0..3 {
eng.on_disconnect();
}
assert!(
eng.is_failed(),
"exceeding max_attempts must fail the engine"
);
}
#[test]
fn on_connect_resets_attempt_counter() {
let mut eng = ReconnectEngine::new(policy(Some(2)));
eng.on_disconnect();
eng.on_disconnect();
assert!(eng.is_failed());
eng.on_connect();
assert!(!eng.is_failed());
assert!(eng.should_connect());
}
#[test]
fn should_connect_respects_backoff_timing() {
let mut eng = ReconnectEngine::new(policy(None));
assert!(eng.should_connect());
eng.on_disconnect();
assert!(eng.time_until_retry() > Duration::ZERO);
assert!(!eng.should_connect() || eng.time_until_retry().is_zero());
assert!(!eng.is_failed());
}
}