use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
use crate::pipeline::{SampleSource, run_pipeline};
use crate::store::{HealthState, MediaStore};
const DEFAULT_BACKOFF_MIN: Duration = Duration::from_millis(500);
const DEFAULT_BACKOFF_MAX: Duration = Duration::from_secs(30);
const DEFAULT_BACKOFF_FACTOR: f64 = 2.0;
pub trait SourceConnector: Send + Sync + 'static {
type Source: SampleSource + Send;
fn connect(&self) -> impl Future<Output = crate::Result<Self::Source>> + Send;
}
impl SourceConnector for crate::source::rtsp::RtspSource {
type Source = crate::source::rtsp::RtspSession;
async fn connect(&self) -> crate::Result<Self::Source> {
crate::source::rtsp::RtspSource::connect(self).await
}
}
impl SourceConnector for crate::source::rtp_udp::RtpUdpSource {
type Source = crate::source::rtp_udp::RtpUdpSession;
async fn connect(&self) -> crate::Result<Self::Source> {
crate::source::rtp_udp::RtpUdpSource::connect(self).await
}
}
impl SourceConnector for crate::source::ts_udp::TsUdpSource {
type Source = crate::source::ts_udp::TsUdpSession;
async fn connect(&self) -> crate::Result<Self::Source> {
crate::source::ts_udp::TsUdpSource::connect(self).await
}
}
impl SourceConnector for crate::source::ts_http::TsHttpSource {
type Source = crate::source::ts_http::TsHttpSession;
async fn connect(&self) -> crate::Result<Self::Source> {
crate::source::ts_http::TsHttpSource::connect(self).await
}
}
impl SourceConnector for crate::source::hls_pull::HlsPullSource {
type Source = crate::source::hls_pull::HlsPullSession;
async fn connect(&self) -> crate::Result<Self::Source> {
crate::source::hls_pull::HlsPullSource::connect(self).await
}
}
#[derive(Debug, Clone)]
pub struct Backoff {
min: Duration,
max: Duration,
factor: f64,
current: Duration,
}
impl Backoff {
pub fn new(min: Duration, max: Duration, factor: f64) -> Self {
Backoff {
min,
max,
factor,
current: min,
}
}
pub fn production_default() -> Self {
Backoff::new(
DEFAULT_BACKOFF_MIN,
DEFAULT_BACKOFF_MAX,
DEFAULT_BACKOFF_FACTOR,
)
}
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Duration {
let delay = self.current;
let grown = self.current.mul_f64(self.factor);
self.current = grown.min(self.max);
delay
}
pub fn reset(&mut self) {
self.current = self.min;
}
}
fn record_route_up(name: &str, state: HealthState) {
let up = if matches!(state, HealthState::Live) {
1.0
} else {
0.0
};
metrics::gauge!(crate::prometheus::ROUTE_UP, "route" => name.to_string()).set(up);
}
fn record_reconnect(name: &str) {
metrics::counter!(crate::prometheus::SOURCE_RECONNECTS_TOTAL, "route" => name.to_string())
.increment(1);
}
#[tracing::instrument(
name = "route",
skip(connector, store, target_duration_secs, part_target_ms, backoff, name, shutdown),
fields(route = %name)
)]
pub async fn supervise<C: SourceConnector>(
connector: C,
store: Arc<MediaStore>,
target_duration_secs: f64,
part_target_ms: u32,
mut backoff: Backoff,
name: String,
mut shutdown: watch::Receiver<bool>,
) {
tracing::info!("connecting");
store.set_health(HealthState::Connecting);
record_route_up(&name, HealthState::Connecting);
let mut attempt: u64 = 0;
loop {
if *shutdown.borrow() {
break;
}
match connector.connect().await {
Ok(source) => {
attempt = 0;
tracing::info!("connected, ingest live");
store.set_health(HealthState::Live);
record_route_up(&name, HealthState::Live);
backoff.reset();
if let Err(e) = run_pipeline(
store.clone(),
target_duration_secs,
part_target_ms,
source,
&name,
)
.await
{
tracing::warn!(error = %e, "pipeline stopped");
}
store.set_health(HealthState::Reconnecting);
record_route_up(&name, HealthState::Reconnecting);
record_reconnect(&name);
}
Err(e) => {
attempt += 1;
tracing::warn!(error = %e, attempt, "failed to connect");
store.set_health(HealthState::Reconnecting);
record_route_up(&name, HealthState::Reconnecting);
record_reconnect(&name);
}
}
if *shutdown.borrow() {
break;
}
let delay = backoff.next();
tracing::warn!(
delay_ms = delay.as_millis() as u64,
attempt,
"reconnecting after backoff"
);
tokio::select! {
() = tokio::time::sleep(delay) => {}
_ = shutdown.changed() => {
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::MockSource;
use std::sync::atomic::{AtomicUsize, Ordering};
use transmux::avc_config_from_sprop;
use transmux::pipeline::{CodecConfig, Sample, TrackSpec};
const SPROP: &str = "Z0IAKeKQFAe2AtwEBAaQeJEV,aM48gA==";
const VIDEO_TIMESCALE: u32 = 90_000;
const FRAME_DUR: u32 = VIDEO_TIMESCALE / 30;
fn video_track_spec() -> TrackSpec {
let config = avc_config_from_sprop(SPROP).expect("valid sprop");
TrackSpec::new(
1,
VIDEO_TIMESCALE,
CodecConfig::Avc {
config,
width: 0,
height: 0,
},
)
}
fn sample_batches(n: u32) -> Vec<Vec<(u32, Sample)>> {
(0..n)
.map(|i| {
let data = vec![0xABu8.wrapping_add(i as u8); 32];
let sample = Sample::new(data, FRAME_DUR, i == 0, 0);
vec![(1u32, sample)]
})
.collect()
}
fn tiny_backoff() -> Backoff {
Backoff::new(Duration::from_millis(1), Duration::from_millis(20), 2.0)
}
struct FlakyConnector {
fail_times: usize,
connect_count: Arc<AtomicUsize>,
specs: Vec<TrackSpec>,
batches: Vec<Vec<(u32, Sample)>>,
}
impl SourceConnector for FlakyConnector {
type Source = MockSource;
async fn connect(&self) -> crate::Result<MockSource> {
let attempt = self.connect_count.fetch_add(1, Ordering::SeqCst);
if attempt < self.fail_times {
return Err(crate::MultimuxError::Connect {
reason: "flaky connector: simulated failure".into(),
});
}
Ok(MockSource::new(self.specs.clone(), self.batches.clone()))
}
}
struct PacedSource {
specs: Vec<TrackSpec>,
batches: std::vec::IntoIter<Vec<(u32, Sample)>>,
delay: Duration,
}
impl SampleSource for PacedSource {
fn track_specs(&self) -> Vec<TrackSpec> {
self.specs.clone()
}
async fn next_samples(&mut self) -> crate::Result<Option<Vec<(u32, Sample)>>> {
tokio::time::sleep(self.delay).await;
Ok(self.batches.next())
}
}
struct PacedFlakyConnector {
fail_times: usize,
connect_count: Arc<AtomicUsize>,
specs: Vec<TrackSpec>,
batches: Vec<Vec<(u32, Sample)>>,
delay: Duration,
}
impl SourceConnector for PacedFlakyConnector {
type Source = PacedSource;
async fn connect(&self) -> crate::Result<PacedSource> {
let attempt = self.connect_count.fetch_add(1, Ordering::SeqCst);
if attempt < self.fail_times {
return Err(crate::MultimuxError::Connect {
reason: "flaky connector: simulated failure".into(),
});
}
Ok(PacedSource {
specs: self.specs.clone(),
batches: self.batches.clone().into_iter(),
delay: self.delay,
})
}
}
async fn wait_until(timeout: Duration, mut f: impl FnMut() -> bool) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if f() {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
}
#[test]
fn backoff_grows_and_caps() {
let mut b = Backoff::new(Duration::from_millis(10), Duration::from_millis(100), 2.0);
assert_eq!(b.next(), Duration::from_millis(10));
assert_eq!(b.next(), Duration::from_millis(20));
assert_eq!(b.next(), Duration::from_millis(40));
assert_eq!(b.next(), Duration::from_millis(80));
assert_eq!(b.next(), Duration::from_millis(100));
assert_eq!(b.next(), Duration::from_millis(100), "stays capped");
}
#[test]
fn backoff_reset_returns_to_min() {
let mut b = Backoff::new(Duration::from_millis(10), Duration::from_millis(100), 2.0);
let _ = b.next();
let _ = b.next();
b.reset();
assert_eq!(
b.next(),
Duration::from_millis(10),
"back to min after reset"
);
}
#[tokio::test]
async fn reconnects_after_connect_failure_and_reaches_live() {
let store = Arc::new(MediaStore::new(1.0, 500, 8));
let connect_count = Arc::new(AtomicUsize::new(0));
let connector = PacedFlakyConnector {
fail_times: 1,
connect_count: connect_count.clone(),
specs: vec![video_track_spec()],
batches: sample_batches(50),
delay: Duration::from_millis(2),
};
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise(
connector,
store.clone(),
1.0,
500,
tiny_backoff(),
"test-route".to_string(),
shutdown_rx,
));
let reached_live = wait_until(Duration::from_secs(2), || {
store.health() == HealthState::Live && store.init_bytes().is_some()
})
.await;
assert!(
reached_live,
"route must recover to Live after one connect failure"
);
assert!(
connect_count.load(Ordering::SeqCst) >= 2,
"connector must have been retried after the first failure"
);
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(1), handle)
.await
.expect("supervise returns promptly on shutdown")
.expect("supervise task did not panic");
}
#[tokio::test]
async fn reconnects_after_source_eof() {
let store = Arc::new(MediaStore::new(1.0, 500, 8));
let connect_count = Arc::new(AtomicUsize::new(0));
let connector = FlakyConnector {
fail_times: 0,
connect_count: connect_count.clone(),
specs: vec![video_track_spec()],
batches: sample_batches(3),
};
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise(
connector,
store.clone(),
1.0,
500,
tiny_backoff(),
"test-route".to_string(),
shutdown_rx,
));
let reconnected = wait_until(Duration::from_secs(2), || {
connect_count.load(Ordering::SeqCst) >= 2
})
.await;
assert!(
reconnected,
"connector must be called again after source EOF"
);
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(1), handle)
.await
.expect("supervise returns promptly on shutdown")
.expect("supervise task did not panic");
}
#[tokio::test]
async fn shutdown_stops_the_loop_promptly() {
let store = Arc::new(MediaStore::new(1.0, 500, 8));
let connect_count = Arc::new(AtomicUsize::new(0));
let connector = FlakyConnector {
fail_times: usize::MAX,
connect_count,
specs: vec![video_track_spec()],
batches: Vec::new(),
};
let backoff = Backoff::new(Duration::from_secs(10), Duration::from_secs(30), 2.0);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(supervise(
connector,
store,
1.0,
500,
backoff,
"test-route".to_string(),
shutdown_rx,
));
tokio::time::sleep(Duration::from_millis(20)).await;
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_millis(500), handle)
.await
.expect("supervise must return promptly on shutdown, not after the 10s backoff")
.expect("supervise task did not panic");
}
}