use std::collections::VecDeque;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use broadcast_common::Parse;
use media_plane::trunk::{SampleCursorItem, Trunk};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream, UdpSocket};
use tokio::sync::{Mutex as TokioMutex, mpsc};
use tokio_util::sync::CancellationToken;
use transmux::ir::Track;
use transmux::pipeline::{CodecConfig, Sample, TrackSpec};
use transmux::{DEFAULT_AUDIO_PT, DEFAULT_MTU, RtpPacketiser, VIDEO_CLOCK_RATE};
use webrtc_runtime::media::{
Datagram, MediaEvent, MediaTransport, MediaTransportConfig, SetupRole,
};
use webrtc_runtime::whep::{content_type, status};
use crate::error::{MultimuxError, Result};
pub const DEFAULT_WHEP_MAX_SESSIONS: usize = 64;
const SESSION_POLL_INTERVAL: Duration = Duration::from_millis(10);
const ACCEPT_QUEUE_CAPACITY: usize = 32;
const MAX_UDP_DATAGRAM: usize = 65_536;
#[derive(Debug, Clone)]
pub struct WhepRoute {
listen: String,
max_sessions: usize,
}
impl WhepRoute {
pub fn new(listen: impl Into<String>) -> Self {
WhepRoute {
listen: listen.into(),
max_sessions: DEFAULT_WHEP_MAX_SESSIONS,
}
}
#[must_use]
pub fn with_max_sessions(mut self, max_sessions: usize) -> Self {
self.max_sessions = max_sessions;
self
}
pub fn listen(&self) -> &str {
&self.listen
}
}
struct AdmittedWhep {
socket: Arc<UdpSocket>,
media: Arc<TokioMutex<MediaTransport>>,
spec: TrackSpec,
pt: u8,
ssrc: u32,
}
async fn read_http_request(stream: &mut TcpStream) -> std::io::Result<(String, Vec<u8>)> {
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
loop {
let n = stream.read(&mut tmp).await?;
if n == 0 {
return Ok((String::new(), Vec::new()));
}
buf.extend_from_slice(&tmp[..n]);
let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
continue;
};
let head = String::from_utf8_lossy(&buf[..pos]).to_string();
let mut lines = head.lines();
let request_line = lines.next().unwrap_or_default().to_string();
let content_length: usize = lines
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.and_then(|v| v.trim().parse().ok())
})
.unwrap_or(0);
let body_start = pos + 4;
while buf.len() < body_start + content_length {
let n = stream.read(&mut tmp).await?;
if n == 0 {
break;
}
buf.extend_from_slice(&tmp[..n]);
}
let body_end = (body_start + content_length).min(buf.len());
return Ok((request_line, buf[body_start..body_end].to_vec()));
}
}
struct ParsedWhepOffer {
remote_ufrag: String,
remote_pwd: String,
mid: String,
candidates: Vec<String>,
setup: Option<String>,
payload_type: u8,
}
fn parse_whep_offer(offer: &str) -> Result<ParsedWhepOffer> {
let session = sdp_types::Session::parse(offer.as_bytes()).map_err(|e| MultimuxError::Sdp {
reason: format!("whep: parse offer: {e}"),
})?;
let video_medias: Vec<_> = session
.medias
.iter()
.filter(|m| m.media == "video")
.collect();
if session.medias.len() != video_medias.len() || video_medias.len() != 1 {
return Err(MultimuxError::Sdp {
reason: format!(
"whep: this route accepts exactly one m=video section and nothing else \
(Opus audio has no RTP packetiser in this workspace yet); offer had {} \
total section(s), {} of them video",
session.medias.len(),
video_medias.len()
),
});
}
let media = video_medias[0];
let remote_ufrag =
sdp_attr_anywhere(offer, "a=ice-ufrag:").ok_or_else(|| MultimuxError::Sdp {
reason: "whep: offer has no a=ice-ufrag".into(),
})?;
let remote_pwd = sdp_attr_anywhere(offer, "a=ice-pwd:").ok_or_else(|| MultimuxError::Sdp {
reason: "whep: offer has no a=ice-pwd".into(),
})?;
let mid = media
.get_first_attribute_value("mid")
.ok()
.flatten()
.unwrap_or("0")
.to_string();
let candidates: Vec<String> = offer
.lines()
.filter_map(|l| l.strip_prefix("a=candidate:"))
.map(str::to_string)
.collect();
let setup = sdp_attr_anywhere(offer, "a=setup:");
let mut payload_type = None;
for tok in media.fmt.split_whitespace() {
let Ok(pt) = tok.parse::<u8>() else { continue };
let rtpmap = offer
.lines()
.find(|l| l.starts_with(&format!("a=rtpmap:{pt} ")));
let Some(rtpmap) = rtpmap else { continue };
if rtpmap.to_ascii_uppercase().contains("H264") {
payload_type = Some(pt);
break;
}
}
let payload_type = payload_type.ok_or_else(|| MultimuxError::Sdp {
reason: "whep: m=video has no H.264 (a=rtpmap naming H264) payload type".into(),
})?;
Ok(ParsedWhepOffer {
remote_ufrag,
remote_pwd,
mid,
candidates,
setup,
payload_type,
})
}
fn sdp_attr_anywhere(sdp: &str, prefix: &str) -> Option<String> {
sdp.lines()
.find_map(|l| l.strip_prefix(prefix))
.map(|v| v.trim().to_string())
}
fn choose_setup_role(offer_setup: Option<&str>) -> SetupRole {
match offer_setup {
Some("active") => SetupRole::Passive,
Some("passive") => SetupRole::Active,
_ => SetupRole::Passive,
}
}
fn build_whep_answer(
parsed: &ParsedWhepOffer,
media: &MediaTransport,
local_addr: SocketAddr,
local_ice_ufrag: &str,
local_ice_pwd: &str,
setup_role: SetupRole,
config: &transmux::AVCDecoderConfigurationRecord,
) -> String {
let pt = parsed.payload_type;
let profile_level_id = format!(
"{:02X}{:02X}{:02X}",
config.profile_indication, config.profile_compatibility, config.level_indication
);
let mut sprop = String::new();
for (i, sps) in config.sps.iter().enumerate() {
if i > 0 {
sprop.push(',');
}
sprop.push_str(&transmux::rtp::base64_encode(&sps.0));
}
for pps in &config.pps {
if !sprop.is_empty() {
sprop.push(',');
}
sprop.push_str(&transmux::rtp::base64_encode(&pps.0));
}
let candidate_line = format!(
"0 1 udp 2130706431 {} {} typ host",
local_addr.ip(),
local_addr.port()
);
let mut answer = String::new();
answer.push_str("v=0\r\n");
answer.push_str("o=- 0 0 IN IP4 127.0.0.1\r\n");
answer.push_str("s=-\r\n");
answer.push_str("t=0 0\r\n");
answer.push_str(&format!("m=video 9 UDP/TLS/RTP/SAVPF {pt}\r\n"));
answer.push_str("c=IN IP4 127.0.0.1\r\n");
answer.push_str("a=rtcp:9 IN IP4 0.0.0.0\r\n");
answer.push_str(&format!("a=rtpmap:{pt} H264/{VIDEO_CLOCK_RATE}\r\n"));
answer.push_str(&format!(
"a=fmtp:{pt} packetization-mode=1;profile-level-id={profile_level_id};\
sprop-parameter-sets={sprop}\r\n"
));
answer.push_str("a=sendonly\r\n");
answer.push_str(&format!("a=mid:{}\r\n", parsed.mid));
answer.push_str("a=rtcp-mux\r\n");
answer.push_str(&format!("a=ice-ufrag:{local_ice_ufrag}\r\n"));
answer.push_str(&format!("a=ice-pwd:{local_ice_pwd}\r\n"));
answer.push_str(&format!(
"a=fingerprint:sha-256 {}\r\n",
media.local_fingerprint()
));
answer.push_str(&format!("a=setup:{}\r\n", setup_role.name()));
answer.push_str(&format!("a=candidate:{candidate_line}\r\n"));
answer.push_str("a=end-of-candidates\r\n");
answer
}
fn rand_token(len: usize) -> String {
use std::collections::hash_map::RandomState;
use std::hash::BuildHasher;
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let state = RandomState::new();
(0..len)
.map(|i| {
let idx = (state.hash_one(i) as usize) % CHARS.len();
CHARS[idx] as char
})
.collect()
}
fn rand_ssrc() -> u32 {
use std::collections::hash_map::RandomState;
use std::hash::BuildHasher;
let state = RandomState::new();
(state.hash_one(Instant::now()) as u32) | 1
}
async fn handle_whep_connection(
mut stream: TcpStream,
trunk: &Trunk,
tx: &mpsc::Sender<AdmittedWhep>,
active_sessions: &Arc<AtomicUsize>,
max_sessions: usize,
) -> Result<()> {
let (request_line, body) =
read_http_request(&mut stream)
.await
.map_err(|e| MultimuxError::Connect {
reason: format!("whep: read request: {e}"),
})?;
if request_line.starts_with("OPTIONS") {
let resp = "HTTP/1.1 204 No Content\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\r\n";
let _ = stream.write_all(resp.as_bytes()).await;
return Ok(());
}
if !request_line.starts_with("POST") {
let resp = "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n";
let _ = stream.write_all(resp.as_bytes()).await;
return Ok(());
}
let offer_sdp = String::from_utf8_lossy(&body).into_owned();
let parsed = parse_whep_offer(&offer_sdp)?;
let spec = trunk
.tracks()
.iter()
.find(|t| matches!(t.config, CodecConfig::Avc { .. }))
.cloned();
let Some(spec) = spec else {
let resp = format!(
"HTTP/1.1 {} Conflict\r\nRetry-After: 2\r\nContent-Length: 0\r\n\r\n",
status::CONFLICT
);
let _ = stream.write_all(resp.as_bytes()).await;
return Ok(());
};
let CodecConfig::Avc { config, .. } = &spec.config else {
unreachable!("filtered to CodecConfig::Avc above");
};
let prev = active_sessions.fetch_add(1, Ordering::SeqCst);
if prev >= max_sessions {
active_sessions.fetch_sub(1, Ordering::SeqCst);
let resp = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n";
let _ = stream.write_all(resp.as_bytes()).await;
return Ok(());
}
let advertise_ip = stream
.local_addr()
.map(|a| a.ip())
.map_err(|e| MultimuxError::Connect {
reason: format!("whep: signalling connection local_addr: {e}"),
})?;
let socket = UdpSocket::bind((advertise_ip, 0))
.await
.map_err(|e| MultimuxError::Connect {
reason: format!("whep: bind media socket on {advertise_ip}: {e}"),
})?;
let local_addr = socket.local_addr().map_err(|e| MultimuxError::Connect {
reason: format!("whep: media socket local_addr: {e}"),
})?;
let setup_role = choose_setup_role(parsed.setup.as_deref());
let local_ice_ufrag = rand_token(8);
let local_ice_pwd = rand_token(24);
let mut media = MediaTransport::new(MediaTransportConfig {
local_addr,
local_ice_ufrag: local_ice_ufrag.clone(),
local_ice_pwd: local_ice_pwd.clone(),
remote_ice_ufrag: parsed.remote_ufrag.clone(),
remote_ice_pwd: parsed.remote_pwd.clone(),
is_controlling: false,
local_setup: setup_role,
stun_server: None,
})
.map_err(|e| {
active_sessions.fetch_sub(1, Ordering::SeqCst);
MultimuxError::Connect {
reason: format!("whep: build media transport: {e}"),
}
})?;
for raw in &parsed.candidates {
let _ = media.add_remote_candidate(raw);
}
let answer = build_whep_answer(
&parsed,
&media,
local_addr,
&local_ice_ufrag,
&local_ice_pwd,
setup_role,
&config.config,
);
let resp = format!(
"HTTP/1.1 201 Created\r\n\
Content-Type: {}\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Expose-Headers: Location\r\n\
Location: /whep/session\r\n\
Content-Length: {}\r\n\r\n{}",
content_type::SDP,
answer.len(),
answer
);
stream
.write_all(resp.as_bytes())
.await
.map_err(|e| MultimuxError::Connect {
reason: format!("whep: write answer: {e}"),
})?;
let _ = stream.shutdown().await;
let admitted = AdmittedWhep {
socket: Arc::new(socket),
media: Arc::new(TokioMutex::new(media)),
spec,
pt: parsed.payload_type,
ssrc: rand_ssrc(),
};
if tx.send(admitted).await.is_err() {
active_sessions.fetch_sub(1, Ordering::SeqCst);
}
Ok(())
}
fn rescale_to_90k(ticks: u64, from_timescale: u32) -> u32 {
if from_timescale == 0 || from_timescale == VIDEO_CLOCK_RATE {
return ticks as u32;
}
(((ticks as u128) * VIDEO_CLOCK_RATE as u128 + (from_timescale as u128) / 2)
/ from_timescale as u128) as u32
}
const RTP_SEQ_OFFSET: usize = 2;
const RTP_SEQ_LEN: usize = 2;
const RTP_TIMESTAMP_OFFSET: usize = RTP_SEQ_OFFSET + RTP_SEQ_LEN;
const RTP_TIMESTAMP_LEN: usize = 4;
fn patch_seq_and_timestamp(contiguous: &[u8], seq: u16, timestamp: u32) -> Option<Vec<u8>> {
if contiguous.len() < rtp_packet::FIXED_HEADER_LEN {
return None;
}
let mut v = contiguous.to_vec();
v[RTP_SEQ_OFFSET..RTP_SEQ_OFFSET + RTP_SEQ_LEN].copy_from_slice(&seq.to_be_bytes());
v[RTP_TIMESTAMP_OFFSET..RTP_TIMESTAMP_OFFSET + RTP_TIMESTAMP_LEN]
.copy_from_slice(×tamp.to_be_bytes());
Some(v)
}
struct SessionMedia<'a> {
pt: u8,
ssrc: u32,
spec: &'a TrackSpec,
}
async fn send_sample(
socket: &UdpSocket,
media: &TokioMutex<MediaTransport>,
peer: SocketAddr,
next_seq: &mut u16,
session: &SessionMedia<'_>,
sample: &Sample,
) {
let Some(dts) = sample.dts else {
return;
};
let timestamp = rescale_to_90k(dts.max(0) as u64, session.spec.timescale);
let packetiser = RtpPacketiser {
mtu: DEFAULT_MTU,
video_pt: session.pt,
audio_pt: DEFAULT_AUDIO_PT,
ssrc: session.ssrc,
stap_a_parameter_sets: sample.flags.is_sync,
};
let track = Track::new(session.spec.clone(), vec![sample.clone()]);
let packets = match packetiser.packetise_video(&track, session.pt) {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, "whep: packetise_video failed; dropping sample");
return;
}
};
let mut guard = media.lock().await;
for pkt in &packets {
let contiguous = pkt.as_contiguous();
let seq = *next_seq;
*next_seq = next_seq.wrapping_add(1);
let Some(patched) = patch_seq_and_timestamp(&contiguous, seq, timestamp) else {
tracing::error!(
len = contiguous.len(),
min = rtp_packet::FIXED_HEADER_LEN,
"whep: packetiser emitted a packet shorter than the RFC 3550 §5.1 fixed \
header; dropping it rather than sending one still carrying the \
packetiser's own sequence number/timestamp"
);
continue;
};
let wire = match rtp_packet::RtpPacket::parse(&patched) {
Ok(w) => w,
Err(e) => {
tracing::warn!(error = %e, "whep: re-parsing patched RTP packet failed");
continue;
}
};
match guard.encrypt_rtp(&wire) {
Ok(protected) => {
let _ = socket.send_to(&protected, peer).await;
}
Err(e) => {
tracing::warn!(error = %e, "whep: encrypt_rtp failed");
}
}
}
}
async fn run_whep_session(
admitted: AdmittedWhep,
trunk: Arc<Trunk>,
cancel: CancellationToken,
active_sessions: Arc<AtomicUsize>,
) {
let AdmittedWhep {
socket,
media,
spec,
pt,
ssrc,
} = admitted;
let session = SessionMedia {
pt,
ssrc,
spec: &spec,
};
let mut cursor = trunk.subscribe();
let mut peer_addr: Option<SocketAddr> = None;
let mut handshake_done = false;
let mut next_seq: u16 = 0;
let mut buf = vec![0u8; MAX_UDP_DATAGRAM];
loop {
if cancel.is_cancelled() {
break;
}
match tokio::time::timeout(SESSION_POLL_INTERVAL, socket.recv_from(&mut buf)).await {
Ok(Ok((n, peer))) => {
peer_addr = Some(peer);
let mut guard = media.lock().await;
match guard.handle_datagram(Instant::now(), peer, &buf[..n]) {
Ok(events) => {
if events
.iter()
.any(|e| matches!(e, MediaEvent::DtlsHandshakeComplete))
{
handshake_done = true;
}
while let Some(Datagram { peer, bytes }) = guard.poll_transmit() {
let _ = socket.send_to(&bytes, peer).await;
}
}
Err(e) => {
tracing::warn!(error = %e, "whep: datagram handling failed");
}
}
}
Ok(Err(e)) => {
tracing::warn!(error = %e, "whep: session socket read failed");
break;
}
Err(_) => {
let mut guard = media.lock().await;
guard.handle_timeout(Instant::now());
while let Some(Datagram { peer, bytes }) = guard.poll_transmit() {
let _ = socket.send_to(&bytes, peer).await;
}
}
}
if handshake_done && let Some(peer) = peer_addr {
while let Some(item) = cursor.poll() {
match item {
SampleCursorItem::Timed { track_id, sample }
| SampleCursorItem::Sparse { track_id, sample }
if track_id == session.spec.track_id =>
{
send_sample(&socket, &media, peer, &mut next_seq, &session, &sample).await;
}
_ => {}
}
}
}
}
active_sessions.fetch_sub(1, Ordering::Relaxed);
}
pub async fn run_whep(route: &WhepRoute, trunk: Arc<Trunk>, cancel: CancellationToken) {
let listener = match TcpListener::bind(&route.listen).await {
Ok(l) => l,
Err(e) => {
tracing::error!(listen = %route.listen, error = %e, "whep: bind failed");
return;
}
};
let (admit_tx, mut admit_rx) = mpsc::channel::<AdmittedWhep>(ACCEPT_QUEUE_CAPACITY);
let active_sessions = Arc::new(AtomicUsize::new(0));
let max_sessions = route.max_sessions;
let accept_trunk = Arc::clone(&trunk);
let accept_cancel = cancel.clone();
let accept_active = Arc::clone(&active_sessions);
tokio::spawn(async move {
loop {
tokio::select! {
() = accept_cancel.cancelled() => break,
accepted = listener.accept() => {
match accepted {
Ok((stream, _peer)) => {
let tx = admit_tx.clone();
let trunk = Arc::clone(&accept_trunk);
let active = Arc::clone(&accept_active);
tokio::spawn(async move {
if let Err(e) =
handle_whep_connection(stream, &trunk, &tx, &active, max_sessions)
.await
{
tracing::warn!(
error = %e,
"whep: signalling connection failed"
);
}
});
}
Err(e) => {
tracing::warn!(
error = %e,
"whep: accept-pump ending after a listen-socket error"
);
break;
}
}
}
}
}
});
let mut sessions: VecDeque<tokio::task::JoinHandle<()>> = VecDeque::new();
loop {
sessions.retain(|h| !h.is_finished());
tokio::select! {
() = cancel.cancelled() => break,
admitted = admit_rx.recv() => {
match admitted {
Some(a) => {
let trunk = Arc::clone(&trunk);
let session_cancel = cancel.clone();
let active = Arc::clone(&active_sessions);
sessions.push_back(tokio::spawn(run_whep_session(
a, trunk, session_cancel, active,
)));
}
None => break,
}
}
() = tokio::time::sleep(Duration::from_secs(1)) => {}
}
}
for h in sessions {
h.abort();
}
}
#[cfg(test)]
mod tests {
use super::*;
const OFFER: &str = "v=0\r\n\
o=- 0 0 IN IP4 127.0.0.1\r\n\
s=-\r\n\
t=0 0\r\n\
m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
c=IN IP4 0.0.0.0\r\n\
a=ice-ufrag:abcd\r\n\
a=ice-pwd:abcdefghijklmnopqrstuvwx\r\n\
a=fingerprint:sha-256 00:11\r\n\
a=setup:actpass\r\n\
a=mid:0\r\n\
a=rtcp-mux\r\n\
a=rtpmap:96 H264/90000\r\n\
a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\n\
a=candidate:1 1 udp 2130706431 10.0.0.5 54321 typ host\r\n";
#[test]
fn parses_video_only_offer() {
let parsed = parse_whep_offer(OFFER).expect("parse");
assert_eq!(parsed.payload_type, 96);
assert_eq!(parsed.remote_ufrag, "abcd");
assert_eq!(parsed.remote_pwd, "abcdefghijklmnopqrstuvwx");
assert_eq!(parsed.mid, "0");
assert_eq!(parsed.candidates.len(), 1);
assert_eq!(parsed.setup.as_deref(), Some("actpass"));
}
#[test]
fn rejects_offer_with_no_video() {
let offer = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n\
m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=ice-ufrag:x\r\na=ice-pwd:xxxxxxxxxxxxxxxxxxxxxxxx\r\n";
assert!(parse_whep_offer(offer).is_err());
}
#[test]
fn rejects_offer_with_audio_and_video() {
let offer = OFFER.replace("m=video 9", "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\nm=video 9");
assert!(parse_whep_offer(&offer).is_err());
}
#[test]
fn choose_setup_role_picks_the_complementary_role() {
assert_eq!(choose_setup_role(Some("active")), SetupRole::Passive);
assert_eq!(choose_setup_role(Some("passive")), SetupRole::Active);
assert_eq!(choose_setup_role(Some("actpass")), SetupRole::Passive);
assert_eq!(choose_setup_role(None), SetupRole::Passive);
}
#[test]
fn rescale_to_90k_is_identity_at_90k() {
assert_eq!(rescale_to_90k(12345, VIDEO_CLOCK_RATE), 12345);
}
#[test]
fn rescale_to_90k_scales_a_different_timescale() {
assert_eq!(rescale_to_90k(1000, 1000), VIDEO_CLOCK_RATE);
}
#[test]
fn patch_seq_and_timestamp_overwrites_the_right_bytes() {
let mut original = vec![0x80u8, 96];
original.extend_from_slice(&1u16.to_be_bytes());
original.extend_from_slice(&1000u32.to_be_bytes());
original.extend_from_slice(&0xDEAD_BEEFu32.to_be_bytes());
original.extend_from_slice(&[0xAA, 0xBB]);
let patched = patch_seq_and_timestamp(&original, 42, 90_000).expect("full header patches");
assert_eq!(u16::from_be_bytes([patched[2], patched[3]]), 42);
assert_eq!(
u32::from_be_bytes([patched[4], patched[5], patched[6], patched[7]]),
90_000
);
assert_eq!(patched[0], original[0]);
assert_eq!(patched[1], original[1]);
assert_eq!(&patched[8..12], &original[8..12]);
assert_eq!(&patched[12..], &original[12..]);
}
#[test]
fn patch_seq_and_timestamp_refuses_a_packet_shorter_than_the_fixed_header() {
let short = vec![0u8; rtp_packet::FIXED_HEADER_LEN - 1];
assert!(
patch_seq_and_timestamp(&short, 42, 90_000).is_none(),
"a packet shorter than the fixed header must be refused, not returned unpatched"
);
let exact = vec![0u8; rtp_packet::FIXED_HEADER_LEN];
let patched = patch_seq_and_timestamp(&exact, 42, 90_000)
.expect("a bare fixed header is long enough to patch");
assert_eq!(u16::from_be_bytes([patched[2], patched[3]]), 42);
assert_eq!(
u32::from_be_bytes([patched[4], patched[5], patched[6], patched[7]]),
90_000
);
}
}