use crate::core::context::output::Output;
use crate::error::Error::{
RtmpCreateStream, RtmpRegistrationQueueFull, RtmpServerAlreadyStarted, RtmpStreamAlreadyExists,
};
use crate::flv::flv_buffer::FlvBuffer;
use crate::flv::flv_tag::FlvTag;
use crate::rtmp::poller::{waker_pair, WakeHandle, Waker};
use crate::rtmp::reactor::{
effective_max_connections, EnqueueRefused, PublisherFeed, PublisherRegistration,
PublisherSource, Reactor, RegistrationHandoff, RegistrationKillSwitch, StreamKeyClaim,
CHANNEL_HEADROOM, PUBLISHER_CHANNEL_CAPACITY,
};
use bytes::{BufMut, Bytes};
use log::{debug, error, info, warn};
use rml_rtmp::chunk_io::ChunkSerializer;
use rml_rtmp::messages::{MessagePayload, RtmpMessage};
use rml_rtmp::rml_amf0::Amf0Value;
use rml_rtmp::time::RtmpTimestamp;
use std::collections::HashMap;
use std::marker::PhantomData;
use std::net::{Shutdown, TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::thread::JoinHandle;
#[derive(Clone)]
pub struct Initialization;
#[derive(Clone)]
pub struct Running;
#[derive(Clone)]
pub struct Ended;
#[derive(Clone)]
pub struct EmbedRtmpServer<S> {
address: String,
bound_addr: Option<std::net::SocketAddr>,
status: Arc<AtomicUsize>,
stream_keys: Arc<dashmap::DashSet<String>>,
registrations: Option<Arc<RegistrationHandoff>>,
wake_handle: Option<WakeHandle>,
threads: Arc<Mutex<Vec<JoinHandle<()>>>>,
server_thread_ids: Arc<Mutex<Vec<std::thread::ThreadId>>>,
terminal_cause: Arc<AtomicUsize>,
gop_limit: usize,
max_connections: Option<usize>,
state: PhantomData<S>,
}
const STATUS_INIT: usize = 0;
const STATUS_RUN: usize = 1;
const STATUS_END: usize = 2;
const CAUSE_NONE: usize = 0;
const CAUSE_DELIBERATE: usize = 1;
const CAUSE_FATAL: usize = 2;
fn close_intake_and_publish_end(
registrations: &RegistrationHandoff,
status: &AtomicUsize,
terminal_cause: &AtomicUsize,
) {
let _ = terminal_cause.compare_exchange(
CAUSE_NONE,
CAUSE_FATAL,
Ordering::AcqRel,
Ordering::Acquire,
);
registrations.close();
status.store(STATUS_END, Ordering::Release);
}
fn settle_server_threads(
server_thread_ids: &Mutex<Vec<std::thread::ThreadId>>,
threads: &Mutex<Vec<JoinHandle<()>>>,
) {
let current = std::thread::current().id();
let is_server_thread = server_thread_ids
.lock()
.unwrap_or_else(PoisonError::into_inner)
.contains(¤t);
if is_server_thread {
return;
}
let mut panicked: Vec<String> = Vec::new();
let mut payloads: Vec<Box<dyn std::any::Any + Send>> = Vec::new();
{
let mut registry = threads.lock().unwrap_or_else(PoisonError::into_inner);
for handle in registry.drain(..) {
if handle.thread().id() == current {
continue;
}
let name = handle.thread().name().unwrap_or("rtmp-server").to_string();
if let Err(payload) = handle.join() {
payloads.push(payload);
panicked.push(name);
}
}
}
for payload in payloads {
std::mem::forget(payload);
}
if !panicked.is_empty() {
let report = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
for name in &panicked {
error!("Thread[{name}] terminated by an uncontained panic");
}
}));
if let Err(payload) = report {
std::mem::forget(payload);
}
}
}
struct StartFailGuard {
armed: bool,
registrations: Arc<RegistrationHandoff>,
status: Arc<AtomicUsize>,
wake_handle: Option<WakeHandle>,
threads: Arc<Mutex<Vec<JoinHandle<()>>>>,
server_thread_ids: Arc<Mutex<Vec<std::thread::ThreadId>>>,
terminal_cause: Arc<AtomicUsize>,
}
impl Drop for StartFailGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
close_intake_and_publish_end(&self.registrations, &self.status, &self.terminal_cause);
if let Some(wake_handle) = &self.wake_handle {
wake_handle.wake();
}
settle_server_threads(&self.server_thread_ids, &self.threads);
}
}
impl<S: 'static> EmbedRtmpServer<S> {
fn into_state<T>(self) -> EmbedRtmpServer<T> {
EmbedRtmpServer {
address: self.address,
bound_addr: self.bound_addr,
status: self.status,
stream_keys: self.stream_keys,
registrations: self.registrations,
wake_handle: self.wake_handle,
threads: self.threads,
server_thread_ids: self.server_thread_ids,
terminal_cause: self.terminal_cause,
gop_limit: self.gop_limit,
max_connections: self.max_connections,
state: Default::default(),
}
}
pub fn is_stopped(&self) -> bool {
self.status.load(Ordering::Acquire) == STATUS_END
}
fn signal_stop(&self) {
let _ = self.terminal_cause.compare_exchange(
CAUSE_NONE,
CAUSE_DELIBERATE,
Ordering::AcqRel,
Ordering::Acquire,
);
match &self.registrations {
Some(registrations) => {
close_intake_and_publish_end(registrations, &self.status, &self.terminal_cause)
}
None => self.status.store(STATUS_END, Ordering::Release),
}
if let Some(wake_handle) = &self.wake_handle {
wake_handle.wake();
}
}
}
impl EmbedRtmpServer<Initialization> {
pub fn new(address: impl Into<String>) -> EmbedRtmpServer<Initialization> {
Self::new_with_gop_limit(address, 1)
}
pub fn new_with_gop_limit(
address: impl Into<String>,
gop_limit: usize,
) -> EmbedRtmpServer<Initialization> {
Self {
address: address.into(),
bound_addr: None,
status: Arc::new(AtomicUsize::new(STATUS_INIT)),
stream_keys: Default::default(),
registrations: None,
wake_handle: None,
threads: Default::default(),
server_thread_ids: Default::default(),
terminal_cause: Default::default(),
gop_limit,
max_connections: None,
state: Default::default(),
}
}
pub fn set_max_connections(mut self, max_connections: usize) -> Self {
self.max_connections = Some(max_connections);
self
}
pub fn start(mut self) -> crate::error::Result<EmbedRtmpServer<Running>> {
if self.status.load(Ordering::Acquire) != STATUS_INIT {
return Err(RtmpServerAlreadyStarted);
}
let listener = TcpListener::bind(self.address.clone())
.map_err(|e| <std::io::Error as Into<crate::error::Error>>::into(e))?;
let actual_addr = listener
.local_addr()
.map_err(|e| <std::io::Error as Into<crate::error::Error>>::into(e))?;
self.bound_addr = Some(actual_addr);
listener
.set_nonblocking(true)
.map_err(|e| <std::io::Error as Into<crate::error::Error>>::into(e))?;
let effective_max = effective_max_connections(self.max_connections);
let channel_capacity = effective_max.saturating_add(CHANNEL_HEADROOM);
let (stream_sender, stream_receiver) = crossbeam_channel::bounded(channel_capacity);
let registrations = Arc::new(RegistrationHandoff::new());
self.registrations = Some(registrations.clone());
let (waker, wake_handle) = match waker_pair() {
Ok((waker, handle)) => (Some(waker), Some(handle)),
Err(e) => {
warn!("PERF-3: reactor waker unavailable ({e:?}); falling back to the poll-timeout for in-process media latency");
(None, None)
}
};
self.wake_handle = wake_handle;
if self
.status
.compare_exchange(STATUS_INIT, STATUS_RUN, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err(RtmpServerAlreadyStarted);
}
let mut start_fail_guard = StartFailGuard {
armed: true,
registrations: registrations.clone(),
status: self.status.clone(),
wake_handle: self.wake_handle.clone(),
threads: self.threads.clone(),
server_thread_ids: self.server_thread_ids.clone(),
terminal_cause: self.terminal_cause.clone(),
};
let status = self.status.clone();
let max_connections = self.max_connections;
let worker_registrations = registrations.clone();
let worker_terminal_cause = self.terminal_cause.clone();
let result = std::thread::Builder::new()
.name("rtmp-server-worker".to_string())
.spawn(move || {
handle_connections(
stream_receiver,
worker_registrations,
self.gop_limit,
max_connections,
status,
worker_terminal_cause,
waker,
)
});
match result {
Ok(handle) => {
self.server_thread_ids
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(handle.thread().id());
self.threads
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(handle);
}
Err(e) => {
self.signal_stop();
error!("Thread[rtmp-server-worker] exited with error: {e}");
return Err(crate::error::Error::RtmpThreadExited);
}
}
info!(
"Embed rtmp server listening for connections on {} (actual: {}, max_connections: {}).",
&self.address, actual_addr, effective_max
);
let status = self.status.clone();
let terminal_cause = self.terminal_cause.clone();
let result = std::thread::Builder::new()
.name("rtmp-server-io".to_string())
.spawn(move || {
for stream in listener.incoming() {
if status.load(Ordering::Acquire) == STATUS_END {
info!("Embed rtmp server stopped.");
break;
}
match stream {
Ok(stream) => {
match stream_sender.try_send(stream) {
Ok(_) => {
debug!("New rtmp connection accepted.");
}
Err(crossbeam_channel::TrySendError::Full(s)) => {
let _ = s.shutdown(Shutdown::Both);
debug!(
"Connection rejected: server at capacity (channel full)"
);
}
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {
error!("Connection channel disconnected");
close_intake_and_publish_end(
®istrations,
&status,
&terminal_cause,
);
return;
}
}
}
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
std::thread::sleep(std::time::Duration::from_millis(100));
} else if is_fd_exhaustion(&e) {
warn!("Accept failed, file descriptors exhausted: {e}");
std::thread::sleep(std::time::Duration::from_millis(100));
} else {
debug!("Rtmp connection error: {:?}", e);
}
}
}
}
});
match result {
Ok(handle) => {
self.server_thread_ids
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(handle.thread().id());
self.threads
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(handle);
}
Err(e) => {
self.signal_stop();
settle_server_threads(&self.server_thread_ids, &self.threads);
error!("Thread[rtmp-server-io] exited with error: {e}");
return Err(crate::error::Error::RtmpThreadExited);
}
}
start_fail_guard.armed = false;
Ok(self.into_state())
}
}
#[derive(Clone)]
pub struct RtmpStreamSender {
inner: crossbeam_channel::Sender<Vec<u8>>,
wake_handle: Option<WakeHandle>,
}
impl RtmpStreamSender {
pub fn send(&self, chunk: Vec<u8>) -> crate::error::Result<()> {
self.inner
.send(chunk)
.map_err(|_| crate::error::Error::RtmpStreamClosed)?;
if let Some(wake) = &self.wake_handle {
wake.wake();
}
Ok(())
}
}
impl EmbedRtmpServer<Running> {
pub fn local_addr(&self) -> Option<std::net::SocketAddr> {
self.bound_addr
}
pub fn create_rtmp_input(
&self,
app_name: impl Into<String>,
stream_key: impl Into<String>,
) -> crate::error::Result<Output> {
let feed_sender = self.create_bypass_feed_sender(app_name, stream_key)?;
let wake_handle = self.wake_handle.clone();
if let Some(waker) = &wake_handle {
waker.wake();
}
let mut flv_buffer = FlvBuffer::new();
let mut serializer = ChunkSerializer::new();
let terminal_cause = self.terminal_cause.clone();
let classify_feed_send_failure = move |what: &str, e: &dyn std::fmt::Debug| {
if terminal_cause.load(Ordering::Acquire) == CAUSE_DELIBERATE {
info!("The rtmp server was deliberately stopped; ending the in-process publisher (failing its {what} write)");
} else {
error!("Failed to send in-process {what}: {e:?}");
}
};
let write_callback: Box<dyn FnMut(&[u8]) -> i32 + Send> =
Box::new(move |buf: &[u8]| -> i32 {
flv_buffer.write_data(buf);
while let Some(mut flv_tag) = flv_buffer.get_flv_tag() {
flv_tag.header.stream_id = 1;
let tag_type = flv_tag.header.tag_type;
if tag_type == 0x08 || tag_type == 0x09 {
let timestamp = flv_tag.header.timestamp
| ((flv_tag.header.timestamp_ext as u32) << 24);
let feed = PublisherFeed::Media {
tag_type,
timestamp: RtmpTimestamp { value: timestamp },
data: flv_tag.data,
};
if let Err(e) = feed_sender.send(feed) {
classify_feed_send_failure("media tag", &e);
return -1;
}
if let Some(waker) = &wake_handle {
waker.wake();
}
continue;
}
match serializer.serialize(&flv_tag_to_message_payload(flv_tag), false, true) {
Ok(packet) => {
if let Err(e) = feed_sender.send(PublisherFeed::Raw(packet.bytes)) {
classify_feed_send_failure("RTMP packet", &e);
return -1;
}
if let Some(waker) = &wake_handle {
waker.wake();
}
}
Err(e) => {
error!("Failed to serialize RTMP message: {:?}", e);
return -1;
}
}
}
buf.len() as i32
});
let output: Output = write_callback.into();
Ok(output
.set_format("flv")
.set_video_codec("h264")
.set_audio_codec("aac")
.set_format_opt("flvflags", "no_duration_filesize"))
}
pub fn create_stream_sender(
&self,
app_name: impl Into<String>,
stream_key: impl Into<String>,
) -> crate::error::Result<RtmpStreamSender> {
let stream_key = stream_key.into();
let Ok(claim) = StreamKeyClaim::claim(self.stream_keys.clone(), stream_key.clone()) else {
return Err(RtmpStreamAlreadyExists(stream_key));
};
let (sender, receiver) = crossbeam_channel::bounded(PUBLISHER_CHANNEL_CAPACITY);
self.register_publisher(claim, PublisherSource::Raw(receiver))?;
for packet_bytes in build_publish_control(app_name.into(), stream_key)? {
if sender.send(packet_bytes).is_err() {
error!("Can't send publish control command to rtmp server.");
return Err(RtmpCreateStream.into());
}
}
if let Some(wake_handle) = &self.wake_handle {
wake_handle.wake();
}
Ok(RtmpStreamSender {
inner: sender,
wake_handle: self.wake_handle.clone(),
})
}
fn create_bypass_feed_sender(
&self,
app_name: impl Into<String>,
stream_key: impl Into<String>,
) -> crate::error::Result<crossbeam_channel::Sender<PublisherFeed>> {
let stream_key = stream_key.into();
let Ok(claim) = StreamKeyClaim::claim(self.stream_keys.clone(), stream_key.clone()) else {
return Err(RtmpStreamAlreadyExists(stream_key));
};
let (sender, receiver) = crossbeam_channel::bounded(PUBLISHER_CHANNEL_CAPACITY);
self.register_publisher(claim, PublisherSource::Feed(receiver))?;
for packet_bytes in build_publish_control(app_name.into(), stream_key)? {
if sender.send(PublisherFeed::Raw(packet_bytes)).is_err() {
error!("Can't send publish control command to rtmp server.");
return Err(RtmpCreateStream.into());
}
}
Ok(sender)
}
fn register_publisher(
&self,
claim: StreamKeyClaim,
source: PublisherSource,
) -> crate::error::Result<()> {
let registration = PublisherRegistration { claim, source };
let registrations = match self.registrations.as_ref() {
Some(registrations) => registrations,
None => {
error!("Publisher registration queue not initialized");
return Err(RtmpCreateStream.into());
}
};
match registrations.enqueue(registration) {
Ok(()) => Ok(()),
Err(EnqueueRefused::Closed(registration)) => {
drop(registration);
if self.status.load(Ordering::Acquire) != STATUS_END {
warn!("Rtmp server worker already exited. Can't create stream sender.");
} else {
error!("Rtmp Server aborted. Can't create stream sender.");
}
Err(RtmpCreateStream)
}
Err(EnqueueRefused::Full(registration)) => {
drop(registration);
warn!("Rtmp registration queue is full. Can't create stream sender.");
Err(RtmpRegistrationQueueFull)
}
}
}
pub fn stop(self) -> EmbedRtmpServer<Ended> {
self.signal_stop();
settle_server_threads(&self.server_thread_ids, &self.threads);
self.into_state()
}
}
fn is_fd_exhaustion(e: &std::io::Error) -> bool {
#[cfg(unix)]
{
matches!(e.raw_os_error(), Some(code) if code == libc::EMFILE || code == libc::ENFILE)
}
#[cfg(windows)]
{
e.raw_os_error() == Some(10024)
}
#[cfg(not(any(unix, windows)))]
{
let _ = e;
false
}
}
fn contain_reactor_panic(
registrations: &RegistrationHandoff,
status: &AtomicUsize,
terminal_cause: &AtomicUsize,
run_reactor: impl FnOnce(),
) {
if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run_reactor)) {
close_intake_and_publish_end(registrations, status, terminal_cause);
let msg = payload
.downcast_ref::<&str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("non-string panic payload");
error!("Rtmp reactor panicked ({msg}); the server is stopped and all connections will be closed.");
}
}
fn handle_connections(
connection_receiver: crossbeam_channel::Receiver<TcpStream>,
registrations: Arc<RegistrationHandoff>,
gop_limit: usize,
max_connections: Option<usize>,
status: Arc<AtomicUsize>,
terminal_cause: Arc<AtomicUsize>,
waker: Option<Waker>,
) {
let kill_switch = RegistrationKillSwitch::arm(registrations);
let mut reactor_slot: Option<Reactor> = None;
contain_reactor_panic(kill_switch.handoff(), &status, &terminal_cause, || {
let reactor = match Reactor::new(gop_limit, max_connections, status.clone()) {
Ok(r) => r,
Err(e) => {
close_intake_and_publish_end(kill_switch.handoff(), &status, &terminal_cause);
error!("Failed to create Reactor: {:?}", e);
return;
}
};
reactor_slot
.insert(reactor)
.run(connection_receiver, kill_switch.handoff(), waker)
});
if status.load(Ordering::Acquire) != STATUS_END {
error!("Rtmp Server aborted.");
}
}
pub(crate) fn build_publish_control(
app_name: String,
stream_key: String,
) -> crate::error::Result<[Vec<u8>; 3]> {
let mut serializer = ChunkSerializer::new();
let mut properties: HashMap<String, Amf0Value> = HashMap::new();
properties.insert("app".to_string(), Amf0Value::Utf8String(app_name));
let connect_cmd = RtmpMessage::Amf0Command {
command_name: "connect".to_string(),
transaction_id: 1.0,
command_object: Amf0Value::Object(properties),
additional_arguments: Vec::new(),
}
.into_message_payload(RtmpTimestamp { value: 0 }, 0)
.map_err(|e| {
error!("Failed to create connect command: {:?}", e);
RtmpCreateStream
})?;
let connect_packet = serializer
.serialize(&connect_cmd, false, true)
.map_err(|e| {
error!("Failed to serialize connect command: {:?}", e);
RtmpCreateStream
})?;
let create_stream_cmd = RtmpMessage::Amf0Command {
command_name: "createStream".to_string(),
transaction_id: 2.0,
command_object: Amf0Value::Null,
additional_arguments: Vec::new(),
}
.into_message_payload(RtmpTimestamp { value: 0 }, 1)
.map_err(|e| {
error!("Failed to create createStream command: {:?}", e);
RtmpCreateStream
})?;
let create_stream_packet = serializer
.serialize(&create_stream_cmd, false, true)
.map_err(|e| {
error!("Failed to serialize createStream command: {:?}", e);
RtmpCreateStream
})?;
let arguments = vec![
Amf0Value::Utf8String(stream_key),
Amf0Value::Utf8String("live".into()),
];
let publish_cmd = RtmpMessage::Amf0Command {
command_name: "publish".to_string(),
transaction_id: 3.0,
command_object: Amf0Value::Null,
additional_arguments: arguments,
}
.into_message_payload(RtmpTimestamp { value: 0 }, 1)
.map_err(|e| {
error!("Failed to create publish command: {:?}", e);
RtmpCreateStream
})?;
let publish_packet = serializer
.serialize(&publish_cmd, false, true)
.map_err(|e| {
error!("Failed to serialize publish command: {:?}", e);
RtmpCreateStream
})?;
Ok([
connect_packet.bytes,
create_stream_packet.bytes,
publish_packet.bytes,
])
}
pub(crate) fn flv_tag_to_message_payload(flv_tag: FlvTag) -> MessagePayload {
let timestamp = flv_tag.header.timestamp | ((flv_tag.header.timestamp_ext as u32) << 24);
let type_id = flv_tag.header.tag_type;
let message_stream_id = flv_tag.header.stream_id;
let data = if type_id == 0x12 {
wrap_metadata(flv_tag.data)
} else {
flv_tag.data
};
MessagePayload {
timestamp: RtmpTimestamp { value: timestamp },
type_id,
message_stream_id,
data,
}
}
fn wrap_metadata(data: Bytes) -> Bytes {
let s = "@setDataFrame";
let insert_len = 16;
let mut bytes = bytes::BytesMut::with_capacity(insert_len + data.len());
bytes.put_u8(0x02);
bytes.put_u16(s.len() as u16);
bytes.put(s.as_bytes());
bytes.put(data);
bytes.freeze()
}
use crate::core::context::ffmpeg_context::FfmpegContext;
use crate::core::context::input::Input;
use crate::core::scheduler::ffmpeg_scheduler::{FfmpegScheduler, Running as SchedulerRunning};
use crate::error::StreamError;
use std::path::{Path, PathBuf};
struct ServerStopGuard {
server: Option<Arc<EmbedRtmpServer<Running>>>,
}
impl Drop for ServerStopGuard {
fn drop(&mut self) {
if let Some(server) = &self.server {
server.signal_stop();
}
}
}
impl ServerStopGuard {
fn disarm(mut self) -> Arc<EmbedRtmpServer<Running>> {
self.server
.take()
.expect("ServerStopGuard is armed exactly once before disarm")
}
}
pub struct StreamBuilder {
address: Option<String>,
app_name: Option<String>,
stream_key: Option<String>,
input_file: Option<PathBuf>,
readrate: Option<f32>,
gop_limit: Option<usize>,
max_connections: Option<usize>,
}
impl Default for StreamBuilder {
fn default() -> Self {
Self::new()
}
}
impl StreamBuilder {
pub fn new() -> Self {
Self {
address: None,
app_name: None,
stream_key: None,
input_file: None,
readrate: Some(1.0), gop_limit: None,
max_connections: None,
}
}
pub fn address(mut self, address: impl Into<String>) -> Self {
self.address = Some(address.into());
self
}
pub fn app_name(mut self, app_name: impl Into<String>) -> Self {
self.app_name = Some(app_name.into());
self
}
pub fn stream_key(mut self, stream_key: impl Into<String>) -> Self {
self.stream_key = Some(stream_key.into());
self
}
pub fn input_file(mut self, path: impl AsRef<Path>) -> Self {
self.input_file = Some(path.as_ref().to_path_buf());
self
}
pub fn readrate(mut self, rate: f32) -> Self {
self.readrate = Some(rate);
self
}
pub fn gop_limit(mut self, limit: usize) -> Self {
self.gop_limit = Some(limit);
self
}
pub fn max_connections(mut self, max: usize) -> Self {
self.max_connections = Some(max);
self
}
pub fn start(self) -> Result<StreamHandle, StreamError> {
let address = self
.address
.ok_or(StreamError::MissingParameter("address"))?;
let app_name = self
.app_name
.ok_or(StreamError::MissingParameter("app_name"))?;
let stream_key = self
.stream_key
.ok_or(StreamError::MissingParameter("stream_key"))?;
let input_file = self
.input_file
.ok_or(StreamError::MissingParameter("input_file"))?;
if !input_file.is_file() {
return Err(StreamError::InputNotFound { path: input_file });
}
let mut server = if let Some(gop_limit) = self.gop_limit {
EmbedRtmpServer::new_with_gop_limit(&address, gop_limit)
} else {
EmbedRtmpServer::new(&address)
};
if let Some(max_conn) = self.max_connections {
server = server.set_max_connections(max_conn);
}
let server = server.start().map_err(StreamError::Ffmpeg)?;
let guard = ServerStopGuard {
server: Some(Arc::new(server)),
};
let output = guard
.server
.as_ref()
.unwrap()
.create_rtmp_input(&app_name, &stream_key)
.map_err(StreamError::Ffmpeg)?;
let input_path = input_file.to_string_lossy().to_string();
let mut input = Input::from(input_path);
if let Some(rate) = self.readrate {
input = input.set_readrate(rate);
}
let scheduler = FfmpegContext::builder()
.input(input)
.output(output)
.build()
.map_err(StreamError::Ffmpeg)?
.start()
.map_err(StreamError::Ffmpeg)?;
let server = guard.disarm();
Ok(StreamHandle {
server,
scheduler: Some(scheduler),
})
}
}
pub struct StreamHandle {
server: Arc<EmbedRtmpServer<Running>>,
scheduler: Option<FfmpegScheduler<SchedulerRunning>>,
}
impl StreamHandle {
pub fn wait(mut self) -> Result<(), StreamError> {
if let Some(scheduler) = self.scheduler.take() {
scheduler.wait().map_err(StreamError::Ffmpeg)?;
}
Ok(())
}
pub fn local_addr(&self) -> Option<std::net::SocketAddr> {
self.server.local_addr()
}
}
impl Drop for StreamHandle {
fn drop(&mut self) {
if let Some(scheduler) = self.scheduler.take() {
let _ = scheduler.wait();
}
self.server.signal_stop();
}
}
impl EmbedRtmpServer<Initialization> {
pub fn stream_builder() -> StreamBuilder {
StreamBuilder::new()
}
}
#[cfg(test)]
mod bypass_parity_tests {
use super::*;
use crate::flv::flv_tag::FlvTag;
use crate::flv::flv_tag_header::FlvTagHeader;
use rml_rtmp::chunk_io::ChunkDeserializer;
fn make_tag(tag_type: u8, timestamp: u32, timestamp_ext: u8, data: Vec<u8>) -> FlvTag {
FlvTag {
header: FlvTagHeader {
tag_type,
data_size: data.len() as u32,
timestamp,
timestamp_ext,
stream_id: 1,
},
data: Bytes::from(data),
previous_tag_size: 0,
}
}
fn assert_parity(tag_type: u8, timestamp: u32, timestamp_ext: u8, data: Vec<u8>) {
let tag = make_tag(tag_type, timestamp, timestamp_ext, data);
let bypass_timestamp = tag.header.timestamp | ((tag.header.timestamp_ext as u32) << 24);
let bypass_data = tag.data.clone();
let payload = flv_tag_to_message_payload(tag);
let mut serializer = ChunkSerializer::new();
let packet = serializer
.serialize(&payload, false, true)
.expect("serialize");
let mut deserializer = ChunkDeserializer::new();
let round = deserializer
.get_next_message(&packet.bytes)
.expect("deserialize")
.expect("a complete message from the serialized chunks");
assert_eq!(
round.type_id, tag_type,
"tag type parity for {tag_type:#04x}"
);
assert_eq!(
round.timestamp.value, bypass_timestamp,
"timestamp parity for tag {tag_type:#04x}"
);
assert_eq!(
round.data, bypass_data,
"payload parity for tag {tag_type:#04x}"
);
}
#[test]
fn video_sequence_header_round_trips_identically() {
assert_parity(
0x09,
0,
0,
vec![0x17, 0x00, 0x00, 0x00, 0x00, 0x01, 0x64, 0x00, 0x1f],
);
}
#[test]
fn audio_sequence_header_round_trips_identically() {
assert_parity(0x08, 0, 0, vec![0xaf, 0x00, 0x12, 0x10]);
}
#[test]
fn large_keyframe_spanning_multiple_chunks_round_trips_identically() {
let mut data = vec![0x17, 0x01, 0x00, 0x00, 0x00];
data.extend((0u16..400).map(|i| (i & 0xff) as u8));
assert_parity(0x09, 0x1234, 0, data);
}
#[test]
fn delta_frame_round_trips_identically() {
assert_parity(0x09, 0x0001_0000, 0, vec![0x27, 0x01, 0x00, 0x11, 0x22]);
}
#[test]
fn extended_timestamp_round_trips_identically() {
assert_parity(0x08, 0x00ff_ffff, 0x01, vec![0xaf, 0x01, 0xAA, 0xBB]);
}
#[test]
fn audio_and_video_tags_never_wrap_their_payload() {
let audio = make_tag(0x08, 10, 0, vec![0xaf, 0x01, 0x01, 0x02, 0x03]);
let video = make_tag(0x09, 10, 0, vec![0x27, 0x01, 0x09, 0x08, 0x07]);
assert_eq!(
flv_tag_to_message_payload(audio.clone()).data,
audio.data,
"audio payload must not be wrapped"
);
assert_eq!(
flv_tag_to_message_payload(video.clone()).data,
video.data,
"video payload must not be wrapped"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::context::ffmpeg_context::FfmpegContext;
use crate::core::context::input::Input;
use crate::core::context::output::Output;
use crate::core::scheduler::ffmpeg_scheduler::FfmpegScheduler;
use ffmpeg_next::time::current;
use std::sync::atomic::AtomicBool;
use std::thread::sleep;
use std::time::Duration;
fn wait_for_port_release(addr: std::net::SocketAddr) -> bool {
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
match std::net::TcpListener::bind(addr) {
Ok(_) => return true,
Err(_) if std::time::Instant::now() < deadline => sleep(Duration::from_millis(20)),
Err(_) => return false,
}
}
}
#[test]
fn stream_sender_reports_stream_closed_and_clones_share_the_stream() {
let (tx, rx) = crossbeam_channel::bounded::<Vec<u8>>(4);
let sender = RtmpStreamSender {
inner: tx,
wake_handle: None,
};
let clone = sender.clone();
clone
.send(b"chunk".to_vec())
.expect("send on a live stream");
assert_eq!(rx.recv().unwrap(), b"chunk".to_vec());
drop(rx);
assert_eq!(
sender.send(b"late".to_vec()),
Err(crate::error::Error::RtmpStreamClosed),
);
}
#[test]
fn stopped_server_releases_its_port_for_rebind() {
let server = EmbedRtmpServer::new("127.0.0.1:0").start().expect("start");
let addr = server.local_addr().expect("bound address");
assert!(
std::net::TcpListener::bind(addr).is_err(),
"the running server must hold its port"
);
let stopped = server.stop();
assert!(stopped.is_stopped());
assert!(
wait_for_port_release(addr),
"the port must be rebindable within 2s of stop()"
);
}
#[test]
fn stream_handle_drop_stops_the_server() {
let server = EmbedRtmpServer::new("127.0.0.1:0").start().expect("start");
let addr = server.local_addr().expect("bound address");
let server = Arc::new(server);
let observer = server.clone();
let handle = StreamHandle {
server,
scheduler: None,
};
drop(handle);
assert!(
observer.is_stopped(),
"dropping the handle must signal the server to stop"
);
assert!(
wait_for_port_release(addr),
"the port must be rebindable within 2s of the handle drop"
);
}
#[test]
fn server_stop_guard_drop_releases_the_port() {
let server = EmbedRtmpServer::new("127.0.0.1:0").start().expect("start");
let addr = server.local_addr().expect("bound address");
assert!(
std::net::TcpListener::bind(addr).is_err(),
"the running server must hold its port while the guard is armed"
);
let guard = ServerStopGuard {
server: Some(Arc::new(server)),
};
drop(guard);
assert!(
wait_for_port_release(addr),
"dropping the armed guard (a post-start failure) must release the port"
);
}
#[test]
fn duplicate_stream_key_is_rejected_once_registered() {
let server = EmbedRtmpServer::new("127.0.0.1:0").start().expect("start");
let _output = server
.create_rtmp_input("app", "dup-key")
.expect("first create must succeed");
assert!(
server.stream_keys.contains("dup-key"),
"the stream key must be claimed by the time create returns"
);
let second = server.create_rtmp_input("app", "dup-key");
assert!(
matches!(
second,
Err(crate::error::Error::RtmpStreamAlreadyExists(ref key)) if key == "dup-key"
),
"a second create for a registered key must fail with RtmpStreamAlreadyExists"
);
server.stop();
}
#[test]
fn racing_creates_for_same_key_yield_exactly_one_winner() {
let server = EmbedRtmpServer::new("127.0.0.1:0").start().expect("start");
for round in 0..8 {
let key = format!("race-key-{round}");
let barrier = std::sync::Barrier::new(2);
let (a, b) = std::thread::scope(|s| {
let ta = s.spawn(|| {
barrier.wait();
server.create_rtmp_input("app", key.as_str())
});
let tb = s.spawn(|| {
barrier.wait();
server.create_rtmp_input("app", key.as_str())
});
(ta.join().expect("thread a"), tb.join().expect("thread b"))
});
let oks = a.is_ok() as usize + b.is_ok() as usize;
assert_eq!(
oks, 1,
"round {round}: exactly one racing create may claim the key, got {oks} Ok"
);
let loser = if a.is_ok() { b } else { a };
assert!(
matches!(
loser,
Err(crate::error::Error::RtmpStreamAlreadyExists(ref k)) if k == &key
),
"round {round}: the losing create must fail with RtmpStreamAlreadyExists"
);
}
server.stop();
}
#[test]
fn double_stop_signal_is_idempotent() {
let server = EmbedRtmpServer::new("127.0.0.1:0").start().expect("start");
let addr = server.local_addr().expect("bound address");
server.signal_stop();
assert!(server.is_stopped());
server.signal_stop();
assert!(server.is_stopped());
let ended = server.stop();
assert!(ended.is_stopped());
assert!(wait_for_port_release(addr));
}
#[test]
fn stop_settles_the_server_threads_before_returning() {
let server = EmbedRtmpServer::new("127.0.0.1:0").start().expect("start");
let addr = server.local_addr().expect("bound address");
let observer = server.clone();
let _live = server
.create_rtmp_input("app", "settlement-key")
.expect("create while running must succeed");
assert!(observer.stream_keys.contains("settlement-key"));
let ended = server.stop();
assert!(ended.is_stopped());
assert!(
!observer.stream_keys.contains("settlement-key"),
"stop() must not return before the worker released the key claims"
);
assert!(
std::net::TcpListener::bind(addr).is_ok(),
"stop() must not return before the accept thread released the listener"
);
}
#[test]
fn settlement_from_a_server_thread_is_signal_only_and_preserves_handles() {
let server_thread_ids: Arc<Mutex<Vec<std::thread::ThreadId>>> = Default::default();
let threads: Arc<Mutex<Vec<JoinHandle<()>>>> = Default::default();
let (registered_tx, registered_rx) = std::sync::mpsc::channel::<()>();
let (settled_tx, settled_rx) = std::sync::mpsc::channel::<()>();
let ids_probe = server_thread_ids.clone();
let threads_probe = threads.clone();
let handle = std::thread::Builder::new()
.name("reentrancy-probe".to_string())
.spawn(move || {
registered_rx
.recv()
.expect("the registration signal must arrive");
settle_server_threads(&ids_probe, &threads_probe);
settled_tx
.send(())
.expect("report that the settlement returned");
})
.expect("spawn the probe thread");
server_thread_ids.lock().unwrap().push(handle.thread().id());
threads.lock().unwrap().push(handle);
registered_tx.send(()).expect("signal the registration");
settled_rx
.recv_timeout(Duration::from_secs(5))
.expect("settlement on a server thread must be signal-only, not a deadlock");
assert_eq!(
threads.lock().unwrap().len(),
1,
"the reentrant call must leave the registry untouched"
);
settle_server_threads(&server_thread_ids, &threads);
assert!(
threads.lock().unwrap().is_empty(),
"a later settlement from a non-server thread must consume and join the handle"
);
}
#[test]
fn concurrent_settlements_both_wait_for_the_thread_exit() {
let server_thread_ids: Arc<Mutex<Vec<std::thread::ThreadId>>> = Default::default();
let threads: Arc<Mutex<Vec<JoinHandle<()>>>> = Default::default();
let exited = Arc::new(AtomicBool::new(false));
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let exited_worker = exited.clone();
let worker = std::thread::Builder::new()
.name("held-worker".to_string())
.spawn(move || {
release_rx.recv().expect("the release signal must arrive");
exited_worker.store(true, Ordering::Release);
})
.expect("spawn the held worker");
server_thread_ids.lock().unwrap().push(worker.thread().id());
threads.lock().unwrap().push(worker);
let settler = |ids: Arc<Mutex<Vec<std::thread::ThreadId>>>,
threads: Arc<Mutex<Vec<JoinHandle<()>>>>,
exited: Arc<AtomicBool>| {
std::thread::spawn(move || {
settle_server_threads(&ids, &threads);
assert!(
exited.load(Ordering::Acquire),
"settlement returned before the registered thread exited"
);
})
};
let first = settler(
server_thread_ids.clone(),
threads.clone(),
exited.clone(),
);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while threads.try_lock().is_ok() {
assert!(
std::time::Instant::now() < deadline,
"the first settler must be inside the settlement (holding the registry lock) within 5s"
);
sleep(Duration::from_millis(1));
}
let second = settler(server_thread_ids, threads, exited);
for _ in 0..50 {
assert!(
!second.is_finished(),
"the second settlement returned while the worker was still parked"
);
sleep(Duration::from_millis(1));
}
release_tx.send(()).expect("release the held worker");
first
.join()
.expect("the first settler must observe the exit");
second
.join()
.expect("the second settler must observe the exit");
}
#[test]
fn settlement_joins_handles_out_of_a_poisoned_registry() {
let server_thread_ids: Arc<Mutex<Vec<std::thread::ThreadId>>> = Default::default();
let threads: Arc<Mutex<Vec<JoinHandle<()>>>> = Default::default();
let exited = Arc::new(AtomicBool::new(false));
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let exited_worker = exited.clone();
let worker = std::thread::spawn(move || {
release_rx.recv().expect("the release signal must arrive");
exited_worker.store(true, Ordering::Release);
});
threads.lock().unwrap().push(worker);
let poisoner = threads.clone();
let _ = std::thread::spawn(move || {
let _guard = poisoner.lock().unwrap();
panic!("poison the registry");
})
.join();
assert!(threads.lock().is_err(), "the registry must be poisoned");
release_tx.send(()).expect("release the worker");
settle_server_threads(&server_thread_ids, &threads);
assert!(
exited.load(Ordering::Acquire),
"settlement must still join the handle held by the poisoned registry"
);
assert!(
threads
.lock()
.unwrap_or_else(PoisonError::into_inner)
.is_empty(),
"the poisoned registry must be drained by the settlement"
);
}
#[test]
fn settlement_contains_a_panicked_thread_join() {
let server_thread_ids: Arc<Mutex<Vec<std::thread::ThreadId>>> = Default::default();
let threads: Arc<Mutex<Vec<JoinHandle<()>>>> = Default::default();
threads.lock().unwrap().push(std::thread::spawn(|| {
panic!("uncontained thread panic");
}));
settle_server_threads(&server_thread_ids, &threads);
assert!(
threads.lock().unwrap().is_empty(),
"the panicked thread's handle must still be consumed"
);
}
#[test]
fn settlement_contains_a_panicking_join_payload() {
struct PayloadBomb;
impl Drop for PayloadBomb {
fn drop(&mut self) {
panic!("panic payload drop bomb");
}
}
let server_thread_ids: Arc<Mutex<Vec<std::thread::ThreadId>>> = Default::default();
let threads: Arc<Mutex<Vec<JoinHandle<()>>>> = Default::default();
let survivor_joined = Arc::new(AtomicBool::new(false));
threads.lock().unwrap().push(std::thread::spawn(|| {
std::panic::panic_any(PayloadBomb);
}));
let survivor_flag = survivor_joined.clone();
threads.lock().unwrap().push(std::thread::spawn(move || {
survivor_flag.store(true, Ordering::Release);
}));
settle_server_threads(&server_thread_ids, &threads);
assert!(
survivor_joined.load(Ordering::Acquire),
"the handle behind the bomb payload must still be joined"
);
assert!(
threads.lock().unwrap().is_empty(),
"settlement must consume every handle despite the bomb payload"
);
}
#[test]
fn settlement_contains_a_chained_panic_payload() {
struct ChainBomb(u32);
impl Drop for ChainBomb {
fn drop(&mut self) {
if self.0 > 0 {
std::panic::panic_any(ChainBomb(self.0 - 1));
}
}
}
struct PlainBomb;
impl Drop for PlainBomb {
fn drop(&mut self) {
panic!("plain payload bomb");
}
}
struct TwoBomb {
_a: PlainBomb,
_b: PlainBomb,
}
let server_thread_ids: Arc<Mutex<Vec<std::thread::ThreadId>>> = Default::default();
let threads: Arc<Mutex<Vec<JoinHandle<()>>>> = Default::default();
let survivor_joined = Arc::new(AtomicBool::new(false));
threads.lock().unwrap().push(std::thread::spawn(|| {
std::panic::panic_any(ChainBomb(2));
}));
threads.lock().unwrap().push(std::thread::spawn(|| {
std::panic::panic_any(PlainBomb);
}));
threads.lock().unwrap().push(std::thread::spawn(|| {
std::panic::panic_any(TwoBomb {
_a: PlainBomb,
_b: PlainBomb,
});
}));
let survivor_flag = survivor_joined.clone();
threads.lock().unwrap().push(std::thread::spawn(move || {
survivor_flag.store(true, Ordering::Release);
}));
settle_server_threads(&server_thread_ids, &threads);
assert!(
survivor_joined.load(Ordering::Acquire),
"the survivor behind the bombs must still be joined"
);
assert!(
threads.lock().unwrap().is_empty(),
"settlement must consume every handle despite the bomb payloads"
);
}
#[test]
fn terminal_cause_is_first_writer_wins() {
let server = EmbedRtmpServer::<Running> {
address: String::new(),
bound_addr: None,
status: Arc::new(AtomicUsize::new(STATUS_RUN)),
stream_keys: Default::default(),
registrations: Some(Arc::new(RegistrationHandoff::new())),
wake_handle: None,
threads: Default::default(),
server_thread_ids: Default::default(),
terminal_cause: Default::default(),
gop_limit: 1,
max_connections: None,
state: PhantomData,
};
assert_eq!(server.terminal_cause.load(Ordering::Acquire), CAUSE_NONE);
server.signal_stop();
assert!(server.is_stopped());
assert_eq!(
server.terminal_cause.load(Ordering::Acquire),
CAUSE_DELIBERATE,
"signal_stop must claim the deliberate cause"
);
let registrations = server.registrations.clone().expect("handoff installed");
close_intake_and_publish_end(®istrations, &server.status, &server.terminal_cause);
assert_eq!(
server.terminal_cause.load(Ordering::Acquire),
CAUSE_DELIBERATE,
"a fatal funnel after a deliberate stop must not relabel it"
);
let crashed = EmbedRtmpServer::<Running> {
address: String::new(),
bound_addr: None,
status: Arc::new(AtomicUsize::new(STATUS_RUN)),
stream_keys: Default::default(),
registrations: Some(Arc::new(RegistrationHandoff::new())),
wake_handle: None,
threads: Default::default(),
server_thread_ids: Default::default(),
terminal_cause: Default::default(),
gop_limit: 1,
max_connections: None,
state: PhantomData,
};
let registrations = crashed.registrations.clone().expect("handoff installed");
close_intake_and_publish_end(®istrations, &crashed.status, &crashed.terminal_cause);
assert!(crashed.is_stopped());
assert_eq!(
crashed.terminal_cause.load(Ordering::Acquire),
CAUSE_FATAL,
"a fatal terminal transition must claim the fatal cause"
);
crashed.signal_stop();
assert_eq!(
crashed.terminal_cause.load(Ordering::Acquire),
CAUSE_FATAL,
"a stop() after the crash must not relabel the crash as deliberate"
);
}
#[test]
fn stop_signal_closes_the_registration_intake() {
let server = EmbedRtmpServer::<Running> {
address: String::new(),
bound_addr: None,
status: Arc::new(AtomicUsize::new(STATUS_RUN)),
stream_keys: Default::default(),
registrations: Some(Arc::new(RegistrationHandoff::new())),
wake_handle: None,
threads: Default::default(),
server_thread_ids: Default::default(),
terminal_cause: Default::default(),
gop_limit: 1,
max_connections: None,
state: PhantomData,
};
let _live = server
.create_rtmp_input("app", "before")
.expect("create while running must succeed");
server.signal_stop();
assert!(server.is_stopped());
let after = server.create_rtmp_input("app", "after");
assert!(
matches!(after, Err(crate::error::Error::RtmpCreateStream)),
"a create issued after the stop signal must fail with the stopped error"
);
assert!(
!server.stream_keys.contains("after"),
"the refused create must release its key claim immediately"
);
}
#[test]
fn stopped_server_releases_accepted_stream_keys() {
let registrations = Arc::new(RegistrationHandoff::new());
let status = Arc::new(AtomicUsize::new(STATUS_RUN));
let server = EmbedRtmpServer::<Running> {
address: String::new(),
bound_addr: None,
status: status.clone(),
stream_keys: Default::default(),
registrations: Some(registrations.clone()),
wake_handle: None,
threads: Default::default(),
server_thread_ids: Default::default(),
terminal_cause: Default::default(),
gop_limit: 1,
max_connections: None,
state: PhantomData,
};
let (_connection_sender, connection_receiver) =
crossbeam_channel::bounded::<TcpStream>(1);
let worker = {
let status = status.clone();
std::thread::Builder::new()
.name("rtmp-server-worker".to_string())
.spawn(move || {
handle_connections(
connection_receiver,
registrations,
1,
None,
status,
Default::default(),
None,
)
})
.expect("spawn the worker thread")
};
let sender = server
.create_stream_sender("app", "lifecycle-key")
.expect("create on the running server must succeed");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !sender.inner.is_empty() {
assert!(
std::time::Instant::now() < deadline,
"the worker must drain the primed publish handshake within 5s"
);
sleep(Duration::from_millis(10));
}
assert!(
matches!(
server.create_stream_sender("app", "lifecycle-key"),
Err(crate::error::Error::RtmpStreamAlreadyExists(ref key)) if key == "lifecycle-key"
),
"the key must stay held while its publisher is accepted and live"
);
let observer = server.clone();
assert!(server.stop().is_stopped());
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !worker.is_finished() {
assert!(
std::time::Instant::now() < deadline,
"the worker must exit within 5s of stop()"
);
sleep(Duration::from_millis(10));
}
worker.join().expect("the worker must exit cleanly");
assert!(
matches!(
observer.create_stream_sender("app", "lifecycle-key"),
Err(crate::error::Error::RtmpCreateStream)
),
"after stop the key must be free; only the closed intake may refuse the create"
);
assert!(
!observer.stream_keys.contains("lifecycle-key"),
"no claim may survive the worker's teardown"
);
}
fn probe_registration(key: &str) -> PublisherRegistration {
let (_feed_tx, feed_rx) = crossbeam_channel::bounded::<PublisherFeed>(1);
PublisherRegistration {
claim: StreamKeyClaim::claim(Arc::new(dashmap::DashSet::new()), key.to_string())
.expect("a fresh key set accepts its first claim"),
source: PublisherSource::Feed(feed_rx),
}
}
#[test]
fn reactor_panic_publishes_terminal_status() {
let registrations = RegistrationHandoff::new();
let status = AtomicUsize::new(STATUS_RUN);
let terminal_cause = AtomicUsize::new(CAUSE_NONE);
contain_reactor_panic(®istrations, &status, &terminal_cause, || {
panic!("injected reactor panic")
});
assert_eq!(status.load(Ordering::Acquire), STATUS_END);
assert_eq!(
terminal_cause.load(Ordering::Acquire),
CAUSE_FATAL,
"a contained panic is a fatal cause"
);
assert!(
matches!(
registrations.enqueue(probe_registration("panicked")),
Err(EnqueueRefused::Closed(_))
),
"the contained panic must close the intake, not just flip the status"
);
}
#[test]
fn reactor_clean_return_leaves_status_untouched() {
let registrations = RegistrationHandoff::new();
let status = AtomicUsize::new(STATUS_RUN);
let terminal_cause = AtomicUsize::new(CAUSE_NONE);
contain_reactor_panic(®istrations, &status, &terminal_cause, || {});
assert_eq!(status.load(Ordering::Acquire), STATUS_RUN);
assert_eq!(
terminal_cause.load(Ordering::Acquire),
CAUSE_NONE,
"a clean return must not claim any terminal cause"
);
assert!(
registrations
.enqueue(probe_registration("still-open"))
.is_ok(),
"a clean reactor return must leave the intake open"
);
}
#[test]
fn reactor_panic_refuses_creates_on_surviving_handles() {
let registrations = Arc::new(RegistrationHandoff::new());
let status = Arc::new(AtomicUsize::new(STATUS_RUN));
let server = EmbedRtmpServer::<Running> {
address: String::new(),
bound_addr: None,
status: status.clone(),
stream_keys: Default::default(),
registrations: Some(registrations.clone()),
wake_handle: None,
threads: Default::default(),
server_thread_ids: Default::default(),
terminal_cause: Default::default(),
gop_limit: 1,
max_connections: None,
state: PhantomData,
};
let _live = server
.create_rtmp_input("app", "before-panic")
.expect("create while running must succeed");
contain_reactor_panic(®istrations, &status, &server.terminal_cause, || {
panic!("injected reactor panic")
});
assert!(
server.is_stopped(),
"the contained panic must stop the server"
);
let refused = server.create_rtmp_input("app", "after-panic").err();
assert!(
matches!(refused, Some(crate::error::Error::RtmpCreateStream)),
"a create observing the panic-stopped server must be refused, got {refused:?}"
);
assert!(
!server.stream_keys.contains("after-panic"),
"the refused create must release its key claim immediately"
);
}
#[test]
fn armed_start_fail_guard_publishes_terminal_state_on_unwind() {
let registrations = Arc::new(RegistrationHandoff::new());
let status = Arc::new(AtomicUsize::new(STATUS_RUN));
let guard = StartFailGuard {
armed: true,
registrations: registrations.clone(),
status: status.clone(),
wake_handle: None,
threads: Default::default(),
server_thread_ids: Default::default(),
terminal_cause: Default::default(),
};
let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let _guard = guard;
panic!("injected panic between the claim and the handoff");
}));
assert!(unwind.is_err(), "the injected panic must unwind");
assert_eq!(status.load(Ordering::Acquire), STATUS_END);
assert!(
matches!(
registrations.enqueue(probe_registration("after-unwind")),
Err(EnqueueRefused::Closed(_))
),
"the guard must close the intake, not just flip the status"
);
}
#[test]
fn disarmed_start_fail_guard_leaves_the_lifecycle_running() {
let registrations = Arc::new(RegistrationHandoff::new());
let status = Arc::new(AtomicUsize::new(STATUS_RUN));
let mut guard = StartFailGuard {
armed: true,
registrations: registrations.clone(),
status: status.clone(),
wake_handle: None,
threads: Default::default(),
server_thread_ids: Default::default(),
terminal_cause: Default::default(),
};
guard.armed = false;
drop(guard);
assert_eq!(status.load(Ordering::Acquire), STATUS_RUN);
assert!(
registrations
.enqueue(probe_registration("still-open"))
.is_ok(),
"a disarmed guard must leave the intake open"
);
}
fn family_sibling<S: 'static>(
family: &EmbedRtmpServer<S>,
address: impl Into<String>,
) -> EmbedRtmpServer<Initialization> {
EmbedRtmpServer::<Initialization> {
address: address.into(),
bound_addr: None,
status: family.status.clone(),
stream_keys: family.stream_keys.clone(),
registrations: None,
wake_handle: None,
threads: Default::default(),
server_thread_ids: Default::default(),
terminal_cause: Default::default(),
gop_limit: 1,
max_connections: None,
state: PhantomData,
}
}
#[test]
fn second_start_of_a_cloned_family_is_refused() {
let first = EmbedRtmpServer::new("127.0.0.1:0");
let second = first.clone();
let third = first.clone();
let running = first.start().expect("the family's first start must win");
let addr = running.local_addr().expect("bound address");
let refused = second.start().err();
assert!(
matches!(refused, Some(crate::error::Error::RtmpServerAlreadyStarted)),
"a second start on a running family must be refused, got {refused:?}"
);
assert!(
!running.is_stopped(),
"a refused start must not perturb the running server"
);
let refused = family_sibling(&running, addr.to_string()).start().err();
assert!(
matches!(refused, Some(crate::error::Error::RtmpServerAlreadyStarted)),
"a second start on the winner's own port must get the typed refusal, got {refused:?}"
);
assert!(
!running.is_stopped(),
"the fixed-port refusal must not perturb the running server"
);
let ended = running.stop();
assert!(ended.is_stopped());
let refused = third.start().err();
assert!(
matches!(refused, Some(crate::error::Error::RtmpServerAlreadyStarted)),
"a start after the family stopped must be refused, got {refused:?}"
);
let refused = family_sibling(&ended, addr.to_string()).start().err();
assert!(
matches!(refused, Some(crate::error::Error::RtmpServerAlreadyStarted)),
"a fixed-port start right after stop must get the typed refusal, got {refused:?}"
);
}
#[test]
fn failed_bind_leaves_the_family_retryable() {
let blocker = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve a port");
let blocked_addr = blocker.local_addr().expect("blocker address");
let first = EmbedRtmpServer::new(blocked_addr.to_string());
let survivor = first.clone();
let failed = first.start().err();
assert!(
matches!(failed, Some(crate::error::Error::IO(_))),
"binding a held port must surface the typed IO error, got {failed:?}"
);
let running = family_sibling(&survivor, "127.0.0.1:0")
.start()
.expect("a failed bind must leave the family startable");
assert!(running.stop().is_stopped());
}
#[test]
#[ignore] fn test_concat_stream_loop() {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let embed_rtmp_server = EmbedRtmpServer::new("localhost:1935");
let embed_rtmp_server = embed_rtmp_server.start().unwrap();
let output = embed_rtmp_server
.create_rtmp_input("my-app", "my-stream")
.unwrap();
let start = current();
let result = FfmpegContext::builder()
.input(Input::from("test.mp4").set_readrate(1.0).set_stream_loop(3))
.input(Input::from("test.mp4").set_readrate(1.0).set_stream_loop(3))
.input(Input::from("test.mp4").set_readrate(1.0).set_stream_loop(3))
.filter_desc("[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1")
.output(output)
.build()
.unwrap()
.start()
.unwrap()
.wait();
assert!(result.is_ok());
info!("elapsed time: {}", current() - start);
}
#[test]
#[ignore] fn test_stream_loop() {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let embed_rtmp_server = EmbedRtmpServer::new("localhost:1935");
let embed_rtmp_server = embed_rtmp_server.start().unwrap();
let output = embed_rtmp_server
.create_rtmp_input("my-app", "my-stream")
.unwrap();
let start = current();
let result = FfmpegContext::builder()
.input(
Input::from("test.mp4")
.set_readrate(1.0)
.set_stream_loop(-1),
)
.output(output.set_video_codec("h264_videotoolbox"))
.build()
.unwrap()
.start()
.unwrap()
.wait();
assert!(result.is_ok());
info!("elapsed time: {}", current() - start);
}
#[test]
#[ignore] fn test_concat_realtime() {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let embed_rtmp_server = EmbedRtmpServer::new("localhost:1935");
let embed_rtmp_server = embed_rtmp_server.start().unwrap();
let output = embed_rtmp_server
.create_rtmp_input("my-app", "my-stream")
.unwrap();
let start = current();
let result = FfmpegContext::builder()
.independent_readrate()
.input(Input::from("test.mp4").set_readrate(1.0))
.input(Input::from("test.mp4").set_readrate(1.0))
.input(Input::from("test.mp4").set_readrate(1.0))
.filter_desc("[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1")
.output(output)
.build()
.unwrap()
.start()
.unwrap()
.wait();
assert!(result.is_ok());
sleep(Duration::from_secs(1));
info!("elapsed time: {}", current() - start);
}
#[test]
#[ignore] fn test_realtime() {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let embed_rtmp_server = EmbedRtmpServer::new("localhost:1935");
let embed_rtmp_server = embed_rtmp_server.start().unwrap();
let output = embed_rtmp_server
.create_rtmp_input("my-app", "my-stream")
.unwrap();
let start = current();
let result = FfmpegContext::builder()
.input(Input::from("test.mp4").set_readrate(1.0))
.output(output)
.build()
.unwrap()
.start()
.unwrap()
.wait();
assert!(result.is_ok());
info!("elapsed time: {}", current() - start);
}
#[test]
#[ignore] fn test_readrate() {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let mut output: Output = "output.flv".into();
output.audio_codec = Some("adpcm_swf".to_string());
let mut input: Input = "test.mp4".into();
input.readrate = Some(1.0);
let context = FfmpegContext::builder()
.input(input)
.output(output)
.build()
.unwrap();
let result = FfmpegScheduler::new(context).start().unwrap().wait();
if let Err(error) = result {
println!("Error: {error}");
}
}
#[test]
#[ignore] fn test_embed_rtmp_server() {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let embed_rtmp_server = EmbedRtmpServer::new("localhost:1935");
let embed_rtmp_server = embed_rtmp_server.start().unwrap();
let output = embed_rtmp_server
.create_rtmp_input("my-app", "my-stream")
.unwrap();
let mut input: Input = "test.mp4".into();
input.readrate = Some(1.0);
let context = FfmpegContext::builder()
.input(input)
.output(output)
.build()
.unwrap();
let result = FfmpegScheduler::new(context).start().unwrap().wait();
assert!(result.is_ok());
sleep(Duration::from_secs(3));
}
}