Bump : 0.5.4 : WHIP cleanup guarded by session id, stale detach task can't kill a newer stream on the same key

This commit is contained in:
2026-08-20 19:08:12 +01:00
parent f855e0e471
commit 0a039a97c4
6 changed files with 47 additions and 12 deletions
Generated
+1 -1
View File
@@ -2906,7 +2906,7 @@ dependencies = [
[[package]] [[package]]
name = "server" name = "server"
version = "0.5.2" version = "0.5.4"
dependencies = [ dependencies = [
"argon2", "argon2",
"async-broadcast", "async-broadcast",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "server" name = "server"
version = "0.5.3" version = "0.5.4"
edition = "2024" edition = "2024"
[target.x86_64-unknown-linux-gnu] [target.x86_64-unknown-linux-gnu]
+4
View File
@@ -13,6 +13,7 @@ use migration::{Migrator, MigratorTrait};
use sea_orm::Database; use sea_orm::Database;
use tokio::{net::TcpListener, sync::Mutex, task::JoinSet}; use tokio::{net::TcpListener, sync::Mutex, task::JoinSet};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use uuid::Uuid;
use crate::{ use crate::{
audio::OpusAudioFrame, audio::OpusAudioFrame,
@@ -64,6 +65,9 @@ pub struct StreamSession {
/// profile instead of all three H.264 profiles. /// profile instead of all three H.264 profiles.
pub video_profile_level_id: Option<u32>, pub video_profile_level_id: Option<u32>,
// ---- // ----
/// Unique id per publish. Lets a session's cleanup verify it is still the
/// live session for its key instead of clobbering a newer one.
pub session_id: Uuid,
pub started_at: DateTime<Utc>, pub started_at: DateTime<Utc>,
pub active_clients: AtomicU32, pub active_clients: AtomicU32,
} }
+2
View File
@@ -15,6 +15,7 @@ use tokio::{
time::timeout, time::timeout,
}; };
use tracing::{debug, info, trace, warn}; use tracing::{debug, info, trace, warn};
use uuid::Uuid;
use crate::{ use crate::{
StreamCodec, StreamSession, StreamCodec, StreamSession,
@@ -320,6 +321,7 @@ impl Rtmp {
codec: None, codec: None,
started_at: Utc::now(), started_at: Utc::now(),
active_clients: 0.into(), active_clients: 0.into(),
session_id: Uuid::new_v4(),
video_pt: None, video_pt: None,
video_profile_level_id: None, video_profile_level_id: None,
}, },
+31 -4
View File
@@ -23,6 +23,7 @@ use str0m::{
}; };
use tokio::{net::UdpSocket, sync::mpsc::Receiver}; use tokio::{net::UdpSocket, sync::mpsc::Receiver};
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
use uuid::Uuid;
use crate::{ use crate::{
StreamSession, audio::OpusAudioFrame, codec::VideoFrame, http::HttpServer, StreamSession, audio::OpusAudioFrame, codec::VideoFrame, http::HttpServer,
@@ -65,6 +66,9 @@ pub async fn handle_whip_injest_delete(
} }
state.appstate.lock().await.stream_sessions.remove(&key.id); state.appstate.lock().await.stream_sessions.remove(&key.id);
// Drop the trickle channel too, so PATCHes to a deleted session 404 and
// the lingering detach task's own cleanup can't reach a newer session.
state.appstate.lock().await.webrtc_proxy.trickle_tx.remove(&key.id);
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
@@ -86,7 +90,10 @@ pub async fn handle_whip_injest_patch(
// Look up the trickle sender for this session. // Look up the trickle sender for this session.
let trickle_map = state.appstate.lock().await.webrtc_proxy.trickle_tx.clone(); let trickle_map = state.appstate.lock().await.webrtc_proxy.trickle_tx.clone();
let tx = trickle_map.get(&stream_key_id).ok_or_else(|| { let tx = trickle_map
.get(&stream_key_id)
.map(|e| e.value().1.clone())
.ok_or_else(|| {
warn!(stream_key_id, "Whip PATCH: no trickle channel for session"); warn!(stream_key_id, "Whip PATCH: no trickle channel for session");
HttpError::NotFound HttpError::NotFound
})?; })?;
@@ -283,6 +290,9 @@ pub async fn handle_whip_injest(
let (socket, rx) = state.appstate.lock().await.webrtc_proxy.add_client(ufrag); let (socket, rx) = state.appstate.lock().await.webrtc_proxy.add_client(ufrag);
let stream_sessions = state.appstate.lock().await.stream_sessions.clone(); let stream_sessions = state.appstate.lock().await.stream_sessions.clone();
// Unique per publish: cleanup only removes the session this task created,
// never a newer one that re-published on the same stream key.
let session_id = Uuid::new_v4();
let (mut video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(32); let (mut video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(32);
let (mut audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(32); let (mut audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(32);
// Never block the ingest loop on slow/missing viewers: overwrite the // Never block the ingest loop on slow/missing viewers: overwrite the
@@ -308,6 +318,7 @@ pub async fn handle_whip_injest(
frame_channel: video_tx, frame_channel: video_tx,
audio_channel: audio_tx, audio_channel: audio_tx,
codec: negotiated_codec, codec: negotiated_codec,
session_id,
started_at: Utc::now(), started_at: Utc::now(),
active_clients: 0.into(), active_clients: 0.into(),
video_pt, video_pt,
@@ -323,7 +334,7 @@ pub async fn handle_whip_injest(
// initial offer. We forward them to the Rtc task for add_remote_candidate. // initial offer. We forward them to the Rtc task for add_remote_candidate.
let (trickle_tx, trickle_rx) = tokio::sync::mpsc::unbounded_channel(); let (trickle_tx, trickle_rx) = tokio::sync::mpsc::unbounded_channel();
let trickle_map = state.appstate.lock().await.webrtc_proxy.trickle_tx.clone(); let trickle_map = state.appstate.lock().await.webrtc_proxy.trickle_tx.clone();
trickle_map.insert(key.id, trickle_tx); trickle_map.insert(key.id, (session_id, trickle_tx));
let db = state.db.clone(); let db = state.db.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -338,10 +349,12 @@ pub async fn handle_whip_injest(
public_addr, public_addr,
video_rx, video_rx,
audio_rx, audio_rx,
session_id,
) )
.await; .await;
// Clean up trickle channel when done. // Drop only our own trickle channel; a newer session on the same key
trickle_map.remove(&key.id); // must keep its own.
trickle_map.remove_if(&key.id, |_, (sid, _)| *sid == session_id);
}); });
stream_session::Model::create_stream_session(&state.db, key.id, Local::now().into()) stream_session::Model::create_stream_session(&state.db, key.id, Local::now().into())
@@ -374,11 +387,25 @@ async fn detach_inject_rtc(
local_addr: SocketAddr, local_addr: SocketAddr,
video_rx: async_broadcast::Receiver<Arc<VideoFrame>>, video_rx: async_broadcast::Receiver<Arc<VideoFrame>>,
audio_rx: async_broadcast::Receiver<Arc<OpusAudioFrame>>, audio_rx: async_broadcast::Receiver<Arc<OpusAudioFrame>>,
session_id: Uuid,
) { ) {
let cleanup = || { let cleanup = || {
let sessions_ref = sessions_ref.clone(); let sessions_ref = sessions_ref.clone();
let db = db.clone(); let db = db.clone();
async move { async move {
// Only clean up the session this task created. If the user has
// already opened another stream on the same key (e.g. DELETE then
// immediate republish), that newer session must not be touched.
let is_ours = sessions_ref
.get(&stream_key_id)
.is_some_and(|s| s.session_id == session_id);
if !is_ours {
debug!(
stream_key_id,
"skipping cleanup: session replaced by a newer stream on this key"
);
return;
}
debug!("Cleaning up {:?}", &stream_key_id); debug!("Cleaning up {:?}", &stream_key_id);
sessions_ref.remove(&stream_key_id); sessions_ref.remove(&stream_key_id);
if let Ok(Some(s)) = if let Ok(Some(s)) =
+5 -3
View File
@@ -12,6 +12,7 @@ use tokio::{
sync::mpsc::{self, Receiver}, sync::mpsc::{self, Receiver},
}; };
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
use uuid::Uuid;
pub struct WebRtcProxyConfig { pub struct WebRtcProxyConfig {
pub proxy_port: i32, pub proxy_port: i32,
@@ -24,9 +25,10 @@ pub struct WebrtcProxy {
socket: Arc<UdpSocket>, socket: Arc<UdpSocket>,
public_addr: SocketAddr, public_addr: SocketAddr,
/// Trickle-ICE candidate channels for WHIP ingest. /// Trickle-ICE candidate channels for WHIP ingest.
/// Keyed by stream_key_id; sender stored here so PATCH handler /// Keyed by stream_key_id; each entry is tagged with the owning session's
/// can forward candidates to the detach task. /// id so cleanup can remove only its own entry and never a newer session
pub trickle_tx: Arc<DashMap<i32, tokio::sync::mpsc::UnboundedSender<String>>>, /// that re-published on the same key.
pub trickle_tx: Arc<DashMap<i32, (Uuid, tokio::sync::mpsc::UnboundedSender<String>)>>,
} }
const STUN_MAGIC: u32 = 0x2112A442; const STUN_MAGIC: u32 = 0x2112A442;